From b57272b715e5c8122f19db42866dc9d317a8735a Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:50:13 +0800 Subject: [PATCH] fix(markdown): make Structure autoscroll work with Preview only (#1037) In Preview only the editor is display:none and the preview only followed it in the split layout, so Structure's Autoscroll to Source moved a hidden caret and Autoscroll from Source never saw the preview scroll. Caret moves without an edit now scroll a Preview-only preview to that line, and the preview publishes the source line on screen through editor/api (setShownLine/useShownLine) so Structure selects the heading being read; it is cleared when the editor comes back or the preview closes. Fixes #977 Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- src/editor/api.ts | 21 +++ .../__tests__/structureAutoscroll.test.tsx | 54 ++++++++ .../search/structure/StructureView.tsx | 11 +- .../viewers/markdown/MarkdownEditor.tsx | 66 +++++++++- src/features/viewers/markdown/live.ts | 29 ++++- .../viewers/markdown/previewOnly.test.tsx | 122 ++++++++++++++++++ src/features/viewers/markdown/render.test.ts | 16 ++- src/features/viewers/markdown/render.ts | 10 ++ src/ui/selectableSurfaces.test.tsx | 10 +- 9 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 src/features/search/__tests__/structureAutoscroll.test.tsx create mode 100644 src/features/viewers/markdown/previewOnly.test.tsx diff --git a/src/editor/api.ts b/src/editor/api.ts index fb5554b03..26a803448 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -516,6 +516,27 @@ export const codeLenses = Facet.define let openPathsProvider: (() => string[]) | null = null; /** Paths of every file open in an editor tab (all panes, visual order, unique). */ +/** + * 0-based source line at the top of a view that shows a file instead of its text + * editor (Markdown Preview only), per path. Structure's Autoscroll from Source + * follows it while set; the owning view clears it when it stops standing in. + */ +const useShownLines = create>(() => ({})); + +export function setShownLine(path: string, line: number | null) { + if ((useShownLines.getState()[path] ?? null) === line) return; + useShownLines.setState((s) => { + const next = { ...s }; + if (line === null) delete next[path]; + else next[path] = line; + return next; + }, true); +} + +export function useShownLine(path: string | null): number | null { + return useShownLines((s) => (path ? (s[path] ?? null) : null)); +} + export function openEditorPaths(): string[] { return openPathsProvider?.() ?? []; } diff --git a/src/features/search/__tests__/structureAutoscroll.test.tsx b/src/features/search/__tests__/structureAutoscroll.test.tsx new file mode 100644 index 000000000..e0b23caf1 --- /dev/null +++ b/src/features/search/__tests__/structureAutoscroll.test.tsx @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { setShownLine } from "@/editor/api"; +import type { OutlineItem } from "@/platform/ipc"; + +vi.mock("../structure/store", async (orig) => ({ + ...(await orig()), + useLiveOutline: () => undefined, +})); + +const { StructureView } = await import("../structure/StructureView"); +const { useOutline, useStructureOptions } = await import("../structure/store"); +const { useTracking } = await import("../common/tracking"); + +const path = "/p/README.md"; +const section = (name: string, line: number): OutlineItem => ({ + name, + detail: null, + kind: "heading" as OutlineItem["kind"], + range: { start: { line, col: 0 }, end: { line: line + 9, col: 0 } }, + selection_range: { start: { line, col: 2 }, end: { line, col: 4 } }, + children: [], +}); +const selected = () => screen.getAllByRole("treeitem").find((r) => r.getAttribute("aria-selected") === "true"); + +describe("Structure Autoscroll from Source", () => { + afterEach(() => { + cleanup(); + act(() => setShownLine(path, null)); + useTracking.setState({ caret: null }); + }); + + it("follows the line a view shows instead of the editor, else the caret", () => { + useOutline.setState({ + path, + doc: 1, + loading: false, + error: null, + items: [section("H0", 0), section("H1", 10), section("H2", 20), section("H3", 30)], + }); + useStructureOptions.setState({ autoscrollFromSource: true }); + useTracking.setState({ caret: { path, doc: 1, line: 12, col: 0, offset: 0 } }); + render(); + expect(selected()?.textContent).toContain("H1"); + + act(() => setShownLine(path, 25)); + expect(selected()?.textContent).toContain("H2"); + act(() => setShownLine("/p/other.md", 35)); + expect(selected()?.textContent).toContain("H2"); + + act(() => setShownLine(path, null)); + expect(selected()?.textContent).toContain("H1"); + }); +}); diff --git a/src/features/search/structure/StructureView.tsx b/src/features/search/structure/StructureView.tsx index 685fbcfe0..c1b55621f 100644 --- a/src/features/search/structure/StructureView.tsx +++ b/src/features/search/structure/StructureView.tsx @@ -18,6 +18,7 @@ import { T, } from "@/ui"; import type { OutlineItem } from "@/platform/ipc"; +import { useShownLine } from "@/editor/api"; import { badgeKind, basename, openLocation } from "../common/open"; import { useTracking } from "../common/tracking"; import { @@ -143,6 +144,7 @@ export function StructureView() { const error = useOutline((s) => s.error); const autoscroll = useStructureOptions((s) => s.autoscrollFromSource); const caret = useTracking((s) => s.caret); + const shownLine = useShownLine(path); const collapsed = useCollapsed((s) => s.keys); const nodes = useOutlineNodes(); const tree = useRef(null); @@ -155,11 +157,14 @@ export function StructureView() { [nodes, path], ); - // Autoscroll from source: select the innermost symbol around the caret. + // Autoscroll from source: select the innermost symbol around the caret, or + // around the line on screen of a Markdown file shown Preview only. const caretPath = useMemo(() => { - if (!autoscroll || !caret || caret.path !== path) return []; + if (!autoscroll) return []; + if (shownLine !== null) return symbolPathAt(nodes, { line: shownLine, col: 0 }); + if (!caret || caret.path !== path) return []; return symbolPathAt(nodes, { line: caret.line, col: caret.col }); - }, [autoscroll, caret, path, nodes]); + }, [autoscroll, caret, shownLine, path, nodes]); const caretKey = caretPath[caretPath.length - 1] ?? null; useEffect(() => { diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index 89dd187c5..917a3e55e 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -1,7 +1,9 @@ /** * Text editor wrapper of Markdown files: the editor, a live preview, or both * side by side, switched from the tab strip or a floating toolbar. The - * preview follows the editor's scroll position in the split layout. + * preview follows the editor's scroll position in the split layout; Preview + * only, it follows navigation in the hidden editor and reports the line on + * screen (Structure's Autoscroll from Source). */ import { useDeferredValue, @@ -18,13 +20,21 @@ import { import clsx from "clsx"; import { call, commands, isTauri } from "@/platform/ipc"; 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 { hasMermaid, MERMAID_SELECTOR, renderMermaidIn, subscribeTheme, themeKey } from "./mermaid"; import { modeOf, useMarkdownModes } from "./modes"; import { MarkdownLayoutActions } from "./MarkdownLayoutActions"; -import { ANCHOR_PREFIX, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render"; +import { + ANCHOR_PREFIX, + previewLineAtOffset, + previewOffsetForLine, + renderMarkdown, + resolveLink, + slugify, +} from "./render"; import styles from "./Markdown.module.css"; import { tx } from "@/platform/i18n"; @@ -74,17 +84,29 @@ export function MarkdownEditor({ ); } +const blocksOf = (root: HTMLElement) => + [...root.querySelectorAll(":scope > [data-line]")].map((el) => ({ + line: Number(el.dataset.line), + top: el.offsetTop, + })); + function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { const text = useLiveMarkdown((s) => s[path]?.text); const fontZoom = useLiveMarkdown((s) => s[path]?.fontZoom ?? 0); const topLine = useLiveMarkdown((s) => s[path]?.topLine ?? 0); 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 deferred = useDeferredValue(text ?? ""); const html = useMemo(() => renderMarkdown(deferred), [deferred]); const ref = useRef(null); const theme = useSyncExternalStore(subscribeTheme, themeKey, () => ""); const diagrams = useRef([]); const [diagramsDrawn, setDiagramsDrawn] = useState(0); + const seenCaret = useRef(caretSeq); + /** scrollTop set by navigation, whose scroll event isn't the user's. */ + const scrolledTo = useRef(null); + const scrollFrame = useRef(0); // Mermaid diagrams: before paint, so cached ones don't flash their source. useLayoutEffect(() => { @@ -137,11 +159,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { useLayoutEffect(() => { const root = ref.current; if (!root || !sync) return; - const blocks = [...root.querySelectorAll(":scope > [data-line]")].map((el) => ({ - line: Number(el.dataset.line), - top: el.offsetTop, - })); - root.scrollTop = topLine <= 0 ? 0 : previewOffsetForLine(blocks, topLine, root.scrollHeight); + root.scrollTop = topLine <= 0 ? 0 : previewOffsetForLine(blocksOf(root), topLine, root.scrollHeight); }, [html, topLine, scrollSeq, sync, diagramsDrawn]); // The preview's own scroll offset: tab switches and mode toggles unmount the @@ -163,6 +181,39 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { if (saved > 0) root.scrollTop = saved; }, [path, sync]); + // Preview only: go where the hidden editor's caret was sent (Structure, Go to Symbol…). + useLayoutEffect(() => { + const root = ref.current; + if (!root || caretSeq === seenCaret.current) return; + seenCaret.current = caretSeq; + if (sync) return; + root.scrollTop = previewOffsetForLine(blocksOf(root), caretLine, root.scrollHeight); + scrolledTo.current = root.scrollTop; + setShownLine(path, caretLine); + }, [caretSeq, caretLine, sync, 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. + useEffect(() => { + const root = ref.current; + if (!root || sync) return; + setShownLine(path, previewLineAtOffset(blocksOf(root), root.scrollTop)); + return () => setShownLine(path, null); + }, [sync, path]); + useEffect(() => () => cancelAnimationFrame(scrollFrame.current), []); + + const onScroll = () => { + const root = ref.current; + if (!root || sync || scrollFrame.current) return; + scrollFrame.current = requestAnimationFrame(() => { + scrollFrame.current = 0; + const own = scrolledTo.current !== null && Math.abs(root.scrollTop - scrolledTo.current) < 1; + if (own) return; + scrolledTo.current = null; + setShownLine(path, previewLineAtOffset(blocksOf(root), root.scrollTop)); + }); + }; + const onClick = (e: MouseEvent) => { const a = (e.target as Element).closest("a"); if (!a) return; @@ -190,6 +241,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { role="document" aria-label={tx("Markdown preview")} onClick={onClick} + onScroll={onScroll} dangerouslySetInnerHTML={{ __html: html }} /> ); diff --git a/src/features/viewers/markdown/live.ts b/src/features/viewers/markdown/live.ts index 8beb91665..6e1e66b48 100644 --- a/src/features/viewers/markdown/live.ts +++ b/src/features/viewers/markdown/live.ts @@ -1,7 +1,8 @@ /** * Live Markdown source for the preview: an editor contribution on Markdown - * files publishes the document text (debounced while typing) and the first - * visible line of the editor (for scroll sync), per path. + * files publishes the document text (debounced while typing), the first + * 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 { create } from "zustand"; @@ -19,6 +20,10 @@ export interface LiveMarkdown { fontZoom: number; /** The preview div's own scrollTop, kept so Preview Only survives a remount. */ previewTop: number; + /** 0-based caret line, set when the caret moves without an edit (navigation). */ + caretLine: number; + /** Bumped with `caretLine`, so going to the same place again still scrolls. */ + caretSeq: number; } export const useLiveMarkdown = create>(() => ({})); @@ -27,7 +32,15 @@ const TEXT_DELAY = 120; function publish(path: string, patch: Partial) { useLiveMarkdown.setState((s) => { - const prev = s[path] ?? { text: "", topLine: 0, scrollSeq: 0, fontZoom: 0, previewTop: 0 }; + const prev = s[path] ?? { + text: "", + topLine: 0, + scrollSeq: 0, + fontZoom: 0, + caretLine: 0, + caretSeq: 0, + previewTop: 0, + }; return { [path]: { ...prev, ...patch } }; }); } @@ -61,10 +74,18 @@ function livePlugin(path: string) { view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true }); } + private publishCaret() { + const caretLine = this.view.state.doc.lineAt(this.view.state.selection.main.head).number - 1; + publish(path, { caretLine, caretSeq: (useLiveMarkdown.getState()[path]?.caretSeq ?? 0) + 1 }); + } + update(u: ViewUpdate) { const zoom = u.state.field(fontZoom, false) ?? 0; if (zoom !== (u.startState.field(fontZoom, false) ?? 0)) publish(path, { fontZoom: zoom }); - if (!u.docChanged) return; + if (!u.docChanged) { + if (u.selectionSet) this.publishCaret(); + return; + } clearTimeout(this.timer); this.timer = setTimeout(() => publish(path, { text: this.view.state.doc.toString() }), TEXT_DELAY); } diff --git a/src/features/viewers/markdown/previewOnly.test.tsx b/src/features/viewers/markdown/previewOnly.test.tsx new file mode 100644 index 000000000..00720f357 --- /dev/null +++ b/src/features/viewers/markdown/previewOnly.test.tsx @@ -0,0 +1,122 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { editorContributions, useShownLine, type EditorDocContext } from "@/editor/api"; + +const { registerMarkdownLiveSource, useLiveMarkdown } = await import("./live"); +const { MarkdownEditor } = await import("./MarkdownEditor"); +const { useMarkdownModes } = await import("./modes"); + +const path = "/p/README.md"; +const ctx: EditorDocContext = { doc: 1, path, language: "markdown", uiMode: null, readOnly: false }; +const source = Array.from({ length: 40 }, (_, i) => (i % 10 === 0 ? `# H${i / 10}` : i % 10 === 5 ? "text" : "")).join( + "\n", +); +/** Each source line is 10px tall in the fake layout. */ +const LINE = 10; + +describe("Markdown Preview only", () => { + const disposers: Array<() => void> = []; + let view: EditorView; + const offsetTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop")!; + + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetTop", { + configurable: true, + get(this: HTMLElement) { + return Number(this.dataset.line ?? 0) * LINE; + }, + }); + }); + afterAll(() => Object.defineProperty(HTMLElement.prototype, "offsetTop", offsetTop)); + + beforeEach(() => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 0; + }); + disposers.push(registerMarkdownLiveSource()); + const live = editorContributions().find((c) => c.id === "viewers.markdownLive")!; + view = new EditorView({ state: EditorState.create({ doc: source, extensions: [live.extensions(ctx)] }) }); + 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); + vi.restoreAllMocks(); + }); + + const preview = () => { + render( + +
+ , + ); + const el = screen.getByRole("document", { name: "Markdown preview" }); + Object.defineProperty(el, "scrollHeight", { configurable: true, value: 40 * LINE }); + return el; + }; + const shown = () => renderHook(() => useShownLine(path)).result.current; + const goTo = (line: number) => + act(() => view.dispatch({ selection: EditorSelection.cursor(view.state.doc.line(line + 1).from) })); + + it("scrolls to where the hidden editor's caret is sent, again on the same place", () => { + const el = preview(); + expect(shown()).toBe(0); + + goTo(20); + expect(el.scrollTop).toBe(200); + expect(shown()).toBe(20); + + act(() => { + el.scrollTop = 0; + }); + goTo(20); + expect(el.scrollTop).toBe(200); + }); + + it("reports the line on screen as the user scrolls, not after navigating", () => { + const el = preview(); + goTo(30); + fireEvent.scroll(el); + expect(shown()).toBe(30); + + el.scrollTop = 157; + fireEvent.scroll(el); + expect(shown()).toBe(15); + }); + + it("reports the restored line when shown again after a remount", () => { + act(() => useLiveMarkdown.setState((s) => ({ [path]: { ...s[path]!, previewTop: 205 } }))); + const el = preview(); + expect(el.scrollTop).toBe(205); + expect(shown()).toBe(20); + }); + + it("leaves the split preview to the editor's scroll sync", () => { + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [path]: "split" } }))); + const el = preview(); + goTo(20); + expect(el.scrollTop).toBe(0); + el.scrollTop = 157; + fireEvent.scroll(el); + expect(shown()).toBeNull(); + }); + + it("stops standing in for the editor when the editor comes back or the preview closes", () => { + const el = preview(); + goTo(20); + expect(shown()).toBe(20); + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [path]: "split" } }))); + expect(shown()).toBeNull(); + + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [path]: "preview" } }))); + expect(shown()).not.toBeNull(); + cleanup(); + expect(shown()).toBeNull(); + void el; + }); +}); diff --git a/src/features/viewers/markdown/render.test.ts b/src/features/viewers/markdown/render.test.ts index ee162471f..28031903f 100644 --- a/src/features/viewers/markdown/render.test.ts +++ b/src/features/viewers/markdown/render.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { joinPath, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render"; +import { joinPath, previewLineAtOffset, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render"; describe("markdown preview rendering", () => { it("tags blocks with their source line", () => { @@ -62,4 +62,18 @@ describe("markdown preview rendering", () => { expect(previewOffsetForLine(blocks, 10.5, 400)).toBe(250); expect(previewOffsetForLine([], 3, 400)).toBe(0); }); + + it("finds the block at the top of a scrolled preview", () => { + const blocks = [ + { line: 2, top: 0 }, + { line: 10, top: 100 }, + { line: 20, top: 300 }, + ]; + expect(previewLineAtOffset(blocks, 0)).toBe(2); + expect(previewLineAtOffset(blocks, 99)).toBe(10); + expect(previewLineAtOffset(blocks, 299)).toBe(20); + expect(previewLineAtOffset(blocks, 250)).toBe(10); + expect(previewLineAtOffset(blocks, 5000)).toBe(20); + expect(previewLineAtOffset([], 50)).toBe(0); + }); }); diff --git a/src/features/viewers/markdown/render.ts b/src/features/viewers/markdown/render.ts index a07531b6c..1be1d6334 100644 --- a/src/features/viewers/markdown/render.ts +++ b/src/features/viewers/markdown/render.ts @@ -142,3 +142,13 @@ export function previewOffsetForLine( const t = Math.min(1, (line - a.line) / Math.max(1, nextLine - a.line)); return a.top + t * (nextTop - a.top); } + +/** Source line of the block at the top of a preview scrolled to `offset`: the first one if none is above it. */ +export function previewLineAtOffset(blocks: ReadonlyArray<{ line: number; top: number }>, offset: number): number { + let line = blocks[0]?.line ?? 0; + for (const b of blocks) { + if (b.top > offset + 1) break; + line = b.line; + } + return line; +} diff --git a/src/ui/selectableSurfaces.test.tsx b/src/ui/selectableSurfaces.test.tsx index d096d7430..7e7fe8649 100644 --- a/src/ui/selectableSurfaces.test.tsx +++ b/src/ui/selectableSurfaces.test.tsx @@ -22,7 +22,15 @@ describe("selectable read-only surfaces", () => { it("Markdown preview (the reported case)", () => { act(() => useLiveMarkdown.setState({ - "/p/README.md": { text: "# Title\n\nSome *text*", topLine: 0, scrollSeq: 0, fontZoom: 0, previewTop: 0 }, + "/p/README.md": { + text: "# Title\n\nSome *text*", + topLine: 0, + scrollSeq: 0, + fontZoom: 0, + caretLine: 0, + caretSeq: 0, + previewTop: 0, + }, }), ); act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, "/p/README.md": "preview" } })));