feat: Add interactive zoom/pan controls for Mermaid diagrams#1689
feat: Add interactive zoom/pan controls for Mermaid diagrams#1689KhaledSMQ wants to merge 2 commits intodocmost:mainfrom
Conversation
WalkthroughAdded 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 svgrules; keep one consolidated block.You define
.mermaid svgtwice; 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: nonealso 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 byshouldHideCode.Currently, the preview and code can render together while editing/selected. Gate controls and
<MermaidView/>byshouldHideCode.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: TypepanZoomRefto the instance (and consider installing typings).Avoid
anyfor better safety; if available, useReturnType<typeof panzoom>or install@types/svg-pan-zoomand 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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)
apps/client/src/features/editor/components/code-block/code-block-view.tsx
Show resolved
Hide resolved
apps/client/src/features/editor/components/code-block/mermaid-view.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
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 acceptstring | null.The Select component's
onChangecan passnullwhen cleared, butchangeLanguagestill declaresnewLanguage: string. While line 53 guards againstnullwith?? "", 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
dangerouslySetInnerHTMLwithout sanitization. While Mermaid is generally trusted, user-controlled diagram code could potentially inject malicious content.Install DOMPurify:
npm install dompurify npm install -D @types/dompurifyApply 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: Replaceanywith proper svg-pan-zoom type.The
panZoomRefis typed asany, 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
📒 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
requestAnimationFrameprevents 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
isInternalUpdateRefflag combined withrequestAnimationFramecorrectly 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
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
Improvements