From cbae278355faa2fa4eebfae5487d66ef6c40447a Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Mon, 9 Nov 2026 12:52:13 -0800 Subject: [PATCH] feat(markdown): highlight search and selected word occurrences in preview (#2413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘F in a Markdown file only decorated matches in the editor, so the rendered preview (Split and Preview Only) showed none, and selecting a word in the preview didn't highlight its other occurrences. The Markdown viewer now publishes the active Find query and current match, and the preview draws matches, the current match and selected-word occurrences with the CSS Custom Highlight API in the editor scheme's colours, scrolling the current match into view; a selection in the preview seeds Find, and closing Find in Preview Only returns focus to the preview. Matches are indexed once per render, not rescanned per keystroke. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- e2e/editor.spec.ts | 37 ++ src/editor/core/model/slot.ts | 6 + src/editor/core/view/FindBar.tsx | 37 +- .../viewers/markdown/Markdown.module.css | 12 + .../viewers/markdown/MarkdownEditor.tsx | 117 +++++- .../viewers/markdown/highlight.test.tsx | 308 ++++++++++++++ src/features/viewers/markdown/highlight.ts | 392 ++++++++++++++++++ .../viewers/markdown/highlightIndex.test.ts | 92 ++++ src/features/viewers/markdown/live.ts | 150 ++++++- src/test/setup.ts | 8 + 10 files changed, 1154 insertions(+), 5 deletions(-) create mode 100644 src/features/viewers/markdown/highlight.test.tsx create mode 100644 src/features/viewers/markdown/highlight.ts create mode 100644 src/features/viewers/markdown/highlightIndex.test.ts 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>(); }