дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
9 files changed
+323
-16
No files matched your search
@@ -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?.() ?? [];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" } })));
|
||||
|
||||
Reference in new issue
Заблокувати користувача