Compare commits

...

40 Commits

Author SHA1 Message Date
Pavilion
10033dd904 Updated comments 2026-02-17 15:51:36 +00:00
Pavilion
8427a1ae96 Fixed incorrect highlight ranges 2026-02-17 15:26:04 +00:00
Pavilion
ebd82d21d3 Lint fixes 2026-02-17 14:57:12 +00:00
Pavilion
e3f842a5c6 Lint fixes 2026-02-17 14:45:47 +00:00
Pavilion Sahota
e6fbf9360e added mutation filtering and observer target scoping 2026-02-17 14:40:49 +00:00
Pavilion Sahota
e792208d72 Merge branch 'dev' into quick-search-text-highlighting 2026-02-17 14:14:55 +00:00
Pavilion Sahota
deb4a9ecd9 Refactored implementation 2026-02-17 14:06:40 +00:00
Pavilion Sahota
e1db282c7d Fixed unicode bug 2026-02-17 14:06:26 +00:00
Pavilion
1394be628f Refactored search highlighting and centralised logic 2026-02-17 13:21:12 +00:00
Pavilion
41ab292f08 Readability improvements 2026-02-17 10:17:49 +00:00
Pavilion
e9dcc27e98 Using observer // added tests 2026-02-17 09:48:46 +00:00
Pavilion
6940d4519f Refactored search highlight class 2026-02-16 14:30:31 +00:00
Pavilion
1860f1f32e Removed quick search implementations 2026-02-16 14:12:24 +00:00
Pavilion Sahota
0da8a5b42b Merge branch 'dev' into quick-search-text-highlighting 2026-02-16 14:09:05 +00:00
Pavilion
84f4252b17 Removed quick search implementations 2026-02-16 12:10:30 +00:00
Pavilion
e4ed238113 Renamed method 2026-02-16 11:58:25 +00:00
Pavilion
0ce3690d98 Added comments 2026-02-16 11:50:12 +00:00
Pavilion
d517a4bf3c Using lit css 2026-02-16 11:29:18 +00:00
Pavilion
d7b453627a Removed external function 2026-02-16 11:25:28 +00:00
Pavilion
9471b4594f Updated comments 2026-02-16 11:08:45 +00:00
Pavilion
cec3380aef Removed external function 2026-02-16 11:00:29 +00:00
uptimeZERO_
4476086a7f Update src/common/string/search-highlight.ts
Concise comment

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-02-16 10:51:14 +00:00
Pavilion
bd35ca25b0 Added comment to explain need for unique id 2026-02-16 10:49:18 +00:00
Pavilion
0497da3915 Using normalizedIndexMap and added comments 2026-02-12 16:03:26 +00:00
Pavilion
3ccb98ffe0 WIP refactored search highlight and added tests 2026-02-12 15:21:04 +00:00
Pavilion Sahota
a56aabed1e Merge branch 'dev' into quick-search-text-highlighting 2026-02-12 08:54:29 +00:00
Pavilion
65d793135c Moved style to controller 2026-02-11 13:52:43 +00:00
Pavilion
4dd53f0a96 styling inside the controller 2026-02-10 16:02:16 +00:00
Pavilion
95bdca0ce2 Using class instead of data attribute 2026-02-10 15:58:23 +00:00
Pavilion
e4bd5c611d Created HighlightController 2026-02-10 15:56:18 +00:00
Pavilion
4b92648cd0 Created clear function 2026-02-10 15:54:13 +00:00
Pavilion
a656cd1114 Migrating highlight logic 2026-02-10 15:47:16 +00:00
Pavilion
2b72cd9ca1 Highlighting efficiency fixes 2026-02-10 15:37:32 +00:00
Pavilion
ed2790fa0d using CSS Highlight 2026-02-09 13:37:30 +00:00
Pavilion
7949e2798f refactored duplicate logic 2026-02-04 16:10:10 +00:00
Pavilion
0245edfaf3 text highlighting for dashboard config 2026-02-04 15:39:28 +00:00
Pavilion
076ecbb08e text highlighting for apps 2026-02-04 15:27:07 +00:00
Pavilion
b456b6630c text highlighting for label config page 2026-02-04 15:09:11 +00:00
Pavilion
e700b58cc3 text highlighting for integrations page 2026-02-04 14:40:18 +00:00
Pavilion
9c0896fff9 text highlighting for automations and quick search 2026-02-04 14:08:49 +00:00
7 changed files with 1053 additions and 43 deletions

View File

