From bd2df0bd3a0c98d91edd11fd353610906f14e88c Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 20:55:10 +0800 Subject: [PATCH] fix(markdown): keep the preview-only scroll position across remounts (#1009) Tab switches and mode toggles unmount MarkdownPreview, whose only scroll state was the DOM scrollTop; Preview Only started over at the top. Record the preview's own scrollTop in the per-path live markdown store and restore it on mount (split mode keeps its editor-driven sync). --- .../viewers/markdown/MarkdownEditor.tsx | 21 ++++- src/features/viewers/markdown/live.ts | 9 +- .../viewers/markdown/previewScroll.test.tsx | 82 +++++++++++++++++++ src/ui/selectableSurfaces.test.tsx | 2 +- 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src/features/viewers/markdown/previewScroll.test.tsx diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index 299bfaa9..89dd187c 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -20,7 +20,7 @@ import { call, commands, isTauri } from "@/platform/ipc"; import { maybeEditorService } from "@/workbench/contributions"; import { highlightInto } from "@/editor/lsp/markdown"; import { FloatingToolbar, selectableText } from "@/ui"; -import { useLiveMarkdown } from "./live"; +import { publishPreviewTop, useLiveMarkdown } from "./live"; import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn, subscribeTheme, themeKey } from "./mermaid"; import { modeOf, useMarkdownModes } from "./modes"; import { MarkdownLayoutActions } from "./MarkdownLayoutActions"; @@ -144,6 +144,25 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { root.scrollTop = topLine <= 0 ? 0 : previewOffsetForLine(blocks, topLine, root.scrollHeight); }, [html, topLine, scrollSeq, sync, diagramsDrawn]); + // The preview's own scroll offset: tab switches and mode toggles unmount the + // preview, so Preview Only would otherwise start over at the top. Writes made + // by the split sync above are captured too — the last visible position wins. + useEffect(() => { + const root = ref.current; + if (!root) return; + const onScroll = () => publishPreviewTop(path, root.scrollTop); + root.addEventListener("scroll", onScroll, { passive: true }); + return () => root.removeEventListener("scroll", onScroll); + }, [path]); + + // Restore it on mount; in split mode the sync effect positions the preview. + useLayoutEffect(() => { + const root = ref.current; + if (!root || sync) return; + const saved = useLiveMarkdown.getState()[path]?.previewTop ?? 0; + if (saved > 0) root.scrollTop = saved; + }, [path, sync]); + const onClick = (e: MouseEvent) => { const a = (e.target as Element).closest("a"); if (!a) return; diff --git a/src/features/viewers/markdown/live.ts b/src/features/viewers/markdown/live.ts index aaa4348e..8beb9166 100644 --- a/src/features/viewers/markdown/live.ts +++ b/src/features/viewers/markdown/live.ts @@ -17,6 +17,8 @@ export interface LiveMarkdown { scrollSeq: number; /** The editor's font zoom in px; the preview scales its text by the same amount. */ fontZoom: number; + /** The preview div's own scrollTop, kept so Preview Only survives a remount. */ + previewTop: number; } export const useLiveMarkdown = create>(() => ({})); @@ -25,11 +27,16 @@ const TEXT_DELAY = 120; function publish(path: string, patch: Partial) { useLiveMarkdown.setState((s) => { - const prev = s[path] ?? { text: "", topLine: 0, scrollSeq: 0, fontZoom: 0 }; + const prev = s[path] ?? { text: "", topLine: 0, scrollSeq: 0, fontZoom: 0, previewTop: 0 }; return { [path]: { ...prev, ...patch } }; }); } +/** Records the preview's own scroll offset, published by the preview's scroll listener. */ +export function publishPreviewTop(path: string, top: number) { + publish(path, { previewTop: top }); +} + function topLineOf(view: EditorView): number { const block = view.lineBlockAtHeight(view.scrollDOM.scrollTop); return view.state.doc.lineAt(block.from).number - 1; diff --git a/src/features/viewers/markdown/previewScroll.test.tsx b/src/features/viewers/markdown/previewScroll.test.tsx new file mode 100644 index 00000000..383f8d38 --- /dev/null +++ b/src/features/viewers/markdown/previewScroll.test.tsx @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { _addEditor, _removeEditor, editorContributions, type EditorDocContext } from "@/editor/api"; +import { fontZoom } from "@/editor/core/cm/prefs"; +import type { EditorSlot } from "@/editor/core/model/slot"; +import { useContextKeys } from "@/platform/context"; + +let activeSlot: Partial | null = null; +vi.mock("@/editor/core/controller", async (importOriginal) => ({ + ...(await importOriginal()), + editorController: () => ({ + get activeSlot() { + return activeSlot; + }, + }), +})); + +const { registerMarkdownLiveSource, useLiveMarkdown } = await import("./live"); +const { MarkdownEditor } = await import("./MarkdownEditor"); +const { useMarkdownModes } = await import("./modes"); + +const mdCtx: EditorDocContext = { doc: 1, path: "/p/README.md", language: "markdown", uiMode: null, readOnly: false }; + +describe("preview-only scroll position", () => { + const disposers: Array<() => void> = []; + const views: EditorView[] = []; + + const open = (ctx: EditorDocContext) => { + const live = editorContributions().find((c) => c.id === "viewers.markdownLive")!; + const view = new EditorView({ + state: EditorState.create({ doc: "# Title", extensions: [fontZoom, live.extensions(ctx)] }), + }); + const handle = { view, ctx }; + _addEditor(handle); + disposers.push(() => _removeEditor(handle)); + views.push(view); + return view; + }; + const activate = (view: EditorView) => (activeSlot = { view, disposed: false }); + + beforeEach(() => { + disposers.push(registerMarkdownLiveSource()); + useContextKeys.setState({ projectOpen: true, textEditorActive: true, editorFocus: true, popupOpen: true }); + }); + afterEach(() => { + cleanup(); + for (const view of views.splice(0)) view.destroy(); + for (const dispose of disposers.splice(0).reverse()) dispose(); + activeSlot = null; + useLiveMarkdown.setState({}, true); + useContextKeys.setState({}, true); + }); + + it("restores the preview's own scroll after a remount", async () => { + open(mdCtx); + activate(views[0]!); + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [mdCtx.path]: "preview" } }))); + const first = render( + +
+ , + ); + const preview = screen.getByRole("document", { name: "Markdown preview" }); + await act(async () => { + preview.scrollTop = 300; + preview.dispatchEvent(new Event("scroll")); + }); + expect(useLiveMarkdown.getState()[mdCtx.path]?.previewTop).toBe(300); + + // Tab switches and mode toggles unmount the preview; a fresh mount keeps the position. + first.unmount(); + const second = render( + +
+ , + ); + expect(screen.getByRole("document", { name: "Markdown preview" }).scrollTop).toBe(300); + second.unmount(); + }); +}); diff --git a/src/ui/selectableSurfaces.test.tsx b/src/ui/selectableSurfaces.test.tsx index b5375570..d096d743 100644 --- a/src/ui/selectableSurfaces.test.tsx +++ b/src/ui/selectableSurfaces.test.tsx @@ -22,7 +22,7 @@ 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 }, + "/p/README.md": { text: "# Title\n\nSome *text*", topLine: 0, scrollSeq: 0, fontZoom: 0, previewTop: 0 }, }), ); act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, "/p/README.md": "preview" } })));