mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user