diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index 56531525..1983c96a 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -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(null); const scrollFrame = useRef(0); + /** `
` the reader opened or closed, by position: each edit re-renders them as written. */ + const toggled = useRef(new Map()); + + 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; diff --git a/src/features/viewers/markdown/previewDetails.test.tsx b/src/features/viewers/markdown/previewDetails.test.tsx new file mode 100644 index 00000000..959d8c40 --- /dev/null +++ b/src/features/viewers/markdown/previewDetails.test.tsx @@ -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 = + "
\nOne\n\nFirst\n\n
\n\n
\nTwo\n\nSecond\n\n
\n"; + +describe("preview
", () => { + 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( + +
+ , + ); + 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]); + }); +}); diff --git a/src/features/viewers/markdown/render.test.ts b/src/features/viewers/markdown/render.test.ts index cc2b8489..41931713 100644 --- a/src/features/viewers/markdown/render.test.ts +++ b/src/features/viewers/markdown/render.test.ts @@ -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(":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("
\nSummary\n\nContent\n\n
\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", "
\nS\n\nContent\n
\n", "details > p"], + ["kept open", "
\nS\n\n- a\n- b\n\n
\n", "details[open] > ul > li"], + ["a centred README header", '
\n\n# Title\n\n
\n', "div[align='center'] > h1"], + ["a section", "
\n\n```js\nx\n```\n\n
\n", "section > pre > code"], + ["upper case", "
\nS\n\nContent\n\n
\n", "details > p"], + ["a list item", "- item\n
\n S\n\n body\n\n
\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( + "
\nA\n\n
\nB\n\ninner\n\n
\n\nouter\n\n
\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("
Sx
\n\nNext\n").lines).toEqual([ + ["0", "DETAILS"], + ["2", "P"], + ]); + expect(blocks("\n\nOne\n\nTwo\n").lines.map(([l]) => l)).toEqual(["0", "2", "4"]); + expect(blocks("```html\n
\n```\n\nOne\n\n`
` code\n\nTwo\n").lines.map(([l]) => l)).toEqual([ + "0", + "4", + "6", + "8", + ]); + expect(blocks('<div>\n\n
\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
\nS\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
\n\nTwo\n").lines.map(([l]) => l)).toEqual(["0", "2", "4"]); + expect(blocks("
\n\n
\nS\n\nIn\n\n
\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"); diff --git a/src/features/viewers/markdown/render.ts b/src/features/viewers/markdown/render.ts index e3fe1cff..4fac3cbd 100644 --- a/src/features/viewers/markdown/render.ts +++ b/src/features/viewers/markdown/render.ts @@ -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 `${rows.join("")}
`; } +// 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(/|$)/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(); let line = front?.lines ?? 0; let html = front ? `
${frontMatterTable(front.fields)}
` : ""; + // Blocks inside an HTML container left open (`
`, `
`…) 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(/^/, ``); } - html += `
${body}
`; + group ??= { line: start, html: "" }; + group.html += body; + if (token.type === "html") trackContainers(token.raw, open); + if (open.length) continue; + html += `
${group.html}
`; + group = null; } + if (group) html += `
${group.html}
`; // The preview shares the IDE's document: a `