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 <53564781+jaw52@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jaw
2026-11-05 23:08:55 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 846ac97650
commit c179b1a822
4 changed files with 202 additions and 5 deletions
@@ -110,6 +110,16 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
/** 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(() => {
@@ -219,7 +229,13 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
const onClick = (e: MouseEvent) => {
const a = (e.target as Element).closest("a");
if (!a) return;
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;
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } 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 { useContextKeys } from "@/platform/context";
import { registerMarkdownLiveSource, useLiveMarkdown } from "./live";
import { MarkdownEditor } from "./MarkdownEditor";
import { useMarkdownModes } from "./modes";
const ctx: EditorDocContext = { doc: 1, path: "/p/NOTES.md", language: "markdown", uiMode: null, readOnly: false };
const doc =
"<details>\n<summary>One</summary>\n\nFirst\n\n</details>\n\n<details open>\n<summary>Two</summary>\n\nSecond\n\n</details>\n";
describe("preview <details>", () => {
const disposers: Array<() => void> = [];
let view: EditorView;
beforeEach(() => {
disposers.push(registerMarkdownLiveSource());
useContextKeys.setState({ projectOpen: true, textEditorActive: true });
const live = editorContributions().find((c) => c.id === "viewers.markdownLive")!;
view = new EditorView({ state: EditorState.create({ doc, extensions: [fontZoom, live.extensions(ctx)] }) });
const handle = { view, ctx };
_addEditor(handle);
disposers.push(() => _removeEditor(handle));
act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [ctx.path]: "split" } })));
});
afterEach(() => {
cleanup();
view.destroy();
for (const dispose of disposers.splice(0).reverse()) dispose();
useLiveMarkdown.setState({}, true);
useContextKeys.setState({}, true);
});
it("keeps what the reader opened and closed across edits", async () => {
render(
<MarkdownEditor path={ctx.path} tabId="t">
<div />
</MarkdownEditor>,
);
const preview = screen.getByRole("document", { name: "Markdown preview" });
const details = () => [...preview.querySelectorAll("details")];
await waitFor(() => expect(details().map((d) => d.open)).toEqual([false, true]));
expect(details()[0]!.querySelector(":scope > p")?.textContent).toBe("First");
const [one, two] = details();
fireEvent.click(one!.querySelector("summary")!);
one!.open = true; // jsdom has no summary activation behaviour
fireEvent.click(two!.querySelector("summary")!);
two!.open = false;
act(() => view.dispatch({ changes: { from: view.state.doc.length, insert: "\nMore\n" } }));
await waitFor(() => expect(preview.textContent).toContain("More"));
expect(details().map((d) => d.open)).toEqual([true, false]);
});
});
@@ -101,6 +101,87 @@ describe("markdown preview rendering", () => {
expect(root.querySelector("input[type='checkbox']")).not.toBeNull();
});
const blocks = (md: string) => {
const root = document.createElement("div");
root.innerHTML = renderMarkdown(md);
const lines = [...root.querySelectorAll<HTMLElement>(":scope > [data-line]")].map((e) => [
e.dataset.line,
e.firstElementChild?.tagName,
]);
return { root, lines };
};
it("keeps Markdown between a container's tags inside it (#1524)", () => {
const { root, lines } = blocks("<details>\n<summary>Summary</summary>\n\nContent\n\n</details>\n\nAfter\n");
const details = root.querySelector("details")!;
expect(details.open).toBe(false);
expect(details.querySelector(":scope > summary")?.textContent).toBe("Summary");
expect(details.querySelector(":scope > p")?.textContent).toBe("Content");
expect(root.querySelector(":scope > [data-line] > p")?.textContent).toBe("After");
expect(lines).toEqual([
["0", "DETAILS"],
["7", "P"],
]);
});
it.each([
["close right after the text", "<details>\n<summary>S</summary>\n\nContent\n</details>\n", "details > p"],
["kept open", "<details open>\n<summary>S</summary>\n\n- a\n- b\n\n</details>\n", "details[open] > ul > li"],
["a centred README header", '<div align="center">\n\n# Title\n\n</div>\n', "div[align='center'] > h1"],
["a section", "<section>\n\n```js\nx\n```\n\n</section>\n", "section > pre > code"],
["upper case", "<DETAILS>\n<SUMMARY>S</SUMMARY>\n\nContent\n\n</DETAILS>\n", "details > p"],
["a list item", "- item\n <details>\n <summary>S</summary>\n\n body\n\n </details>\n", "li details"],
])("renders the content of a container: %s", (_, md, selector) => {
expect(blocks(md).root.querySelector(selector)).not.toBeNull();
});
it("nests containers and ends each at its own close", () => {
const { root, lines } = blocks(
"<details>\n<summary>A</summary>\n\n<details>\n<summary>B</summary>\n\ninner\n\n</details>\n\nouter\n\n</details>\n\nafter\n",
);
const [outer, inner] = [...root.querySelectorAll("details")];
expect(inner!.parentElement).toBe(outer);
expect(inner!.querySelector(":scope > p")?.textContent).toBe("inner");
expect(outer!.querySelector(":scope > p")?.textContent).toBe("outer");
expect(lines).toEqual([
["0", "DETAILS"],
["14", "P"],
]);
});
it("leaves blocks alone when the container is closed in its own block or only mentioned", () => {
expect(blocks("<details><summary>S</summary>x</details>\n\nNext\n").lines).toEqual([
["0", "DETAILS"],
["2", "P"],
]);
expect(blocks("<!-- <details> -->\n\nOne\n\nTwo\n").lines.map(([l]) => l)).toEqual(["0", "2", "4"]);
expect(blocks("```html\n<details>\n```\n\nOne\n\n`<div>` code\n\nTwo\n").lines.map(([l]) => l)).toEqual([
"0",
"4",
"6",
"8",
]);
expect(blocks('<img src="a.png" alt="<div>">\n\n<br/>\n\nOne\n').lines.map(([l]) => l)).toEqual(["0", "2", "4"]);
});
it("keeps everything after a container that's never closed inside it", () => {
const { root, lines } = blocks("Intro\n\n<details>\n<summary>S</summary>\n\nOne\n\nTwo\n");
expect([...root.querySelectorAll("details > p")].map((p) => p.textContent)).toEqual(["One", "Two"]);
expect(lines).toEqual([
["0", "P"],
["2", "DETAILS"],
]);
});
it("ignores a stray close", () => {
expect(blocks("One\n\n</div>\n\nTwo\n").lines.map(([l]) => l)).toEqual(["0", "2", "4"]);
expect(blocks("</details>\n\n<details>\n<summary>S</summary>\n\nIn\n\n</details>\n\nOut\n").lines).toEqual([
["0", undefined],
["2", "DETAILS"],
["9", "P"],
]);
});
it("dedupes heading anchors", () => {
const root = document.createElement("div");
root.innerHTML = renderMarkdown("## Same\n\n## Same\n");
+45 -4
View File
@@ -1,7 +1,7 @@
/**
* Markdown → sanitized HTML for the preview. Every top-level block is wrapped
* in an element carrying its 0-based source line (`data-line`) for scroll
* sync; images keep their source in `data-src` and are loaded by the preview
* Markdown → sanitized HTML for the preview. Every top-level block (or run of
* blocks inside one HTML container) is wrapped in an element carrying its
* 0-based source line (`data-line`) for scroll sync; images keep their source in `data-src` and are loaded by the preview
* (local files through the backend, as the CSP blocks file URLs). Mermaid
* blocks become `[data-mermaid]` placeholders, drawn by `./mermaid`. YAML front matter is shown
* as a table of its top-level fields.
@@ -99,6 +99,37 @@ function frontMatterTable(fields: readonly [string, string][]): string {
return `<table data-front-matter=""><tbody>${rows.join("")}</tbody></table>`;
}
// Containers whose open and close tags may sit in separate HTML blocks with Markdown between them.
const CONTAINER_TAGS = new Set([
"article",
"aside",
"blockquote",
"center",
"details",
"div",
"dl",
"figure",
"footer",
"header",
"main",
"nav",
"ol",
"section",
"table",
"ul",
]);
const TAG = /<(\/?)([a-zA-Z][\w-]*)(?:"[^"]*"|'[^']*'|[^'">])*?(\/?)>/g;
/** Applies the container tags an HTML block opens and closes to `open`, the stack of unclosed ones. */
function trackContainers(raw: string, open: string[]) {
for (const [, closing, name, selfClosing] of raw.replace(/<!--[\s\S]*?(?:-->|$)/g, "").matchAll(TAG)) {
const tag = name!.toLowerCase();
if (!CONTAINER_TAGS.has(tag) || selfClosing) continue;
if (!closing) open.push(tag);
else if (open.includes(tag)) open.length = open.lastIndexOf(tag);
}
}
export function renderMarkdown(text: string): string {
const front = frontMatter(text);
const body = front ? text.split("\n").slice(front.lines).join("\n") : text;
@@ -106,6 +137,10 @@ export function renderMarkdown(text: string): string {
const slugs = new Map<string, number>();
let line = front?.lines ?? 0;
let html = front ? `<div data-line="0">${frontMatterTable(front.fields)}</div>` : "";
// Blocks inside an HTML container left open (`<details>`, `<div>`…) share its wrapper up to
// the close: a wrapper per block would end the container early and spill its content out.
const open: string[] = [];
let group: { line: number; html: string } | null = null;
for (const token of tokens) {
const start = line;
line += countLines(token.raw);
@@ -118,8 +153,14 @@ export function renderMarkdown(text: string): string {
slugs.set(base, n + 1);
body = body.replace(/^<h(\d)>/, `<h$1 id="${ANCHOR_PREFIX}${n ? `${base}-${n}` : base}">`);
}
html += `<div data-line="${start}">${body}</div>`;
group ??= { line: start, html: "" };
group.html += body;
if (token.type === "html") trackContainers(token.raw, open);
if (open.length) continue;
html += `<div data-line="${group.line}">${group.html}</div>`;
group = null;
}
if (group) html += `<div data-line="${group.line}">${group.html}</div>`;
// The preview shares the IDE's document: a `<style>` in a file would restyle the whole IDE.
return purify().sanitize(html, {
USE_PROFILES: { html: true },