mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" } })));
|
||||
|
||||
Reference in New Issue
Block a user