Merge SVG figure style isolation
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
/** Two display-only repairs for inputs the old HTML embedding accepted.
|
||||||
|
* Do not rewrite stored artifacts/downloads or attempt general XML repair.
|
||||||
|
* Comments, CDATA and processing instructions are copied verbatim. */
|
||||||
|
export function svgImageSource(svg: string): string {
|
||||||
|
let foundRoot = false;
|
||||||
|
return svg.replace(
|
||||||
|
/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<\?[\s\S]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>| /g,
|
||||||
|
(token) => {
|
||||||
|
if (token.startsWith("<!--") || token.startsWith("<![CDATA[") || token.startsWith("<?")) return token;
|
||||||
|
if (!foundRoot && /^<svg(?=[\s/>])/i.test(token)) {
|
||||||
|
foundRoot = true;
|
||||||
|
// Consume complete attribute values, so text mentioning xmlns
|
||||||
|
// inside a different attribute cannot masquerade as a declaration.
|
||||||
|
const attrs = token.matchAll(/\s([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g);
|
||||||
|
const hasNamespace = Array.from(attrs).some((attr) => attr[1] === "xmlns");
|
||||||
|
if (!hasNamespace) token = token.replace(/^<svg/i, '$& xmlns="http://www.w3.org/2000/svg"');
|
||||||
|
}
|
||||||
|
return token.replace(/ /g, " ");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep generated SVG in an image document, outside the app's CSS and ID scope.
|
||||||
|
* This is a display boundary, not a sanitizer for the original SVG download. */
|
||||||
|
export function SvgImage({
|
||||||
|
svg,
|
||||||
|
className,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
svg: string;
|
||||||
|
className?: string;
|
||||||
|
width?: number;
|
||||||
|
}) {
|
||||||
|
// The source is already an in-memory vector image; no Next image optimization.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgImageSource(svg))}`} alt="Figure" className={className} width={width} draggable={false} />;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Toggle } from "@/components/settings/controls";
|
|||||||
import { useSessionMermaid } from "@/components/session-overlay";
|
import { useSessionMermaid } from "@/components/session-overlay";
|
||||||
import { downloadFile } from "@/components/artifacts/download";
|
import { downloadFile } from "@/components/artifacts/download";
|
||||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||||
|
import { SvgImage } from "@/components/artifacts/svg-image";
|
||||||
import type { ArtifactPartProps, VisualResult } from "@/src/core";
|
import type { ArtifactPartProps, VisualResult } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,22 +17,10 @@ import type { ArtifactPartProps, VisualResult } from "@/src/core";
|
|||||||
* the chat view's jobs feed). The SVG itself renders from the steer message
|
* the chat view's jobs feed). The SVG itself renders from the steer message
|
||||||
* (sub-agent result delivery) via VisualOutputCard.
|
* (sub-agent result delivery) via VisualOutputCard.
|
||||||
*
|
*
|
||||||
* The SVG is model-authored for the learner's own machine (their own teacher
|
* Both display modes use an SVG image document: generated markup and its
|
||||||
* wrote it, verified by looking) — dangerouslySetInnerHTML is acceptable
|
* styles never join the app's DOM. Downloads still contain the original SVG.
|
||||||
* here, with a cheap hygiene pass (sanitizeSvg: strip <script> blocks, on*
|
|
||||||
* handlers, and javascript:/data:text/html URLs) since it lands in the
|
|
||||||
* app's DOM.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Cheap SVG hygiene — not a full sanitizer, just removing the obvious exec vectors. */
|
|
||||||
export function sanitizeSvg(svg: string): string {
|
|
||||||
return svg
|
|
||||||
.replace(/<script[\s\S]*?<\/script\s*>/gi, "")
|
|
||||||
.replace(/<script[^>]*\/?>/gi, "")
|
|
||||||
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
|
||||||
.replace(/(href|xlink:href)\s*=\s*(?:"\s*(?:javascript|data:text\/html)[^"]*"|'\s*(?:javascript|data:text\/html)[^']*'|(?:javascript|data:text\/html)[^\s>]*)/gi, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The svg root's DECLARED width in px, when there is one — the natural
|
/** The svg root's DECLARED width in px, when there is one — the natural
|
||||||
* size the card displays at instead of stretching to full width (owner,
|
* size the card displays at instead of stretching to full width (owner,
|
||||||
* 2026-10-07). %, em, "auto", or a missing attribute → null (the caller
|
* 2026-10-07). %, em, "auto", or a missing attribute → null (the caller
|
||||||
@@ -122,13 +111,18 @@ export function VisualOutputCard({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
natural !== null
|
natural !== null
|
||||||
? "w-fit max-w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:max-w-full"
|
? "w-fit max-w-full overflow-hidden rounded-lg bg-white"
|
||||||
: "w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:w-full"
|
: "w-full overflow-hidden rounded-lg bg-white"
|
||||||
}
|
}
|
||||||
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
|
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }}
|
|
||||||
data-testid="visual-svg"
|
data-testid="visual-svg"
|
||||||
/>
|
>
|
||||||
|
<SvgImage
|
||||||
|
svg={result.svg}
|
||||||
|
width={natural ?? undefined}
|
||||||
|
className={natural !== null ? "block h-auto max-w-full" : "block h-auto w-full"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/comp
|
|||||||
import { renderMermaid } from "@/components/mermaid-render";
|
import { renderMermaid } from "@/components/mermaid-render";
|
||||||
import { MermaidError } from "@/components/mermaid-error";
|
import { MermaidError } from "@/components/mermaid-error";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
import { SvgImage } from "@/components/artifacts/svg-image";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
|
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
|
||||||
@@ -269,22 +270,24 @@ export function MermaidOverlay({
|
|||||||
: "transform 180ms ease-out, opacity 160ms ease-out",
|
: "transform 180ms ease-out, opacity 160ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* data-lh-overlay-chart: globals.css pins the svg to the
|
{/* Figures share the card's image boundary and white backing.
|
||||||
wrapper's width (kills mermaid's inline max-width cap)
|
Mermaid keeps its inline renderer; globals.css removes
|
||||||
so the wrapper IS the natural-size box the fit math
|
its max-width cap so it fills the natural-size box. */}
|
||||||
centers. FIGURE MODE: a white stage (the figure's
|
{rawSvg !== undefined ? (
|
||||||
backing law — models draw for a white page) with the
|
<div
|
||||||
caller's invert filter riding it, exactly like the
|
data-lh-overlay-chart
|
||||||
in-card holder. */}
|
className="bg-white"
|
||||||
<div
|
style={inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined}
|
||||||
data-lh-overlay-chart
|
>
|
||||||
className={cn(
|
<SvgImage svg={svg} className="block h-auto w-full" />
|
||||||
"[&_svg]:h-auto [&_svg]:w-full",
|
</div>
|
||||||
rawSvg !== undefined && "bg-white",
|
) : (
|
||||||
)}
|
<div
|
||||||
style={rawSvg !== undefined && inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined}
|
data-lh-overlay-chart
|
||||||
dangerouslySetInnerHTML={{ __html: svg }}
|
className="[&_svg]:h-auto [&_svg]:w-full"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: svg }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||||
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
||||||
|
|||||||
+7
-16
@@ -6,7 +6,7 @@
|
|||||||
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
|
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
|
||||||
* reducer (reduceDemoBridgeState)
|
* reducer (reduceDemoBridgeState)
|
||||||
* 2. card rendering (static markup): sandboxed iframe attrs, error chip,
|
* 2. card rendering (static markup): sandboxed iframe attrs, error chip,
|
||||||
* failure notes, inline sanitized svg
|
* failure notes, SVG image documents
|
||||||
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with
|
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with
|
||||||
* a fix iteration, untested-final verification, never-clean failure,
|
* a fix iteration, untested-final verification, never-clean failure,
|
||||||
* no-Chrome failure)
|
* no-Chrome failure)
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
INITIAL_DEMO_BRIDGE_STATE,
|
INITIAL_DEMO_BRIDGE_STATE,
|
||||||
reduceDemoBridgeState,
|
reduceDemoBridgeState,
|
||||||
} from "../components/artifacts/demo-card.tsx";
|
} from "../components/artifacts/demo-card.tsx";
|
||||||
import { sanitizeSvg, svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx";
|
import { svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx";
|
||||||
import { slugFilename } from "../components/artifacts/download.ts";
|
import { slugFilename } from "../components/artifacts/download.ts";
|
||||||
import { chartSize } from "../components/session-overlay.tsx";
|
import { chartSize } from "../components/session-overlay.tsx";
|
||||||
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
||||||
@@ -373,19 +373,19 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the verified svg inline (sanitized); the maker's notes are glue and stay hidden", () => {
|
it("renders the verified svg as an image; the maker's notes are glue and stay hidden", () => {
|
||||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
|
||||||
const steer = buildSubagentSteer(
|
const steer = buildSubagentSteer(
|
||||||
{ id: "jv", kind: "visual", toolCallId: "cv" },
|
{ id: "jv", kind: "visual", toolCallId: "cv" },
|
||||||
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
|
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.ok(html.includes("circle"), "svg content rendered inline");
|
assert.ok(html.includes(encodeURIComponent(svg)), "the image URL contains the original SVG");
|
||||||
assert.match(html, /Figure/);
|
assert.match(html, /Figure/);
|
||||||
// Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the
|
// Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the
|
||||||
// holder hugs + centers rather than stretching full-width.
|
// holder hugs + centers rather than stretching full-width.
|
||||||
assert.match(html, /w-fit/, "holder hugs the declared size");
|
assert.match(html, /w-fit/, "holder hugs the declared size");
|
||||||
assert.match(html, /svg\]:max-w-full/, "svg shrinks to fit only when oversized");
|
assert.match(html, /class="block h-auto max-w-full"/, "image shrinks to fit only when oversized");
|
||||||
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
|
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -590,17 +590,8 @@ describe("setSteerUiInverted (the persisted invert switch, 2026-10-07)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitizeSvg", () => {
|
// Generated-figure image boundaries are covered in svg-image.test.tsx.
|
||||||
it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => {
|
// There is no longer a partial regex sanitizer in the display path.
|
||||||
const dirty = '<svg width="10" height="10" onload="alert(1)"><script>alert(2)</script><circle r="1" onclick="pwn()"/><a href="javascript:alert(3)"><rect width="2" height="2"/></a></svg>';
|
|
||||||
const clean = sanitizeSvg(dirty);
|
|
||||||
assert.ok(!/<script/i.test(clean), "script tags removed");
|
|
||||||
assert.ok(!/\sonload/i.test(clean), "onload removed");
|
|
||||||
assert.ok(!/\sonclick/i.test(clean), "onclick removed");
|
|
||||||
assert.ok(!/javascript:/i.test(clean), "javascript: urls removed");
|
|
||||||
assert.ok(clean.includes("circle") && clean.includes("rect"), "geometry preserved");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── 3. the demo-builder loop ────────────────────────────────────────────────
|
// ─── 3. the demo-builder loop ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { act, useState } from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { VisualOutputCard } from "../components/artifacts/visual-card.tsx";
|
||||||
|
import { svgImageSource } from "../components/artifacts/svg-image.tsx";
|
||||||
|
import { MermaidOverlay, SessionMermaidContext } from "../components/session-overlay.tsx";
|
||||||
|
|
||||||
|
// Structure-only checks: linkedom does not load images or implement CSS cascade.
|
||||||
|
// No fixture script, link, or external reference is executed by these tests.
|
||||||
|
const FIGURE = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160" viewBox="0 0 320 160"><style>text{font-family:Arial,Helvetica,sans-serif;fill:#202830}</style><defs><marker id="arrow"><path d="M0 0L5 5"/></marker></defs><path marker-end="url(#arrow)" d="M0 0L20 20"/><text>θ & φ: 50% #1</text></svg>`;
|
||||||
|
const ACTIVE_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" onload="example()"><script>example()</script><foreignObject><div xmlns="http://www.w3.org/1999/xhtml" onclick="example()">Example</div></foreignObject><a href="javascript:example()"><text>Link</text></a><image href="https://example.invalid/image.png"/></svg>`;
|
||||||
|
const PREFIX = "data:image/svg+xml;charset=utf-8,";
|
||||||
|
|
||||||
|
function assertImageBoundary(holder: Element, source: string) {
|
||||||
|
const image = holder.querySelector("img");
|
||||||
|
assert.ok(image, "figure is an image");
|
||||||
|
assert.equal(holder.children.length, 1, "no figure markup alongside the image");
|
||||||
|
assert.equal(holder.querySelector("svg, style, script, foreignObject, a, [onload], [onclick]"), null,
|
||||||
|
"generated elements and CSS never enter the app document");
|
||||||
|
assert.equal(image.getAttribute("alt"), "Figure");
|
||||||
|
assert.equal(image.getAttribute("draggable"), "false", "native image dragging must not interfere with panning");
|
||||||
|
const src = image.getAttribute("src")!;
|
||||||
|
assert.ok(src.startsWith(PREFIX));
|
||||||
|
assert.equal(decodeURIComponent(src.slice(PREFIX.length)), source, "image URL contains the expected SVG display source, not a raster");
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SVG display compatibility", () => {
|
||||||
|
it("leaves standalone SVG unchanged, including comments and CDATA", () => {
|
||||||
|
const source = FIGURE.replace("</svg>", '<!-- <svg> --><![CDATA[ <svg>]]></svg>');
|
||||||
|
assert.equal(svgImageSource(source), source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds only a missing root namespace, ignoring comments and attribute values", () => {
|
||||||
|
const source = '<?xml version="1.0"?><!-- <svg xmlns="wrong"> --><svg data-note=\' xmlns="not-a-declaration" >\' xmlns:xlink="http://www.w3.org/1999/xlink"><svg/></svg>';
|
||||||
|
const expected = source.replace('<svg data-note=', '<svg xmlns="http://www.w3.org/2000/svg" data-note=');
|
||||||
|
assert.equal(svgImageSource(source), expected);
|
||||||
|
assert.equal(svgImageSource(expected), expected, "normalization is idempotent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replace an explicit namespace declaration", () => {
|
||||||
|
for (const declaration of ['xmlns="http://www.w3.org/2000/svg"', "xmlns = 'other'", 'xmlns=""']) {
|
||||||
|
const source = `<svg ${declaration}><text>Example</text></svg>`;
|
||||||
|
assert.equal(svgImageSource(source), source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts nbsp references in text and attributes, but not comments, CDATA or processing instructions", () => {
|
||||||
|
const source = '<?example ?><svg xmlns="http://www.w3.org/2000/svg" aria-label="A B"><text>A B &nbsp;  </text><!-- --><![CDATA[ ]]></svg>';
|
||||||
|
const expected = source.replace('aria-label="A B"', 'aria-label="A B"').replace('<text>A B', '<text>A B');
|
||||||
|
assert.equal(svgImageSource(source), expected);
|
||||||
|
assert.equal(svgImageSource(expected), expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generated SVG image boundary", () => {
|
||||||
|
for (const mode of ["inline", "fullscreen"] as const) {
|
||||||
|
for (const [name, source] of [["reported style rule and fragment IDs", FIGURE], ["active markup", ACTIVE_MARKUP]] as const) {
|
||||||
|
it(`${mode}: isolates ${name} inside the image URL`, () => {
|
||||||
|
const html = renderToStaticMarkup(mode === "inline"
|
||||||
|
? <VisualOutputCard output={{ svg: source }} />
|
||||||
|
: <MermaidOverlay svg={source} onClose={() => {}} />);
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
|
||||||
|
assertImageBoundary(holder, source);
|
||||||
|
assert.ok(holder.classList.contains("bg-white"), "white backing retained");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, original, display] of [
|
||||||
|
["missing namespace", '<svg width="320" height="160"><text>Example</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160"><text>Example</text></svg>'],
|
||||||
|
["HTML entity", '<svg xmlns="http://www.w3.org/2000/svg"><text>A B</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg"><text>A B</text></svg>'],
|
||||||
|
] as const) {
|
||||||
|
it(`${mode}: repairs ${name} only in the image source`, () => {
|
||||||
|
const output = { svg: original };
|
||||||
|
const html = renderToStaticMarkup(mode === "inline"
|
||||||
|
? <VisualOutputCard output={output} />
|
||||||
|
: <MermaidOverlay svg={output.svg} onClose={() => {}} />);
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
|
||||||
|
assertImageBoundary(holder, display);
|
||||||
|
assert.equal(output.svg, original, "artifact source was not mutated");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it(`${mode}: retains inversion on the white backing`, () => {
|
||||||
|
const html = renderToStaticMarkup(mode === "inline"
|
||||||
|
? <VisualOutputCard output={{ svg: FIGURE }} initialInverted />
|
||||||
|
: <MermaidOverlay svg={FIGURE} inverted onClose={() => {}} />);
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
|
||||||
|
assert.match(holder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
|
||||||
|
assert.ok(holder.classList.contains("bg-white"));
|
||||||
|
assertImageBoundary(holder, FIGURE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("inline: retains natural width and only shrinks oversized figures", () => {
|
||||||
|
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: FIGURE }} />));
|
||||||
|
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, FIGURE);
|
||||||
|
assert.equal(image.getAttribute("width"), "320");
|
||||||
|
assert.equal(image.className, "block h-auto max-w-full");
|
||||||
|
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-fit"));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of ["", ' width="100%"']) {
|
||||||
|
it(`inline: keeps the full-width fallback for ${width || "viewBox-only"} SVG`, () => {
|
||||||
|
const source = `<svg xmlns="http://www.w3.org/2000/svg"${width} viewBox="0 0 320 160"><text>Example</text></svg>`;
|
||||||
|
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: source }} />));
|
||||||
|
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, source);
|
||||||
|
assert.equal(image.getAttribute("width"), null);
|
||||||
|
assert.equal(image.className, "block h-auto w-full");
|
||||||
|
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-full"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("fullscreen: keeps the natural-size box and zoom controls", () => {
|
||||||
|
const { document } = parseHTML(renderToStaticMarkup(<MermaidOverlay svg={FIGURE} onClose={() => {}} />));
|
||||||
|
const holder = document.querySelector("[data-lh-overlay-chart]")!;
|
||||||
|
assert.equal(assertImageBoundary(holder, FIGURE).className, "block h-auto w-full");
|
||||||
|
assert.match(holder.parentElement!.getAttribute("style")!, /width:320px/);
|
||||||
|
for (const label of ["Zoom in", "Zoom out", "Fit diagram to view", "Close"]) {
|
||||||
|
assert.ok(document.querySelector(`button[aria-label="${label}"]`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the fullscreen button opens the same SVG image and inversion; download keeps original bytes", async () => {
|
||||||
|
const originalSvg = FIGURE.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace("θ & φ", "θ & φ");
|
||||||
|
const displaySvg = FIGURE.replace("θ & φ", "θ & φ");
|
||||||
|
const dom = parseHTML("<html><body></body></html>");
|
||||||
|
const names = ["window", "document", "Element", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const;
|
||||||
|
const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: dom.window,
|
||||||
|
document: dom.document,
|
||||||
|
Element: dom.window.Element,
|
||||||
|
HTMLElement: dom.window.HTMLElement,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
});
|
||||||
|
const host = dom.document.createElement("div");
|
||||||
|
dom.document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const originalCreateUrl = URL.createObjectURL;
|
||||||
|
const originalRevokeUrl = URL.revokeObjectURL;
|
||||||
|
const originalCreateElement = dom.document.createElement;
|
||||||
|
let downloaded: Blob | undefined;
|
||||||
|
let revoked: string | undefined;
|
||||||
|
let clicked: { name: string; href: string } | undefined;
|
||||||
|
URL.createObjectURL = (blob) => {
|
||||||
|
assert.ok(blob instanceof Blob);
|
||||||
|
downloaded = blob;
|
||||||
|
return "blob:figure-test";
|
||||||
|
};
|
||||||
|
URL.revokeObjectURL = (url) => { revoked = url; };
|
||||||
|
function Harness() {
|
||||||
|
const [focus, setFocus] = useState<{ svg: string; inverted: boolean } | null>(null);
|
||||||
|
return (
|
||||||
|
<SessionMermaidContext.Provider value={{ openMermaid: () => {}, openFigure: (svg, inverted) => setFocus({ svg, inverted }) }}>
|
||||||
|
<VisualOutputCard output={{ svg: originalSvg }} initialInverted />
|
||||||
|
{focus && <MermaidOverlay svg={focus.svg} inverted={focus.inverted} onClose={() => setFocus(null)} />}
|
||||||
|
</SessionMermaidContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<Harness />));
|
||||||
|
await act(async () => { host.querySelector('[data-testid="visual-open"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
|
||||||
|
const inline = assertImageBoundary(host.querySelector('[data-testid="visual-svg"]')!, displaySvg);
|
||||||
|
const overlayHolder = host.querySelector("[data-lh-overlay-chart]")!;
|
||||||
|
const fullscreen = assertImageBoundary(overlayHolder, displaySvg);
|
||||||
|
assert.equal(fullscreen.getAttribute("src"), inline.getAttribute("src"));
|
||||||
|
assert.match(overlayHolder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
|
||||||
|
|
||||||
|
// Intercept only the download anchor; never navigate or write a real file.
|
||||||
|
dom.document.createElement = ((...args: Parameters<typeof originalCreateElement>) => {
|
||||||
|
const element = originalCreateElement.apply(dom.document, args);
|
||||||
|
if (args[0] === "a") {
|
||||||
|
element.click = () => { clicked = { name: element.getAttribute("download")!, href: element.getAttribute("href")! }; };
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}) as typeof originalCreateElement;
|
||||||
|
await act(async () => { host.querySelector('[data-testid="visual-download"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
|
||||||
|
assert.ok(downloaded);
|
||||||
|
assert.equal(await downloaded.text(), originalSvg);
|
||||||
|
assert.equal(downloaded.type, "image/svg+xml");
|
||||||
|
assert.deepEqual(clicked, { name: "figure.svg", href: "blob:figure-test" });
|
||||||
|
assert.equal(revoked, "blob:figure-test");
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
} finally {
|
||||||
|
URL.createObjectURL = originalCreateUrl;
|
||||||
|
URL.revokeObjectURL = originalRevokeUrl;
|
||||||
|
dom.document.createElement = originalCreateElement;
|
||||||
|
host.remove();
|
||||||
|
for (const [i, name] of names.entries()) {
|
||||||
|
if (originals[i]) Object.defineProperty(globalThis, name, originals[i]!);
|
||||||
|
else Reflect.deleteProperty(globalThis, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user