diff --git a/znai-docs/znai/llm.txt b/znai-docs/znai/llm.txt index 9071bff5e..4848613a0 100644 --- a/znai-docs/znai/llm.txt +++ b/znai-docs/znai/llm.txt @@ -146,8 +146,8 @@ answer-link: znai-from-export/introduction/getting-started#command-line ## CLI download Download and unzip -[znai](https://repo.maven.apache.org/maven2/org/testingisdocumenting/znai/znai-dist/1.90.1/znai-dist-1.90.1-znai.zip). -Add it to your `PATH`. +[znai](https://repo.maven.apache.org/maven2/org/testingisdocumenting/znai/znai-dist/1.91/znai-dist-1.91-znai.zip). Add +it to your `PATH`. ## Brew @@ -162,7 +162,7 @@ answer-link: znai-from-export/introduction/getting-started#maven-plugin org.testingisdocumenting.znai znai-maven-plugin - 1.90.1 + 1.91 ``` @@ -4399,6 +4399,66 @@ answer-link: znai-from-export/visuals/attention-signs#attention-block-types attention- ``` +# Visuals :: Attention Signs :: Custom Attention Block +answer-link: znai-from-export/visuals/attention-signs#custom-attention-block + +Use `attention-custom` when the built-in types are not enough. The free form parameter defines the type. It is used as a +CSS class name, exactly like `note`, `warning`, and the other built-in types. + +```markdown +```attention-custom my-type +hello world +``` +``` + + +`attention-custom` only provides the markup placeholders. Each guide is responsible for implementing the CSS for its own +types. + +Use in combination with `style.css`. Scope rules under `.theme-znai-dark` to define dark mode colors. + +```css +.znai-attention-block.my-type { + border-left: 3px solid #6f42c1; + background: #f3effb; +} + +.znai-attention-block.my-type .znai-attention-block-icon { + color: #6f42c1; +} + +.theme-znai-dark .znai-attention-block.my-type { + border-left-color: #b794f6; + background: rgba(159, 122, 234, 0.12); +} + +.theme-znai-dark .znai-attention-block.my-type .znai-attention-block-icon { + color: #b794f6; +} +``` + +# Visuals :: Attention Signs :: Custom Icon +answer-link: znai-from-export/visuals/attention-signs#custom-icon + +Unlike the built-in types, a custom type has no icon by default. Use the `icon` parameter to display one. + +To pick an icons to use go to [Feather icons](https://feathericons.com/). + +```markdown +```attention-custom my-type {icon: "zap"} +hello world +``` +``` + + +Combine `icon` with the optional `label` + +```markdown +```attention-custom my-type {icon: "zap", label: "Consider"} +hello world +``` +``` + # Visuals :: Images :: Standard Markdown answer-link: znai-from-export/visuals/images#standard-markdown @@ -4744,7 +4804,7 @@ scenario("capture screenshot") { "type" : "badge", "text" : "2", "x" : 367, - "y" : 486, + "y" : 285, "align" : "Center" } ], "pixelRatio" : 2 @@ -5114,6 +5174,15 @@ In presentation mode, rendered expressions will automatically scale to make use Note: Rendering is done by using [Mermaid](https://mermaid-js.github.io/mermaid/#/) library. +# Visuals :: Mermaid Diagrams :: Large Diagrams +answer-link: znai-from-export/visuals/mermaid-diagrams#large-diagrams + +A diagram that is too large to fit the page width is shrunk to fit and can become hard to read. Click the diagram to +open it in a full screen overlay where you can: + +- scroll/`wheel` to zoom towards the cursor +- drag to pan + # Visuals :: Mermaid Diagrams :: External File answer-link: znai-from-export/visuals/mermaid-diagrams#external-file @@ -8950,7 +9019,7 @@ scenario("capture screenshot") { "type" : "badge", "text" : "2", "x" : 367, - "y" : 486, + "y" : 285, "align" : "Center" } ], "pixelRatio" : 2 @@ -9239,8 +9308,8 @@ answer-link: znai-from-export/plugins/javascript-plugin#function-signature Your function receives three arguments: * `node` — the parent `div` to append content to. -* `args` — the parameters passed from markdown. Framework-level keys (`title`, `wide`, `className`, `anchorId`) are -handled by znai and not forwarded. +* `args` — the parameters passed from markdown. Framework-level keys (`title`, `wide`, `className`, `anchorId`, `height`) +are handled by znai and not forwarded. * `themeObservable` — live access to the current znai theme. ```javascript @@ -9305,6 +9374,39 @@ Pass `wide: true` to span the full page width, matching wide images and iframes. } ``` +# Plugins :: Javascript Plugin :: Height +answer-link: znai-from-export/plugins/javascript-plugin#height + +By default the block grows to fit whatever the function renders into it. Pass `height` to pin the block to a fixed size +— content past it scrolls inside the viewport znai gives the function. + +`height` accepts either a number (treated as pixels) or any CSS length string like `"320px"` or `"30vh"`. + +The `activityFeed` function below appends one row per event. Without `height`, all twelve rows render and the block +grows to fit them: + +```markdown +:include-javascript-function: activityFeed { + title: "deploys", + events: [ + {time: "09:14", action: "build started", detail: "commit a31f9b on main"}, + {time: "09:17", action: "tests passed", detail: "248 / 248 green"}, + ... + ] +} +``` + + +Add `height` and the same twelve events scroll inside a fixed-size box instead: + +```markdown +:include-javascript-function: activityFeed { + title: "deploys", + height: 220, + events: [/* same twelve events */] +} +``` + # Plugins :: Javascript Plugin :: Styling With A Class Name answer-link: znai-from-export/plugins/javascript-plugin#styling-with-a-class-name @@ -9469,6 +9571,52 @@ The function below powers the examples on this page. It reads the initial theme })(); ``` +```js +/* + * sample plugin showing a list of rows that grows to fit its content. + * + * the parent node sized by znai (`height` arg) is what constrains us — when + * unset, the feed renders all rows tall enough to show them all; when set, + * the same content scrolls inside the fixed viewport znai gave us. + */ +(function () { + function createElement(tagName, className, textContent) { + var el = document.createElement(tagName); + el.className = className; + if (textContent !== undefined) { + el.textContent = textContent; + } + return el; + } + + function buildRow(event) { + var row = createElement("div", "activity-feed-row"); + row.appendChild(createElement("span", "activity-feed-time", event.time || "")); + row.appendChild(createElement("span", "activity-feed-action", event.action || "")); + row.appendChild(createElement("span", "activity-feed-detail", event.detail || "")); + return row; + } + + window.activityFeed = function (node, args, themeObservable) { + var events = Array.isArray(args.events) ? args.events : []; + + var feed = createElement("div", "activity-feed"); + events.forEach(function (event) { + feed.appendChild(buildRow(event)); + }); + + node.appendChild(feed); + + function applyTheme(themeName) { + feed.classList.toggle("activity-feed-dark", themeName === "dark"); + } + + applyTheme(themeObservable.current); + themeObservable.subscribe(applyTheme); + }; +})(); +``` + # Plugins :: User Defined Plugins :: Overview answer-link: znai-from-export/plugins/user-defined-plugins#overview @@ -9941,6 +10089,11 @@ export PATH=$(pwd)/dist:$PATH znai --version ``` +# Release Notes :: 2026 :: 1.91 +answer-link: znai-from-export/release-notes/2026#191 + + + # Release Notes :: 2026 :: 1.90.1 answer-link: znai-from-export/release-notes/2026#1901 diff --git a/znai-docs/znai/release-notes/1.92/add-2026-08-23-search-and-read-more.md b/znai-docs/znai/release-notes/1.92/add-2026-08-23-search-and-read-more.md new file mode 100644 index 000000000..f5f0befea --- /dev/null +++ b/znai-docs/znai/release-notes/1.92/add-2026-08-23-search-and-read-more.md @@ -0,0 +1,3 @@ +* Add: Search on the page with [Read More](visuals/read-more) blocks auto opens the blocks +* Add: Optimize guide level search rendering and debounce +* Add: Guide level search keeps `_` symbol as it is often part of the search term diff --git a/znai-docs/znai/release-notes/2026.md b/znai-docs/znai/release-notes/2026.md index 351aaaa0c..524f4030d 100644 --- a/znai-docs/znai/release-notes/2026.md +++ b/znai-docs/znai/release-notes/2026.md @@ -1,3 +1,7 @@ +# 1.92 + +:include-markdowns: 1.92 + # 1.91 :include-markdowns: 1.91 diff --git a/znai-reactjs/src/doc-elements/bullets/bulletUtils.js b/znai-reactjs/src/doc-elements/bullets/bulletUtils.js index 5229dcc1d..374f362a3 100644 --- a/znai-reactjs/src/doc-elements/bullets/bulletUtils.js +++ b/znai-reactjs/src/doc-elements/bullets/bulletUtils.js @@ -15,6 +15,8 @@ * limitations under the License. */ +import { walkContentNodes } from '../default-elements/contentTreeWalker' + export function startsWithIcon(content) { return content && content.length && content[0].type === 'Paragraph' && @@ -64,29 +66,17 @@ export function extractTextLinesEmphasisOrFull(content) { function extractText(listItem, emphasisedOnly) { const result = [] - collectTextRecursively(result, listItem.content, emphasisedOnly, false) - - return capitalizeFirstLetter(result.join(" ")) -} - -function collectTextRecursively(result, content, emphasisedOnly, withinEmphasis) { - if (! content) { - return - } + walkContentNodes(listItem.content, (item, ancestors) => { + if (item.type !== "SimpleText") { + return + } - content.forEach(item => { - if (item.type === "SimpleText") { - if (emphasisedOnly && withinEmphasis) { - result.push(item.text) - } else if (! emphasisedOnly) { - result.push(item.text) - } - } else { - collectTextRecursively(result, item.content, emphasisedOnly, withinEmphasis || isEmphasis(item)) + if (! emphasisedOnly || ancestors.some(isEmphasis)) { + result.push(item.text) } }) - return result + return capitalizeFirstLetter(result.join(" ")) } function isEmphasis(docElement) { diff --git a/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx b/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx index 14c127a17..6fae03d06 100644 --- a/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx +++ b/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx @@ -21,7 +21,9 @@ export type ElementsLibraryMap = { [key: string]: any }; export type DocElementContent = DocElementPayload[]; interface CommonProps { - isPartOfSearch?: boolean; // when element is rendered in search preview or after section is selected as search result + // matched search terms, present only when element is rendered in search preview or after section + // is selected as search result; lets elements with hidden content decide what to reveal during search + searchSnippets?: string[]; noGap?: boolean; content?: DocElementContent; next?: DocElementPayload; @@ -41,7 +43,7 @@ export interface WithElementsLibrary { /** * uses a given set of components to render DocElements like links, paragraphs, code blocks, etc */ -export function DocElement({ content, elementsLibrary, isPartOfSearch }: DocElementProps) { +export function DocElement({ content, elementsLibrary, searchSnippets }: DocElementProps) { if (!content) { return null; } @@ -52,7 +54,7 @@ export function DocElement({ content, elementsLibrary, isPartOfSearch }: DocElem while (contentProvider.peekCurrent()) { const found = findRenderComponent(elementsLibrary, contentProvider); const ElementToUse = found.component; - const propsToUse = { isPartOfSearch, ...found.propsToUse }; + const propsToUse = { searchSnippets, ...found.propsToUse }; if (!ElementToUse) { console.warn("can't find component to display: " + JSON.stringify(contentProvider.peekCurrent())); diff --git a/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.test.ts b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.test.ts new file mode 100644 index 000000000..25bfc35b8 --- /dev/null +++ b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { someTextValue, walkContentNodes } from "./contentTreeWalker"; +import { DocElementContent } from "./DocElement"; + +const nestedContent: DocElementContent = [ + { + type: "Section", + content: [ + { + type: "Paragraph", + content: [{ type: "SimpleText", text: "hello" } as any], + }, + { type: "Snippet" }, + ], + }, + { type: "Meta" }, +]; + +describe("walkContentNodes", () => { + it("visits nodes depth-first passing ancestors chain", () => { + const visited: string[] = []; + const stopped = walkContentNodes(nestedContent, (node, ancestors) => { + visited.push([...ancestors, node].map((n) => n.type).join(">")); + }); + + expect(stopped).toBe(false); + expect(visited).toEqual([ + "Section", + "Section>Paragraph", + "Section>Paragraph>SimpleText", + "Section>Snippet", + "Meta", + ]); + }); + + it("does not descend into children when visitor returns skip-children", () => { + const visited: string[] = []; + walkContentNodes(nestedContent, (node) => { + visited.push(node.type); + if (node.type === "Paragraph") { + return "skip-children"; + } + }); + + expect(visited).toEqual(["Section", "Paragraph", "Snippet", "Meta"]); + }); + + it("ends the walk early when visitor returns stop", () => { + const visited: string[] = []; + const stopped = walkContentNodes(nestedContent, (node) => { + visited.push(node.type); + if (node.type === "Paragraph") { + return "stop"; + } + }); + + expect(stopped).toBe(true); + expect(visited).toEqual(["Section", "Paragraph"]); + }); + + it("handles missing content", () => { + const stopped = walkContentNodes(undefined, () => "stop"); + expect(stopped).toBe(false); + }); +}); + +describe("someTextValue", () => { + const value = { + type: "Paragraph", + content: [{ type: "SimpleText", text: "hello world" }], + meta: { title: "nested title" }, + }; + + it("matches strings in nested arrays and objects under any prop", () => { + expect(someTextValue(value, (text) => text === "hello world")).toBe(true); + expect(someTextValue(value, (text) => text === "nested title")).toBe(true); + expect(someTextValue(value, (text) => text === "missing")).toBe(false); + }); + + it("skips doc element type names", () => { + expect(someTextValue(value, (text) => text === "Paragraph")).toBe(false); + expect(someTextValue(value, (text) => text === "SimpleText")).toBe(false); + }); + + it("stops scanning after the first match", () => { + const scanned: string[] = []; + someTextValue([{ text: "one" }, { text: "two" }], (text) => { + scanned.push(text); + return text === "one"; + }); + + expect(scanned).toEqual(["one"]); + }); +}); diff --git a/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.ts b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.ts new file mode 100644 index 000000000..a41cb89bf --- /dev/null +++ b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.ts @@ -0,0 +1,83 @@ +/* + * 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, DocElementPayload } from "./DocElement"; + +export type ContentWalkControl = "continue" | "skip-children" | "stop"; + +export type ContentNodeVisitor = (node: DocElementPayload, ancestors: DocElementPayload[]) => ContentWalkControl | void; + +/** + * depth-first walk over doc element nodes, recursing through nested content arrays. + * visitor receives each node plus its ancestors chain (walk root first) and can return + * "skip-children" to avoid descending into a node or "stop" to end the whole walk early. + * returns true when the walk was stopped early by the visitor. + */ +export function walkContentNodes(content: DocElementContent | undefined, visit: ContentNodeVisitor): boolean { + if (!content) { + return false; + } + + // single ancestors array is mutated in place to avoid allocations per node, + // visitors must copy it if they need to keep it after returning + return walk(content, []); + + function walk(nodes: DocElementContent, ancestors: DocElementPayload[]): boolean { + for (const node of nodes) { + const control = visit(node, ancestors); + if (control === "stop") { + return true; + } + + if (control !== "skip-children" && Array.isArray(node.content)) { + ancestors.push(node); + const stopped = walk(node.content, ancestors); + ancestors.pop(); + + if (stopped) { + return true; + } + } + } + + return false; + } +} + +/** + * early-exit scan over every string in the tree: strings inside arrays and nested objects under any prop, + * not just content. returns true as soon as the predicate matches a string value. + */ +export function someTextValue(value: unknown, predicate: (text: string) => boolean): boolean { + if (typeof value === "string") { + return predicate(value); + } + + if (Array.isArray(value)) { + return value.some((entry) => someTextValue(entry, predicate)); + } + + if (typeof value === "object" && value !== null) { + for (const key in value) { + // type holds doc element names like Snippet and is not a visible text + if (key !== "type" && someTextValue((value as Record)[key], predicate)) { + return true; + } + } + } + + return false; +} diff --git a/znai-reactjs/src/doc-elements/hidden-content/hiddenContent.css b/znai-reactjs/src/doc-elements/hidden-content/hiddenContent.css new file mode 100644 index 000000000..87b88da70 --- /dev/null +++ b/znai-reactjs/src/doc-elements/hidden-content/hiddenContent.css @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/* normalize.css resets [hidden] to display none, which would also apply to hidden="until-found". + a display none subtree is invisible to find-in-page, so the reset silently defeats the reveal: + browsers hide until-found content via content-visibility and need the element renderable. + revert restores the browser default. browsers without until-found support never get this + attribute value, useHiddenUntilFound falls back to plain hidden there */ +[hidden="until-found"] { + display: revert; +} diff --git a/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts new file mode 100644 index 000000000..3acf8ff4d --- /dev/null +++ b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts @@ -0,0 +1,69 @@ +/* + * 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 { RefObject, useEffect, useLayoutEffect, useRef } from "react"; + +import "./hiddenContent.css"; + +/** + * hides collapsible content with hidden="until-found" so browser find-in-page can still match it, + * onReveal is invoked on a beforematch event so the owner can sync its expanded state. + * both the browser (find-in-page match) and the search highlighter (snippet marked inside + * hidden content) deliver reveals through that one event. + * + * the attribute is set manually because react normalizes hidden to a boolean and drops the value. + * browsers without until-found support treat it as plain hidden which matches display none behavior + */ +export function useHiddenUntilFound( + hiddenContainerRef: RefObject, + hidden: boolean, + onReveal: () => void +) { + useLayoutEffect(() => { + const hiddenContainer = hiddenContainerRef.current; + if (!hiddenContainer) { + return; + } + + if (hidden) { + hiddenContainer.setAttribute("hidden", supportsHiddenUntilFound() ? "until-found" : ""); + } else { + hiddenContainer.removeAttribute("hidden"); + } + }, [hidden]); + + // latest callback is kept in a ref so callers can pass inline arrows + // without re-subscribing the listener on every render + const onRevealRef = useRef(onReveal); + onRevealRef.current = onReveal; + + // browser fires beforematch right before revealing hidden content matched by find-in-page, + // the search highlighter dispatches the same event for highlights inside hidden content + useEffect(() => { + const hiddenContainer = hiddenContainerRef.current; + if (!hiddenContainer) { + return; + } + + const listener = () => onRevealRef.current(); + hiddenContainer.addEventListener("beforematch", listener); + return () => hiddenContainer.removeEventListener("beforematch", listener); + }, []); +} + +function supportsHiddenUntilFound() { + return "onbeforematch" in document.body; +} diff --git a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx index b37052182..d0f4dc440 100644 --- a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx +++ b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx @@ -20,7 +20,7 @@ import { afterTitleId } from "../../../layout/classNamesAndIds"; import { DocElementProps } from "../../default-elements/DocElement"; import { SearchResultId } from "../../search/SearchResultId"; import { TocItem } from "../../../structure/TocItem"; -import { highlightSearchResultAndMaybeScroll, removeSearchHighlight } from "../../search/searchResultHighlighter"; +import { removeSearchHighlight, startSearchHighlightSession } from "../../search/searchResultHighlighter"; interface Props extends DocElementProps { tocItem: TocItem; @@ -41,7 +41,7 @@ export function DefaultPageContent(props: Props) { useEffect(() => { if (searchSnippetsToHighlight && isSearchResultOnThisPage && contentRootDom) { - highlightSearchResultAndMaybeScroll(contentRootDom, searchSnippetsToHighlight, false); + return startSearchHighlightSession(contentRootDom, searchSnippetsToHighlight); } }, [searchSnippetsToHighlight]); @@ -63,14 +63,14 @@ export function DefaultPageContent(props: Props) { const renderedSections = content!.map((section) => { // @ts-ignore const id = section.id; - const isPartOfSearch = isSearchResultOnThisPage && id === searchResultId.pageSectionId; + const isSelectedAsSearchResult = isSearchResultOnThisPage && id === searchResultId.pageSectionId; return ( ); }); diff --git a/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts b/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts index e2e09f268..50f72e05f 100644 --- a/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts +++ b/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts @@ -15,38 +15,24 @@ */ import { DocElementContent, DocElementPayload } from "../../default-elements/DocElement"; +import { walkContentNodes } from "../../default-elements/contentTreeWalker"; const TAB_CONTENT_TYPE = "TabContent"; -interface SectionPayload extends DocElementPayload { - id: string; - title: string; -} - /** * extracts unique tab IDs from all TabContent elements in page content, preserving order. * searches recursively through nested content (e.g. TabContent inside AttentionBlock) */ export function extractTabIds(pageContent: DocElementContent | undefined): string[] { - if (!pageContent) { - return []; - } - const allTabIds: string[] = []; - collect(pageContent); + walkContentNodes(pageContent, (el) => { + const tabId = (el as { tabId?: string }).tabId; + if (el.type === TAB_CONTENT_TYPE && tabId) { + allTabIds.push(tabId); + } + }); return [...new Set(allTabIds)]; - - function collect(content: DocElementContent): void { - for (const el of content) { - if (el.type === TAB_CONTENT_TYPE && el.tabId) { - allTabIds.push(el.tabId); - } - if (Array.isArray(el.content)) { - collect(el.content); - } - } - } } /** @@ -58,10 +44,7 @@ export function extractTabIds(pageContent: DocElementContent | undefined): strin * * returns sections with their content filtered */ -export function buildContentForTab( - pageContent: DocElementContent, - selectedTabId: string -): DocElementContent { +export function buildContentForTab(pageContent: DocElementContent, selectedTabId: string): DocElementContent { return pageContent.map((section) => { if (!section.content) { return section; diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.css b/znai-reactjs/src/doc-elements/read-more/ReadMore.css index ad82af422..104677bbf 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.css +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.css @@ -26,6 +26,12 @@ padding-top: 8px; } +/* collapsed content uses hidden="until-found" which hides via content-visibility and keeps the element own box, + remove the padding so the collapsed block takes no space */ +.znai-read-more-content[hidden] { + padding: 0; +} + .znai-read-more-title-block { display: flex; align-items: center; diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx new file mode 100644 index 000000000..0391aee67 --- /dev/null +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { render, fireEvent } from "@testing-library/react"; +import React from "react"; + +import { ReadMore } from "./ReadMore"; +import { DocElement, DocElementContent } from "../default-elements/DocElement"; + +// jsdom implements onbeforematch on a prototype, remove it there to mimic browsers without support +function withoutBeforematchSupport(test: () => void) { + let owner: any = document.body; + while (owner && !Object.getOwnPropertyDescriptor(owner, "onbeforematch")) { + owner = Object.getPrototypeOf(owner); + } + const descriptor = owner && Object.getOwnPropertyDescriptor(owner, "onbeforematch"); + if (owner) { + delete owner.onbeforematch; + } + + try { + test(); + } finally { + if (owner && descriptor) { + Object.defineProperty(owner, "onbeforematch", descriptor); + } + } +} + +const elementsLibrary: any = { + DocElement, + TestText: ({ text }: { text: string }) =>
{text}
, +}; + +const content: DocElementContent = [{ type: "TestText", text: "hidden cancel_trade details" } as any]; + +function renderReadMore(extraProps: object = {}) { + return render( + + ); +} + +describe("ReadMore", () => { + it("keeps collapsed content mounted on a regular page for the highlight engine", () => { + const { container } = renderReadMore(); + + expect(container.querySelector(".znai-read-more")).toHaveClass("collapsed"); + expect(container.querySelector(".test-text")).not.toBeNull(); + }); + + it("hides collapsed content with until-found so browser find-in-page can match it", () => { + const { container } = renderReadMore(); + + expect(container.querySelector(".znai-read-more-content")).toHaveAttribute("hidden", "until-found"); + }); + + it("falls back to plain hidden when the browser has no beforematch support", () => { + // hiddenContent.css reverts the normalize display none reset for until-found, + // so non supporting browsers must not receive that attribute value or content would show + withoutBeforematchSupport(() => { + const { container } = renderReadMore(); + + expect(container.querySelector(".znai-read-more-content")).toHaveAttribute("hidden", ""); + }); + }); + + it("expands when browser find-in-page reveals hidden content", () => { + const { container } = renderReadMore(); + + fireEvent(container.querySelector(".znai-read-more-content")!, new Event("beforematch")); + + expect(container.querySelector(".znai-read-more")).toHaveClass("expanded"); + expect(container.querySelector(".znai-read-more-content")).not.toHaveAttribute("hidden"); + }); + + it("reveals content during search when it contains matched terms", () => { + const { container } = renderReadMore({ searchSnippets: ["cancel_trade"] }); + + expect(container.querySelector(".znai-read-more")).toHaveClass("expanded"); + expect(container.querySelector(".test-text")).not.toBeNull(); + }); + + it("keeps content mounted when snippets arrive after mount, highlighter reveals it via beforematch", () => { + const { container, rerender } = renderReadMore(); + + rerender( + + ); + + // still mounted and collapsed, waiting for the highlight session to dispatch beforematch + expect(container.querySelector(".znai-read-more")).toHaveClass("collapsed"); + expect(container.querySelector(".test-text")).not.toBeNull(); + + fireEvent(container.querySelector(".znai-read-more-content")!, new Event("beforematch")); + + expect(container.querySelector(".znai-read-more")).toHaveClass("expanded"); + }); + + it("does not mount content during search when no terms match", () => { + const { container } = renderReadMore({ searchSnippets: ["deploy"] }); + + expect(container.querySelector(".znai-read-more")).toHaveClass("collapsed"); + expect(container.querySelector(".test-text")).toBeNull(); + }); + + it("mounts content on manual reveal during search", () => { + const { container } = renderReadMore({ searchSnippets: ["deploy"] }); + + expect(container.querySelector(".test-text")).toBeNull(); + + fireEvent.click(container.querySelector(".znai-read-more-title-block")!); + + expect(container.querySelector(".test-text")).not.toBeNull(); + }); +}); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx index 2b91a01c7..466a09aa3 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx @@ -20,17 +20,39 @@ import { DocElementProps } from "../default-elements/DocElement"; import { Icon } from "../icons/Icon"; import { useHighlightOfHiddenElement } from "../text-selection/componentsHighlightUtils"; +import { useHiddenUntilFound } from "../hidden-content/hiddenContentUtils"; +import { contentMatchesSearchSnippets } from "../search/searchSnippetsContentMatch"; import "./ReadMore.css"; interface Props extends DocElementProps { title: string; } -export function ReadMore({ title, content, isPartOfSearch, elementsLibrary }: Props) { - const [expanded, setExpanded] = useState(() => isPartOfSearch); +export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Props) { + const isPartOfSearch = searchSnippets !== undefined; + + // during search auto reveal only when content has matched search terms, + // pages with dozens of read more blocks are too expensive to render and highlight fully expanded + const [expanded, setExpanded] = useState(() => contentMatchesSearchSnippets(content, searchSnippets)); + const containerRef = useRef(null); const hiddenContainerRef = useRef(null); - const hasHiddenHighlightedElement = useHighlightOfHiddenElement(containerRef, hiddenContainerRef, expanded); + const hasHiddenHighlightedElement = useHighlightOfHiddenElement( + containerRef, + hiddenContainerRef, + expanded, + isPartOfSearch + ); + + useHiddenUntilFound(hiddenContainerRef, !expanded, () => setExpanded(true)); + + // captured at mount: a block that first rendered outside of search keeps content mounted + // when snippets arrive later, unmounting would leave nothing to highlight and reveal + const [wasMountedOutsideSearch] = useState(!isPartOfSearch); + + // during search preview, collapsed content is not mounted to avoid rendering and highlighting hidden blocks, + // on regular pages hidden content stays mounted so the highlight engine and find-in-page can reach it + const renderContent = expanded || !isPartOfSearch || wasMountedOutsideSearch; const expandedClassName = expanded ? "expanded" : "collapsed"; const topClassName = "znai-read-more content-block " + expandedClassName; @@ -44,12 +66,13 @@ export function ReadMore({ title, content, isPartOfSearch, elementsLibrary }: Pr {title} ); - const style = expanded ? { display: "block" } : { display: "none" }; return (
{summary} -
- +
+ {renderContent && ( + + )}
); diff --git a/znai-reactjs/src/doc-elements/search/QueryResult.ts b/znai-reactjs/src/doc-elements/search/QueryResult.ts index 7d312b943..db850863d 100644 --- a/znai-reactjs/src/doc-elements/search/QueryResult.ts +++ b/znai-reactjs/src/doc-elements/search/QueryResult.ts @@ -15,22 +15,32 @@ * limitations under the License. */ -import { SearchResult } from "./flexSearch"; +import { deriveTermsToHighlight } from "./flexSearch"; export default class QueryResult { - private readonly queryResultsById: Record; - constructor(queryResults: SearchResult[]) { - this.queryResultsById = queryResults.reduce((acc, searchResult) => { - acc[searchResult.id] = searchResult; - return acc; - }, {} as Record); + private readonly ids: string[]; + private readonly encodedQueryTerms: string[]; + private readonly textToHighlightById: (id: string) => string; + // terms are derived lazily per id, only the displayed result pays for it + private readonly termsToHighlightById: Record = {}; + + constructor(ids: string[], encodedQueryTerms: string[], textToHighlightById: (id: string) => string) { + this.ids = ids; + this.encodedQueryTerms = encodedQueryTerms; + this.textToHighlightById = textToHighlightById; } getIds() { - return Object.keys(this.queryResultsById); + return this.ids; } getSnippetsToHighlight(id: string) { - return this.queryResultsById[id].termsToHighlight; + let terms = this.termsToHighlightById[id]; + if (!terms) { + terms = deriveTermsToHighlight(this.encodedQueryTerms, this.textToHighlightById(id)); + this.termsToHighlightById[id] = terms; + } + + return terms; } } diff --git a/znai-reactjs/src/doc-elements/search/Search.js b/znai-reactjs/src/doc-elements/search/Search.js index 2142f79b3..6111eba6f 100644 --- a/znai-reactjs/src/doc-elements/search/Search.js +++ b/znai-reactjs/src/doc-elements/search/Search.js @@ -16,13 +16,15 @@ */ import QueryResult from "./QueryResult"; -import { searchWithHighlight, truncateQueryByMinLength } from "./flexSearch.js"; +import { encodeSearchQuery, searchIds, truncateQueryByMinLength } from "./flexSearch.js"; class Search { constructor(allPages) { this.allPages = allPages; this.searchIdx = window.znaiSearchIdx; this.searchDataById = mapById(window.znaiSearchData); + // built lazily on first preview lookup, the constructor runs on doc load even if search is never used + this.sectionByIndexId = null; } static convertIndexIdToSectionCoords(indexId) { @@ -31,14 +33,25 @@ class Search { } search(term) { - const matches = searchWithHighlight(this.searchIdx, truncateQueryByMinLength(term, 3)); - return new QueryResult(matches); + const query = truncateQueryByMinLength(term, 3); + const ids = searchIds(this.searchIdx, query); + return new QueryResult(ids, encodeSearchQuery(query), (id) => this._textToHighlightById(id)); } findSearchEntryById(id) { return this.searchDataById[id]; } + // same text pieces the index docs are built from, see populateLocalSearchIndexWithData + _textToHighlightById(id) { + const searchEntry = this.searchDataById[id]; + if (!searchEntry) { + return ""; + } + + return [searchEntry.pageTitle, searchEntry.pageSection, searchEntry.textStandard, searchEntry.textHigh].join(" "); + } + previewDetails(id, queryResult) { const section = this._findSectionById(id); const snippets = queryResult.getSnippetsToHighlight(id); @@ -47,31 +60,53 @@ class Search { } _findSectionById(indexId) { + if (this.sectionByIndexId === null) { + this.sectionByIndexId = buildSectionByIndexId(this.allPages); + } + const sectionCoords = Search.convertIndexIdToSectionCoords(indexId); + const key = sectionLookupKey(sectionCoords.dirName, sectionCoords.fileName, sectionCoords.pageSectionId || ""); - const matching = []; + const section = this.sectionByIndexId.get(key); + if (section === undefined) { + console.error("expected section associated with", indexId); + } - this.allPages.pages.forEach((p) => { - const tocItem = p.tocItem; + return section; + } +} - const sections = p.content.filter((de) => { - return ( - tocItem.dirName === sectionCoords.dirName && - tocItem.fileName === sectionCoords.fileName && - de.type === "Section" && - (!sectionCoords.pageSectionId || de.id === sectionCoords.pageSectionId) - ); - }); +function sectionLookupKey(dirName, fileName, pageSectionId) { + return dirName + "@@" + fileName + "@@" + pageSectionId; +} - sections.forEach((s) => matching.push(s)); +// index sections both by their id and by page alone (empty section id means first section of the page) +function buildSectionByIndexId(allPages) { + const result = new Map(); + + allPages.pages.forEach((p) => { + const tocItem = p.tocItem; + + p.content.forEach((de) => { + if (de.type !== "Section") { + return; + } + + const pageKey = sectionLookupKey(tocItem.dirName, tocItem.fileName, ""); + if (!result.has(pageKey)) { + result.set(pageKey, de); + } + + if (de.id) { + const idKey = sectionLookupKey(tocItem.dirName, tocItem.fileName, de.id); + if (!result.has(idKey)) { + result.set(idKey, de); + } + } }); + }); - if (!matching) { - console.error("expected section associated with", indexId); - } - - return matching[0]; - } + return result; } function mapById(searchData) { diff --git a/znai-reactjs/src/doc-elements/search/Search.test.ts b/znai-reactjs/src/doc-elements/search/Search.test.ts new file mode 100644 index 000000000..18d766afc --- /dev/null +++ b/znai-reactjs/src/doc-elements/search/Search.test.ts @@ -0,0 +1,89 @@ +/* + * 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, beforeEach, describe, expect, it, vi } from "vitest"; +import Search from "./Search"; + +const sectionOne = { type: "Section", id: "section-one", title: "Section One" }; +const sectionTwo = { type: "Section", id: "section-two", title: "Section Two" }; +const otherPageSection = { type: "Section", id: "other-section", title: "Other Section" }; + +const allPages = { + pages: [ + { + tocItem: { dirName: "chapter", fileName: "page" }, + content: [{ type: "Paragraph" }, sectionOne, sectionTwo], + }, + { + tocItem: { dirName: "chapter", fileName: "other-page" }, + content: [otherPageSection], + }, + ], +}; + +function createSearch() { + // search constructor reads pre-built index and data from window globals + (window as any).znaiSearchIdx = {}; + (window as any).znaiSearchData = []; + + return new Search(allPages); +} + +describe("Search", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("finds section by full id", () => { + const search = createSearch(); + const queryResult = { getSnippetsToHighlight: () => ["snippet"] }; + + const previewDetails = search.previewDetails("chapter@@page@@section-two", queryResult); + expect(previewDetails.section).toBe(sectionTwo); + expect(previewDetails.snippets).toEqual(["snippet"]); + }); + + it("finds first section of the page when page section id is empty", () => { + const search = createSearch(); + + expect(search._findSectionById("chapter@@page@@")).toBe(sectionOne); + expect(search._findSectionById("chapter@@other-page@@")).toBe(otherPageSection); + }); + + it("returns undefined and logs error for unknown id", () => { + const search = createSearch(); + + expect(search._findSectionById("chapter@@page@@no-such-section")).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith("expected section associated with", "chapter@@page@@no-such-section"); + }); + + it("builds section lookup lazily once and reuses it on repeated calls", () => { + const search = createSearch(); + expect(search.sectionByIndexId).toBeNull(); + + search._findSectionById("chapter@@page@@section-one"); + const lookup = search.sectionByIndexId; + expect(lookup).not.toBeNull(); + + search._findSectionById("chapter@@page@@section-two"); + search._findSectionById("chapter@@other-page@@"); + expect(search.sectionByIndexId).toBe(lookup); + }); +}); diff --git a/znai-reactjs/src/doc-elements/search/SearchBox.jsx b/znai-reactjs/src/doc-elements/search/SearchBox.jsx index cc234363a..1c5aec138 100644 --- a/znai-reactjs/src/doc-elements/search/SearchBox.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchBox.jsx @@ -1,4 +1,5 @@ /* + * Copyright 2026 znai maintainers * Copyright 2019 TWO SIGMA OPEN SOURCE, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,43 +15,51 @@ * limitations under the License. */ -import React, {Component} from 'react' +import React, { Component } from "react"; + +const searchDebounceMs = 150; class SearchBox extends Component { - constructor(props) { - super(props) - this.state = {value: ""} - } + constructor(props) { + super(props); + this.state = { value: "" }; + } - render() { - return ( -
- this.dom = dom} - placeholder="Type terms to search..." - onKeyDown={this.onKeyDown} - value={this.state.value} - onChange={this.onInputChange}/> -
- ) - } + render() { + return ( +
+ (this.dom = dom)} + placeholder="Type terms to search..." + onKeyDown={this.onKeyDown} + value={this.state.value} + onChange={this.onInputChange} + /> +
+ ); + } - componentDidMount() { - this.dom.focus(); - } + componentDidMount() { + this.dom.focus(); + } - // TODO debounce? - onInputChange = (e) => { - const value = e.target.value - this.props.onChange(value) - this.setState({value}) - } + componentWillUnmount() { + clearTimeout(this.debounceTimer); + } + + onInputChange = (e) => { + const value = e.target.value; + this.setState({ value }); + + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => this.props.onChange(value), searchDebounceMs); + }; - onKeyDown = (e) => { - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - e.preventDefault() - } + onKeyDown = (e) => { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); } + }; } -export default SearchBox +export default SearchBox; diff --git a/znai-reactjs/src/doc-elements/search/SearchPopup.jsx b/znai-reactjs/src/doc-elements/search/SearchPopup.jsx index 98895319f..c19a3b49a 100644 --- a/znai-reactjs/src/doc-elements/search/SearchPopup.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchPopup.jsx @@ -81,6 +81,9 @@ class SearchPopup extends Component { const { elementsLibrary } = this.props; const { search } = this.state; + // key by content identity so the preview remounts and re-highlights only when the result or matched terms change + const previewKey = ids[selectedIdx] + "#" + previewDetails.snippets.join(" "); + return (
@@ -93,7 +96,7 @@ class SearchPopup extends Component { />
- +
); diff --git a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx index 8ae921705..e10e6de0d 100644 --- a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx @@ -16,31 +16,30 @@ */ import React, { Component } from "react"; -import { highlightSearchResultAndMaybeScroll } from "./searchResultHighlighter.ts"; +import { startSearchHighlightSession } from "./searchResultHighlighter.ts"; class SearchPreview extends Component { componentDidMount() { - this.highlight(); + this.disposeHighlightSession = startSearchHighlightSession(this.dom, this.props.snippets); } - componentDidUpdate(_prevProp, _prevState) { - this.highlight(); + componentWillUnmount() { + this.disposeHighlightSession(); + } + + shouldComponentUpdate() { + // SearchPopup keys the preview by result id and matched terms, any content change remounts it + return false; } render() { - const { section, elementsLibrary } = this.props; - const key = section.id + "#" + section.title; + const { section, snippets, elementsLibrary } = this.props; return ( -
(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(''); + 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; }