Skip to content

feat: Add interactive zoom/pan controls for Mermaid diagrams#1689

Open
KhaledSMQ wants to merge 2 commits intodocmost:mainfrom
KhaledSMQ:feat/mermaid-zoom-controls
Open

feat: Add interactive zoom/pan controls for Mermaid diagrams#1689
KhaledSMQ wants to merge 2 commits intodocmost:mainfrom
KhaledSMQ:feat/mermaid-zoom-controls

Conversation

@KhaledSMQ
Copy link

@KhaledSMQ KhaledSMQ commented Oct 18, 2025

This PR enhances the Mermaid diagram rendering in code blocks by adding interactive pan/zoom functionality, external link generation to Mermaid Live Editor, and improved UI controls. It transforms static Mermaid SVGs into a more user-friendly, explorable experience, especially for complex diagrams,

original panzoom idea taken from : https://github.com/mermaid-js/mermaid-live-editor/blob/739ef6dfe249619af8d2a1bb860fa2aea1d8075c/src/lib/util/panZoom.ts

Screen.Recording.2025-10-18.at.9.09.35.PM.mov

Summary by CodeRabbit

  • New Features

    • Interactive zoom & pan controls for Mermaid diagrams (Zoom In/Out, Reset) with transient zoom percentage display
    • "Open in Mermaid Live" link for diagrams
  • Improvements

    • Better touch/pinch gesture support and smoother diagram navigation
    • Improved code-block selection and copy behavior to avoid accidental editor interactions
    • Updated visual styling for diagram containers and controls

@CLAassistant
Copy link

CLAassistant commented Oct 18, 2025

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link

coderabbitai bot commented Oct 18, 2025

Walkthrough

Added runtime deps (hammerjs, js-base64, pako, svg-pan-zoom) and types; implemented pan/zoom/touch interactions and external state/link generation for Mermaid diagrams in the code block UI; refactored MermaidView lifecycle with debounced rendering, MutationObserver binding, and cleanup; added related styles and UI controls.

Changes

Cohort / File(s) Summary
Dependencies
apps/client/package.json
Added runtime dependencies: hammerjs, js-base64, pako, svg-pan-zoom; added dev type packages: @types/hammerjs, @types/pako.
Code Block Zoom/Pan UI
apps/client/src/features/editor/components/code-block/code-block-view.tsx
Added scale (0.2–12) and position state, zoom in/out/reset controls with enablement, transient zoom percent display, mermaidLink state, RAF-throttled selection handling, copy action propagation prevention, shouldHideCode logic, and forwarded mermaid props (scale/position/setters/onLinkGenerated).
Code Block Styles
apps/client/src/features/editor/components/code-block/code-block.module.css
Added styling for Mermaid container and error block (padding, radii, background, min-height, overflow), sticky menuGroup with print rule, and pointer-events/touch-action rules for SVG interaction.
MermaidView Interactive Rendering
apps/client/src/features/editor/components/code-block/mermaid-view.tsx
Expanded MermaidView props (scale, position, setScale, setPosition, onLinkGenerated); added DEFAULT_STATE and serializeState; implemented debounced render (300ms), MutationObserver-based SVG binding, svg-pan-zoom setup, Hammer gesture integration, wheel blocking, state synchronization with loop guards, external link serialization, and comprehensive cleanup.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CodeBlockView
    participant MermaidView
    participant SVGPanZoom as svg-pan-zoom
    participant Hammer as Hammer.js
    participant Observer as MutationObserver

    User->>CodeBlockView: Open code block / interact
    CodeBlockView->>MermaidView: Render with props (scale, position, setters, onLinkGenerated)
    MermaidView->>MermaidView: Debounced render (300ms)
    MermaidView->>Observer: Observe DOM for rendered <svg>
    Observer->>MermaidView: SVG attached
    MermaidView->>SVGPanZoom: Initialize/sync scale & position
    MermaidView->>Hammer: Attach gesture handlers

    User->>Hammer: Pinch / pan / wheel
    Hammer->>SVGPanZoom: Gesture → update transform
    SVGPanZoom->>MermaidView: Transform updated
    MermaidView->>CodeBlockView: Call setScale / setPosition

    User->>CodeBlockView: Click Zoom In/Out or Reset
    CodeBlockView->>MermaidView: setScale / setPosition call
    MermaidView->>SVGPanZoom: Apply external state

    User->>CodeBlockView: Request "Open in Mermaid Live"
    CodeBlockView->>MermaidView: onLinkGenerated
    MermaidView->>CodeBlockView: Serialized link (state)
    CodeBlockView->>User: Open link in new tab
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 I hopped through code to make diagrams dance,

