дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
The preview wraps each top-level block in its own data-line element for scroll sync, so an HTML container (<details>, <div align=center>, <section>…) whose close tag came in a later block was closed at the first wrapper and its Markdown content spilled out: <details> couldn't be collapsed and centred README headers weren't centred. Blocks inside an unclosed container now share one wrapper up to its close, and the reader's open/closed state of each <details> survives edits in split mode. Closes #1524 Co-authored-by: jaw52 <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
273 рядки
9.8 KiB
TypeScript
273 рядки
9.8 KiB
TypeScript
/**
|
|
* 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
|
|
* only, it follows navigation in the hidden editor and reports the line on
|
|
* screen (Structure's Autoscroll from Source).
|
|
*/
|
|
import {
|
|
useDeferredValue,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
type CSSProperties,
|
|
type MouseEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import clsx from "clsx";
|
|
import { call, commands, isTauri } from "@/platform/ipc";
|
|
import { useMarkdownPreviewFontSize } from "@/platform/languageSettings";
|
|
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 { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
|
import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn } from "./mermaid";
|
|
import { modeOf, useMarkdownModes } from "./modes";
|
|
import { MarkdownLayoutActions } from "./MarkdownLayoutActions";
|
|
import {
|
|
ANCHOR_PREFIX,
|
|
previewLineAtOffset,
|
|
previewOffsetForLine,
|
|
renderMarkdown,
|
|
resolveLink,
|
|
slugify,
|
|
} from "./render";
|
|
import styles from "./Markdown.module.css";
|
|
import { tx } from "@/platform/i18n";
|
|
|
|
/** Loaded local images by absolute path (data URLs), shared by all previews. */
|
|
const imageCache = new Map<string, Promise<string>>();
|
|
const IMAGE_CACHE_LIMIT = 64;
|
|
|
|
function imageUrl(path: string): Promise<string> {
|
|
let p = imageCache.get(path);
|
|
if (!p) {
|
|
p = call(commands.fsReadImage(path));
|
|
p.catch(() => imageCache.delete(path));
|
|
if (imageCache.size >= IMAGE_CACHE_LIMIT) imageCache.delete(imageCache.keys().next().value!);
|
|
imageCache.set(path, p);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
function openExternal(url: string) {
|
|
if (isTauri()) void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(url)).catch(() => {});
|
|
else window.open(url, "_blank", "noopener");
|
|
}
|
|
|
|
export function MarkdownEditor({
|
|
path,
|
|
tabId,
|
|
children,
|
|
actionsInTabs = false,
|
|
}: {
|
|
path: string;
|
|
tabId: string;
|
|
children: ReactNode;
|
|
actionsInTabs?: boolean;
|
|
}) {
|
|
const mode = useMarkdownModes((s) => modeOf(s, path));
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
return (
|
|
<div ref={ref} className={clsx(styles.split, styles[`mode-${mode}`])}>
|
|
<div className={styles.editor}>{children}</div>
|
|
{mode !== "editor" && <MarkdownPreview path={path} sync={mode === "split"} />}
|
|
{!actionsInTabs && (
|
|
<FloatingToolbar targetRef={ref}>
|
|
<MarkdownLayoutActions path={path} tabId={tabId} />
|
|
</FloatingToolbar>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 fontSize = useMarkdownPreviewFontSize();
|
|
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);
|
|
/** `<details>` the reader opened or closed, by position: each edit re-renders them as written. */
|
|
const toggled = useRef(new Map<number, boolean>());
|
|
|
|
useLayoutEffect(() => toggled.current.clear(), [path]);
|
|
useLayoutEffect(() => {
|
|
ref.current?.querySelectorAll("details").forEach((d, i) => {
|
|
const open = toggled.current.get(i);
|
|
if (open !== undefined) d.open = open;
|
|
});
|
|
}, [html]);
|
|
|
|
// Mermaid diagrams: before paint, so cached ones don't flash their source.
|
|
useLayoutEffect(() => {
|
|
const root = ref.current;
|
|
if (!root || !hasMermaid(root)) return;
|
|
const pass = renderMermaidIn(root, diagrams.current);
|
|
let live = true;
|
|
if (pass.async) void pass.done.then(() => live && setDiagramsDrawn((n) => n + 1));
|
|
return () => {
|
|
live = false;
|
|
pass.cancel();
|
|
};
|
|
}, [html, theme]);
|
|
|
|
// Images and code highlighting after each render.
|
|
useEffect(() => {
|
|
const root = ref.current;
|
|
if (!root) return;
|
|
let live = true;
|
|
root.querySelectorAll<HTMLImageElement>("img[data-src]").forEach((img) => {
|
|
const src = img.dataset.src ?? "";
|
|
if (/^data:image\//i.test(src)) {
|
|
img.src = src;
|
|
return;
|
|
}
|
|
const target = resolveLink(path, src);
|
|
if (target?.kind !== "file") {
|
|
img.classList.add(styles.blocked!);
|
|
img.title = "Remote images are not loaded";
|
|
return;
|
|
}
|
|
imageUrl(target.path).then(
|
|
(url) => live && (img.src = url),
|
|
() => live && img.classList.add(styles.blocked!),
|
|
);
|
|
});
|
|
root.querySelectorAll<HTMLElement>("pre > code").forEach((code) => {
|
|
if (code.closest(MERMAID_SELECTOR)) return;
|
|
const lang = [...code.classList].find((c) => c.startsWith("language-"))?.slice("language-".length) ?? null;
|
|
const src = (code.textContent ?? "").replace(/\n+$/, "");
|
|
code.textContent = src;
|
|
void highlightInto(code, src, lang);
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [html, path]);
|
|
|
|
// Scroll sync: editor → preview.
|
|
useLayoutEffect(() => {
|
|
const root = ref.current;
|
|
if (!root || !sync) return;
|
|
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
|
|
// 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]);
|
|
|
|
// 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) {
|
|
const details = (e.target as Element).closest("summary")?.parentElement;
|
|
if (details instanceof HTMLDetailsElement && ref.current) {
|
|
toggled.current.set([...ref.current.querySelectorAll("details")].indexOf(details), !details.open);
|
|
}
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
const target = resolveLink(path, a.getAttribute("href") ?? "");
|
|
if (!target) return;
|
|
const scrollTo = (id: string) =>
|
|
ref.current?.querySelector(`[id="${CSS.escape(ANCHOR_PREFIX + slugify(id))}"]`)?.scrollIntoView();
|
|
if (target.kind === "external") openExternal(target.url);
|
|
else if (target.kind === "anchor") scrollTo(target.id);
|
|
else if (target.path === path && target.anchor) scrollTo(target.anchor);
|
|
else
|
|
void maybeEditorService()?.openFile(target.path, {
|
|
focus: true,
|
|
...(target.line !== undefined ? { selection: { line: target.line, col: 0 } } : {}),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={clsx(styles.preview, selectableText)}
|
|
style={
|
|
{
|
|
"--markdown-preview-font-size": `${fontSize}px`,
|
|
"--markdown-font-zoom": `${fontZoom}px`,
|
|
} as CSSProperties
|
|
}
|
|
tabIndex={-1}
|
|
role="document"
|
|
aria-label={tx("Markdown preview")}
|
|
onClick={onClick}
|
|
onScroll={onScroll}
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
);
|
|
}
|