+
+ {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,
- }]
- }
- }
- }
- }
-})
+ },
+ ],
+ },
+ },
+ },
+ },
+});