Pinch and pan now give visuals a chance,
Scales and links spun from serialized art,
Hammer taps, SVGs move — a merry start,
A rabbit's small cheer for interactive charts.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "feat: Add interactive zoom/pan controls for Mermaid diagrams" is directly and specifically related to the main changes in the changeset. The raw summary confirms that the primary enhancement involves adding scale and pan position state management, introducing zoom controls (Zoom In, Zoom Out, Reset view), and implementing a comprehensive pan/zoom system using svg-pan-zoom and Hammer gestures for Mermaid diagram visualization. The title is concise, clear, and specific enough that a teammate scanning the history would immediately understand the core feature being added. While supporting changes exist (such as CSS styling and external link generation), the title accurately captures the main objective of making Mermaid diagrams interactive and explorable.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/client/package.json (1)

34-61: Add DOMPurify dependency to safely sanitize Mermaid SVG before injection.

You’re injecting HTML into the DOM; add DOMPurify to sanitize SVG strings produced by Mermaid.

Apply this diff to add the dependency:

   "dependencies": {
+    "dompurify": "^3.0.0",
     "hammerjs": "^2.0.8",
     ...
     "svg-pan-zoom": "^3.6.2",
   },
   "devDependencies": {
+    "@types/dompurify": "^3.0.0",
     "@types/hammerjs": "^2.0.46",
     ...
   }
apps/client/src/features/editor/components/code-block/mermaid-view.tsx (1)

59-64: Enable Mermaid strict security level.

Explicitly set securityLevel: "strict" to harden against script/HTML injection inside diagrams.

Apply this diff:

   mermaid.initialize({
     startOnLoad: false,
     suppressErrorRendering: true,
     theme: computedColorScheme === "light" ? "default" : "dark",
+    securityLevel: "strict",
   });
🧹 Nitpick comments (7)
apps/client/package.json (1)

34-34: Heads‑up: Hammer.js is largely unmaintained; consider Pointer Events–based alternatives.

Current use is fine short‑term, but plan to migrate (e.g., use-gesture + Pointer Events) to avoid future breakage and improve interop. Based on learnings.

apps/client/src/features/editor/components/code-block/code-block.module.css (2)

29-34: Duplicate .mermaid svg rules; keep one consolidated block.

You define .mermaid svg twice; the latter adds pointer/touch props. Delete the first to avoid confusion.

Apply this diff:

-.mermaid svg {
-    width: 100% !important;
-    height: auto !important;
-    max-width: 100%;
-    display: block;
-}

Also applies to: 50-57


56-56: Minor: touch-action: none also set inline.

You set touch-action both in CSS and inline style; one is sufficient. Prefer CSS for consistency.

apps/client/src/features/editor/components/code-block/code-block-view.tsx (2)

82-85: Show either code or diagram, not both; gate controls + preview by shouldHideCode.

Currently, the preview and code can render together while editing/selected. Gate controls and <MermaidView/> by shouldHideCode.

Apply this diff:

