Compare commits

...
Author SHA1 Message Date
l0ng-ai 5107fe8502 fix(markdown): redraw a Mermaid diagram's full-screen view when the diagram is redrawn
The full-screen view kept a copy of the diagram as it was when opened, so a theme
change (or an edit) redrew the inline diagram but left the open view in the old
colours. The view now replaces its copy in place whenever its block is redrawn,
keeping the current pan and zoom; Download saves the newest drawing.
2026-11-12 04:56:31 +08:00
2 changed files with 55 additions and 4 deletions

No files matched your search

@@ -484,6 +484,41 @@ describe("mermaid in the markdown preview", () => {
expect(document.activeElement).toBe(button);
});
it("redraws the full-screen view when the diagram is redrawn behind it", async () => {
const html = document.documentElement;
const previous = html.dataset.colorTheme;
try {
html.dataset.colorTheme = "one-dark";
const root = preview(SEQ);
await renderMermaidIn(root).done;
root.querySelector<HTMLButtonElement>('[data-mermaid-tools] button[aria-label="Full screen"]')!.click();
const overlay = document.querySelector<HTMLElement>("[data-mermaid-preview]")!;
const pan = overlay.querySelector<HTMLElement>(".pan-zoom-content")!;
const before = overlay.querySelector("svg")!;
pan.parentElement!.dispatchEvent(
new WheelEvent("wheel", { deltaY: -120, ctrlKey: true, bubbles: true, cancelable: true }),
);
const zoomed = pan.style.transform;
html.dataset.colorTheme = "solarized-dark";
m.render.mockImplementationOnce(async (id: string) => ({ svg: `<svg id="${id}"><text>redrawn</text></svg>` }));
await renderMermaidIn(root).done;
const after = overlay.querySelector("svg")!;
expect(after).not.toBe(before);
expect(after.textContent).toBe("redrawn");
expect(after.parentElement).toBe(pan);
expect(pan.style.transform).toBe(zoomed);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
html.dataset.colorTheme = "one-dark";
await renderMermaidIn(root).done;
expect(document.querySelector("[data-mermaid-preview]")).toBeNull();
} finally {
if (previous === undefined) delete html.dataset.colorTheme;
else html.dataset.colorTheme = previous;
}
});
it("saves a diagram as a PNG through the file dialog", async () => {
const context = { fillStyle: "", scale: vi.fn(), fillRect: vi.fn(), drawImage: vi.fn() };
const getContext = vi
+20 -4
View File
@@ -27,10 +27,12 @@ export function attachDiagramTools(block: HTMLElement, svg: SVGSVGElement): void
tools.className = styles.tools!;
tools.setAttribute(TOOLS_ATTR, "");
tools.append(
toolButton("fullscreen", tx("Full screen"), (button) => openDiagramPreview(svg, button)),
toolButton("fullscreen", tx("Full screen"), (button) => openDiagramPreview(block, svg, button)),
toolButton("download", tx("Download image"), () => void saveDiagram(svg)),
);
block.append(tools);
// Redrawn (a theme change, an edit) while shown full screen: the preview follows.
if (preview?.block === block) preview.redraw(svg);
}
function toolButton(icon: ToolIcon, label: string, onClick: (button: HTMLButtonElement) => void): HTMLButtonElement {
@@ -49,13 +51,15 @@ function toolButton(icon: ToolIcon, label: string, onClick: (button: HTMLButtonE
}
let closeActive: (() => void) | null = null;
/** The block shown full screen, and how to show its newest drawing. */
let preview: { block: HTMLElement; redraw: (svg: SVGSVGElement) => void } | null = null;
/**
* The diagram over the whole window, fitted and centered: drag pans it anywhere,
* ⌘/Ctrl+wheel zooms, double-click resets. Escape (or a click outside, or Close)
* returns to the page, with focus back on the button that opened it.
*/
function openDiagramPreview(svg: SVGSVGElement, returnTo?: HTMLElement): void {
function openDiagramPreview(block: HTMLElement, svg: SVGSVGElement, returnTo?: HTMLElement): void {
closeActive?.();
const returnFocus = returnTo ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
const overlay = document.createElement("div");
@@ -71,6 +75,7 @@ function openDiagramPreview(svg: SVGSVGElement, returnTo?: HTMLElement): void {
if (closed) return;
closed = true;
if (closeActive === close) closeActive = null;
if (preview?.redraw === redraw) preview = null;
document.removeEventListener("keydown", onKey, true);
layer.release();
overlay.remove();
@@ -93,7 +98,7 @@ function openDiagramPreview(svg: SVGSVGElement, returnTo?: HTMLElement): void {
const tools = document.createElement("div");
tools.className = styles.previewTools!;
const download = toolButton("download", tx("Download image"), () => void saveDiagram(svg));
const download = toolButton("download", tx("Download image"), () => void saveDiagram(current));
tools.append(download, toolButton("close", tx("Close"), close));
overlay.append(tools);
@@ -106,7 +111,10 @@ function openDiagramPreview(svg: SVGSVGElement, returnTo?: HTMLElement): void {
// The canvas fills the window: the fitted drawing is centred in it, and the
// whole window stays a drag surface once zoomed in. Top rows stay free of it
// so the Close and Download buttons never cover the drawing.
const fitted = fittedCopy(svg, canvas.clientWidth, Math.max(1, canvas.clientHeight - 32));
const fit = (next: SVGSVGElement) => fittedCopy(next, canvas.clientWidth, Math.max(1, canvas.clientHeight - 32));
let current = svg;
let shown = fit(svg);
const fitted = shown;
canvas.append(fitted);
const width = Number(fitted.getAttribute("width"));
const height = Number(fitted.getAttribute("height"));
@@ -118,6 +126,14 @@ function openDiagramPreview(svg: SVGSVGElement, returnTo?: HTMLElement): void {
y: (canvas.clientHeight - height) / 2,
},
});
// In place, inside the pan/zoom layer, so the current pan and zoom stay.
const redraw = (next: SVGSVGElement) => {
current = next;
const copy = fit(next);
shown.replaceWith(copy);
shown = copy;
};
preview = { block, redraw };
closeActive = close;
download.focus({ preventScroll: true });
}