Файли
openidea/src/features/viewers/markdown/MarkdownEditor.tsx
T
JawтаClaude Opus 5.5 c179b1a822 fix(markdown): keep the blocks between an HTML container's tags inside it (#1566)
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]>
2026-11-05 23:08:55 +08:00

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 }}
/>
);
}