-  const isMermaidDiagram = language === "mermaid";
-  const showMermaidControls = isMermaidDiagram && node.textContent.length > 0;
-  const shouldHideCode = isMermaidDiagram && node.textContent.length > 0 && (!editor.isEditable || !isSelected);
+  const isMermaidDiagram = language === "mermaid";
+  const hasDiagram = isMermaidDiagram && node.textContent.length > 0;
+  const shouldHideCode = hasDiagram && (!editor.isEditable || !isSelected);
+  const showMermaidControls = hasDiagram && shouldHideCode;
-        {showMermaidControls && (
+        {showMermaidControls && (
           <>
             ...
           </>
         )}
-      {isMermaidDiagram && (
+      {isMermaidDiagram && shouldHideCode && (
         <Suspense fallback={null}>
           <MermaidView
             props={props}
             ...
           />
         </Suspense>
       )}

Also applies to: 110-183, 214-224


31-49: Initialize selection state on mount to avoid a one‑frame flash.

Call the handler once after subscribing.

Apply this diff:

   useEffect(() => {
     const onSel = () => {
       cancelAnimationFrame(rafRef.current);
       rafRef.current = requestAnimationFrame(() => {
         try {
           const { from, to } = editor.state.selection;
           const pos = getPos();
           const end = pos + node.nodeSize;
           setIsSelected((from >= pos && from < end) || (to > pos && to <= end));
         } catch {
           // node may be temporarily detached; ignore
         }
       });
     };
     editor.on("selectionUpdate", onSel);
+    onSel();
     return () => {
       cancelAnimationFrame(rafRef.current);
       editor.off("selectionUpdate", onSel);
     };
   }, [editor, getPos, node.nodeSize]);
apps/client/src/features/editor/components/code-block/mermaid-view.tsx (2)

308-313: Mermaid Live link: match theme to current color scheme.

Embed the current theme in the serialized state so the live preview matches.

Apply this diff:

-            const link = serializeState({
-              ...DEFAULT_STATE,
-              code: node.textContent,
-            });
+            const link = serializeState({
+              ...DEFAULT_STATE,
+              code: node.textContent,
+              mermaid: JSON.stringify(
+                { theme: computedColorScheme === "light" ? "default" : "dark" },
+                undefined,
+                2
+              ),
+            });

48-49: Type panZoomRef to the instance (and consider installing typings).

Avoid any for better safety; if available, use ReturnType<typeof panzoom> or install @types/svg-pan-zoom and type the ref accordingly.

Apply this diff:

-  const panZoomRef = useRef<any>(null);
+  const panZoomRef = useRef<ReturnType<typeof panzoom> | null>(null);

If typings are unavailable, consider adding @types/svg-pan-zoom.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 042836c and 619fabc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/client/package.json (3 hunks)
  • apps/client/src/features/editor/components/code-block/code-block-view.tsx (4 hunks)
  • apps/client/src/features/editor/components/code-block/code-block.module.css (1 hunks)
  • apps/client/src/features/editor/components/code-block/mermaid-view.tsx (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/client/src/features/editor/components/code-block/code-block-view.tsx (1)
apps/client/src/features/editor/components/code-block/mermaid-view.tsx (1)
  • MermaidView (35-473)
🪛 ast-grep (0.39.6)
apps/client/src/features/editor/components/code-block/mermaid-view.tsx

[warning] 461-461: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)
apps/client/src/features/editor/components/code-block/mermaid-view.tsx

[error] 462-462: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
apps/client/src/features/editor/components/code-block/code-block-view.tsx (1)

51-55: Type mismatch remains: parameter should accept string | null.

The Select component's onChange can pass null when cleared, but changeLanguage still declares newLanguage: string. While line 53 guards against null with ?? "", the type signature should reflect this.

Apply this diff:

-  const changeLanguage = useCallback((newLanguage: string) => {
+  const changeLanguage = useCallback((newLanguage: string | null) => {
     updateAttributes({
       language: newLanguage ?? "",
     });
   }, [updateAttributes]);
apps/client/src/features/editor/components/code-block/mermaid-view.tsx (1)

306-306: Critical: Sanitize SVG before injection to prevent XSS.

Mermaid-generated SVG is injected via dangerouslySetInnerHTML without sanitization. While Mermaid is generally trusted, user-controlled diagram code could potentially inject malicious content.

Install DOMPurify:

npm install dompurify
npm install -D @types/dompurify

Apply this diff:

 import { useComputedColorScheme } from "@mantine/core";
 import { deflate } from "pako";
 import { fromUint8Array } from "js-base64";
 import panzoom from "svg-pan-zoom";
 import Hammer from "hammerjs";
+import DOMPurify from "dompurify";
           .then((item) => {
             // Check if component is still mounted
             if (!isMountedRef.current) return;

-            setPreview(item.svg);
+            const safeSvg = DOMPurify.sanitize(item.svg, {
+              USE_PROFILES: { svg: true, svgFilters: true },
+            });
+            setPreview(safeSvg);

No changes needed to the render - you're still using dangerouslySetInnerHTML, but now with sanitized content.

Also applies to: 461-463

🧹 Nitpick comments (3)
apps/client/src/features/editor/components/code-block/code-block-view.tsx (1)

111-182: Consider extracting zoom controls into a separate component.

The 70+ lines of inline JSX for zoom controls reduce readability and make this component harder to test. Extracting to a <MermaidControls> component would improve maintainability.

Example structure:

// New file: mermaid-controls.tsx
interface MermaidControlsProps {
  scale: number;
  position: { x: number; y: number };
  mermaidLink: string;
  onZoomIn: (e: React.MouseEvent) => void;
  onZoomOut: (e: React.MouseEvent) => void;
  onResetZoom: (e: React.MouseEvent) => void;
}

export function MermaidControls({ scale, position, mermaidLink, onZoomIn, onZoomOut, onResetZoom }: MermaidControlsProps) {
  const { t } = useTranslation();
  const isResetDisabled = Math.abs(scale - 1) < 0.01 && Math.abs(position.x) < 1 && Math.abs(position.y) < 1;
  
  return (
    <>
      {/* Move the entire controls JSX here */}
    </>
  );
}

Then use it in code-block-view.tsx:

{showMermaidControls && (
  <MermaidControls
    scale={scale}
    position={position}
    mermaidLink={mermaidLink}
    onZoomIn={handleZoomIn}
    onZoomOut={handleZoomOut}
    onResetZoom={handleResetZoom}
  />
)}
apps/client/src/features/editor/components/code-block/mermaid-view.tsx (2)

48-48: Type safety: Replace any with proper svg-pan-zoom type.

The panZoomRef is typed as any, which bypasses type checking. While svg-pan-zoom may not export perfect types, you can use a more specific type.

Apply this diff:

-  const panZoomRef = useRef<any>(null);
+  const panZoomRef = useRef<ReturnType<typeof panzoom> | null>(null);

This provides better autocomplete and catches method typos at compile time.


112-281: Consider refactoring setupPanZoom into smaller functions.

This 170-line function handles initialization, event handlers, and state restoration. Breaking it into focused helpers would improve readability and testability.

Example structure:

const createHammerHandlers = (
  hammer: HammerManager,
  instance: any,
  initialScaleRef: React.MutableRefObject<number>,
  pannedRef: React.MutableRefObject<{ x: number; y: number }>
) => {
  // Extract pan/pinch handler setup
};

const createCustomEventsHandler = (
  hammer: HammerManager,
  touchMoveListener: (e: Event) => void
) => {
  // Return the customEventsHandler object
};

const setupPanZoom = useCallback((svgElement: SVGElement) => {
  // Cleanup existing instances
  // Initialize with extracted helpers
  // Restore state
}, [setScale, setPosition]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 619fabc and 1d5c33e.

📒 Files selected for processing (2)
  • apps/client/src/features/editor/components/code-block/code-block-view.tsx (4 hunks)
  • apps/client/src/features/editor/components/code-block/mermaid-view.tsx (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/client/src/features/editor/components/code-block/code-block-view.tsx (1)
apps/client/src/features/editor/components/code-block/mermaid-view.tsx (1)
  • MermaidView (35-473)
🪛 ast-grep (0.39.6)
apps/client/src/features/editor/components/code-block/mermaid-view.tsx

[warning] 461-461: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)
apps/client/src/features/editor/components/code-block/mermaid-view.tsx

[error] 462-462: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

🔇 Additional comments (13)
apps/client/src/features/editor/components/code-block/code-block-view.tsx (5)

20-26: Good state management for zoom/pan controls.

Lifting scale, position, and mermaidLink state to the parent component enables external control and proper separation of concerns.


28-49: Excellent: RAF-based selection with error handling.

Using requestAnimationFrame prevents layout thrashing, and the try/catch guards against rare race conditions when nodes are detached.


57-75: Zoom handlers are correctly implemented.

Event propagation is properly prevented, and scale bounds (0.2–12) match the pan-zoom configuration. The reset logic correctly restores both scale and position.


194-197: Good: Copy handler now prevents propagation.

This ensures the copy action doesn't trigger editor-level handlers, improving UX.


214-223: MermaidView integration is well-structured.

The new props (scale, position, setters, and callback) are properly passed and enable external control of pan/zoom state while maintaining the component boundary.

apps/client/src/features/editor/components/code-block/mermaid-view.tsx (8)

8-24: Compression and serialization implementation is solid.

The use of pako (deflate) with level 9 compression and base64 encoding produces compact, URL-safe Mermaid Live links. The DEFAULT_STATE structure is appropriate.

Based on learnings.


74-109: Excellent cleanup implementation.

The comprehensive unmount cleanup properly releases all resources (timers, pan-zoom instance, Hammer, MutationObserver) and guards against exceptions during cleanup. This prevents memory leaks.


172-172: Good: Pinch recognizer is now explicitly added.

Using hammer.add(new Hammer.Pinch({ enable: true })) ensures the recognizer exists before use, addressing the previous review concern.


223-250: Feedback loop prevention logic is well-designed.

The isInternalUpdateRef flag combined with requestAnimationFrame correctly prevents infinite update loops between external controls and internal pan-zoom events. The 50ms throttle reduces unnecessary state updates.


284-387: Debounced rendering with MutationObserver is robust.

The implementation correctly:

  • Debounces edits (300ms) while rendering immediately in read mode
  • Uses MutationObserver to bind pan-zoom after SVG insertion
  • Cleans up observers to prevent duplicates
  • Handles mount state to avoid updates after unmount
  • Generates external links and handles errors gracefully

389-420: External control synchronization is properly implemented.

The thresholds (0.01 for zoom, 1px for pan) prevent noisy updates while ensuring significant changes are applied. The feedback loop guard is correctly reused.


422-440: Wheel event handling prevents unwanted page scrolls.

Preventing default on wheel events (when pan-zoom is active or modifier keys are pressed) ensures users can zoom the diagram without scrolling the page.


442-472: Render container has proper accessibility and interaction setup.

The container includes:

  • Proper ARIA label for screen readers
  • Non-editable content boundary
  • Touch-action and user-select to prevent conflicts
  • Event propagation prevention
  • Overflow hidden for clean pan-zoom bounds

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants