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).
This commit is contained in:
arookieofc
2026-11-03 20:55:10 +08:00
committed by GitHub
parent 8ca25e1984
commit bd2df0bd3a
4 changed files with 111 additions and 3 deletions
@@ -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;
+8 -1
View File
@@ -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<Record<string, LiveMarkdown | undefined>>(() => ({}));
@@ -25,11 +27,16 @@ const TEXT_DELAY = 120;
function publish(path: string, patch: Partial<LiveMarkdown>) {
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;
@@ -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<EditorSlot> | null = null;
vi.mock("@/editor/core/controller", async (importOriginal) => ({
...(await importOriginal<object>()),
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(
<MarkdownEditor path={mdCtx.path} tabId="t">
<div />
</MarkdownEditor>,
);
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(
<MarkdownEditor path={mdCtx.path} tabId="t">
<div />
</MarkdownEditor>,
);
expect(screen.getByRole("document", { name: "Markdown preview" }).scrollTop).toBe(300);
second.unmount();
});
});
+1 -1
View File
@@ -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" } })));