@@ -0,0 +1,507 @@
import type { TemplateResult } from "lit";
import { css, html, unsafeCSS } from "lit";
import { normalizeSearchText, splitSearchTerms } from "./search-query";
export interface HighlightRange {
start: number;
end: number;
}
interface NormalizedIndexMap {
normalizedText: string;
normalizedIndexMap: HighlightRange[];
}
export type HighlightedText =
| string
| TemplateResult
| (string | TemplateResult)[]
| null
| undefined;
const HIGHLIGHT_NAME_PREFIX = "ha-search";
// Shared selector so range extraction and mutation checks stay in sync.
const HIGHLIGHT_MARK_SELECTOR = "mark.ha-highlight";
const tokenizeSearchQuery = (query: string): string[] => [
...new Set(splitSearchTerms(query)),
];
/**
* Build normalized text and an index map back to original indexes.
* Needed because normalization can change character length/index positions.
*/
const buildNormalizedIndexMap = (
text: string,
language?: string
): NormalizedIndexMap => {
let normalizedText = "";
const normalizedIndexMap: HighlightRange[] = [];
let originalIndex = 0;
for (const char of text) {
const start = originalIndex;
const end = start + char.length;
const normalizedChar = normalizeSearchText(char, language);
normalizedText += normalizedChar;
// One original character can normalize into multiple UTF-16 code units.
// Keep a mapping entry for each normalized code unit because String#indexOf
// and String#length operate on UTF-16 indexes.
for (const _codeUnit of normalizedChar.split("")) {
normalizedIndexMap.push({ start, end });
}
originalIndex = end;
}
return { normalizedText, normalizedIndexMap };
};
const mergeHighlightRanges = (ranges: HighlightRange[]): HighlightRange[] => {
if (!ranges.length) {
return [];
}
const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
const mergedRanges: HighlightRange[] = [{ ...sortedRanges[0] }];
// Merge overlapping/adjacent ranges so the rendered marks stay minimal.
for (let i = 1; i < sortedRanges.length; i++) {
const previousRange = mergedRanges[mergedRanges.length - 1];
const currentRange = sortedRanges[i];
if (currentRange.start <= previousRange.end) {
previousRange.end = Math.max(previousRange.end, currentRange.end);
continue;
}
mergedRanges.push({ ...currentRange });
}
return mergedRanges;
};
/**
* Convert rendered `<mark>` nodes into DOM Ranges for `CSS.highlights`.
* We walk text nodes because Lit templates can place comment markers before
* text inside `<mark>`, so `firstChild` is not reliably the text node.
*/
const getHighlightRangesFromMarks = (root: ShadowRoot): Range[] => {
const ranges: Range[] = [];
root.querySelectorAll(HIGHLIGHT_MARK_SELECTOR).forEach((mark) => {
const textWalker = document.createTreeWalker(mark, NodeFilter.SHOW_TEXT);
let textNode = textWalker.nextNode();
while (textNode) {
const text = textNode.textContent;
if (text) {
const range = new Range();
range.setStart(textNode, 0);
range.setEnd(textNode, text.length);
ranges.push(range);
}
textNode = textWalker.nextNode();
}
});
return ranges;
};
const createHighlightStyle = (highlightName: string): string => css`
.ha-highlight {
/* Visual highlight comes from ::highlight(...), not the <mark> itself. */
background-color: transparent;
color: inherit;
border-radius: 0;
padding: 0;
box-shadow: none;
}
::highlight(${unsafeCSS(highlightName)}) {
background-color: var(
--ha-highlight-bg,
var(--ha-color-fill-primary-normal-hover)
);
color: var(--ha-highlight-color, var(--primary-text-color));
}
`.cssText;
const renderHighlightedParts = (
text: string,
ranges: HighlightRange[]
): (string | TemplateResult)[] => {
const parts: (string | TemplateResult)[] = [];
let previousIndex = 0;
for (const range of ranges) {
if (range.start > previousIndex) {
parts.push(text.slice(previousIndex, range.start));
}
parts.push(
html`<mark class="ha-highlight"
>${text.slice(range.start, range.end)}</mark
>`
);
previousIndex = range.end;
}
if (previousIndex < text.length) {
parts.push(text.slice(previousIndex));
}
return parts;
};
/**
* Search highlighting helper with two integration paths:
* 1) call `renderHighlightedText` + `applyFromMarks` when updates are driven
* by known state changes (like filter changes),
* 2) call `startAutoSyncFromMarks` when highlighted DOM can change
* independently of filter changes (like virtualized rows).
*/
export class SearchHighlight {
// `CSS.highlights` is document-global, not per shadow root.
// Each instance needs a unique key so that components do not overwrite each
// other's highlight ranges.
private static _nextHighlightId = 0;
// Fingerprints include Node identity, so map nodes to stable numeric IDs.
private static _nodeIds = new WeakMap<Node, number>();
private static _nextNodeId = 0;
// Cache the last apply inputs to avoid re-registering identical highlights.
private _lastCacheKey?: string;
private _lastFingerprint?: string;
private readonly _highlightName?: string;
private _autoSyncObserver?: MutationObserver;
private _autoSyncQueued = false;
private _autoSyncCacheKeyProvider?: () => string | null | undefined;
private _autoSyncObservedTarget?: Node;
public constructor(private readonly _root?: ShadowRoot) {
if (this._root) {
this._highlightName = `${HIGHLIGHT_NAME_PREFIX}-${SearchHighlight._nextHighlightId++}`;
this._addHighlightStyle();
}
}
/**
* Return text ranges that should be highlighted for the given query.
* Useful when the caller needs ranges without rendering `<mark>` output.
*/
public getHighlightRanges(
text: string,
query: string,
language?: string
): HighlightRange[] {
if (!text) {
return [];
}
const terms = tokenizeSearchQuery(query);
if (!terms.length) {
return [];
}
const { normalizedText, normalizedIndexMap } = buildNormalizedIndexMap(
text,
language
);
// Text can normalize to empty (for example, combining marks only).
if (!normalizedText) {
return [];
}
const ranges: HighlightRange[] = [];
for (const term of terms) {
const normalizedTerm = normalizeSearchText(term, language);
// Some tokens normalize to empty (like combining marks); skip them.
if (!normalizedTerm) {
continue;
}
let matchIndex = normalizedText.indexOf(normalizedTerm);
while (matchIndex !== -1) {
// Convert normalized-text match indexes back to original-text indexes.
// `indexOf` guarantees the full normalized term is within bounds, and
// we append one mapping item per normalized UTF-16 code unit.
const start = normalizedIndexMap[matchIndex]!.start;
const end =
normalizedIndexMap[matchIndex + normalizedTerm.length - 1]!.end;
ranges.push({ start, end });
matchIndex = normalizedText.indexOf(
normalizedTerm,
matchIndex + normalizedTerm.length
);
}
}
return mergeHighlightRanges(ranges);
}
/**
* Render plain text with matching segments wrapped in `<mark>`.
* `<mark>` nodes are used as stable anchors for range extraction.
*/
public renderHighlightedText(
text: string | null | undefined,
query: string | null | undefined,
language?: string
): HighlightedText {
if (!text) {
return text;
}
const ranges = this.getHighlightRanges(text, query ?? "", language);
if (!ranges.length) {
return text;
}
return renderHighlightedParts(text, ranges);
}
/**
* Read rendered `<mark>` nodes from the root and apply matching
* `CSS.highlights` ranges.
* `cacheKey` should represent the current query/filter used to build marks.
*/
public applyFromMarks(cacheKey?: string): void {
if (!this._root) {
return;
}
this.applyFromRanges(getHighlightRangesFromMarks(this._root), cacheKey);
}
/**
* Apply precomputed ranges directly to `CSS.highlights`.
* Use this when ranges are built outside this class.
* `cacheKey` should represent the inputs used to build `ranges`.
*/
public applyFromRanges(ranges: Range[], cacheKey?: string): void {
if (!this._root || !this._highlightName) {
return;
}
const highlightRegistry = globalThis.CSS?.highlights;
if (!highlightRegistry || typeof Highlight === "undefined") {
return;
}
if (!ranges.length) {
this.clear();
return;
}
const fingerprint = this._getRangesFingerprint(ranges);
// Skip writes only when both the caller key and concrete range positions
// are unchanged.
if (
cacheKey === this._lastCacheKey &&
fingerprint === this._lastFingerprint
) {
return;
}
this._lastCacheKey = cacheKey;
this._lastFingerprint = fingerprint;
highlightRegistry.set(this._highlightName, new Highlight(...ranges));
}
/**
* Auto-sync `CSS.highlights` from `<mark>` nodes whenever marked DOM changes.
* Use this for components where highlighted DOM can change without filter
* changes (for example, virtualized lists).
* `cacheKeyProvider` should return the current query/filter string.
* `observedTarget` allows callers to scope observation to a subtree.
*/
public startAutoSyncFromMarks(
cacheKeyProvider: () => string | null | undefined,
observedTarget?: Node
): void {
if (!this._root || !this._highlightName) {
return;
}
this._autoSyncCacheKeyProvider = cacheKeyProvider;
this._autoSyncObservedTarget = observedTarget ?? this._root;
if (!this._autoSyncObserver) {
this._autoSyncObserver = new MutationObserver((records) => {
if (
!records.some((record) => this._mutationAffectsHighlights(record))
) {
return;
}
this._queueAutoSyncFromMarks();
});
}
this._autoSyncObserver.disconnect();
this._autoSyncObserver.observe(this._autoSyncObservedTarget, {
childList: true,
subtree: true,
characterData: true,
});
this._queueAutoSyncFromMarks();
}
/**
* Stop auto-sync started via `startAutoSyncFromMarks`.
*/
public stopAutoSyncFromMarks(): void {
this._autoSyncObserver?.disconnect();
this._autoSyncQueued = false;
this._autoSyncCacheKeyProvider = undefined;
this._autoSyncObservedTarget = undefined;
}
public clear(): void {
if (!this._root) {
return;
}
globalThis.CSS?.highlights?.delete(this._highlightName!);
this._lastCacheKey = undefined;
this._lastFingerprint = undefined;
}
private _getNodeId(node: Node): number {
let nodeId = SearchHighlight._nodeIds.get(node);
if (nodeId !== undefined) {
return nodeId;
}
nodeId = SearchHighlight._nextNodeId++;
SearchHighlight._nodeIds.set(node, nodeId);
return nodeId;
}
/**
* Build a stable signature for a set of ranges so we can detect real range
* changes even when the count stays the same.
*/
private _getRangesFingerprint(ranges: Range[]): string {
return ranges
.map((range) => {
const startNodeId = this._getNodeId(range.startContainer);
const endNodeId = this._getNodeId(range.endContainer);
return `${startNodeId}:${range.startOffset}-${endNodeId}:${range.endOffset}`;
})
.join("|");
}
private _queueAutoSyncFromMarks(): void {
if (this._autoSyncQueued) {
return;
}
this._autoSyncQueued = true;
// Coalesce bursts of mutations into a single highlight recomputation.
queueMicrotask(() => {
this._autoSyncQueued = false;
if (!this._root || !(this._root.host as HTMLElement).isConnected) {
return;
}
const cacheKey = this._autoSyncCacheKeyProvider?.()?.trim();
if (!cacheKey) {
this.clear();
return;
}
this.applyFromMarks(cacheKey);
});
}
private _mutationAffectsHighlights(mutation: MutationRecord): boolean {
if (mutation.type === "characterData") {
return this._nodeContainsHighlightMark(mutation.target);
}
if (mutation.type !== "childList") {
return false;
}
if (this._nodeContainsHighlightMark(mutation.target)) {
return true;
}
for (const node of mutation.addedNodes) {
if (this._nodeContainsHighlightMark(node)) {
return true;
}
}
for (const node of mutation.removedNodes) {
if (this._nodeContainsHighlightMark(node)) {
return true;
}
}
return false;
}
/**
* Returns true when a node is a highlight mark, contains one, or is a text/comment
* node inside one. The text/comment case covers Lit marker nodes.
*/
private _nodeContainsHighlightMark(node: Node): boolean {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
return (
element.matches(HIGHLIGHT_MARK_SELECTOR) ||
Boolean(element.querySelector(HIGHLIGHT_MARK_SELECTOR))
);
}
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return Boolean(
(node as DocumentFragment).querySelector?.(HIGHLIGHT_MARK_SELECTOR)
);
}
if (
node.nodeType === Node.TEXT_NODE ||
node.nodeType === Node.COMMENT_NODE
) {
const parentElement = (node as ChildNode).parentElement;
return Boolean(parentElement?.closest(HIGHLIGHT_MARK_SELECTOR));
}
return false;
}
/**
* Inject marker styles and `::highlight()` theme colors.
*/
private _addHighlightStyle(): void {
if (!this._root || !this._highlightName) {
return;
}
const style = document.createElement("style");
style.textContent = createHighlightStyle(this._highlightName);
this._root.appendChild(style);
}
}

