From 29031fb4987ed32a96398f467db0d7b9520dd332 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 31 May 2026 15:22:50 -0400 Subject: [PATCH 1/4] mermaid: zoom diagrams --- .../1.91/add-2026-05-28-mermaid-zoom-pan.md | 1 + znai-docs/znai/release-notes/2026.md | 4 + znai-docs/znai/visuals/mermaid-diagrams.md | 55 ++++ .../src/doc-elements/mermaid/Mermaid.css | 24 ++ .../src/doc-elements/mermaid/Mermaid.demo.tsx | 8 + .../src/doc-elements/mermaid/Mermaid.tsx | 110 +++---- .../doc-elements/mermaid/MermaidZoomed.css | 79 +++++ .../mermaid/MermaidZoomed.test.tsx | 63 ++++ .../doc-elements/mermaid/MermaidZoomed.tsx | 298 ++++++++++++++++++ .../doc-elements/mermaid/large-diagram.txt | 34 ++ .../mermaid/mermaidSvgSize.test.ts | 61 ++++ .../doc-elements/mermaid/mermaidSvgSize.ts | 64 ++++ .../src/doc-elements/mermaid/useMermaidSvg.ts | 105 ++++++ znai-reactjs/src/test/setup.ts | 26 +- znai-reactjs/vite.config.ts | 47 ++- 15 files changed, 886 insertions(+), 93 deletions(-) create mode 100644 znai-docs/znai/release-notes/1.91/add-2026-05-28-mermaid-zoom-pan.md create mode 100644 znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.css create mode 100644 znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.test.tsx create mode 100644 znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx create mode 100644 znai-reactjs/src/doc-elements/mermaid/large-diagram.txt create mode 100644 znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.test.ts create mode 100644 znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.ts create mode 100644 znai-reactjs/src/doc-elements/mermaid/useMermaidSvg.ts diff --git a/znai-docs/znai/release-notes/1.91/add-2026-05-28-mermaid-zoom-pan.md b/znai-docs/znai/release-notes/1.91/add-2026-05-28-mermaid-zoom-pan.md new file mode 100644 index 000000000..b4f1906a3 --- /dev/null +++ b/znai-docs/znai/release-notes/1.91/add-2026-05-28-mermaid-zoom-pan.md @@ -0,0 +1 @@ +* Add: Large [Mermaid diagrams](visuals/mermaid-diagrams) can be zoomed and panned in a full screen overlay diff --git a/znai-docs/znai/release-notes/2026.md b/znai-docs/znai/release-notes/2026.md index 4142c17c8..351aaaa0c 100644 --- a/znai-docs/znai/release-notes/2026.md +++ b/znai-docs/znai/release-notes/2026.md @@ -1,3 +1,7 @@ +# 1.91 + +:include-markdowns: 1.91 + # 1.90.1 :include-markdowns: 1.90.1 diff --git a/znai-docs/znai/visuals/mermaid-diagrams.md b/znai-docs/znai/visuals/mermaid-diagrams.md index c1e4c7542..2d22ea4b0 100644 --- a/znai-docs/znai/visuals/mermaid-diagrams.md +++ b/znai-docs/znai/visuals/mermaid-diagrams.md @@ -27,6 +27,61 @@ In presentation mode, rendered expressions will automatically scale to make use Note: Rendering is done by using [Mermaid](https://mermaid-js.github.io/mermaid/#/) library. +# Large Diagrams + +A diagram that is too large to fit the page width is shrunk to fit and can become hard to read. +Znai detects this automatically: hovering over such a diagram shows a `Click to zoom & pan` hint. + +Click the diagram to open it in a full screen overlay where you can: + +- scroll/`wheel` to zoom towards the cursor +- drag to pan +- use the `+` / `-` buttons to zoom and the fit button to fit the diagram back to the screen +- press `Escape`, click the backdrop, or use the close button to exit + +No extra configuration is required — only diagrams that don't fit become zoomable, smaller diagrams render as before. + +The diagram below is wider than the page, so it is shrunk to fit. Hover over it and click to zoom and pan. + +```mermaid +flowchart LR + Client[Web & Mobile Clients] --> Gateway[API Gateway] + Gateway --> Auth[Auth Service] + Gateway --> Catalog[Catalog Service] + Gateway --> Cart[Cart Service] + Gateway --> Orders[Order Service] + Gateway --> Payments[Payment Service] + Gateway --> Shipping[Shipping Service] + Gateway --> Notifications[Notification Service] + + Auth --> AuthDb[(Auth DB)] + Catalog --> CatalogDb[(Catalog DB)] + Catalog --> Search[(Search Index)] + Cart --> CartCache[(Cart Cache)] + Orders --> OrdersDb[(Orders DB)] + Orders --> Queue[[Event Queue]] + Payments --> PaymentsDb[(Payments DB)] + Payments --> Gatewayp[External Payment Gateway] + Shipping --> ShippingDb[(Shipping DB)] + Shipping --> Carrier[External Carrier API] + + Queue --> Notifications + Queue --> Analytics[Analytics Pipeline] + Queue --> Warehouse[(Data Warehouse)] + Analytics --> Warehouse + Notifications --> Email[Email Provider] + Notifications --> SMS[SMS Provider] + Notifications --> Push[Push Provider] + + Analytics --> Dashboards[BI Dashboards] + Warehouse --> Dashboards + + click Catalog "visuals/graphviz-diagrams" + click Payments href "https://mermaid.js.org" "Mermaid documentation" +``` + +Node links work inside the overlay too: open the diagram above and click the `Catalog` or `Payments` node. + # External File Use include plugin to render a Mermaid diagram from a file. diff --git a/znai-reactjs/src/doc-elements/mermaid/Mermaid.css b/znai-reactjs/src/doc-elements/mermaid/Mermaid.css index 796feed3f..318f61dbb 100644 --- a/znai-reactjs/src/doc-elements/mermaid/Mermaid.css +++ b/znai-reactjs/src/doc-elements/mermaid/Mermaid.css @@ -14,11 +14,23 @@ * limitations under the License. */ +.znai-mermaid { + position: relative; +} + .znai-mermaid.wide { display: flex; justify-content: center; } +.znai-mermaid-zoomable { + cursor: zoom-in; +} + +.znai-mermaid-zoomable:hover::after { + opacity: 0.95; +} + .znai-mermaid svg text.messageText { stroke: none !important; } @@ -27,6 +39,18 @@ cursor: pointer; } +/* + * Mermaid scopes the node text color to the diagram id (e.g. #znai-mermaid-id-3 span), so a linked + * node's label looks like any other node. Recolor it with znai's link color - !important is needed + * to win over that id-scoped rule - so links are recognizable, including in the dark theme where + * znai links carry no underline. + */ +.znai-mermaid svg .clickable .nodeLabel, +.znai-mermaid svg .clickable .nodeLabel p { + color: var(--znai-link-color) !important; + fill: var(--znai-link-color) !important; +} + .znai-mermaid svg .clickable:hover rect, .znai-mermaid svg .clickable:hover circle, .znai-mermaid svg .clickable:hover polygon, diff --git a/znai-reactjs/src/doc-elements/mermaid/Mermaid.demo.tsx b/znai-reactjs/src/doc-elements/mermaid/Mermaid.demo.tsx index 4c085638b..806a93b7d 100644 --- a/znai-reactjs/src/doc-elements/mermaid/Mermaid.demo.tsx +++ b/znai-reactjs/src/doc-elements/mermaid/Mermaid.demo.tsx @@ -17,12 +17,20 @@ import React from "react"; import Mermaid from "./Mermaid"; +import { ZoomOverlay } from "../zoom/ZoomOverlay"; import { Registry } from "react-component-viewer"; import c4DiagramContent from './c4context.txt?raw'; // Add this import import awsDiagramContent from './aws-diagram.txt?raw'; +import largeDiagramContent from './large-diagram.txt?raw'; export function mermaidDemo(registry: Registry) { registry.add("simple", () => B; B-->C;"} />); + registry.add("large (zoom & pan)", () => ( + <> + + + + )); registry.add("with links", () => ( ; + iconpacks?: MermaidIconPack[]; } -let mermaidIdIdx = 0; -function generateNewMermaidId() { - return "znai-mermaid-id-" + mermaidIdIdx++; -} - -let isMermaidInitialized = false; -function initMermaidIfRequired() { - if (isMermaidInitialized) { - return; - } - - window.znaiTheme.addChangeHandler(initializeMermaid); +export default function Mermaid(props: Props) { + const html = useMermaidSvg(props.mermaid, props.iconpacks); + const containerRef = useRef(null); - initializeMermaid(); - isMermaidInitialized = true; + // a large diagram is shrunk to fit the page width and becomes hard to read; in that case we let + // the reader open it in a full screen zoom & pan overlay. Re-checked on resize as the available + // width (and thus whether the diagram fits) changes with the window/layout. + const [isZoomable, setIsZoomable] = useState(false); - function initializeMermaid() { - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - theme: mermaidThemeName(), - }); - } -} + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } -export default function Mermaid(props: Props) { - const [html, setHTML] = React.useState(""); - const [znaiThemeName, setZnaiThemeName] = useState(() => window.znaiTheme.name); - - useZnaiThemeChange((name) => { - setZnaiThemeName(name); - }); - - React.useEffect(() => { - initMermaidIfRequired(); - }, []); - - React.useEffect(() => { - const id = generateNewMermaidId(); - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - theme: mermaidThemeName(), - }); - // Register icon packs if provided, otherwise use default logos pack - if (props.iconpacks && props.iconpacks.length > 0) { - mermaid.registerIconPacks(props.iconpacks.map(pack => ({ - name: pack.name, - loader: () => fetch(pack.url).then(res => res.json()) - }))); + function update() { + setIsZoomable(isMermaidSvgShrunk(containerRef.current)); } - mermaid.render(id, props.mermaid) - .then(({ svg }) => { - setHTML(svg); - }) - .catch((error) => { - console.error('Error rendering mermaid diagram:', error); - }); - }, [props.mermaid, props.iconpacks, znaiThemeName]); + update(); - const containerRef = useRef(null); + const observer = new ResizeObserver(update); + observer.observe(container); + return () => observer.disconnect(); + }, [html]); - React.useEffect(() => { + useEffect(() => { const container = containerRef.current; if (!container) { return; @@ -114,12 +79,21 @@ export default function Mermaid(props: Props) { container.addEventListener("click", handleClick); return () => container.removeEventListener("click", handleClick); - }, [html]); + }, [html, isZoomable]); - const className = "znai-mermaid " + (props.wide ? "wide" : "content-block"); - return
; -} + function openZoom() { + zoom.zoom(); + } -function mermaidThemeName() { - return isZnaiDarkTheme() ? "dark" : "default"; -} \ No newline at end of file + const className = + "znai-mermaid " + (props.wide ? "wide" : "content-block") + (isZoomable ? " znai-mermaid-zoomable" : ""); + + return ( +
+ ); +} diff --git a/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.css b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.css new file mode 100644 index 000000000..c166f40ad --- /dev/null +++ b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.css @@ -0,0 +1,79 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.znai-mermaid.zoomed { + width: 92vw; + height: 92vh; + background-color: var(--znai-background-color); + overflow: hidden; +} + +.znai-mermaid-zoomed-surface { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow: hidden; + touch-action: none; + user-select: none; +} + +.znai-mermaid-zoomed-content { + position: absolute; + top: 0; + left: 0; + will-change: transform; +} + +.znai-mermaid-zoomed-svg svg { + display: block; +} + +.znai-mermaid-zoomed-toolbar { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; + + display: flex; + align-items: center; + gap: 10px; + + padding: 6px 12px; + border-radius: 6px; + border: var(--znai-zoom-container-border); + background-color: var(--znai-background-color); +} + +.znai-mermaid-zoomed-button { + cursor: pointer; + opacity: 0.55; + width: 20px; + height: 20px; +} + +.znai-mermaid-zoomed-button:hover { + opacity: 1; +} + +.znai-mermaid-zoomed-percent { + min-width: 42px; + text-align: center; + font-size: var(--znai-small-meta-text-size); + color: var(--znai-regular-text-light-color); + user-select: none; +} diff --git a/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.test.tsx b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.test.tsx new file mode 100644 index 000000000..32940b5cf --- /dev/null +++ b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, fireEvent } from "@testing-library/react"; +import React from "react"; +import { MermaidZoomed } from "./MermaidZoomed"; +import { zoom } from "../zoom/Zoom"; + +vi.mock("mermaid", () => ({ + default: { + initialize: vi.fn(), + render: vi.fn(() => Promise.resolve({ svg: '
Mocked SVG
' })), + registerIconPacks: vi.fn(), + }, +})); + +describe("MermaidZoomed", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, "znaiTheme", { + value: { + name: "znai-light", + addChangeHandler: vi.fn(), + removeChangeHandler: vi.fn(), + }, + writable: true, + configurable: true, + }); + }); + + it("renders the zoom & pan toolbar", () => { + const { container } = render(); + + expect(container.querySelector(".znai-mermaid-zoomed-toolbar")).toBeTruthy(); + expect(container.querySelectorAll(".znai-mermaid-zoomed-button")).toHaveLength(4); + // the percent readout is rendered once the diagram is measured; its value depends on layout + expect(container.querySelector(".znai-mermaid-zoomed-percent")).toBeTruthy(); + }); + + it("closes the overlay when the close button is clicked", () => { + const clearZoomSpy = vi.spyOn(zoom, "clearZoom"); + + const { container } = render(); + fireEvent.click(container.querySelector(".znai-mermaid-zoomed-close") as HTMLElement); + + expect(clearZoomSpy).toHaveBeenCalled(); + clearZoomSpy.mockRestore(); + }); +}); diff --git a/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx new file mode 100644 index 000000000..dae5a3b85 --- /dev/null +++ b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx @@ -0,0 +1,298 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { Icon } from "../icons/Icon"; +import { isLocalUrl } from "../../structure/links"; +import { documentationNavigation } from "../../structure/DocumentationNavigation"; +import { zoom } from "../zoom/Zoom"; +import { useMermaidSvg, MermaidIconPack } from "./useMermaidSvg"; +import { mermaidSvgNaturalSize } from "./mermaidSvgSize"; + +import "./MermaidZoomed.css"; + +interface Props { + mermaid: string; + iconpacks?: MermaidIconPack[]; +} + +interface View { + scale: number; + x: number; + y: number; +} + +// the overlay opens at the diagram's natural (100%) size; from there the user can zoom in for +// detail up to MAX_SCALE, or zoom out down to the size at which the whole diagram is visible +const MAX_SCALE = 4; +const WHEEL_ZOOM_SENSITIVITY = 0.0015; +const BUTTON_ZOOM_FACTOR = 1.3; +// drag distance (px) past which the gesture is a pan, not a click on a node link +const CLICK_MOVE_THRESHOLD = 4; + +/** + * Full screen, interactive view of a mermaid diagram. Rendered inside the shared zoom overlay + * (Escape / backdrop click close it). Wheel zooms towards the cursor, drag pans, and the toolbar + * exposes zoom in/out, fit-to-screen and close. + */ +export function MermaidZoomed(props: Props) { + const html = useMermaidSvg(props.mermaid, props.iconpacks); + + const surfaceRef = useRef(null); + const svgWrapRef = useRef(null); + + const naturalRef = useRef({ width: 0, height: 0 }); + const fitScaleRef = useRef(1); + // the very first layout fits the diagram to the screen; later re-layouts must not throw away + // the zoom the user has already set + const initializedRef = useRef(false); + + const [view, setView] = useState({ scale: 1, x: 0, y: 0 }); + const [ready, setReady] = useState(false); + const [dragging, setDragging] = useState(false); + + // current view mirrored into a ref for use at the start of a drag + const viewRef = useRef(view); + viewRef.current = view; + + const dragRef = useRef<{ pointerX: number; pointerY: number; viewX: number; viewY: number; scale: number; moved: boolean } | null>(null); + // a pan ends with a click event we don't want to treat as a link activation + const suppressClickRef = useRef(false); + + function clamp(candidate: View): View { + const surface = surfaceRef.current; + const { width: natW, height: natH } = naturalRef.current; + if (!surface || natW === 0 || natH === 0) { + return candidate; + } + + const surfaceW = surface.clientWidth; + const surfaceH = surface.clientHeight; + + // zoom out is allowed down to the whole-diagram-visible scale (or natural size, whichever is + // smaller, so a diagram that already fits is never shrunk); zoom in goes up to MAX_SCALE + const minScale = Math.min(fitScaleRef.current, 1); + const scale = Math.min(Math.max(candidate.scale, minScale), MAX_SCALE); + const contentW = natW * scale; + const contentH = natH * scale; + + const x = contentW <= surfaceW ? (surfaceW - contentW) / 2 : Math.min(0, Math.max(surfaceW - contentW, candidate.x)); + const y = contentH <= surfaceH ? (surfaceH - contentH) / 2 : Math.min(0, Math.max(surfaceH - contentH, candidate.y)); + + return { scale, x, y }; + } + + // zoom keeping the point under (anchorX, anchorY) - relative to the surface - fixed on screen + function zoomAround(anchorX: number, anchorY: number, factor: number) { + setView((prev) => { + const targetScale = prev.scale * factor; + const worldX = (anchorX - prev.x) / prev.scale; + const worldY = (anchorY - prev.y) / prev.scale; + return clamp({ scale: targetScale, x: anchorX - worldX * targetScale, y: anchorY - worldY * targetScale }); + }); + } + + function zoomByButton(factor: number) { + const surface = surfaceRef.current; + if (!surface) { + return; + } + zoomAround(surface.clientWidth / 2, surface.clientHeight / 2, factor); + } + + // fit the whole diagram into the view + function fitToScreen() { + setView(clamp({ scale: fitScaleRef.current, x: 0, y: 0 })); + } + + // measure the diagram and fit it to the available space. The svg can re-render after the user + // has already zoomed (a late mermaid render, a theme change); on those re-layouts keep the + // current zoom and only re-bound it, instead of snapping back to the fit scale. + useLayoutEffect(() => { + const surface = surfaceRef.current; + const svgWrap = svgWrapRef.current; + if (!surface || !svgWrap) { + return; + } + + const svg = svgWrap.querySelector("svg"); + if (!svg) { + return; + } + + const natural = mermaidSvgNaturalSize(svg); + if (natural.width === 0 || natural.height === 0) { + return; + } + + // render the svg at its natural size so our transform is relative to it + svg.style.maxWidth = "none"; + svg.style.width = natural.width + "px"; + svg.style.height = natural.height + "px"; + naturalRef.current = natural; + + // the scale at which the whole diagram is visible (the zoom-out limit / fit button target) + fitScaleRef.current = Math.min(surface.clientWidth / natural.width, surface.clientHeight / natural.height); + + if (initializedRef.current) { + setView((prev) => clamp(prev)); + } else { + initializedRef.current = true; + // open at the diagram's natural (100%) size, centered + setView( + clamp({ scale: 1, x: (surface.clientWidth - natural.width) / 2, y: (surface.clientHeight - natural.height) / 2 }) + ); + setReady(true); + } + }, [html]); + + // wheel zoom needs a non-passive listener so we can prevent the page from scrolling + useEffect(() => { + const surface = surfaceRef.current; + if (!surface) { + return; + } + + function onWheel(e: WheelEvent) { + e.preventDefault(); + const rect = surface!.getBoundingClientRect(); + const factor = Math.exp(-e.deltaY * WHEEL_ZOOM_SENSITIVITY); + zoomAround(e.clientX - rect.left, e.clientY - rect.top, factor); + } + + surface.addEventListener("wheel", onWheel, { passive: false }); + return () => surface.removeEventListener("wheel", onWheel); + }, []); + + // navigate internal links inside the diagram (a pan gesture is not treated as a click) + useEffect(() => { + const svgWrap = svgWrapRef.current; + if (!svgWrap) { + return; + } + + function handleClick(e: MouseEvent) { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + + const anchor = (e.target as HTMLElement).closest("a"); + if (!anchor) { + return; + } + + const url = anchor.getAttribute("href") || anchor.getAttributeNS("http://www.w3.org/1999/xlink", "href"); + if (url && isLocalUrl(url)) { + e.preventDefault(); + zoom.clearZoom(); + documentationNavigation.navigateToUrl(url); + } + } + + svgWrap.addEventListener("click", handleClick); + return () => svgWrap.removeEventListener("click", handleClick); + }, [html]); + + function onPointerDown(e: React.PointerEvent) { + if (e.button !== 0) { + return; + } + dragRef.current = { + pointerX: e.clientX, + pointerY: e.clientY, + viewX: viewRef.current.x, + viewY: viewRef.current.y, + scale: viewRef.current.scale, + moved: false, + }; + } + + function onPointerMove(e: React.PointerEvent) { + const drag = dragRef.current; + if (!drag) { + return; + } + + const dx = e.clientX - drag.pointerX; + const dy = e.clientY - drag.pointerY; + + // capture the pointer only once this becomes an actual pan - capturing on press would + // retarget the click to the surface and break clicks on node links + if (!drag.moved) { + if (Math.abs(dx) <= CLICK_MOVE_THRESHOLD && Math.abs(dy) <= CLICK_MOVE_THRESHOLD) { + return; + } + drag.moved = true; + surfaceRef.current?.setPointerCapture(e.pointerId); + setDragging(true); + } + + setView(clamp({ scale: drag.scale, x: drag.viewX + dx, y: drag.viewY + dy })); + } + + function onPointerUp(e: React.PointerEvent) { + const drag = dragRef.current; + if (!drag) { + return; + } + suppressClickRef.current = drag.moved; + if (drag.moved) { + surfaceRef.current?.releasePointerCapture(e.pointerId); + setDragging(false); + } + dragRef.current = null; + } + + // a click on the diagram (drag, button) should not bubble to the overlay backdrop and close it + function stopPropagation(e: React.MouseEvent) { + e.stopPropagation(); + } + + return ( +
+
+ {ready ? `${Math.round(view.scale * 100)}%` : ""} + zoomByButton(1 / BUTTON_ZOOM_FACTOR)} /> + zoomByButton(BUTTON_ZOOM_FACTOR)} /> + + zoom.clearZoom()} /> +
+ +
+
+
+
+
+
+ ); +} diff --git a/znai-reactjs/src/doc-elements/mermaid/large-diagram.txt b/znai-reactjs/src/doc-elements/mermaid/large-diagram.txt new file mode 100644 index 000000000..a079b9716 --- /dev/null +++ b/znai-reactjs/src/doc-elements/mermaid/large-diagram.txt @@ -0,0 +1,34 @@ +flowchart LR + Client[Web & Mobile Clients] --> Gateway[API Gateway] + Gateway --> Auth[Auth Service] + Gateway --> Catalog[Catalog Service] + Gateway --> Cart[Cart Service] + Gateway --> Orders[Order Service] + Gateway --> Payments[Payment Service] + Gateway --> Shipping[Shipping Service] + Gateway --> Notifications[Notification Service] + + Auth --> AuthDb[(Auth DB)] + Catalog --> CatalogDb[(Catalog DB)] + Catalog --> Search[(Search Index)] + Cart --> CartCache[(Cart Cache)] + Orders --> OrdersDb[(Orders DB)] + Orders --> Queue[[Event Queue]] + Payments --> PaymentsDb[(Payments DB)] + Payments --> Gatewayp[External Payment Gateway] + Shipping --> ShippingDb[(Shipping DB)] + Shipping --> Carrier[External Carrier API] + + Queue --> Notifications + Queue --> Analytics[Analytics Pipeline] + Queue --> Warehouse[(Data Warehouse)] + Analytics --> Warehouse + Notifications --> Email[Email Provider] + Notifications --> SMS[SMS Provider] + Notifications --> Push[Push Provider] + + Analytics --> Dashboards[BI Dashboards] + Warehouse --> Dashboards + + click Catalog "/test-doc/visuals/mermaid-diagrams" + click Payments href "https://mermaid.js.org" "Mermaid documentation" diff --git a/znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.test.ts b/znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.test.ts new file mode 100644 index 000000000..f7642e818 --- /dev/null +++ b/znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { mermaidSvgNaturalSize, isMermaidSvgShrunk } from "./mermaidSvgSize"; + +// jsdom does not lay out svg, so the dimensions are supplied via a lightweight stand-in +function svgStub(opts: { viewBox?: { width: number; height: number }; maxWidth?: string; rect?: { width: number; height: number } }) { + return { + viewBox: { baseVal: { x: 0, y: 0, width: opts.viewBox?.width ?? 0, height: opts.viewBox?.height ?? 0 } }, + style: { maxWidth: opts.maxWidth ?? "" }, + getBoundingClientRect: () => ({ width: opts.rect?.width ?? 0, height: opts.rect?.height ?? 0 }), + } as unknown as SVGSVGElement; +} + +function containerWith(svg: SVGSVGElement | null) { + return { querySelector: () => svg } as unknown as HTMLElement; +} + +describe("mermaidSvgNaturalSize", () => { + it("uses the viewBox when present", () => { + expect(mermaidSvgNaturalSize(svgStub({ viewBox: { width: 1500, height: 800 } }))).toEqual({ width: 1500, height: 800 }); + }); + + it("falls back to the max-width style and measured height when there is no viewBox", () => { + expect(mermaidSvgNaturalSize(svgStub({ maxWidth: "640px", rect: { width: 400, height: 480 } }))).toEqual({ + width: 640, + height: 480, + }); + }); +}); + +describe("isMermaidSvgShrunk", () => { + it("is true when the diagram is rendered well below its natural width", () => { + const svg = svgStub({ viewBox: { width: 1500, height: 800 }, rect: { width: 900, height: 480 } }); + expect(isMermaidSvgShrunk(containerWith(svg))).toBe(true); + }); + + it("is false when the diagram fits at its natural width", () => { + const svg = svgStub({ viewBox: { width: 800, height: 400 }, rect: { width: 800, height: 400 } }); + expect(isMermaidSvgShrunk(containerWith(svg))).toBe(false); + }); + + it("is false when there is no container or svg yet", () => { + expect(isMermaidSvgShrunk(null)).toBe(false); + expect(isMermaidSvgShrunk(containerWith(null))).toBe(false); + }); +}); diff --git a/znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.ts b/znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.ts new file mode 100644 index 000000000..3d02af377 --- /dev/null +++ b/znai-reactjs/src/doc-elements/mermaid/mermaidSvgSize.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// once a diagram is rendered smaller than this fraction of its natural size it is worth zooming +const SHRUNK_THRESHOLD = 0.95; + +export interface Size { + width: number; + height: number; +} + +/** + * natural (unscaled) size of a mermaid svg. Mermaid encodes it in the viewBox, falling back to + * the max-width style / measured size for diagram types that don't set one. + */ +export function mermaidSvgNaturalSize(svg: SVGSVGElement): Size { + const viewBox = svg.viewBox?.baseVal; + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height }; + } + + const rect = svg.getBoundingClientRect(); + const maxWidth = parseFloat(svg.style.maxWidth); + return { + width: isNaN(maxWidth) ? rect.width : maxWidth, + height: rect.height, + }; +} + +/** + * true when the diagram is being shrunk to fit the available width and is therefore hard to read + * at its current size (the case where zoom & pan helps). + */ +export function isMermaidSvgShrunk(container: HTMLElement | null): boolean { + if (!container) { + return false; + } + + const svg = container.querySelector("svg"); + if (!svg) { + return false; + } + + const natural = mermaidSvgNaturalSize(svg); + const displayedWidth = svg.getBoundingClientRect().width; + if (natural.width === 0 || displayedWidth === 0) { + return false; + } + + return displayedWidth / natural.width < SHRUNK_THRESHOLD; +} diff --git a/znai-reactjs/src/doc-elements/mermaid/useMermaidSvg.ts b/znai-reactjs/src/doc-elements/mermaid/useMermaidSvg.ts new file mode 100644 index 000000000..4862107ab --- /dev/null +++ b/znai-reactjs/src/doc-elements/mermaid/useMermaidSvg.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from "react"; + +import mermaid from "mermaid"; +import { isZnaiDarkTheme, useZnaiThemeChange } from "../../theme/znaiTheme"; + +export interface MermaidIconPack { + name: string; + url: string; +} + +let mermaidIdIdx = 0; +function generateNewMermaidId() { + return "znai-mermaid-id-" + mermaidIdIdx++; +} + +let isMermaidInitialized = false; +function initMermaidIfRequired() { + if (isMermaidInitialized) { + return; + } + + window.znaiTheme.addChangeHandler(initializeMermaid); + + initializeMermaid(); + isMermaidInitialized = true; +} + +function initializeMermaid() { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: mermaidThemeName(), + }); +} + +function mermaidThemeName() { + return isZnaiDarkTheme() ? "dark" : "default"; +} + +/** + * Renders mermaid source into an svg string, re-rendering on theme change. Each render uses a + * fresh diagram id so the same source can be rendered multiple times on a page (e.g. inline and + * inside the zoom overlay) without sharing internal marker/gradient ids. + */ +export function useMermaidSvg(mermaidSource: string, iconpacks?: MermaidIconPack[]): string { + const [html, setHtml] = useState(""); + const [znaiThemeName, setZnaiThemeName] = useState(() => window.znaiTheme.name); + + useZnaiThemeChange((name) => { + setZnaiThemeName(name); + }); + + useEffect(() => { + initMermaidIfRequired(); + }, []); + + useEffect(() => { + const id = generateNewMermaidId(); + initializeMermaid(); + + // Register icon packs if provided, otherwise use default logos pack + if (iconpacks && iconpacks.length > 0) { + mermaid.registerIconPacks( + iconpacks.map((pack) => ({ + name: pack.name, + loader: () => fetch(pack.url).then((res) => res.json()), + })) + ); + } + + let cancelled = false; + mermaid + .render(id, mermaidSource) + .then(({ svg }) => { + if (!cancelled) { + setHtml(svg); + } + }) + .catch((error) => { + console.error("Error rendering mermaid diagram:", error); + }); + + return () => { + cancelled = true; + }; + }, [mermaidSource, iconpacks, znaiThemeName]); + + return html; +} diff --git a/znai-reactjs/src/test/setup.ts b/znai-reactjs/src/test/setup.ts index 241f48aa5..bcf323ce9 100644 --- a/znai-reactjs/src/test/setup.ts +++ b/znai-reactjs/src/test/setup.ts @@ -30,19 +30,19 @@ global.getSelection = vi.fn(() => ({ type: 'None' })) -// Mock ResizeObserver if needed -global.ResizeObserver = vi.fn(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})) - -// Mock IntersectionObserver if needed -global.IntersectionObserver = vi.fn(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})) +// Mock ResizeObserver if needed (class so it can be used with `new`) +global.ResizeObserver = class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() +} as any + +// Mock IntersectionObserver if needed (class so it can be used with `new`) +global.IntersectionObserver = class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() +} as any // Mock fetch globally global.fetch = vi.fn() diff --git a/znai-reactjs/vite.config.ts b/znai-reactjs/vite.config.ts index 04cbcca15..478121d90 100644 --- a/znai-reactjs/vite.config.ts +++ b/znai-reactjs/vite.config.ts @@ -1,5 +1,19 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig, ProxyOptions } from "vite"; +import react from "@vitejs/plugin-react"; + +function serveEmptyWhenPreviewOffline(body: string, contentType: string): ProxyOptions["configure"] { + return (proxy) => { + queueMicrotask(() => { + proxy.removeAllListeners("error"); + proxy.on("error", (_err, _req, res) => { + if (res && "writeHead" in res && !res.headersSent) { + res.writeHead(200, { "Content-Type": contentType }); + res.end(body); + } + }); + }); + }; +} // https://vite.dev/config/ export default defineConfig({ @@ -9,31 +23,40 @@ export default defineConfig({ "/preview/all-pages.json": { target: "http://localhost:3334", changeOrigin: true, + configure: serveEmptyWhenPreviewOffline("[]", "application/json"), }, "/search-index.js": { target: "http://localhost:3334", changeOrigin: true, rewrite: (path) => path.replace("/search-index.js", "/preview/search-index.js"), + // Real search-index.js sets `window.znaiSearchData` (see LocalSearchEntries.java); + // App.jsx feeds it to populateLocalSearchIndexWithData, so the offline stub + // must define it as an empty array rather than be a no-op. + configure: serveEmptyWhenPreviewOffline( + "/* znai preview server offline */ window.znaiSearchData = [];", + "application/javascript" + ), }, }, }, build: { - target: ['es2020', 'chrome87', 'firefox78', 'safari14'], + target: ["es2020", "chrome87", "firefox78", "safari14"], rolldownOptions: { jsx: { - mode: 'automatic' + mode: "automatic", }, - logLevel: 'debug', + logLevel: "debug", output: { advancedChunks: { groups: [ { - name: 'mermaid', + name: "mermaid", test: /node_modules[\\/]mermaid/, priority: 20, - }] - } - } - } - } -}) + }, + ], + }, + }, + }, + }, +}); From 9b9c389e774f22659a04082727385927c4b7e998 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 31 May 2026 19:25:03 -0400 Subject: [PATCH 2/4] mermaid: zoom diagrams --- .../doc-elements/mermaid/MermaidZoomed.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx index dae5a3b85..2d66fc443 100644 --- a/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx +++ b/znai-reactjs/src/doc-elements/mermaid/MermaidZoomed.tsx @@ -36,9 +36,9 @@ interface View { y: number; } -// the overlay opens at the diagram's natural (100%) size; from there the user can zoom in for -// detail up to MAX_SCALE, or zoom out down to the size at which the whole diagram is visible -const MAX_SCALE = 4; +// the overlay opens at the diagram's natural (100%) size; from there the user can zoom out down to +// the size at which the whole diagram is visible. natural (100%) size is the maximum zoom +const MAX_SCALE = 1; const WHEEL_ZOOM_SENSITIVITY = 0.0015; const BUTTON_ZOOM_FACTOR = 1.3; // drag distance (px) past which the gesture is a pan, not a click on a node link @@ -73,6 +73,13 @@ export function MermaidZoomed(props: Props) { // a pan ends with a click event we don't want to treat as a link activation const suppressClickRef = useRef(false); + // zoom out is allowed down to the whole-diagram-visible scale (or natural size, whichever is + // smaller, so a diagram that already fits is never shrunk); zoom in goes up to MAX_SCALE + function clampScale(scale: number): number { + const minScale = Math.min(fitScaleRef.current, 1); + return Math.min(Math.max(scale, minScale), MAX_SCALE); + } + function clamp(candidate: View): View { const surface = surfaceRef.current; const { width: natW, height: natH } = naturalRef.current; @@ -83,10 +90,7 @@ export function MermaidZoomed(props: Props) { const surfaceW = surface.clientWidth; const surfaceH = surface.clientHeight; - // zoom out is allowed down to the whole-diagram-visible scale (or natural size, whichever is - // smaller, so a diagram that already fits is never shrunk); zoom in goes up to MAX_SCALE - const minScale = Math.min(fitScaleRef.current, 1); - const scale = Math.min(Math.max(candidate.scale, minScale), MAX_SCALE); + const scale = clampScale(candidate.scale); const contentW = natW * scale; const contentH = natH * scale; @@ -96,10 +100,12 @@ export function MermaidZoomed(props: Props) { return { scale, x, y }; } - // zoom keeping the point under (anchorX, anchorY) - relative to the surface - fixed on screen + // zoom keeping the point under (anchorX, anchorY) - relative to the surface - fixed on screen. + // the scale is clamped up front so that, once the zoom limit is reached, continued wheel input + // does not keep shifting the pan (which would scroll the diagram sideways while standing still) function zoomAround(anchorX: number, anchorY: number, factor: number) { setView((prev) => { - const targetScale = prev.scale * factor; + const targetScale = clampScale(prev.scale * factor); const worldX = (anchorX - prev.x) / prev.scale; const worldY = (anchorY - prev.y) / prev.scale; return clamp({ scale: targetScale, x: anchorX - worldX * targetScale, y: anchorY - worldY * targetScale }); From eda9b52f58c73fbd86fa5fb8f25f4a81fd3a4b61 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 31 May 2026 20:32:19 -0400 Subject: [PATCH 3/4] mermaid: zoom diagrams --- znai-reactjs/src/doc-elements/Documentation.jsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/znai-reactjs/src/doc-elements/Documentation.jsx b/znai-reactjs/src/doc-elements/Documentation.jsx index 802d671b2..4a87a84a7 100644 --- a/znai-reactjs/src/doc-elements/Documentation.jsx +++ b/znai-reactjs/src/doc-elements/Documentation.jsx @@ -205,9 +205,8 @@ export class Documentation extends React.Component { ); const footnotes = isFootnoteListHidden() ? [] : pageContentProcessor.extractFootnotes(page.content); - const renderedFootnotesList = footnotes.length > 0 ? ( - - ) : null; + const renderedFootnotesList = + footnotes.length > 0 ? : null; const NextPrevNavigation = pageTypesRegistry.nextPrevNavigationComponent(page.tocItem); const renderedNextPrevNavigation = ( @@ -467,6 +466,10 @@ export class Documentation extends React.Component { this.extractPageSectionNodes(); + if (this.cancelPendingScrollRestore) { + this.cancelPendingScrollRestore(); + } + const currentPageLocation = documentationNavigation.currentPageLocation(); if (urlHistoryState && urlHistoryState.scrollTop) { From eea5d82afb836cfb9714bb38257cf3a3086ad3f6 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 31 May 2026 20:33:23 -0400 Subject: [PATCH 4/4] tweak docs --- znai-docs/znai/visuals/mermaid-diagrams.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/znai-docs/znai/visuals/mermaid-diagrams.md b/znai-docs/znai/visuals/mermaid-diagrams.md index 2d22ea4b0..25f16d102 100644 --- a/znai-docs/znai/visuals/mermaid-diagrams.md +++ b/znai-docs/znai/visuals/mermaid-diagrams.md @@ -30,18 +30,10 @@ Note: Rendering is done by using [Mermaid](https://mermaid-js.github.io/mermaid/ # Large Diagrams A diagram that is too large to fit the page width is shrunk to fit and can become hard to read. -Znai detects this automatically: hovering over such a diagram shows a `Click to zoom & pan` hint. - Click the diagram to open it in a full screen overlay where you can: - scroll/`wheel` to zoom towards the cursor - drag to pan -- use the `+` / `-` buttons to zoom and the fit button to fit the diagram back to the screen -- press `Escape`, click the backdrop, or use the close button to exit - -No extra configuration is required — only diagrams that don't fit become zoomable, smaller diagrams render as before. - -The diagram below is wider than the page, so it is shrunk to fit. Hover over it and click to zoom and pan. ```mermaid flowchart LR @@ -80,8 +72,6 @@ flowchart LR click Payments href "https://mermaid.js.org" "Mermaid documentation" ``` -Node links work inside the overlay too: open the diagram above and click the `Catalog` or `Payments` node. - # External File Use include plugin to render a Mermaid diagram from a file.