(this.dom = dom)}>
-
+
(this.dom = dom)}>
+
);
}
-
- highlight() {
- const { snippets } = this.props;
- highlightSearchResultAndMaybeScroll(this.dom, snippets);
- }
}
export default SearchPreview;
diff --git a/znai-reactjs/src/doc-elements/search/flexSearch.test.ts b/znai-reactjs/src/doc-elements/search/flexSearch.test.ts
index 45518c97f..7972c7bb1 100644
--- a/znai-reactjs/src/doc-elements/search/flexSearch.test.ts
+++ b/znai-reactjs/src/doc-elements/search/flexSearch.test.ts
@@ -14,71 +14,164 @@
* limitations under the License.
*/
-import { describe } from "vitest";
-import { createLocalSearchIndex, searchWithHighlight, truncateQueryByMinLength } from "./flexSearch";
+import { describe, expect, it, vi } from "vitest";
+import { createLocalSearchIndex, encodeSearchQuery, searchIds, truncateQueryByMinLength } from "./flexSearch";
+import QueryResult from "./QueryResult";
+
+// type alias instead of interface so the implicit index signature satisfies flexsearch DocumentData
+type TestDoc = {
+ id: string;
+ title?: string;
+ content?: string;
+ contentHigh?: string;
+};
+
+function createIndexAndTextById(docs: TestDoc[]) {
+ const index = createLocalSearchIndex();
+ const textById: Record
= {};
+ docs.forEach((doc) => {
+ index.add(doc);
+ textById[doc.id] = [doc.title, doc.content, doc.contentHigh].filter(Boolean).join(" ");
+ });
+
+ return { index, textById };
+}
+
+function searchQueryResult(docs: TestDoc[], query: string) {
+ const { index, textById } = createIndexAndTextById(docs);
+ const ids = searchIds(index, query);
+ return new QueryResult(ids, encodeSearchQuery(query), (id) => textById[id]);
+}
describe("flex search", () => {
it("multiple words highlight", () => {
- const index = createLocalSearchIndex();
- index.add({
- id: "id1",
- title: "some title",
- content: "running webs is future for you",
- });
- index.add({
- id: "id2",
- title: "some title",
- content: "running fast into the future for you",
- });
- index.add({
- id: "id3",
- title: "running future",
- content: "brown fox",
- });
-
- const result = searchWithHighlight(index, "ru future");
- expect(result).toEqual([
- { id: "id3", type: "title", termsToHighlight: [] },
+ const queryResult = searchQueryResult(
+ [
+ {
+ id: "id1",
+ title: "some title",
+ content: "running webs is future for you",
+ },
+ {
+ id: "id2",
+ title: "some title",
+ content: "running fast into the future for you",
+ },
+ {
+ id: "id3",
+ title: "running future",
+ content: "brown fox",
+ },
+ ],
+ "ru future"
+ );
+
+ expect(queryResult.getIds()).toEqual(["id3", "id1", "id2"]);
+ expect(queryResult.getSnippetsToHighlight("id3")).toEqual(["running", "future"]);
+ expect(queryResult.getSnippetsToHighlight("id1")).toEqual(["running", "future"]);
+ expect(queryResult.getSnippetsToHighlight("id2")).toEqual(["running", "future"]);
+ });
+
+ it("high content should go first", () => {
+ const queryResult = searchQueryResult(
+ [
+ {
+ id: "id1",
+ title: "some title",
+ content: "running webs is future for you",
+ },
+ {
+ id: "id3",
+ title: "running future",
+ content: "apiCall",
+ },
+ {
+ id: "id2",
+ title: "some title",
+ contentHigh: "apiCall",
+ },
+ ],
+ "apicall"
+ );
+
+ expect(queryResult.getIds()).toEqual(["id2", "id3"]);
+ // terms keep the original doc casing so downstream highlighting can locate them in the dom
+ expect(queryResult.getSnippetsToHighlight("id2")).toEqual(["apiCall"]);
+ expect(queryResult.getSnippetsToHighlight("id3")).toEqual(["apiCall"]);
+ });
+
+ it("min query term length", () => {
+ expect(truncateQueryByMinLength("ty", 3)).toEqual("");
+ expect(truncateQueryByMinLength("typing sl", 3)).toEqual("typing");
+ expect(truncateQueryByMinLength("typing slo", 3)).toEqual("typing slo");
+ });
+
+ it("min query term length only counts searchable chars as the encoder strips the rest", () => {
+ expect(truncateQueryByMinLength("bu-", 3)).toEqual("");
+ expect(truncateQueryByMinLength("c++", 3)).toEqual("");
+ expect(truncateQueryByMinLength("bu_", 3)).toEqual("bu_");
+ expect(truncateQueryByMinLength("bu_id", 3)).toEqual("bu_id");
+ expect(truncateQueryByMinLength("c+ typing", 3)).toEqual("typing");
+ });
+
+ it("underscore is part of code identifiers and matches by prefix", () => {
+ const docs: TestDoc[] = [
{
id: "id1",
- type: "content",
- termsToHighlight: ["running", "future"],
+ title: "trading",
+ content: "use bu_id to identify business unit",
},
{
id: "id2",
- type: "content",
- termsToHighlight: ["running", "future"],
+ title: "building",
+ content: "how to build and bundle",
},
- ]);
+ ];
+
+ const partialQueryResult = searchQueryResult(docs, "bu_");
+ expect(partialQueryResult.getIds()).toEqual(["id1"]);
+ expect(partialQueryResult.getSnippetsToHighlight("id1")).toEqual(["bu_id"]);
+
+ const fullQueryResult = searchQueryResult(docs, "bu_id");
+ expect(fullQueryResult.getIds()).toEqual(["id1"]);
+ expect(fullQueryResult.getSnippetsToHighlight("id1")).toEqual(["bu_id"]);
});
- it("high content should go first", () => {
- const index = createLocalSearchIndex();
- index.add({
- id: "id1",
- title: "some title",
- content: "running webs is future for you",
- });
- index.add({
- id: "id3",
- title: "running future",
- content: "apiCall",
- });
- index.add({
- id: "id2",
- title: "some title",
- contentHigh: "apiCall",
- });
- const result = searchWithHighlight(index, "apicall");
- expect(result).toEqual([
- { id: "id2", type: "contentHigh", termsToHighlight: ["apiCall"] },
- { id: "id3", type: "content", termsToHighlight: ["apiCall"] },
- ]);
+ it("code identifier with underscore matches by its start", () => {
+ const docs: TestDoc[] = [
+ {
+ id: "id1",
+ title: "config",
+ content: "defines build_config for projects",
+ },
+ ];
+
+ const prefixQueryResult = searchQueryResult(docs, "build");
+ expect(prefixQueryResult.getIds()).toEqual(["id1"]);
+ expect(prefixQueryResult.getSnippetsToHighlight("id1")).toEqual(["build_config"]);
+
+ const underscoreQueryResult = searchQueryResult(docs, "build_c");
+ expect(underscoreQueryResult.getIds()).toEqual(["id1"]);
+ expect(underscoreQueryResult.getSnippetsToHighlight("id1")).toEqual(["build_config"]);
});
- it("min query term length", () => {
- expect(truncateQueryByMinLength("ty", 3)).toEqual("");
- expect(truncateQueryByMinLength("typing sl", 3)).toEqual("typing");
- expect(truncateQueryByMinLength("typing slo", 3)).toEqual("typing slo");
+ it("terms to highlight are derived lazily on demand and memoized per id", () => {
+ const textToHighlightById = vi.fn((id: string) =>
+ id === "id1" ? "running webs is future for you" : "running fast into the future"
+ );
+
+ const queryResult = new QueryResult(["id1", "id2"], encodeSearchQuery("ru future"), textToHighlightById);
+ expect(textToHighlightById).not.toHaveBeenCalled();
+
+ const terms = queryResult.getSnippetsToHighlight("id1");
+ expect(terms).toEqual(["running", "future"]);
+ expect(textToHighlightById).toHaveBeenCalledTimes(1);
+
+ // repeated access reuses the memoized terms without re-deriving
+ expect(queryResult.getSnippetsToHighlight("id1")).toBe(terms);
+ expect(textToHighlightById).toHaveBeenCalledTimes(1);
+
+ expect(queryResult.getSnippetsToHighlight("id2")).toEqual(["running", "future"]);
+ expect(textToHighlightById).toHaveBeenCalledTimes(2);
});
});
diff --git a/znai-reactjs/src/doc-elements/search/flexSearch.ts b/znai-reactjs/src/doc-elements/search/flexSearch.ts
index 234ef9d29..7b229861a 100644
--- a/znai-reactjs/src/doc-elements/search/flexSearch.ts
+++ b/znai-reactjs/src/doc-elements/search/flexSearch.ts
@@ -17,6 +17,16 @@
import { Document } from "flexsearch";
import FlexSearch from "flexsearch";
+// default encoder splits terms on any non alphanumeric char, keep underscore as part of terms
+// so code identifiers like `bu_id` are indexed as is and can be found by typing `bu_`
+const searchEncoder = new FlexSearch.Encoder({
+ include: {
+ letter: true,
+ number: true,
+ char: "_",
+ },
+});
+
export function createLocalSearchIndex() {
return new FlexSearch.Document({
preset: "score",
@@ -24,6 +34,7 @@ export function createLocalSearchIndex() {
context: true,
store: true,
resolution: 3,
+ encoder: searchEncoder,
document: {
id: "id",
index: [
@@ -31,8 +42,6 @@ export function createLocalSearchIndex() {
field: "title",
tokenize: "forward",
},
- // for contentHigh use custom encoder that allows underscored and symbols like semicolons(?)
- // maybe allow search from the middle as well
{
field: "contentHigh",
tokenize: "forward",
@@ -61,45 +70,78 @@ export function populateLocalSearchIndexWithData(index: Document, data: string[]
});
}
-export interface SearchResult {
- id: string;
- type?: string;
- termsToHighlight: string[];
-}
+// flexsearch default is 100 results per field
+const resultsPerFieldLimit = 30;
-const highlightRegex = /@\w+\b/g;
-export function searchWithHighlight(index: Document, query: string) {
- const searchResults = index.search(query, { enrich: true, highlight: { template: "@$1" } });
+// per keystroke search fetches ids only, no enrich/highlight, so cost does not grow
+// with the size of the stored content, highlight terms are derived lazily by QueryResult
+// when a result is actually displayed
+export function searchIds(index: Document, query: string): string[] {
+ const searchResults = index.search(query, { limit: resultsPerFieldLimit });
- const withHighlights: SearchResult[] = [];
+ // union across fields keeping first occurrence, so title matches stay ranked before content matches
+ const ids: string[] = [];
+ const seen = new Set();
for (let idx = 0; idx < searchResults.length; idx++) {
- const forFieldParent = searchResults[idx];
- const results = forFieldParent.result;
-
- for (let resultIdx = 0; resultIdx < results.length; resultIdx++) {
- const subResult = results[resultIdx];
- let termsToHighlight: string[] = [];
- if (subResult.highlight) {
- termsToHighlight = (subResult.highlight.match(highlightRegex) || [])
- .map((term) => term.substring(1))
- .filter((term) => term.length > 2);
+ const forFieldResult = searchResults[idx].result;
+ for (let resultIdx = 0; resultIdx < forFieldResult.length; resultIdx++) {
+ const id = forFieldResult[resultIdx].toString();
+ if (!seen.has(id)) {
+ seen.add(id);
+ ids.push(id);
}
+ }
+ }
+
+ return ids;
+}
+
+export function encodeSearchQuery(query: string): string[] {
+ return searchEncoder.encode(query);
+}
- withHighlights.push({
- id: subResult.id.toString(),
- type: forFieldParent.field,
- termsToHighlight,
- });
+// encoder normalizes words (lowercase, letter dedupe: "running" -> "runing"), so encoded tokens
+// can't be handed to the dom highlighter, split the raw text with the encoder's own word splitter instead,
+// the splitter is not part of the public typings but is derived from the include config above
+const encoderWordSplit = (searchEncoder as unknown as { split: RegExp }).split;
+
+// index uses tokenize: "forward", so words whose encoded form prefix matches an encoded query term
+// mirror what flexsearch matched, searchEncoder is the single source of truth for tokenization
+export function deriveTermsToHighlight(encodedQueryTerms: string[], text: string): string[] {
+ if (encodedQueryTerms.length === 0) {
+ return [];
+ }
+
+ // dedupe so the same word is not highlighted multiple times downstream,
+ // short terms produce too much highlight noise
+ const terms = new Set();
+ const words = text.split(encoderWordSplit);
+ for (let idx = 0; idx < words.length; idx++) {
+ const word = words[idx];
+ if (word.length <= 2 || terms.has(word)) {
+ continue;
+ }
+
+ const encodedWord = searchEncoder.encode(word);
+ if (encodedWord.some((token) => encodedQueryTerms.some((queryTerm) => token.startsWith(queryTerm)))) {
+ terms.add(word);
}
}
- return withHighlights;
+ return [...terms];
}
export function truncateQueryByMinLength(query: string, minLength: number) {
return query
.split(" ")
.map((e) => e.trim())
- .filter((e) => e.length >= minLength)
+ .filter((e) => effectiveTermLength(e) >= minLength)
.join(" ");
}
+
+// search encoder strips chars other than alphanumeric and underscore, e.g. "c++" is searched as the one char prefix "c",
+// so only searchable chars count towards the min length, otherwise "c++" bypasses the guard
+// and triggers an expensive short prefix search
+function effectiveTermLength(term: string) {
+ return searchEncoder.encode(term).join("").length;
+}
diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts
new file mode 100644
index 000000000..36f2cdd18
--- /dev/null
+++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2026 znai maintainers
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { afterEach, describe, expect, it } from "vitest";
+import { removeSearchHighlight, startSearchHighlightSession } from "./searchResultHighlighter";
+
+let root: HTMLElement;
+let disposeSession: (() => void) | undefined;
+
+function startSession(snippets: string[], html = "use cancel_trade to abort
") {
+ root = document.createElement("div");
+ root.innerHTML = html;
+ document.body.appendChild(root);
+
+ disposeSession = startSearchHighlightSession(root, snippets);
+}
+
+function markedTexts() {
+ return Array.from(root.querySelectorAll("mark")).map((mark) => mark.textContent);
+}
+
+function mountLateContent() {
+ const lateMounted = document.createElement("div");
+ lateMounted.innerHTML = "late mounted cancel_trade details
";
+ root.appendChild(lateMounted);
+ return lateMounted;
+}
+
+// MutationObserver delivers records in a microtask
+function observerDelivery() {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+afterEach(() => {
+ disposeSession?.();
+ root.remove();
+});
+
+describe("startSearchHighlightSession", () => {
+ it("highlights snippets present at session start", () => {
+ startSession(["cancel_trade"]);
+
+ expect(root.querySelectorAll("mark").length).toBe(1);
+ });
+
+ it("highlights content mounted after session start", async () => {
+ startSession(["cancel_trade"]);
+ const lateMounted = mountLateContent();
+
+ await observerDelivery();
+
+ expect(lateMounted.querySelectorAll("mark").length).toBe(1);
+ // marks added by the session itself must not re-trigger another marking pass
+ expect(root.querySelectorAll("mark").length).toBe(2);
+ });
+
+ it("stops highlighting late mounted content after dispose", async () => {
+ startSession(["cancel_trade"]);
+ disposeSession!();
+
+ const lateMounted = mountLateContent();
+ await observerDelivery();
+
+ expect(lateMounted.querySelector("mark")).toBeNull();
+ });
+
+ it("removeSearchHighlight removes session marks", () => {
+ startSession(["cancel_trade"]);
+ removeSearchHighlight(root);
+
+ expect(root.querySelector("mark")).toBeNull();
+ });
+});
+
+// highlights landing inside hidden="until-found" content (e.g. collapsed read more) trigger
+// the same beforematch event browser find-in-page uses, owning components reveal through one path
+describe("hidden content reveal", () => {
+ function trackReveal(container: Element) {
+ const revealed = { count: 0 };
+ container.addEventListener("beforematch", () => revealed.count++);
+ return revealed;
+ }
+
+ function createRoot(html: string) {
+ root = document.createElement("div");
+ root.innerHTML = html;
+ document.body.appendChild(root);
+ }
+
+ it("fires beforematch on a hidden container that received a highlight", () => {
+ createRoot('use cancel_trade to abort
');
+ const revealed = trackReveal(root.querySelector("[hidden]")!);
+
+ disposeSession = startSearchHighlightSession(root, ["cancel_trade"]);
+
+ expect(revealed.count).toBe(1);
+ });
+
+ it("does not fire beforematch when hidden content has no highlight", () => {
+ createRoot('');
+ const revealed = trackReveal(root.querySelector("[hidden]")!);
+
+ disposeSession = startSearchHighlightSession(root, ["cancel_trade"]);
+
+ expect(revealed.count).toBe(0);
+ });
+
+ it("fires beforematch on every hidden ancestor of a highlight, like nested until-found regions", () => {
+ createRoot('');
+ const outerRevealed = trackReveal(root.querySelector(".outer")!);
+ const innerRevealed = trackReveal(root.querySelector(".outer [hidden]")!);
+
+ disposeSession = startSearchHighlightSession(root, ["cancel_trade"]);
+
+ expect(innerRevealed.count).toBe(1);
+ // outer count includes the inner event only if it bubbled, beforematch does not bubble
+ expect(outerRevealed.count).toBe(1);
+ });
+
+ it("fires beforematch for hidden content mounted after session start", async () => {
+ createRoot("visible cancel_trade
");
+ disposeSession = startSearchHighlightSession(root, ["cancel_trade"]);
+
+ const lateMounted = document.createElement("div");
+ lateMounted.innerHTML = '';
+ const revealed = trackReveal(lateMounted.querySelector("[hidden]")!);
+ root.appendChild(lateMounted);
+
+ await observerDelivery();
+
+ expect(revealed.count).toBe(1);
+ });
+});
+
+// underscore is part of indexed symbols (see flexSearch.ts encoder), so the highlighter
+// treats it as a literal character of the snippet, not as ignorable punctuation
+describe("underscore as part of searched symbols", () => {
+ it("highlights underscore identifier surrounded by text and punctuation", () => {
+ startSession(["my_func"], "call my_func() now
");
+
+ expect(markedTexts()).toEqual(["my_func"]);
+ });
+
+ it("highlights prefix snippet occurrences including the underscore", () => {
+ // accuracy "partially" matches the snippet anywhere, so a forward tokenization prefix
+ // like bu_id also highlights the start of a longer identifier
+ startSession(["bu_id"], "use bu_id here and bu_id_extra there
");
+
+ expect(markedTexts()).toEqual(["bu_id", "bu_id"]);
+ });
+
+ it("does not match text with underscore when snippet has none", () => {
+ // canceltrade and cancel_trade are different symbols now that underscore is not ignorable
+ startSession(["canceltrade"], "use cancel_trade to abort
");
+
+ expect(root.querySelector("mark")).toBeNull();
+ });
+});
diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts
index 7b56b86fc..bd285fdb2 100644
--- a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts
+++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts
@@ -18,7 +18,52 @@
// @ts-ignore
import Mark from "mark.js/dist/mark.js";
-export function highlightSearchResultAndMaybeScroll(root: HTMLElement, snippets: string[], scroll: boolean) {
+const observeConfig = { childList: true, subtree: true };
+
+/**
+ * highlights search snippets inside root and keeps highlighting content that mounts later,
+ * e.g. a read more block revealed while a search result is displayed.
+ * content components stay unaware of the highlight mechanics this way.
+ *
+ * returned dispose stops watching for late mounted content,
+ * highlights are removed separately with removeSearchHighlight
+ */
+export function startSearchHighlightSession(root: HTMLElement, snippets: string[]): () => void {
+ markSnippets(root, snippets);
+
+ const observer = new MutationObserver((mutations) => {
+ const lateMounted: HTMLElement[] = [];
+ for (const mutation of mutations) {
+ for (const node of Array.from(mutation.addedNodes)) {
+ if (node instanceof HTMLElement && node.tagName !== "MARK") {
+ lateMounted.push(node);
+ }
+ }
+ }
+
+ if (lateMounted.length === 0) {
+ return;
+ }
+
+ // pause observing while marking so mark.js own dom changes don't re-trigger this callback
+ observer.disconnect();
+ markSnippets(lateMounted, snippets);
+ observer.observe(root, observeConfig);
+ });
+
+ observer.observe(root, observeConfig);
+
+ return () => observer.disconnect();
+}
+
+export function removeSearchHighlight(root: HTMLElement) {
+ const mark = new Mark(root);
+ mark.unmark({});
+}
+
+// mark.js accepts a single element or an array of elements as context,
+// late mounted nodes from one react commit are marked in a single pass
+function markSnippets(root: HTMLElement | HTMLElement[], snippets: string[]) {
const mark = new Mark(root);
mark.unmark({
done: () => {
@@ -28,20 +73,28 @@ export function highlightSearchResultAndMaybeScroll(root: HTMLElement, snippets:
caseSensitive: false,
ignoreJoiners: false,
diacritics: false,
- ignorePunctuation: ["(", ")", ";", "[", "]", "-", "_", ".", ",", '"', "'", "~"],
+ // underscore is deliberately not here: it is part of indexed symbols like cancel_trade
+ ignorePunctuation: ["(", ")", ";", "[", "]", "-", ".", ",", '"', "'", "~"],
accuracy: "partially",
- done: () => {
- const marked = root.querySelector("mark");
- if (marked && scroll) {
- marked.scrollIntoView();
- }
- },
+ done: () => revealHiddenMatches(root),
});
},
});
}
-export function removeSearchHighlight(root: HTMLElement) {
- const mark = new Mark(root);
- mark.unmark({});
+// highlights can land inside content collapsed with hidden="until-found", e.g. a read more block
+// on a page a search result points to. fire the same beforematch event the browser fires for
+// find-in-page matches, so owning components reveal through their one existing reveal path
+function revealHiddenMatches(root: HTMLElement | HTMLElement[]) {
+ const hiddenContainers = new Set();
+ for (const element of Array.isArray(root) ? root : [root]) {
+ for (const mark of Array.from(element.querySelectorAll("mark[data-markjs]"))) {
+ // reveal every hidden ancestor, matching browser behavior for nested until-found regions
+ for (let hidden = mark.closest("[hidden]"); hidden; hidden = hidden.parentElement?.closest("[hidden]") ?? null) {
+ hiddenContainers.add(hidden);
+ }
+ }
+ }
+
+ hiddenContainers.forEach((container) => container.dispatchEvent(new Event("beforematch")));
}
diff --git a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts
new file mode 100644
index 000000000..f755ef544
--- /dev/null
+++ b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2026 znai maintainers
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect } from "vitest";
+import { contentMatchesSearchSnippets } from "./searchSnippetsContentMatch";
+import { DocElementContent } from "../default-elements/DocElement";
+
+const content: DocElementContent = [
+ {
+ type: "Paragraph",
+ content: [{ type: "SimpleText", text: "use cancel_trade to abort" } as any],
+ },
+ { type: "Snippet", snippet: "def my_func():\n pass" } as any,
+];
+
+describe("contentMatchesSearchSnippets", () => {
+ it("matches term inside nested text", () => {
+ expect(contentMatchesSearchSnippets(content, ["cancel_trade"])).toBe(true);
+ expect(contentMatchesSearchSnippets(content, ["my_func"])).toBe(true);
+ expect(contentMatchesSearchSnippets(content, ["deploy", "abort"])).toBe(true);
+ });
+
+ it("ignores letters case", () => {
+ expect(contentMatchesSearchSnippets(content, ["CANCEL_TRADE"])).toBe(true);
+ });
+
+ it("no match when terms are not part of content", () => {
+ expect(contentMatchesSearchSnippets(content, ["deploy"])).toBe(false);
+ });
+
+ it("does not match against doc element type names", () => {
+ expect(contentMatchesSearchSnippets(content, ["Paragraph"])).toBe(false);
+ });
+
+ it("no match when terms list is empty or content is missing", () => {
+ expect(contentMatchesSearchSnippets(content, [])).toBe(false);
+ expect(contentMatchesSearchSnippets(content, undefined)).toBe(false);
+ expect(contentMatchesSearchSnippets(undefined, ["abort"])).toBe(false);
+ });
+
+ it("treats terms as plain text and not as regexp", () => {
+ expect(contentMatchesSearchSnippets(content, ["my_func():"])).toBe(true);
+ expect(contentMatchesSearchSnippets(content, ["c.n..l"])).toBe(false);
+ });
+});
diff --git a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts
new file mode 100644
index 000000000..129835f2b
--- /dev/null
+++ b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 znai maintainers
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { DocElementContent } from "../default-elements/DocElement";
+import { someTextValue } from "../default-elements/contentTreeWalker";
+
+/**
+ * checks if doc elements content contains any of the matched search terms.
+ * lets elements with hidden content (e.g. read more) decide whether to auto reveal it
+ * during search instead of rendering and highlighting every hidden block.
+ */
+export function contentMatchesSearchSnippets(
+ content: DocElementContent | undefined,
+ searchSnippets: string[] | undefined
+) {
+ if (!content || !searchSnippets || searchSnippets.length === 0) {
+ return false;
+ }
+
+ // single case-insensitive alternation checks every snippet in one scan per string,
+ // instead of lowering and rescanning every string in the content tree per snippet
+ const anySnippetRegex = new RegExp(searchSnippets.map(escapeRegexChars).join("|"), "i");
+ return someTextValue(content, (text) => anySnippetRegex.test(text));
+}
+
+function escapeRegexChars(text: string) {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
diff --git a/znai-reactjs/src/doc-elements/tabs/tabsUtils.js b/znai-reactjs/src/doc-elements/tabs/tabsUtils.js
index 21e68d816..fd6f868d3 100644
--- a/znai-reactjs/src/doc-elements/tabs/tabsUtils.js
+++ b/znai-reactjs/src/doc-elements/tabs/tabsUtils.js
@@ -14,26 +14,20 @@
* limitations under the License.
*/
+import { walkContentNodes } from '../default-elements/contentTreeWalker'
+
export function contentTabNames(content) {
const result = []
- collectTabNamesRecursively(result, content)
-
- return result
-}
-
-function collectTabNamesRecursively(result, content) {
- if (!content || content.length === 0) {
- return
- }
-
- content.forEach(e => {
+ walkContentNodes(content, e => {
if (e.type === 'Tabs') {
addMissingTabNames(result, e)
- } else {
- collectTabNamesRecursively(result, e.content)
+ // nested per tab elements live under tabsContent and are handled above, not walked into
+ return "skip-children"
}
})
+
+ return result
}
function addMissingTabNames(result, tabsDocEl) {
diff --git a/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts b/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts
index c8c9ee08f..d6b777f14 100644
--- a/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts
+++ b/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts
@@ -28,13 +28,20 @@ Hook below attempts to encapsulate this logic.
export function useHighlightOfHiddenElement(
containerRef: MutableRefObject,
hiddenContainerRef: MutableRefObject,
- contentVisibilityTrigger: any
+ contentVisibilityTrigger: any,
+ // during search, content reveal is driven by matched search terms instead,
+ // skip user driven highlights re-apply to avoid page wide highlight passes on every search interaction
+ disabled?: boolean
) {
const restoreFirstHighlightElementFunRef = useRef<(() => void) | null>(null);
const [hasHiddenHighlightedElement, setHasHiddenHighlightedElement] = useState(false);
const onlyOnce = useRef(false);
useEffect(() => {
+ if (disabled) {
+ return;
+ }
+
const listener = {
onUserDrivenTextHighlight: (firstHighlightElement: HTMLElement, hideBubble: () => void) => {
if (
@@ -57,10 +64,14 @@ export function useHighlightOfHiddenElement(
return () => {
removeHighlightedTextListener(listener);
};
- }, []);
+ }, [disabled]);
// this may not work with Tabs
useEffect(() => {
+ if (disabled) {
+ return;
+ }
+
if (restoreFirstHighlightElementFunRef.current && onlyOnce.current) {
restoreFirstHighlightElementFunRef.current();
restoreFirstHighlightElementFunRef.current = null;
@@ -68,7 +79,7 @@ export function useHighlightOfHiddenElement(
// TODO only reapply for the specific highlight(s) that are affected maybe somehow(?)
// or make sure that the scroll to the selected question is triggered everytime someone toggles read more
reapplyTextHighlights();
- }, [contentVisibilityTrigger]);
+ }, [contentVisibilityTrigger, disabled]);
return hasHiddenHighlightedElement;
}