{
- if (!node) return;
- node.replaceChildren(state.svg.cloneNode(true));
- }}
- />
+ className="mermaid-file-preview-body relative flex min-h-full items-start justify-center"
+ >
+
{
+ if (!node) return;
+ node.replaceChildren(
+ state.svg.cloneNode(true),
+ );
+ }}
+ />
+
+
) : null}
{state.status === "error" ? (
{
view.dom.querySelector(".cm-mermaid-preview svg foreignObject")
?.textContent,
).toBe("README\u00a0Diagram");
+ expect(
+ view.dom.querySelector(
+ 'button[aria-label="Open Mermaid fullscreen"]',
+ ),
+ ).not.toBeNull();
expect(view.dom.querySelector(".cm-mermaid-preview-error")).toBeNull();
view.destroy();
diff --git a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts
index bbd18b58..75167739 100644
--- a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts
+++ b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts
@@ -54,6 +54,7 @@ import {
renderEmbedPreview,
} from "./notePreviewSource";
import { renderMermaidDiagram } from "../mermaid/mermaidRenderer";
+import { createMermaidFullscreenButton } from "../mermaid/mermaidFullscreen";
import { parseMermaidSvg } from "../mermaid/mermaidSvg";
import { formatCodeFenceLanguageLabel } from "../codeFencePresentation";
@@ -1029,6 +1030,9 @@ class MermaidDiagramWidget extends WidgetType {
const svg = parseMermaidSvg(result.svg);
if (svg) {
body.appendChild(svg);
+ body.appendChild(
+ createMermaidFullscreenButton(result.svg),
+ );
} else {
renderMermaidError(
body,
diff --git a/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts b/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts
index 07c766e8..a67dd25f 100644
--- a/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts
+++ b/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts
@@ -1069,6 +1069,7 @@ export const livePreviewTheme = EditorView.baseTheme({
fontSize: "0.9em",
},
".cm-mermaid-preview-body-rendered": {
+ position: "relative",
display: "flex",
justifyContent: "center",
alignItems: "center",
diff --git a/apps/desktop/src/features/editor/mermaid/MermaidFullscreenHost.test.tsx b/apps/desktop/src/features/editor/mermaid/MermaidFullscreenHost.test.tsx
new file mode 100644
index 00000000..0a277dd1
--- /dev/null
+++ b/apps/desktop/src/features/editor/mermaid/MermaidFullscreenHost.test.tsx
@@ -0,0 +1,99 @@
+import { act, fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { renderComponent } from "../../../test/test-utils";
+import { MermaidFullscreenHost } from "./MermaidFullscreenHost";
+import {
+ createMermaidFullscreenButton,
+ openMermaidFullscreen,
+} from "./mermaidFullscreen";
+
+const TEST_SVG = `
+
+`;
+
+describe("MermaidFullscreenHost", () => {
+ it("opens from a Mermaid preview button and closes from the toolbar", () => {
+ renderComponent(
);
+ const trigger = createMermaidFullscreenButton(TEST_SVG);
+ document.body.appendChild(trigger);
+
+ fireEvent.click(trigger);
+
+ expect(
+ screen.getByRole("dialog", { name: "Mermaid preview" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Fullscreen diagram")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Reset zoom" })).toHaveTextContent(
+ "100%",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ trigger.remove();
+ });
+
+ it("supports toolbar and trackpad zoom while leaving regular scrolling native", () => {
+ renderComponent(
);
+ act(() => openMermaidFullscreen(TEST_SVG));
+
+ fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
+ expect(screen.getByRole("button", { name: "Reset zoom" })).toHaveTextContent(
+ "125%",
+ );
+
+ const canvas = screen.getByLabelText("Mermaid canvas");
+ fireEvent.click(screen.getByRole("button", { name: "Reset zoom" }));
+ const gestureStart = new Event("gesturestart", {
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => canvas.dispatchEvent(gestureStart));
+ const gestureChange = new Event("gesturechange", {
+ bubbles: true,
+ cancelable: true,
+ });
+ Object.defineProperties(gestureChange, {
+ scale: { value: 1.5 },
+ clientX: { value: 40 },
+ clientY: { value: 40 },
+ });
+ act(() => canvas.dispatchEvent(gestureChange));
+ expect(gestureChange.defaultPrevented).toBe(true);
+ expect(screen.getByRole("button", { name: "Reset zoom" })).toHaveTextContent(
+ "150%",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Reset zoom" }));
+ const plainWheel = new WheelEvent("wheel", {
+ bubbles: true,
+ cancelable: true,
+ deltaY: 40,
+ });
+ canvas.dispatchEvent(plainWheel);
+ expect(plainWheel.defaultPrevented).toBe(false);
+ expect(screen.getByRole("button", { name: "Reset zoom" })).toHaveTextContent(
+ "100%",
+ );
+
+ fireEvent.wheel(canvas, {
+ ctrlKey: true,
+ deltaY: -100,
+ clientX: 40,
+ clientY: 40,
+ });
+ expect(screen.getByRole("button", { name: "Reset zoom" })).toHaveTextContent(
+ "125%",
+ );
+ });
+
+ it("closes on Escape", () => {
+ renderComponent(
);
+ act(() => openMermaidFullscreen(TEST_SVG));
+
+ fireEvent.keyDown(window, { key: "Escape" });
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/desktop/src/features/editor/mermaid/MermaidFullscreenHost.tsx b/apps/desktop/src/features/editor/mermaid/MermaidFullscreenHost.tsx
new file mode 100644
index 00000000..460cb0ce
--- /dev/null
+++ b/apps/desktop/src/features/editor/mermaid/MermaidFullscreenHost.tsx
@@ -0,0 +1,352 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ isWheelZoomGesture,
+ useWheelZoomModifier,
+} from "../../../app/hooks/useWheelZoomModifier";
+import { translate } from "../../../app/i18n";
+import { formatZoomPercentage } from "../../../app/utils/zoom";
+import {
+ OPEN_MERMAID_FULLSCREEN_EVENT,
+ type OpenMermaidFullscreenPayload,
+} from "./mermaidFullscreen";
+import { parseMermaidSvg } from "./mermaidSvg";
+
+const MIN_ZOOM = 0.25;
+const MAX_ZOOM = 8;
+const ZOOM_STEP = 1.25;
+const PINCH_SENSITIVITY = 0.0025;
+
+interface ZoomAnchor {
+ pointerOffsetX: number;
+ pointerOffsetY: number;
+ previousZoom: number;
+ nextZoom: number;
+}
+
+function clampZoom(value: number) {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
+}
+
+function clampScrollOffset(value: number) {
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
+}
+
+export function MermaidFullscreenHost() {
+ const [svgSource, setSvgSource] = useState
(null);
+ const [zoom, setZoom] = useState(1);
+ const viewportRef = useRef(null);
+ const svgMountRef = useRef(null);
+ const zoomRef = useRef(1);
+ const pendingZoomAnchorRef = useRef(null);
+ const previousFocusRef = useRef(null);
+ const wheelZoomModifierRef = useWheelZoomModifier();
+ const svg = useMemo(
+ () => (svgSource ? parseMermaidSvg(svgSource) : null),
+ [svgSource],
+ );
+
+ const close = useCallback(() => {
+ setSvgSource(null);
+ pendingZoomAnchorRef.current = null;
+ window.setTimeout(() => previousFocusRef.current?.focus(), 0);
+ }, []);
+
+ const setZoomAround = useCallback((requestedZoom: number, anchor?: { x: number; y: number }) => {
+ const viewport = viewportRef.current;
+ const previousZoom = zoomRef.current;
+ const nextZoom = clampZoom(requestedZoom);
+ if (!viewport || nextZoom === previousZoom) return;
+
+ const rect = viewport.getBoundingClientRect();
+ pendingZoomAnchorRef.current = {
+ pointerOffsetX: anchor ? anchor.x - rect.left : viewport.clientWidth / 2,
+ pointerOffsetY: anchor ? anchor.y - rect.top : viewport.clientHeight / 2,
+ previousZoom,
+ nextZoom,
+ };
+ zoomRef.current = nextZoom;
+ setZoom(nextZoom);
+ }, []);
+
+ useEffect(() => {
+ const handleOpen = (event: Event) => {
+ const detail = (event as CustomEvent)
+ .detail;
+ if (!detail?.svg.trim() || !parseMermaidSvg(detail.svg)) return;
+
+ previousFocusRef.current =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ zoomRef.current = 1;
+ pendingZoomAnchorRef.current = null;
+ setZoom(1);
+ setSvgSource(detail.svg);
+ };
+
+ window.addEventListener(OPEN_MERMAID_FULLSCREEN_EVENT, handleOpen);
+ return () =>
+ window.removeEventListener(
+ OPEN_MERMAID_FULLSCREEN_EVENT,
+ handleOpen,
+ );
+ }, []);
+
+ useEffect(() => {
+ if (!svgSource) return;
+
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ viewportRef.current?.focus();
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ event.stopPropagation();
+ close();
+ };
+
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener("keydown", handleKeyDown, true);
+ };
+ }, [close, svgSource]);
+
+ useEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport || !svgSource) return;
+ let gestureStartZoom = zoomRef.current;
+
+ const handleWheel = (event: WheelEvent) => {
+ if (!isWheelZoomGesture(event, wheelZoomModifierRef)) return;
+ event.preventDefault();
+ event.stopPropagation();
+
+ setZoomAround(
+ zoomRef.current *
+ (1 - event.deltaY * PINCH_SENSITIVITY),
+ { x: event.clientX, y: event.clientY },
+ );
+ };
+
+ const handleGestureStart = (event: Event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ gestureStartZoom = zoomRef.current;
+ };
+
+ const handleGestureChange = (event: Event) => {
+ const gesture = event as Event & {
+ scale?: number;
+ clientX?: number;
+ clientY?: number;
+ };
+ if (!Number.isFinite(gesture.scale)) return;
+ event.preventDefault();
+ event.stopPropagation();
+
+ setZoomAround(gestureStartZoom * (gesture.scale ?? 1), {
+ x: gesture.clientX ?? viewport.clientWidth / 2,
+ y: gesture.clientY ?? viewport.clientHeight / 2,
+ });
+ };
+
+ viewport.addEventListener("wheel", handleWheel, { passive: false });
+ viewport.addEventListener("gesturestart", handleGestureStart, {
+ passive: false,
+ });
+ viewport.addEventListener("gesturechange", handleGestureChange, {
+ passive: false,
+ });
+ return () => {
+ viewport.removeEventListener("wheel", handleWheel);
+ viewport.removeEventListener("gesturestart", handleGestureStart);
+ viewport.removeEventListener("gesturechange", handleGestureChange);
+ };
+ }, [setZoomAround, svgSource, wheelZoomModifierRef]);
+
+ useLayoutEffect(() => {
+ const viewport = viewportRef.current;
+ const pending = pendingZoomAnchorRef.current;
+ if (!viewport || !pending) return;
+
+ const ratio = pending.nextZoom / pending.previousZoom;
+ viewport.scrollLeft = clampScrollOffset(
+ (viewport.scrollLeft + pending.pointerOffsetX) * ratio -
+ pending.pointerOffsetX,
+ );
+ viewport.scrollTop = clampScrollOffset(
+ (viewport.scrollTop + pending.pointerOffsetY) * ratio -
+ pending.pointerOffsetY,
+ );
+ pendingZoomAnchorRef.current = null;
+ }, [zoom]);
+
+ useLayoutEffect(() => {
+ const mount = svgMountRef.current;
+ if (!mount || !svg) return;
+
+ const clone = svg.cloneNode(true) as SVGSVGElement;
+ clone.style.width = "100%";
+ clone.style.maxWidth = "none";
+ clone.style.height = "auto";
+ clone.style.display = "block";
+ mount.replaceChildren(clone);
+ }, [svg]);
+
+ if (!svgSource || !svg) return null;
+
+ return (
+
+
+ {translate("Mermaid preview")}
+
+
+
+ setZoomAround(zoomRef.current / ZOOM_STEP)}
+ >
+ −
+
+
+ setZoomAround(zoomRef.current * ZOOM_STEP)}
+ >
+ +
+
+
+
+ ×
+
+
+
+
+ {translate("Pinch to zoom • Scroll to pan • Esc to close")}
+
+
+
+
+ );
+}
+
+function ToolbarButton({
+ children,
+ label,
+ onClick,
+}: {
+ children: string;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
+const toolbarButtonStyle = {
+ border: "1px solid transparent",
+ background: "transparent",
+ color: "var(--text-primary)",
+ cursor: "pointer",
+} as const;
diff --git a/apps/desktop/src/features/editor/mermaid/mermaidFullscreen.ts b/apps/desktop/src/features/editor/mermaid/mermaidFullscreen.ts
new file mode 100644
index 00000000..1862a7ea
--- /dev/null
+++ b/apps/desktop/src/features/editor/mermaid/mermaidFullscreen.ts
@@ -0,0 +1,36 @@
+import { translate } from "../../../app/i18n";
+
+export const OPEN_MERMAID_FULLSCREEN_EVENT =
+ "bifrostwrite:open-mermaid-fullscreen";
+
+export interface OpenMermaidFullscreenPayload {
+ svg: string;
+}
+
+export function openMermaidFullscreen(svg: string | SVGElement) {
+ const source = typeof svg === "string" ? svg : svg.outerHTML;
+ if (!source.trim()) return;
+
+ window.dispatchEvent(
+ new CustomEvent(
+ OPEN_MERMAID_FULLSCREEN_EVENT,
+ { detail: { svg: source } },
+ ),
+ );
+}
+
+export function createMermaidFullscreenButton(svg: string) {
+ const button = document.createElement("button");
+ const label = translate("Open Mermaid fullscreen");
+ button.type = "button";
+ button.className = "mermaid-fullscreen-trigger";
+ button.setAttribute("aria-label", label);
+ button.title = label;
+ button.textContent = "⛶";
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ openMermaidFullscreen(svg);
+ });
+ return button;
+}
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css
index 78587efc..1ce350d6 100644
--- a/apps/desktop/src/index.css
+++ b/apps/desktop/src/index.css
@@ -2,6 +2,46 @@
@import "katex/dist/katex.min.css";
@import "react-datasheet-grid/dist/style.css";
+.mermaid-fullscreen-trigger {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 2;
+ display: inline-flex;
+ width: 30px;
+ height: 30px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
+ color: var(--text-primary);
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.14);
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ opacity: 0.82;
+ transition:
+ opacity 120ms ease,
+ background-color 120ms ease,
+ border-color 120ms ease;
+}
+
+.mermaid-fullscreen-trigger:hover,
+.mermaid-fullscreen-trigger:focus-visible {
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
+ background: color-mix(in srgb, var(--accent) 12%, var(--bg-primary));
+ opacity: 1;
+ outline: none;
+}
+
+.mermaid-fullscreen-toolbar-button:hover,
+.mermaid-fullscreen-toolbar-button:focus-visible {
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--border)) !important;
+ background: color-mix(in srgb, var(--accent) 12%, transparent) !important;
+ outline: none;
+}
+
@font-face {
font-family: "JetBrains Mono";
src: url("./assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2")