View File

@@ -0,0 +1,13 @@
import { stripDiacritics } from "./strip-diacritics";
/**
* Normalize text for search comparisons (case-insensitive + diacritics-insensitive).
*/
export const normalizeSearchText = (text: string, language?: string): string =>
stripDiacritics(text).toLocaleLowerCase(language);
/**
* Split a user query into whitespace-delimited search terms.
*/
export const splitSearchTerms = (query: string): string[] =>
query.trim().split(/\s+/).filter(Boolean);

View File

@@ -17,6 +17,7 @@ import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import { SearchHighlight } from "../../common/string/search-highlight";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
@@ -178,6 +179,8 @@ export class HaDataTable extends LitElement {
private _lastUpdate = 0;
private _searchHighlight?: SearchHighlight;
// @ts-ignore
@restoreScroll(".scroller") private _savedScrollPos?: number;
@@ -234,21 +237,36 @@ export class HaDataTable extends LitElement {
// Force update of location of rows
this._filteredData = [...this._filteredData];
}
// Re-attach observer when the element reconnects.
if (this.hasUpdated) {
this._updateSearchHighlightSync();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._searchHighlight?.stopAutoSyncFromMarks();
this._searchHighlight?.clear();
}
protected firstUpdated() {
this.updateComplete.then(() => this._calcTableHeight());
this._updateSearchHighlightSync();
}
protected updated() {
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
if (!header) {
return;
protected updated(changedProperties: PropertyValues) {
if (changedProperties.has("_filter")) {
this._updateSearchHighlightSync();
}
if (header.scrollWidth > header.clientWidth) {
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
} else {
this.style.removeProperty("--table-row-width");
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
if (header) {
if (header.scrollWidth > header.clientWidth) {
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
} else {
this.style.removeProperty("--table-row-width");
}
}
}
@@ -620,7 +638,12 @@ export class HaDataTable extends LitElement {
${column.template
? column.template(row)
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
? html`<div class="primary">
${this._renderValueWithHighlight(
row[key],
column.filterable
)}
</div>
<div class="secondary">
${Object.entries(columns)
.filter(
@@ -638,15 +661,21 @@ export class HaDataTable extends LitElement {
([key2, column2], i) =>
html`${i !== 0
? STRINGS_SEPARATOR_DOT
: nothing}${column2.template
? column2.template(row)
: row[key2]}`
: nothing}${this._renderCellValue(
column2,
key2,
row
)}`
)}
</div>
${column.extraTemplate
? column.extraTemplate(row)
: nothing}`
: html`${row[key]}${column.extraTemplate
: html`${this._renderCellValue(
column,
key,
row
)}${column.extraTemplate
? column.extraTemplate(row)
: nothing}`}
</div>
@@ -656,6 +685,69 @@ export class HaDataTable extends LitElement {
`;
};
private _renderCellValue(
column: DataTableColumnData,
key: string,
row: DataTableRowData
) {
if (column.template) {
return column.template(row);
}
return this._renderValueWithHighlight(row[key], column.filterable);
}
private _renderValueWithHighlight(
value: unknown,
filterable?: boolean
): unknown {
if (!filterable) {
return value;
}
const filter = this._filter.trim();
if (!filter) {
return value;
}
if (typeof value !== "string" && typeof value !== "number") {
return value;
}
const text = String(value);
return this._getSearchHighlight().renderHighlightedText(
text,
filter,
this.hass.locale.language
);
}
private _getSearchHighlight(): SearchHighlight {
if (!this._searchHighlight) {
this._searchHighlight = new SearchHighlight(
this.renderRoot as ShadowRoot
);
}
return this._searchHighlight;
}
private _updateSearchHighlightSync(): void {
if (!this._filter.trim()) {
this._searchHighlight?.stopAutoSyncFromMarks();
this._searchHighlight?.clear();
return;
}
const observedTarget =
this.renderRoot.querySelector("lit-virtualizer") ||
(this.renderRoot as ShadowRoot);
this._getSearchHighlight().startAutoSyncFromMarks(
() => this._filter,
observedTarget
);
}
private async _sortFilterData() {
const startTime = new Date().getTime();
const timeBetweenUpdate = startTime - this._lastUpdate;

View File

@@ -3,7 +3,10 @@ import type { FuseOptionKey, IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import {
normalizeSearchText,
splitSearchTerms,
} from "../../common/string/search-query";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -47,10 +50,10 @@ const getSearchableValue = (
const stringValues = value
.filter((item) => item != null && typeof item !== "object")
.map(String);
return stripDiacritics(stringValues.join(" ").toLowerCase());
return normalizeSearchText(stringValues.join(" "));
}
return stripDiacritics(String(value).toLowerCase());
return normalizeSearchText(String(value));
};
/** Filters data using exact substring matching (all terms must match). */
@@ -141,7 +144,7 @@ const filterData = (
columns: SortableColumnContainer,
filter: string
): DataTableRowData[] => {
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
const normalizedFilter = normalizeSearchText(filter).trim();
if (!normalizedFilter) {
return data;
@@ -153,7 +156,7 @@ const filterData = (
return data;
}
const terms = normalizedFilter.split(/\s+/);
const terms = splitSearchTerms(normalizedFilter);
// First, try exact substring matching
const exactMatches = filterDataExact(data, filterKeys, terms);

View File

@@ -12,6 +12,7 @@ import {
state as litState,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { SearchHighlight } from "../common/string/search-highlight";
interface State {
bold: boolean;
@@ -33,6 +34,8 @@ export class HaAnsiToHtml extends LitElement {
@litState() private _filter = "";
private _searchHighlight?: SearchHighlight;
protected render(): TemplateResult {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
@@ -46,6 +49,11 @@ export class HaAnsiToHtml extends LitElement {
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._searchHighlight?.clear();
}
static styles = css`
pre {
margin: 0;
@@ -114,11 +122,6 @@ export class HaAnsiToHtml extends LitElement {
.bg-white {
background-color: rgb(204, 204, 204);
}
::highlight(search-results) {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
`;
/**
@@ -323,30 +326,29 @@ export class HaAnsiToHtml extends LitElement {
this._filter = filter;
const lines = this.shadowRoot?.querySelectorAll("div") || [];
let numberOfFoundLines = 0;
const filterLower = filter.toLowerCase();
if (!filter) {
lines.forEach((line) => {
line.style.display = "";
});
numberOfFoundLines = lines.length;
if (CSS.highlights) {
CSS.highlights.delete("search-results");
}
this._searchHighlight?.clear();
} else {
const highlightRanges: Range[] = [];
lines.forEach((line) => {
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
if (!line.textContent?.toLowerCase().includes(filterLower)) {
line.style.display = "none";
} else {
line.style.display = "";
numberOfFoundLines++;
if (CSS.highlights && line.firstChild !== null && line.textContent) {
if (line.firstChild !== null && line.textContent) {
const spansOfLine = line.querySelectorAll("span");
spansOfLine.forEach((span) => {
const text = span.textContent.toLowerCase();
const indices: number[] = [];
let startPos = 0;
while (startPos < text.length) {
const index = text.indexOf(filter.toLowerCase(), startPos);
const index = text.indexOf(filterLower, startPos);
if (index === -1) break;
indices.push(index);
startPos = index + filter.length;
@@ -362,8 +364,11 @@ export class HaAnsiToHtml extends LitElement {
}
}
});
if (CSS.highlights) {
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
if (this.shadowRoot) {
this._getSearchHighlight(this.shadowRoot).applyFromRanges(
highlightRanges,
filter
);
}
}
@@ -375,6 +380,13 @@ export class HaAnsiToHtml extends LitElement {
this._pre.innerHTML = "";
}
}
private _getSearchHighlight(root: ShadowRoot): SearchHighlight {
if (!this._searchHighlight) {
this._searchHighlight = new SearchHighlight(root);
}
return this._searchHighlight;
}
}
declare global {

View File

@@ -6,6 +6,10 @@ import type {
IFuseOptions,
} from "fuse.js";
import Fuse from "fuse.js";
import {
normalizeSearchText,
splitSearchTerms,
} from "../common/string/search-query";
export interface FuseWeightedKey {
name: string | string[];
@@ -74,10 +78,7 @@ export function multiTermSearch<T>(
fuseIndex?: FuseIndex<T>,
options: IFuseOptions<T> = {}
): T[] {
const terms = search
.toLowerCase()
.split(" ")
.filter((t) => t.trim());
const terms = splitSearchTerms(normalizeSearchText(search));
if (!terms.length) {
return items;
@@ -147,10 +148,7 @@ export function multiTermSortedSearch<T>(
fuseIndex?: FuseIndex<T>,
options: IFuseOptions<T> = {}
) {
const terms = search
.toLowerCase()
.split(" ")
.filter((t) => t.trim());
const terms = splitSearchTerms(normalizeSearchText(search));
if (!terms.length) {
return items;
@@ -170,10 +168,6 @@ export function multiTermSortedSearch<T>(
let termHits = 0;
terms.forEach((term) => {
if (!term.trim()) {
return;
}
const termResults = searchTerm<T>(items, term, fuseIndex, {
...options,
shouldSort: false,

View File

@@ -0,0 +1,389 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SearchHighlight } from "../../../src/common/string/search-highlight";
interface MockHighlightApi {
set: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
}
const originalCSS = globalThis.CSS;
const originalHighlight = globalThis.Highlight;
const createShadowRoot = () => {
const host = document.createElement("div");
document.body.append(host);
return { host, root: host.attachShadow({ mode: "open" }) };
};
const flushMutationObserver = async () => {
await Promise.resolve();
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
};
const installMockCustomHighlights = (): MockHighlightApi => {
const highlights: MockHighlightApi = {
set: vi.fn(),
delete: vi.fn(),
};
class MockHighlight {
public ranges: Range[];
public constructor(...ranges: Range[]) {
this.ranges = ranges;
}
}
(globalThis as any).CSS = { highlights };
(globalThis as any).Highlight = MockHighlight;
return highlights;
};
describe("search highlight text rendering", () => {
let searchHighlight: SearchHighlight;
beforeEach(() => {
searchHighlight = new SearchHighlight();
});
afterEach(() => {
document.body.innerHTML = "";
});
it("returns substring ranges", () => {
expect(searchHighlight.getHighlightRanges("Hello World", "lo")).toEqual([
{ start: 3, end: 5 },
]);
});
it("returns empty ranges for empty text or query", () => {
expect(searchHighlight.getHighlightRanges("", "hello")).toEqual([]);
expect(searchHighlight.getHighlightRanges("hello", "")).toEqual([]);
});
it("matches diacritics-insensitive", () => {
expect(searchHighlight.getHighlightRanges("Café", "cafe")).toEqual([
{ start: 0, end: 4 },
]);
});
it("deduplicates repeated search terms", () => {
expect(
searchHighlight.getHighlightRanges("alpha alpha", "alpha alpha")
).toEqual([
{ start: 0, end: 5 },
{ start: 6, end: 11 },
]);
});
it("maps matches to original indexes for multi-unit characters", () => {
expect(searchHighlight.getHighlightRanges("A😀B", "😀")).toEqual([
{ start: 1, end: 3 },
]);
});
it("merges overlapping and adjacent ranges", () => {
expect(searchHighlight.getHighlightRanges("abcd", "ab cd")).toEqual([
{ start: 0, end: 4 },
]);
expect(searchHighlight.getHighlightRanges("abcdef", "abc bcd")).toEqual([
{ start: 0, end: 4 },
]);
});
it("handles terms that normalize to empty strings", () => {
expect(searchHighlight.getHighlightRanges("abc", "\u0301")).toEqual([]);
});
it("handles text that normalizes to empty string", () => {
expect(searchHighlight.getHighlightRanges("\u0301", "a")).toEqual([]);
});
it("returns ranges for multiple terms", () => {
expect(
searchHighlight.getHighlightRanges("alpha beta gamma", "alpha gamma")
).toEqual([
{ start: 0, end: 5 },
{ start: 11, end: 16 },
]);
});
it("renders highlighted text parts", () => {
const result = searchHighlight.renderHighlightedText("Hello", "ell");
expect(Array.isArray(result)).toBe(true);
const parts = result as unknown as unknown[];
expect(parts[0]).toBe("H");
expect(parts[2]).toBe("o");
});
it("returns original text when query is empty", () => {
expect(searchHighlight.renderHighlightedText("Hello", "")).toBe("Hello");
});
it("returns original value for null or undefined text", () => {
expect(searchHighlight.renderHighlightedText(null, "a")).toBeNull();
expect(searchHighlight.renderHighlightedText(undefined, "a")).toBe(
undefined
);
});
it("returns original text when query does not match", () => {
expect(searchHighlight.renderHighlightedText("Hello", "xyz")).toBe("Hello");
});
});
describe("search highlight custom highlight API integration", () => {
beforeEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
afterEach(() => {
if (originalCSS === undefined) {
delete (globalThis as any).CSS;
} else {
(globalThis as any).CSS = originalCSS;
}
if (originalHighlight === undefined) {
delete (globalThis as any).Highlight;
} else {
(globalThis as any).Highlight = originalHighlight;
}
});
it("is safe to call instance methods without a root", () => {
const searchHighlight = new SearchHighlight();
expect(() => searchHighlight.applyFromMarks("key")).not.toThrow();
expect(() => searchHighlight.applyFromRanges([], "key")).not.toThrow();
expect(() => searchHighlight.clear()).not.toThrow();
});
it("injects style for highlight pseudo-element when supported", () => {
installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
expect(searchHighlight).toBeDefined();
const style = root.querySelector("style");
expect(style).toBeTruthy();
expect(style!.textContent).toContain("::highlight(ha-search-");
expect(style!.textContent).toContain(".ha-highlight");
});
it("still injects marker style when custom highlights are unavailable", () => {
(globalThis as any).CSS = {};
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
expect(searchHighlight).toBeDefined();
expect(root.querySelector("style")).toBeTruthy();
});
it("applies and clears highlights based on mark nodes", () => {
const highlights = installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const mark = document.createElement("mark");
mark.className = "ha-highlight";
mark.append(document.createComment("?lit$marker$"));
mark.append(document.createTextNode("Alpha"));
const nonTextMark = document.createElement("mark");
nonTextMark.className = "ha-highlight";
const nested = document.createElement("span");
nested.textContent = "ignored";
nonTextMark.append(nested);
root.append(mark, nonTextMark);
searchHighlight.applyFromMarks("k1");
expect(highlights.set).toHaveBeenCalledTimes(1);
searchHighlight.applyFromMarks("k1");
expect(highlights.set).toHaveBeenCalledTimes(1);
searchHighlight.applyFromMarks("k2");
expect(highlights.set).toHaveBeenCalledTimes(2);
mark.remove();
nonTextMark.remove();
searchHighlight.applyFromMarks("k3");
expect(highlights.delete).toHaveBeenCalledTimes(1);
});
it("applies range highlights and skips only exact duplicate range positions", () => {
const highlights = installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const textNode = document.createTextNode("abcdef");
root.append(textNode);
const range = new Range();
range.setStart(textNode, 1);
range.setEnd(textNode, 3);
const movedRange = new Range();
movedRange.setStart(textNode, 2);
movedRange.setEnd(textNode, 4);
searchHighlight.applyFromRanges([range], "same");
searchHighlight.applyFromRanges([range], "same");
expect(highlights.set).toHaveBeenCalledTimes(1);
searchHighlight.applyFromRanges([movedRange], "same");
expect(highlights.set).toHaveBeenCalledTimes(2);
searchHighlight.applyFromRanges([], "clear");
expect(highlights.delete).toHaveBeenCalledTimes(1);
});
it("observes mark mutations and re-applies highlights", async () => {
const highlights = installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const mark = document.createElement("mark");
mark.className = "ha-highlight";
mark.append(document.createTextNode("Alpha"));
root.append(mark);
searchHighlight.startAutoSyncFromMarks(() => "key");
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
(mark.firstChild as Text).textContent = "Alphabet";
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(2);
});
it("ignores unrelated mutations while auto-syncing", async () => {
const highlights = installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const applyFromMarksSpy = vi.spyOn(searchHighlight, "applyFromMarks");
const mark = document.createElement("mark");
mark.className = "ha-highlight";
mark.append(document.createTextNode("Alpha"));
root.append(mark);
const unrelated = document.createElement("div");
unrelated.append(document.createTextNode("outside"));
root.append(unrelated);
searchHighlight.startAutoSyncFromMarks(() => "key");
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
expect(applyFromMarksSpy).toHaveBeenCalledTimes(1);
(unrelated.firstChild as Text).textContent = "outside-updated";
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
expect(applyFromMarksSpy).toHaveBeenCalledTimes(1);
});
it("can observe a specific subtree", async () => {
const highlights = installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const observed = document.createElement("div");
const unobserved = document.createElement("div");
root.append(observed, unobserved);
const observedMark = document.createElement("mark");
observedMark.className = "ha-highlight";
observedMark.append(document.createTextNode("Alpha"));
observed.append(observedMark);
const unobservedMark = document.createElement("mark");
unobservedMark.className = "ha-highlight";
unobservedMark.append(document.createTextNode("Beta"));
unobserved.append(unobservedMark);
searchHighlight.startAutoSyncFromMarks(() => "key", observed);
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
(unobservedMark.firstChild as Text).textContent = "Beta-updated";
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
(observedMark.firstChild as Text).textContent = "Alpha-updated";
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(2);
});
it("stops observing mark mutations when stopped", async () => {
const highlights = installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const mark = document.createElement("mark");
mark.className = "ha-highlight";
mark.append(document.createTextNode("Alpha"));
root.append(mark);
searchHighlight.startAutoSyncFromMarks(() => "key");
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
searchHighlight.stopAutoSyncFromMarks();
(mark.firstChild as Text).textContent = "Alphabet";
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
});
it("clears highlights when observed key becomes empty", async () => {
const highlights = installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const mark = document.createElement("mark");
mark.className = "ha-highlight";
mark.append(document.createTextNode("Alpha"));
root.append(mark);
let key = "key";
searchHighlight.startAutoSyncFromMarks(() => key);
await flushMutationObserver();
expect(highlights.set).toHaveBeenCalledTimes(1);
key = " ";
(mark.firstChild as Text).textContent = "Alphabet";
await flushMutationObserver();
expect(highlights.delete).toHaveBeenCalledTimes(1);
});
it("can apply highlights if support is added after construction", () => {
(globalThis as any).CSS = {};
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
const highlights = installMockCustomHighlights();
const textNode = document.createTextNode("abc");
root.append(textNode);
const range = new Range();
range.setStart(textNode, 0);
range.setEnd(textNode, 1);
searchHighlight.applyFromRanges([range], "late-support");
expect(highlights.set).toHaveBeenCalledTimes(1);
});
it("clear is safe even without CSS.highlights", () => {
installMockCustomHighlights();
const { root } = createShadowRoot();
const searchHighlight = new SearchHighlight(root);
(globalThis as any).CSS = {};
expect(() => searchHighlight.clear()).not.toThrow();
});
});