diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 9dc7b88fd..4b7069044 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -173,3 +173,40 @@ test("⌘R replaces every match in the file", async ({ workbench: page }) => { await page.keyboard.press("Meta+z"); await expect(editor.locator(".cm-line", { hasText: "OrderDto" })).toHaveCount(2); }); + +test("Markdown preview highlights ⌘F matches and selected word occurrences", async ({ workbench: page }) => { + const editor = await openFromTree(page, "README.md"); + const content = editor.locator(".cm-content"); + await content.click(); + await page.keyboard.press("Meta+a"); + await page.keyboard.insertText("# Order Service\n\nManage **Order** items and `Order` status.\n"); + + const preview = page.getByRole("document", { name: "Markdown preview" }); + await expect(preview).toContainText("Manage Order items"); + + // Selecting a word in the preview auto-highlights its other occurrences in the preview and the split editor. + await preview.locator("strong").dblclick(); + + await expect + .poll(() => + page.evaluate(() => { + const set = CSS.highlights.get("markdown-selection-match"); + return set ? [...set].map((r) => (r as Range).toString()) : []; + }), + ) + .toEqual(["Order", "Order"]); + await expect(editor.locator(".cm-selectionMatch")).toHaveCount(3); + + // Pressing ⌘F seeds from the preview selection and highlights current + other matches in the preview. + await page.keyboard.press("Meta+f"); + const find = page.getByRole("textbox", { name: "Find" }); + await expect(find).toHaveValue("Order"); + await expect + .poll(() => + page.evaluate(() => ({ + current: [...(CSS.highlights.get("markdown-find-current") ?? [])].map((r) => (r as Range).toString()), + other: [...(CSS.highlights.get("markdown-find") ?? [])].map((r) => (r as Range).toString()), + })), + ) + .toEqual({ current: ["Order"], other: ["Order", "Order"] }); +}); diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index 5e2673179..d05940bff 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -259,6 +259,12 @@ export class EditorSlot { focus() { this.view?.focus(); + if (this.view && !this.view.hasFocus && this.view.dom.offsetParent === null) { + this.view.dom + .closest("[data-pane-id]") + ?.querySelector('[role="document"]') + ?.focus({ preventScroll: true }); + } } // --- transactions --------------------------------------------------------- diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index e7139b430..47c2ee038 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -184,6 +184,18 @@ export function openFindBarResults(arg?: unknown) { /** Longest selection that becomes the query. */ const MAX_QUERY = 10_000; +/** Selected text in the pane outside `.cm-editor` and the find bar (e.g. Markdown preview). */ +function paneDomSelection(view: EditorView | null): string { + if (!view || typeof window === "undefined") return ""; + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || !sel.rangeCount) return ""; + const pane = view.dom.closest("[data-pane-id]"); + const node = sel.getRangeAt(0).commonAncestorContainer; + const el = node instanceof Element ? node : node.parentElement; + if (!pane || !el || !pane.contains(el) || el.closest(".cm-editor, [role='search']")) return ""; + return sel.toString(); +} + /** * Opens (or refocuses) the bar, as in IDEA: the * selection becomes the query — multi-line too — and the search covers the @@ -210,6 +222,18 @@ export function seedFind(target: FindTarget, replace: boolean) { else useFindSessions.setState({ [target.id]: { ...useFindOptions.getState(), ...patch(useFindOptions.getState()) } }); }; + const domText = paneDomSelection(view); + if (view && domText) { + view.dispatch({ effects: setFindScope.of(null) }); + if (domText.length < MAX_QUERY) { + set((o) => ({ search: o.regexp ? domText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : domText })); + setQuery(view, optionsOf(target.id)); + findNext(view); + } else { + set(() => ({})); + } + return; + } if (view && sel && !sel.empty) { const text = view.state.sliceDoc(sel.from, sel.to); const multiline = text.includes("\n"); @@ -301,7 +325,18 @@ export function FindBar({ paneId }: { paneId: string }) { onReplaceShown={(replace) => setReplaceShown(tabId, replace)} onClose={() => { useFindBars.setState({ [tabId]: undefined }); - target.view()?.focus(); + const view = target.view(); + if (view && view.dom.offsetParent !== null) { + view.focus(); + } else { + if (view && !view.state.selection.main.empty) { + view.dispatch({ selection: { anchor: view.state.selection.main.head } }); + } + view?.dom + .closest("[data-pane-id]") + ?.querySelector('[role="document"]') + ?.focus({ preventScroll: true }); + } }} /> ); diff --git a/src/features/viewers/markdown/Markdown.module.css b/src/features/viewers/markdown/Markdown.module.css index de3a9294f..cc6753259 100644 --- a/src/features/viewers/markdown/Markdown.module.css +++ b/src/features/viewers/markdown/Markdown.module.css @@ -177,3 +177,15 @@ text-align: left; white-space: pre-wrap; } + +::highlight(markdown-selection-match) { + background-color: var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent)); +} +::highlight(markdown-find) { + background-color: var(--bg-search-match, color-mix(in srgb, var(--warning) 30%, transparent)); + color: var(--fg-search-match, inherit); +} +::highlight(markdown-find-current) { + background-color: var(--bg-search-match-current, color-mix(in srgb, var(--warning) 48%, transparent)); + color: var(--fg-search-match, inherit); +} diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index 1983c96a1..46f602b6e 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -19,12 +19,26 @@ import { } from "react"; import clsx from "clsx"; import { call, commands, isTauri } from "@/platform/ipc"; +import { useEditorPrefs } from "@/platform/editorSettings"; import { useMarkdownPreviewFontSize } from "@/platform/languageSettings"; import { maybeEditorService } from "@/workbench/contributions"; import { setShownLine } from "@/editor/api"; import { highlightInto } from "@/editor/lsp/markdown"; import { FloatingToolbar, selectableText } from "@/ui"; -import { publishPreviewTop, useLiveMarkdown } from "./live"; +import { + FIND_CURRENT_HIGHLIGHT, + FIND_HIGHLIGHT, + SELECTION_MATCH_HIGHLIGHT, + clearPreviewHighlights, + findPreviewMatches, + invalidatePreviewIndex, + openDetailsContaining, + previewSelectionOf, + revealPreviewRange, + selectionMatchesInNode, + setPreviewHighlight, +} from "./highlight"; +import { publishPreviewSelection, publishPreviewTop, useLiveMarkdown } from "./live"; import { subscribeTheme, themeKey } from "@/platform/themeWatch"; import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn } from "./mermaid"; import { modeOf, useMarkdownModes } from "./modes"; @@ -100,6 +114,9 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { const scrollSeq = useLiveMarkdown((s) => s[path]?.scrollSeq ?? 0); const caretLine = useLiveMarkdown((s) => s[path]?.caretLine ?? 0); const caretSeq = useLiveMarkdown((s) => s[path]?.caretSeq ?? 0); + const find = useLiveMarkdown((s) => s[path]?.find ?? null); + const editorSelection = useLiveMarkdown((s) => s[path]?.editorSelection ?? ""); + const highlightSelection = useEditorPrefs().codeEditing.highlightSelection; const deferred = useDeferredValue(text ?? ""); const html = useMemo(() => renderMarkdown(deferred), [deferred]); const ref = useRef(null); @@ -107,6 +124,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { const diagrams = useRef([]); const [diagramsDrawn, setDiagramsDrawn] = useState(0); const seenCaret = useRef(caretSeq); + const seenFindMatch = useRef(null); /** scrollTop set by navigation, whose scroll event isn't the user's. */ const scrolledTo = useRef(null); const scrollFrame = useRef(0); @@ -121,6 +139,10 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { }); }, [html]); + useLayoutEffect(() => { + if (ref.current) invalidatePreviewIndex(ref.current); + }, [html]); + // Mermaid diagrams: before paint, so cached ones don't flash their source. useLayoutEffect(() => { const root = ref.current; @@ -200,10 +222,101 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { if (!root || caretSeq === seenCaret.current) return; seenCaret.current = caretSeq; if (sync) return; + if (find && find.currentMatchIndex >= 0) { + const { current } = findPreviewMatches(root, find); + if (current) { + openDetailsContaining(current, root, toggled.current); + if (revealPreviewRange(current, root)) { + scrolledTo.current = root.scrollTop; + setShownLine(path, caretLine); + return; + } + } + } root.scrollTop = previewOffsetForLine(blocksOf(root), caretLine, root.scrollHeight); scrolledTo.current = root.scrollTop; setShownLine(path, caretLine); - }, [caretSeq, caretLine, sync, path]); + }, [caretSeq, caretLine, sync, path, find]); + + // Search (⌘F) and selection occurrence highlights in the preview. + useLayoutEffect(() => { + const root = ref.current; + if (!root) return; + const applyFind = () => { + if (!find) { + seenFindMatch.current = null; + setPreviewHighlight(FIND_HIGHLIGHT, root, []); + setPreviewHighlight(FIND_CURRENT_HIGHLIGHT, root, []); + return; + } + const { ranges, current } = findPreviewMatches(root, find); + if (current) { + openDetailsContaining(current, root, toggled.current); + if (seenFindMatch.current !== find.currentMatchIndex) { + seenFindMatch.current = find.currentMatchIndex; + if (revealPreviewRange(current, root) && !sync) { + scrolledTo.current = root.scrollTop; + } + } + } else { + seenFindMatch.current = find.currentMatchIndex; + } + setPreviewHighlight(FIND_HIGHLIGHT, root, current ? ranges.filter((r) => r !== current) : ranges); + setPreviewHighlight(FIND_CURRENT_HIGHLIGHT, root, current ? [current] : []); + }; + const applySelection = () => { + if (!highlightSelection) { + publishPreviewSelection(path, ""); + setPreviewHighlight(SELECTION_MATCH_HIGHLIGHT, root, []); + return; + } + const domSel = previewSelectionOf(root); + if (domSel) { + publishPreviewSelection(path, domSel.text); + setPreviewHighlight(SELECTION_MATCH_HIGHLIGHT, root, selectionMatchesInNode(root, domSel.text, domSel.range)); + return; + } + publishPreviewSelection(path, ""); + if (editorSelection) { + setPreviewHighlight(SELECTION_MATCH_HIGHLIGHT, root, selectionMatchesInNode(root, editorSelection, null)); + return; + } + setPreviewHighlight(SELECTION_MATCH_HIGHLIGHT, root, []); + }; + const applyAll = () => { + applySelection(); + applyFind(); + }; + applyAll(); + + let timer: ReturnType | undefined; + const observer = new MutationObserver(() => { + invalidatePreviewIndex(root); + clearTimeout(timer); + timer = setTimeout(applyAll, 120); + }); + observer.observe(root, { subtree: true, childList: true, characterData: true }); + document.addEventListener("selectionchange", applySelection); + root.addEventListener("toggle", applyAll, true); + return () => { + if (observer.takeRecords().length) invalidatePreviewIndex(root); + observer.disconnect(); + clearTimeout(timer); + document.removeEventListener("selectionchange", applySelection); + root.removeEventListener("toggle", applyAll, true); + }; + }, [html, path, sync, find, editorSelection, highlightSelection]); + + useEffect(() => { + const root = ref.current; + return () => { + if (root) { + clearPreviewHighlights(root); + invalidatePreviewIndex(root); + } + publishPreviewSelection(path, ""); + }; + }, [path]); // Preview only: the line on screen when shown, then as the user scrolls; // cleared when the editor is back or the preview goes away. diff --git a/src/features/viewers/markdown/highlight.test.tsx b/src/features/viewers/markdown/highlight.test.tsx new file mode 100644 index 000000000..e71abc881 --- /dev/null +++ b/src/features/viewers/markdown/highlight.test.tsx @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { setSearchQuery } from "@codemirror/search"; +import { _addEditor, _removeEditor, editorContributions, type EditorDocContext } from "@/editor/api"; +import { findExtension, findNext, findPrevious, makeQuery, setFindOpen, type FindOptions } from "@/editor/core/cm/find"; +import { fontZoom } from "@/editor/core/cm/prefs"; +import { seedFind, useFindOptions, useFindSessions, type FindTarget } from "@/editor/core/view/FindBar"; +import { FIND_CURRENT_HIGHLIGHT, FIND_HIGHLIGHT, SELECTION_MATCH_HIGHLIGHT } from "./highlight"; +import { registerMarkdownLiveSource, useLiveMarkdown } from "./live"; +import { MarkdownEditor } from "./MarkdownEditor"; +import { useMarkdownModes } from "./modes"; + +const path = "/p/README.md"; +const ctx: EditorDocContext = { doc: 1, path, language: "markdown", uiMode: null, readOnly: false }; + +function highlightTexts(name: string): string[] { + const registry = (globalThis as unknown as { CSS: { highlights: Map> } }).CSS.highlights; + const set = registry.get(name); + return set ? [...set].map((r) => r.toString()) : []; +} + +const defaultFindOptions: FindOptions = { + search: "", + caseSensitive: false, + wholeWord: false, + regexp: false, + replace: "", +}; + +describe("Markdown preview search & selection highlights", () => { + const disposers: Array<() => void> = []; + let view: EditorView; + + const openEditor = (doc: string) => { + const live = editorContributions().find((c) => c.id === "viewers.markdownLive")!; + view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [fontZoom, findExtension(), live.extensions(ctx)], + }), + }); + const handle = { view, ctx }; + _addEditor(handle); + disposers.push(() => _removeEditor(handle)); + return view; + }; + + beforeEach(() => { + disposers.push(registerMarkdownLiveSource()); + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [path]: "preview" } }))); + }); + + afterEach(() => { + cleanup(); + view?.destroy(); + for (const dispose of disposers.splice(0).reverse()) dispose(); + useLiveMarkdown.setState({}, true); + useFindOptions.setState({ + search: "", + caseSensitive: false, + wholeWord: false, + regexp: false, + replace: "", + context: "any", + preserveCase: false, + }); + useFindSessions.setState({}, true); + window.getSelection()?.removeAllRanges(); + }); + + it("highlights all Command+F matches and tracks the current match across steps", () => { + openEditor("# Hello world\n\nSay **hello** and `HELLO` again.\n"); + render( + +
+ , + ); + + act(() => { + view.dispatch({ + effects: [setFindOpen.of(true), setSearchQuery.of(makeQuery({ ...defaultFindOptions, search: "hello" }))], + }); + findNext(view); + }); + + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual(["Hello"]); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual(["hello", "HELLO"]); + + act(() => { + findNext(view); + }); + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual(["hello"]); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual(["Hello", "HELLO"]); + + act(() => { + findPrevious(view); + }); + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual(["Hello"]); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual(["hello", "HELLO"]); + + act(() => { + view.dispatch({ effects: setFindOpen.of(false) }); + }); + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual([]); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual([]); + }); + + it("honours Match Case, Words and Regex options and spans inline markup", () => { + openEditor("cat scatter **cat**s `cat`12\n"); + render( + +
+ , + ); + + // Whole word: matches only the standalone "cat" + act(() => { + view.dispatch({ + effects: [ + setFindOpen.of(true), + setSearchQuery.of(makeQuery({ ...defaultFindOptions, search: "cat", wholeWord: true })), + ], + }); + findNext(view); + }); + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual(["cat"]); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual([]); + + // Spanning inline markup (`**cat**s`) in the rendered preview + act(() => { + view.dispatch({ + effects: [setSearchQuery.of(makeQuery({ ...defaultFindOptions, search: "cats" }))], + }); + }); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual(["cats"]); + + // Case-sensitive + regex (`cat\d+` does not match because `cat` and `12` are separated by backticks in source, + // while `cat` with caseSensitive matches all 4 lowercase occurrences) + act(() => { + view.dispatch({ + effects: [ + setSearchQuery.of( + makeQuery({ ...defaultFindOptions, search: "s[a-z]+r", regexp: true, caseSensitive: true }), + ), + ], + }); + findNext(view); + }); + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual(["scatter"]); + }); + + it("opens a collapsed
when stepping to a match inside it", () => { + openEditor("Outside token\n\n
\nMore\n\nHidden token\n\n
\n"); + render( + +
+ , + ); + const preview = screen.getByRole("document", { name: "Markdown preview" }); + const details = preview.querySelector("details")!; + expect(details.open).toBe(false); + + // First match is outside; the hidden match inside
is not painted while closed. + act(() => { + view.dispatch({ + effects: [setFindOpen.of(true), setSearchQuery.of(makeQuery({ ...defaultFindOptions, search: "token" }))], + }); + findNext(view); + }); + expect(details.open).toBe(false); + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual(["token"]); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual([]); + + // Stepping to the second match opens
and highlights it as current. + act(() => { + findNext(view); + }); + expect(details.open).toBe(true); + expect(highlightTexts(FIND_CURRENT_HIGHLIGHT)).toEqual(["token"]); + expect(highlightTexts(FIND_HIGHLIGHT)).toEqual(["token"]); + }); + + it("auto-highlights all other occurrences when a word is selected in the preview", async () => { + openEditor("alpha beta alpha gamma alpha\n"); + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [path]: "split" } }))); + render( + +
+ , + ); + const preview = screen.getByRole("document", { name: "Markdown preview" }); + const p = preview.querySelector("p")!; + const textNode = p.firstChild as Text; + + // A retained editor selection must not suppress a new preview selection. + act(() => view.dispatch({ selection: EditorSelection.single(6, 10) })); + + // Select the first "alpha" (0..5) in the preview + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 5); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + act(() => { + document.dispatchEvent(new Event("selectionchange")); + }); + + // The 2 other occurrences in the preview are highlighted, and the split editor highlights all 3. + expect(highlightTexts(SELECTION_MATCH_HIGHLIGHT)).toEqual(["alpha", "alpha"]); + await waitFor(() => { + expect(view.contentDOM.querySelectorAll(".cm-selectionMatch")).toHaveLength(3); + }); + + // Collapsing both selections clears both highlights. + act(() => view.dispatch({ selection: EditorSelection.cursor(0) })); + sel.removeAllRanges(); + act(() => { + document.dispatchEvent(new Event("selectionchange")); + }); + expect(highlightTexts(SELECTION_MATCH_HIGHLIGHT)).toEqual([]); + await waitFor(() => { + expect(view.contentDOM.querySelectorAll(".cm-selectionMatch")).toHaveLength(0); + }); + }); + + it("auto-highlights occurrences in the preview when a word is selected in the editor", () => { + openEditor("beta alpha beta\n"); + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [path]: "split" } }))); + render( + +
+ , + ); + + act(() => { + view.dispatch({ selection: EditorSelection.single(0, 4) }); + }); + expect(highlightTexts(SELECTION_MATCH_HIGHLIGHT)).toEqual(["beta", "beta"]); + + act(() => { + view.dispatch({ selection: EditorSelection.cursor(0) }); + }); + expect(highlightTexts(SELECTION_MATCH_HIGHLIGHT)).toEqual([]); + }); + + it("refreshes cached ranges after asynchronous preview text changes", async () => { + openEditor("token\n"); + render( + +
+ , + ); + act(() => { + view.dispatch({ + effects: [setFindOpen.of(true), setSearchQuery.of(makeQuery({ ...defaultFindOptions, search: "token" }))], + }); + findNext(view); + }); + const paragraph = screen.getByRole("document", { name: "Markdown preview" }).querySelector("p")!; + paragraph.innerHTML = "new token"; + await waitFor(() => { + const registry = (globalThis as unknown as { CSS: { highlights: Map> } }).CSS.highlights; + const current = [...registry.get(FIND_CURRENT_HIGHLIGHT)!][0]!; + expect(current.startContainer.parentElement?.tagName).toBe("STRONG"); + expect(current.toString()).toBe("token"); + }); + }); + + it("seeds Command+F from a word selected in the Markdown preview", () => { + openEditor("first second third\n"); + const pane = document.createElement("section"); + pane.setAttribute("data-pane-id", "pane-1"); + document.body.appendChild(pane); + disposers.push(() => pane.remove()); + + render( + +
void (el && view && el.appendChild(view.dom))} /> + , + { container: pane }, + ); + + const preview = screen.getByRole("document", { name: "Markdown preview" }); + const textNode = preview.querySelector("p")!.firstChild as Text; + const range = document.createRange(); + range.setStart(textNode, 6); + range.setEnd(textNode, 12); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + + const target: FindTarget = { + id: "t", + view: () => view, + watch: () => () => {}, + isOpen: () => true, + }; + act(() => { + seedFind(target, false); + }); + + expect(useFindSessions.getState().t?.search).toBe("second"); + expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe("second"); + }); +}); diff --git a/src/features/viewers/markdown/highlight.ts b/src/features/viewers/markdown/highlight.ts new file mode 100644 index 000000000..3575408d2 --- /dev/null +++ b/src/features/viewers/markdown/highlight.ts @@ -0,0 +1,392 @@ +/** + * Search and selection-occurrence highlights in the Markdown preview. + * + * Uses the CSS Custom Highlight API (`::highlight(markdown-find)`, + * `::highlight(markdown-find-current)`, `::highlight(markdown-selection-match)`) + * so DOM nodes (syntax-highlighted code blocks, Mermaid diagrams, links, and + * active user selections) are never mutated or split. + */ +import { MAX_COUNT } from "@/editor/core/cm/find"; +import type { LiveMarkdownFind } from "./live"; + +export const SELECTION_MATCH_HIGHLIGHT = "markdown-selection-match"; +export const FIND_HIGHLIGHT = "markdown-find"; +export const FIND_CURRENT_HIGHLIGHT = "markdown-find-current"; + +const MAX_SELECTION_MATCHES = 500; +const MIN_SELECTION_LENGTH = 2; +const MAX_SELECTION_LENGTH = 200; + +const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g; +const WORD_CHAR = /[\p{L}\p{N}_$]/u; + +const SKIP_TAGS = new Set(["STYLE", "SCRIPT", "NOSCRIPT"]); +const BLOCK_TAGS = new Set([ + "ADDRESS", + "ARTICLE", + "ASIDE", + "BLOCKQUOTE", + "BR", + "DD", + "DETAILS", + "DIV", + "DL", + "DT", + "FIELDSET", + "FIGCAPTION", + "FIGURE", + "FOOTER", + "FORM", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEADER", + "HR", + "LI", + "MAIN", + "NAV", + "OL", + "P", + "PRE", + "SECTION", + "SUMMARY", + "TABLE", + "TBODY", + "TD", + "TFOOT", + "TH", + "THEAD", + "TR", + "UL", +]); + +interface TextPiece { + node: Text; + start: number; + end: number; +} + +interface TextRun { + text: string; + pieces: TextPiece[]; +} + +/** Collects contiguous inline text runs under `root`, separated at block boundaries. */ +function collectRuns(root: Node): TextRun[] { + const runs: TextRun[] = []; + let pieces: TextPiece[] = []; + let text = ""; + const flush = () => { + if (text) runs.push({ text, pieces }); + pieces = []; + text = ""; + }; + const walk = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + const value = node.nodeValue ?? ""; + if (!value) return; + const start = text.length; + text += value; + pieces.push({ node: node as Text, start, end: text.length }); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const el = node as Element; + const tag = el.tagName.toUpperCase(); + if (SKIP_TAGS.has(tag)) return; + if (tag === "PRE" && el.parentElement?.getAttribute("data-mermaid-state") === "ok") return; + const isBlock = BLOCK_TAGS.has(tag); + if (isBlock) flush(); + for (let child = el.firstChild; child; child = child.nextSibling) walk(child); + if (isBlock) flush(); + }; + walk(root); + flush(); + return runs; +} + +function rangeInRun(run: TextRun, from: number, to: number): Range | null { + let startPiece: TextPiece | undefined; + let endPiece: TextPiece | undefined; + for (const p of run.pieces) { + if (!startPiece && from < p.end) startPiece = p; + if (to <= p.end) { + endPiece = p; + break; + } + } + if (!startPiece || !endPiece) return null; + const range = document.createRange(); + range.setStart(startPiece.node, from - startPiece.start); + range.setEnd(endPiece.node, to - endPiece.start); + return range; +} + +function isWholeWordMatch(text: string, start: number, end: number): boolean { + const before = [...text.slice(Math.max(0, start - 2), start)].at(-1) ?? ""; + const after = String.fromCodePoint(text.codePointAt(end) ?? 0); + const first = String.fromCodePoint(text.codePointAt(start) ?? 0); + const last = [...text.slice(Math.max(start, end - 2), end)].at(-1) ?? ""; + const leftWord = WORD_CHAR.test(first) && WORD_CHAR.test(before); + const rightWord = WORD_CHAR.test(last) && WORD_CHAR.test(after); + return !leftWord && !rightWord; +} + +export interface PreviewMatchOptions { + search: string; + caseSensitive: boolean; + wholeWord: boolean; + regexp: boolean; +} + +/** DOM ranges matching `options` under `root`, in document order. */ +export function findMatchesInNode(root: Node, options: PreviewMatchOptions, limit = MAX_COUNT): Range[] { + return matchesInRuns(collectRuns(root), options, limit); +} + +function matchesInRuns(runs: readonly TextRun[], options: PreviewMatchOptions, limit: number): Range[] { + if (!options.search || limit <= 0) return []; + const source = options.regexp ? options.search : options.search.replace(REGEX_SPECIALS, "\\$&"); + let re: RegExp; + try { + re = new RegExp(source, options.caseSensitive ? "gu" : "giu"); + } catch { + return []; + } + const out: Range[] = []; + for (const run of runs) { + re.lastIndex = 0; + let found: RegExpExecArray | null; + while ((found = re.exec(run.text))) { + const start = found.index; + const end = start + found[0].length; + if (end === start) { + re.lastIndex = start + ((run.text.codePointAt(start) ?? 0) > 0xffff ? 2 : 1); + continue; + } + if (options.wholeWord && !isWholeWordMatch(run.text, start, end)) continue; + const range = rangeInRun(run, start, end); + if (range) out.push(range); + if (out.length >= limit) return out; + } + } + return out; +} + +interface PreviewBlock { + startLine: number; + runs: TextRun[]; +} + +interface PreviewIndex { + blocks: PreviewBlock[]; + runs: TextRun[]; + query?: string; + matches?: Range[][]; +} + +const previewIndexes = new WeakMap(); + +export function invalidatePreviewIndex(root: HTMLElement) { + previewIndexes.delete(root); +} + +function previewIndex(root: HTMLElement): PreviewIndex { + let index = previewIndexes.get(root); + if (!index) { + const els = [...root.querySelectorAll(":scope > [data-line]")]; + const blocks = els.map((el) => ({ startLine: Number(el.dataset.line), runs: collectRuns(el) })); + index = { blocks, runs: blocks.length ? blocks.flatMap((b) => b.runs) : collectRuns(root) }; + previewIndexes.set(root, index); + } + return index; +} + +function lowerBound(lines: readonly number[], line: number): number { + let lo = 0; + let hi = lines.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (lines[mid]! < line) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** Reuses the rendered text index across queries and match navigation. */ +export function findPreviewMatches( + root: HTMLElement, + find: LiveMarkdownFind, +): { ranges: Range[]; current: Range | null } { + const index = previewIndex(root); + const key = JSON.stringify([find.search, find.caseSensitive, find.wholeWord, find.regexp]); + if (index.query !== key) { + let remaining = MAX_COUNT; + index.matches = (index.blocks.length ? index.blocks : [{ runs: index.runs }]).map((block) => { + const ranges = matchesInRuns(block.runs, find, remaining); + remaining -= ranges.length; + return ranges; + }); + index.query = key; + } + const matches = index.matches!; + const ranges = matches.flat(); + if (find.currentMatchIndex < 0 || !ranges.length) return { ranges, current: null }; + + const line = find.matchLines[find.currentMatchIndex]; + if (line !== undefined && index.blocks.length) { + let lo = 0; + let hi = index.blocks.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (index.blocks[mid]!.startLine <= line) lo = mid + 1; + else hi = mid; + } + const blockIndex = lo - 1; + if (blockIndex < 0) return { ranges, current: null }; + const block = index.blocks[blockIndex]!; + const ordinal = find.currentMatchIndex - lowerBound(find.matchLines, block.startLine); + return { ranges, current: matches[blockIndex]?.[ordinal] ?? null }; + } + return { ranges, current: ranges[find.currentMatchIndex] ?? null }; +} + +function isSameRange(a: Range, b: Range): boolean { + try { + return ( + (a.compareBoundaryPoints(Range.START_TO_START, b) <= 0 && a.compareBoundaryPoints(Range.END_TO_END, b) >= 0) || + (b.compareBoundaryPoints(Range.START_TO_START, a) <= 0 && b.compareBoundaryPoints(Range.END_TO_END, a) >= 0) + ); + } catch { + return ( + a.startContainer === b.startContainer && + a.startOffset === b.startOffset && + a.endContainer === b.endContainer && + a.endOffset === b.endOffset + ); + } +} + +/** Single-line non-empty selection inside `root` (2..200 chars), matching CodeMirror's `highlightSelectionMatches`. */ +export function previewSelectionOf(root: HTMLElement): { text: string; range: Range } | null { + if (typeof window === "undefined") return null; + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount !== 1) return null; + const range = sel.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return null; + const text = sel.toString(); + if (text.length < MIN_SELECTION_LENGTH || text.length > MAX_SELECTION_LENGTH || text.includes("\n") || !text.trim()) + return null; + return { text, range: range.cloneRange() }; +} + +/** Case-sensitive occurrences of `text` in `root`, excluding `excludeRange` (capped at `maxMatches`). */ +export function selectionMatchesInNode( + root: HTMLElement, + text: string, + excludeRange: Range | null = null, + maxMatches = MAX_SELECTION_MATCHES, +): Range[] { + if (text.length < MIN_SELECTION_LENGTH || text.length > MAX_SELECTION_LENGTH || text.includes("\n") || !text.trim()) + return []; + const all = matchesInRuns( + previewIndex(root).runs, + { search: text, caseSensitive: true, wholeWord: false, regexp: false }, + maxMatches + 2, + ); + const filtered = excludeRange ? all.filter((r) => !isSameRange(r, excludeRange)) : all; + return filtered.length > maxMatches ? [] : filtered; +} + +/** True when `range` is not hidden inside a collapsed `
` element. */ +export function isRangeShown(range: Range, root: HTMLElement): boolean { + for (let el = range.startContainer.parentElement; el && el !== root; el = el.parentElement) { + if (el instanceof HTMLDetailsElement && !el.open) { + const summary = el.querySelector(":scope > summary"); + if (!summary?.contains(range.startContainer)) return false; + } + } + return true; +} + +/** Opens any collapsed `
` ancestors containing `range`. */ +export function openDetailsContaining(range: Range, root: HTMLElement, toggled?: Map): boolean { + let opened = false; + let allDetails: HTMLDetailsElement[] | undefined; + for (let el = range.startContainer.parentElement; el && el !== root; el = el.parentElement) { + if (el instanceof HTMLDetailsElement && !el.open) { + el.open = true; + opened = true; + if (toggled) { + allDetails ??= [...root.querySelectorAll("details")]; + const idx = allDetails.indexOf(el); + if (idx >= 0) toggled.set(idx, true); + } + } + } + return opened; +} + +/** Scrolls `range` into view inside `root` when layout rects are available. */ +export function revealPreviewRange(range: Range, root: HTMLElement): boolean { + const r = range.getBoundingClientRect(); + const box = root.getBoundingClientRect(); + if (r.height <= 0 || box.height <= 0) return false; + if (r.top < box.top + 24 || r.bottom > box.bottom - 24) { + root.scrollTop += r.top - box.top - box.height / 3; + } + return true; +} + +interface HighlightSet { + priority?: number; + clear(): void; + add(range: Range): void; +} + +const PRIORITIES: Record = { + [SELECTION_MATCH_HIGHLIGHT]: 0, + [FIND_HIGHLIGHT]: 1, + [FIND_CURRENT_HIGHLIGHT]: 2, +}; + +const highlightByRoot = new Map>(); + +/** + * Updates `name` for `root` in `CSS.highlights`. One `Highlight` instance per + * name is mutated in place so WebKit repaints removed ranges reliably. + */ +export function setPreviewHighlight(name: string, root: HTMLElement, ranges: readonly Range[]) { + const shown = ranges.filter((r) => isRangeShown(r, root)); + let byRoot = highlightByRoot.get(name); + if (!byRoot) { + byRoot = new Map(); + highlightByRoot.set(name, byRoot); + } + if (shown.length) byRoot.set(root, shown); + else byRoot.delete(root); + + const registry = (globalThis as { CSS?: { highlights?: Map } }).CSS?.highlights; + const HighlightCtor = (globalThis as { Highlight?: new () => HighlightSet }).Highlight; + if (!registry || !HighlightCtor) return; + + let set = registry.get(name); + if (!set) { + set = new HighlightCtor(); + if (PRIORITIES[name] !== undefined) set.priority = PRIORITIES[name]; + registry.set(name, set); + } + set.clear(); + for (const list of byRoot.values()) { + for (const r of list) set.add(r); + } +} + +export function clearPreviewHighlights(root: HTMLElement) { + setPreviewHighlight(SELECTION_MATCH_HIGHLIGHT, root, []); + setPreviewHighlight(FIND_HIGHLIGHT, root, []); + setPreviewHighlight(FIND_CURRENT_HIGHLIGHT, root, []); +} diff --git a/src/features/viewers/markdown/highlightIndex.test.ts b/src/features/viewers/markdown/highlightIndex.test.ts new file mode 100644 index 000000000..ea470ed43 --- /dev/null +++ b/src/features/viewers/markdown/highlightIndex.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { MAX_COUNT } from "@/editor/core/cm/find"; +import { + findMatchesInNode, + findPreviewMatches, + invalidatePreviewIndex, + openDetailsContaining, + selectionMatchesInNode, + setPreviewHighlight, +} from "./highlight"; +import type { LiveMarkdownFind } from "./live"; + +const query: LiveMarkdownFind = { + search: "token", + caseSensitive: false, + wholeWord: false, + regexp: false, + matchLines: [0, 2], + currentMatchIndex: 0, +}; + +function preview(html: string) { + const root = document.createElement("div"); + root.innerHTML = html; + return root; +} + +describe("preview text index", () => { + it("reuses a 5,000-line DOM index for typing, selection and navigation", () => { + const root = preview( + Array.from({ length: 2500 }, (_, i) => `

token

`).join(""), + ); + const scan = vi.spyOn(root, "querySelectorAll"); + const lines = Array.from({ length: 2500 }, (_, i) => i * 2); + const initial = findPreviewMatches(root, { ...query, matchLines: lines }); + expect(initial.ranges).toHaveLength(2500); + let typed = initial; + for (const search of ["t", "to", "tok", "toke", "token"]) { + typed = findPreviewMatches(root, { ...query, search, matchLines: lines }); + } + const next = findPreviewMatches(root, { ...query, matchLines: lines, currentMatchIndex: 2499 }); + expect(next.current).toBe(typed.ranges[2499]); + openDetailsContaining(next.current!, root, new Map()); + expect(selectionMatchesInNode(root, "token")).toEqual([]); + expect(scan).toHaveBeenCalledTimes(1); + }); + + it("rebuilds ranges after rendered text is replaced", () => { + const root = preview('

token

'); + const old = findPreviewMatches(root, query).current; + root.innerHTML = '

new token

'; + invalidatePreviewIndex(root); + const current = findPreviewMatches(root, query).current; + expect(current?.toString()).toBe("token"); + expect(current?.startContainer).not.toBe(old?.startContainer); + expect(root.contains(current!.startContainer)).toBe(true); + }); + + it("does not substitute a match from another block for hidden source text", () => { + const root = preview('

token

plain

'); + expect(findPreviewMatches(root, { ...query, currentMatchIndex: 1 }).current).toBeNull(); + }); + + it("caps ranges across all blocks", () => { + const root = preview(`
${"token ".repeat(MAX_COUNT)}
token
`); + expect(findPreviewMatches(root, query).ranges).toHaveLength(MAX_COUNT); + }); + + it("advances zero-width Unicode regexes by a complete code point", () => { + const root = preview("😀token"); + expect(findMatchesInNode(root, { ...query, search: "(?=.)|token", regexp: true })).toEqual([]); + expect( + findMatchesInNode(root, { ...query, search: "(?=😀)|token", regexp: true }).map((r) => r.toString()), + ).toEqual(["token"]); + }); + + it("recognises astral letters at whole-word boundaries", () => { + const root = preview("𐐀token token𐐀 token"); + expect(findMatchesInNode(root, { ...query, wholeWord: true }).map((r) => r.toString())).toEqual(["token"]); + }); + + it("silently skips registration when the Custom Highlight API is unavailable", () => { + const root = preview("token"); + vi.stubGlobal("Highlight", undefined); + try { + expect(() => setPreviewHighlight("markdown-find", root, findMatchesInNode(root, query))).not.toThrow(); + setPreviewHighlight("markdown-find", root, []); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/features/viewers/markdown/live.ts b/src/features/viewers/markdown/live.ts index 6e1e66b48..1d1bee0c8 100644 --- a/src/features/viewers/markdown/live.ts +++ b/src/features/viewers/markdown/live.ts @@ -4,12 +4,28 @@ * visible line of the editor (for scroll sync) and caret moves (so a Preview * only preview follows navigation in its hidden editor), per path. */ -import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view"; +import { StateEffect, type EditorState } from "@codemirror/state"; +import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view"; +import { SearchCursor, getSearchQuery } from "@codemirror/search"; import { create } from "zustand"; import { registerEditorContribution } from "@/editor/api"; +import { MAX_COUNT, findExcluded, findOpen, findScope } from "@/editor/core/cm/find"; import { fontZoom } from "@/editor/core/cm/prefs"; +import { editorPrefs } from "@/platform/editorSettings"; +import { useProject } from "@/platform/project"; import { isMarkdownPath } from "../fileKinds"; +export interface LiveMarkdownFind { + search: string; + caseSensitive: boolean; + wholeWord: boolean; + regexp: boolean; + /** 0-based source lines of each match in the document, in order. */ + matchLines: readonly number[]; + /** 0-based index of the selected match in `matchLines` (-1 when none). */ + currentMatchIndex: number; +} + export interface LiveMarkdown { text: string; /** 0-based first visible source line of the editor that scrolled last. */ @@ -24,11 +40,19 @@ export interface LiveMarkdown { caretLine: number; /** Bumped with `caretLine`, so going to the same place again still scrolls. */ caretSeq: number; + /** Active Find in File query and match state for the preview. */ + find?: LiveMarkdownFind | null; + /** Single-line text selected in the editor (2..200 chars), for preview occurrence highlighting. */ + editorSelection?: string; + /** Single-line text selected in the preview (2..200 chars), for editor occurrence highlighting. */ + previewSelection?: string; } export const useLiveMarkdown = create>(() => ({})); const TEXT_DELAY = 120; +const setPreviewSelectionEffect = StateEffect.define(); +const previewSelectionMark = Decoration.mark({ class: "cm-selectionMatch" }); function publish(path: string, patch: Partial) { useLiveMarkdown.setState((s) => { @@ -50,6 +74,75 @@ export function publishPreviewTop(path: string, top: number) { publish(path, { previewTop: top }); } +/** Records single-line text selected in the preview so a split editor highlights its occurrences. */ +export function publishPreviewSelection(path: string, previewSelection: string) { + if ((useLiveMarkdown.getState()[path]?.previewSelection ?? "") === previewSelection) return; + publish(path, { previewSelection }); +} + +function findStateOf(state: EditorState): LiveMarkdownFind | null { + if (!state.field(findOpen, false)) return null; + const query = getSearchQuery(state); + if (!query.valid) return null; + const main = state.selection.main; + const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length }; + const matchLines: number[] = []; + let currentMatchIndex = -1; + const cursor = query.getCursor(state, scope.from, scope.to); + for (let r = cursor.next(); !r.done && matchLines.length < MAX_COUNT; r = cursor.next()) { + const m = r.value; + if (m.to === m.from) continue; + if (m.from === main.from && m.to === main.to) currentMatchIndex = matchLines.length; + matchLines.push(state.doc.lineAt(m.from).number - 1); + } + return { + search: query.search, + caseSensitive: query.caseSensitive, + wholeWord: query.wholeWord, + regexp: query.regexp, + matchLines, + currentMatchIndex, + }; +} + +function sameFind(a: LiveMarkdownFind | null | undefined, b: LiveMarkdownFind | null): boolean { + if ((a ?? null) === b) return true; + if (!a || !b) return false; + return ( + a.search === b.search && + a.caseSensitive === b.caseSensitive && + a.wholeWord === b.wholeWord && + a.regexp === b.regexp && + a.currentMatchIndex === b.currentMatchIndex && + a.matchLines.length === b.matchLines.length && + a.matchLines.every((l, i) => l === b.matchLines[i]) + ); +} + +function editorSelectionOf(state: EditorState): string { + if (state.field(findOpen, false) || state.selection.ranges.length !== 1) return ""; + const main = state.selection.main; + const len = main.to - main.from; + if (len < 2 || len > 200) return ""; + const text = state.sliceDoc(main.from, main.to); + if (text.includes("\n") || !text.trim()) return ""; + return text; +} + +function previewSelectionDecorations(view: EditorView, query: string): DecorationSet { + if (!query || query.length < 2 || query.length > 200) return Decoration.none; + if (!editorPrefs(useProject.getState().settings).codeEditing.highlightSelection) return Decoration.none; + const ranges = []; + for (const part of view.visibleRanges) { + const cursor = new SearchCursor(view.state.doc, query, part.from, part.to); + while (!cursor.next().done) { + ranges.push(previewSelectionMark.range(cursor.value.from, cursor.value.to)); + if (ranges.length > 500) return Decoration.none; + } + } + return Decoration.set(ranges); +} + function topLineOf(view: EditorView): number { const block = view.lineBlockAtHeight(view.scrollDOM.scrollTop); return view.state.doc.lineAt(block.from).number - 1; @@ -58,8 +151,11 @@ function topLineOf(view: EditorView): number { function livePlugin(path: string) { return ViewPlugin.fromClass( class { + decorations: DecorationSet = Decoration.none; private timer: ReturnType | undefined; private frame = 0; + private previewSel = ""; + private unsubscribe: () => void; private readonly onScroll = () => { if (this.frame) return; this.frame = requestAnimationFrame(() => { @@ -70,8 +166,24 @@ function livePlugin(path: string) { }; constructor(private view: EditorView) { - publish(path, { text: view.state.doc.toString(), fontZoom: view.state.field(fontZoom, false) ?? 0 }); + const current = useLiveMarkdown.getState()[path]; + this.previewSel = current?.previewSelection ?? ""; + this.decorations = previewSelectionDecorations(view, this.previewSel); + publish(path, { + text: view.state.doc.toString(), + fontZoom: view.state.field(fontZoom, false) ?? 0, + find: findStateOf(view.state), + editorSelection: editorSelectionOf(view.state), + }); view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true }); + this.unsubscribe = useLiveMarkdown.subscribe((s) => { + const next = s[path]?.previewSelection ?? ""; + if (next === this.previewSel) return; + this.previewSel = next; + queueMicrotask(() => { + if (this.previewSel === next) this.view.dispatch({ effects: setPreviewSelectionEffect.of(next) }); + }); + }); } private publishCaret() { @@ -82,6 +194,38 @@ function livePlugin(path: string) { update(u: ViewUpdate) { const zoom = u.state.field(fontZoom, false) ?? 0; if (zoom !== (u.startState.field(fontZoom, false) ?? 0)) publish(path, { fontZoom: zoom }); + + const findChanged = + u.docChanged || + u.selectionSet || + u.startState.field(findOpen, false) !== u.state.field(findOpen, false) || + getSearchQuery(u.startState) !== getSearchQuery(u.state) || + u.startState.field(findScope, false) !== u.state.field(findScope, false) || + u.startState.field(findExcluded, false) !== u.state.field(findExcluded, false); + + if (findChanged) { + const prev = useLiveMarkdown.getState()[path]; + const nextFind = findStateOf(u.state); + const nextEditorSel = editorSelectionOf(u.state); + const patch: Partial = {}; + if (!sameFind(prev?.find, nextFind)) patch.find = nextFind; + if ((prev?.editorSelection ?? "") !== nextEditorSel) patch.editorSelection = nextEditorSel; + if (u.selectionSet && nextEditorSel && this.previewSel) { + this.previewSel = ""; + patch.previewSelection = ""; + } + if (Object.keys(patch).length) publish(path, patch); + } + + if ( + u.docChanged || + u.viewportChanged || + u.selectionSet || + u.transactions.some((tr) => tr.effects.some((e) => e.is(setPreviewSelectionEffect))) + ) { + this.decorations = previewSelectionDecorations(u.view, this.previewSel); + } + if (!u.docChanged) { if (u.selectionSet) this.publishCaret(); return; @@ -93,11 +237,13 @@ function livePlugin(path: string) { destroy() { clearTimeout(this.timer); cancelAnimationFrame(this.frame); + this.unsubscribe(); this.view.scrollDOM.removeEventListener("scroll", this.onScroll); // Flush the latest edit for a preview that outlives this view. publish(path, { text: this.view.state.doc.toString() }); } }, + { decorations: (v) => v.decorations }, ); } diff --git a/src/test/setup.ts b/src/test/setup.ts index 066875e32..0e2d71f14 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -44,4 +44,12 @@ if (typeof window !== "undefined") { Range.prototype.getClientRects ??= () => Object.assign([], { item: () => null }) as unknown as DOMRectList; Range.prototype.getBoundingClientRect ??= () => new DOMRect(); Element.prototype.scrollTo ??= function () {} as Element["scrollTo"]; + if (!("Highlight" in globalThis)) { + class HighlightStub extends Set { + priority = 0; + } + (globalThis as unknown as { Highlight: unknown }).Highlight = HighlightStub; + } + const gCss = ((globalThis as unknown as { CSS?: Record }).CSS ??= {}); + gCss.highlights ??= new Map>(); }