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 <[email protected]>
Цей коміт міститься в:
Jaw authored and GitHub committed 2026-11-03 22:50:13 +08:00
1 джерело 685a9487f3
коміт b57272b715
9 files changed
+323 -16

No files matched your search

+21
Переглянути файл
@@ -516,6 +516,27 @@ export const codeLenses = Facet.define<readonly CodeLens[], readonly CodeLens[]>
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<Record<string, number>>(() => ({}));
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?.() ?? [];
}
+54
Переглянути файл
@@ -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<typeof import("../structure/store")>()),
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(<StructureView />);
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");
});
});
+8 -3
Переглянути файл
@@ -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<TreeHandle>(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(() => {
+59 -7
Переглянути файл
@@ -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<HTMLElement>(":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<HTMLDivElement>(null);
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
const diagrams = useRef<Node[]>([]);
const [diagramsDrawn, setDiagramsDrawn] = useState(0);
const seenCaret = useRef(caretSeq);
/** scrollTop set by navigation, whose scroll event isn't the user's. */
const scrolledTo = useRef<number | null>(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<HTMLElement>(":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 }}
/>
);
+25 -4
Переглянути файл
@@ -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<Record<string, LiveMarkdown | undefined>>(() => ({}));
@@ -27,7 +32,15 @@ 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, 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);
}
+122
Переглянути файл
@@ -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(
<MarkdownEditor path={path} tabId="t">
<div />
</MarkdownEditor>,
);
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;
});
});
+15 -1
Переглянути файл
@@ -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);
});
});
+10
Переглянути файл
@@ -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;
}
+9 -1
Переглянути файл
@@ -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" } })));