diff --git a/e2e/agentMarkdown.spec.ts b/e2e/agentMarkdown.spec.ts
index 06ccd9a54..9d7a1a13d 100644
--- a/e2e/agentMarkdown.spec.ts
+++ b/e2e/agentMarkdown.spec.ts
@@ -340,3 +340,23 @@ test("selects through code blocks and tables while keeping copy controls outside
await copies.last().click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
});
+
+test("keeps mermaid `` +
+ `` +
+ `${text.split("\n")[0]}`,
};
});
mockIPC((cmd, args) => (cmd === "fs_read_image" ? readImage((args as { path: string }).path) : null));
@@ -236,11 +237,15 @@ describe("mermaid diagrams in a reply", () => {
expect(idsOne).toHaveLength(2);
expect(idsTwo).toHaveLength(2);
expect(idsOne.filter((id) => idsTwo.includes(id))).toEqual([]);
- // Every marker reference resolves inside its own copy.
+ // Every marker reference and scoped `` +
+ `` +
+ `` +
+ `${text.split("\n")[0]}`,
};
}),
}));
@@ -34,6 +36,13 @@ function preview(md: string): HTMLElement {
const SEQ = "```mermaid\nsequenceDiagram\n A->B:test\n```\n";
+async function renderedSvg(svg: string): Promise {
+ m.render.mockResolvedValueOnce({ svg });
+ const root = preview(SEQ);
+ await renderMermaidIn(root).done;
+ return root.querySelector("svg")!;
+}
+
describe("mermaid in the markdown preview", () => {
beforeEach(() => {
resetMermaidForTests();
@@ -106,7 +115,7 @@ describe("mermaid in the markdown preview", () => {
const edited = preview(SEQ.replace("test", "edited"));
const next = renderMermaidIn(edited, previous);
expect(next.async).toBe(true);
- expect(edited.querySelector(".mermaid-output svg")?.textContent).toBe("sequenceDiagram");
+ expect(edited.querySelector(".mermaid-output svg text")?.textContent).toBe("sequenceDiagram");
await next.done;
expect(m.render).toHaveBeenCalledTimes(2);
ids.add(m.render.mock.calls[1]![0]);
@@ -128,17 +137,108 @@ describe("mermaid in the markdown preview", () => {
const b = second.querySelector("svg")!;
const idsA = idsOf(a);
const idsB = idsOf(b);
- expect(idsA).toHaveLength(2);
- expect(idsB).toHaveLength(2);
+ expect(idsA).toHaveLength(3);
+ expect(idsB).toHaveLength(3);
expect(idsA.filter((id) => idsB.includes(id))).toEqual([]);
- // Every marker reference resolves within its own copy.
+ // Every marker reference, ``,
+ );
+ expect(svg.querySelector("style")!.textContent).toBe(`#${svg.id}${tail}{fill:none;}`);
+ },
+ );
+
+ it("rewrites fragment links, quoted URLs and marker ids while preserving suffix selectors", async () => {
+ const id = "openidea-mermaid-1";
+ const marker = `${id}_flowchart-pointEnd`;
+ const svg = await renderedSvg(
+ ``,
+ );
+ const markerId = svg.querySelector("marker")!.id;
+ expect(markerId).toBe(`${svg.id}_flowchart-pointEnd`);
+ expect(svg.querySelector("[id$='-pointEnd']")).not.toBeNull();
+ expect(
+ [...svg.querySelectorAll("use")].map((el) => el.getAttribute("href") ?? el.getAttribute("xlink:href")),
+ ).toEqual([`#${markerId}`, `#${markerId}`]);
+ expect([...svg.querySelectorAll("path[marker-end]")].map((el) => el.getAttribute("marker-end"))).toEqual([
+ `url(#${markerId})`,
+ `url('#${markerId}')`,
+ `url("#${markerId}")`,
+ ]);
+ expect(svg.querySelector("path[style]")!.getAttribute("style")).toBe(`marker-end: url( '#${markerId}' );`);
+ expect([...svg.querySelectorAll("style")].map((el) => el.textContent)).toEqual([
+ `#${svg.id} [id$="-pointEnd"]{fill:#dad;}#${markerId}{stroke:#dad;}`,
+ `#${svg.id} path{marker-end:url("#${markerId}");}`,
+ ]);
+ expect(svg.getAttribute("aria-labelledby")).toBe(
+ `${svg.querySelector("title")!.id} ${svg.querySelector("desc")!.id}`,
+ );
+ expect(svg.getAttribute("aria-describedby")).toBe(svg.querySelector("desc")!.id);
+ });
+
+ it("keeps ids with a shared prefix distinct and leaves unknown references unchanged", async () => {
+ const short = "openidea-mermaid-1";
+ const long = "openidea-mermaid-10";
+ const svg = await renderedSvg(
+ ``,
+ );
+ const longId = svg.querySelector("g")!.id;
+ expect(longId).toBe(`${svg.id}0`);
+ expect([...svg.querySelectorAll("use")].map((el) => el.getAttribute("href"))).toEqual([
+ `#${svg.id}`,
+ `#${longId}`,
+ `#${long}0`,
+ ]);
+ expect(svg.querySelector("style")!.textContent).toBe(
+ `#${svg.id}>g,#${longId}:hover{fill:none;}#${long}0{fill:none;}`,
+ );
+ expect(svg.querySelector("path")!.getAttribute("marker-end")).toBe(`url('#${longId}')`);
+ });
+
+ it("leaves ids in ordinary labels and unrelated attributes untouched", async () => {
+ const id = "openidea-mermaid-1";
+ const label = `#${id} ${id} id="${id}" href="#${id}" url(#${id})`;
+ const svg = await renderedSvg(
+ ``,
+ );
+ expect(svg.querySelector("text")!.textContent).toBe(label);
+ expect(svg.querySelector("g")!.getAttribute("data-id")).toBe(id);
+ expect(svg.querySelector("g")!.getAttribute("data-label")).toBe(`#${id}`);
+ expect(svg.querySelector("g")!.getAttribute("title")).toBe(`url(#${id})`);
+ expect(svg.querySelector("a")!.getAttribute("href")).toBe(`https://example.com/#${id}`);
+ });
+
it("re-renders when switching between built-in palette themes of the same light / dark base", async () => {
const root = document.documentElement;
const previous = root.dataset.colorTheme;
diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts
index 3d7f9ac91..bd9ccbbbd 100644
--- a/src/features/viewers/markdown/mermaid.ts
+++ b/src/features/viewers/markdown/mermaid.ts
@@ -120,22 +120,57 @@ let applied = 0;
* hit reapplies the first render's SVG string. Two copies in one document — the same
* diagram twice, or one drawn again while the first sits hidden (a closed details, an
* inactive tab) — collide on their marker ids, and Chromium resolves the `url(#…)`
- * references against the hidden copy, losing arrowheads. Rewrites every id per
- * application: longest first, so an id that extends a shorter one moves out of the way.
+ * references against the hidden copy, losing arrowheads.
+ *
+ * Prefix ids so Mermaid's marker suffix selectors keep matching. Rewrite only
+ * attributes and CSS rules, leaving diagram labels and CSS colours untouched.
*/
function reid(svg: string): string {
- const ids = [...new Set([...svg.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]!))];
- if (!ids.length) return svg;
- const suffix = `-c${++applied}`;
- // References come as `id="…"`, `href="#…"` and `url(#…)`; the lookahead keeps
- // an id that extends this one untouched.
- for (const id of ids.sort((a, b) => b.length - a.length)) {
- svg = svg.replace(
- new RegExp(`(\\bid="|#|url\\(#)${escapeRegExp(id)}(?="|\\))`, "g"),
- (_m, prefix: string) => `${prefix}${id}${suffix}`,
- );
+ const tag = /<[a-zA-Z](?:[^>"']|"[^"]*"|'[^']*')*>/g;
+ const attribute = /(\s)([\w:-]+)(\s*=\s*)(["'])([\s\S]*?)\4/g;
+ const ids = new Set();
+ for (const [markup] of svg.matchAll(tag)) {
+ for (const attr of markup.matchAll(attribute)) if (attr[2] === "id") ids.add(attr[5]!);
}
- return svg;
+ if (!ids.size) return svg;
+ const prefix = `c${++applied}-`;
+ const rename = (id: string) => (ids.has(id) ? `${prefix}${id}` : id);
+ const pattern = [...ids]
+ .sort((a, b) => b.length - a.length)
+ .map(escapeRegExp)
+ .join("|");
+ const selector = new RegExp(`#(${pattern})(?=$|[\\s.:>+~\\[,])`, "g");
+ const urls = (value: string) =>
+ value.replace(
+ /(url\(\s*)(["']?)#([^'"\s)]+)(\2\s*\))/g,
+ (_m, start: string, quote: string, id: string, end: string) => `${start}${quote}#${rename(id)}${end}`,
+ );
+ return svg.replace(
+ /(