Fix Mermaid block sizing, zoom retention, and pinpoint drag#819
Merged
backnotprop merged 4 commits intoMay 29, 2026
Merged
Conversation
Mermaid 11.x emits the root <svg> with style="max-width: <natural-px>;" and no preserveAspectRatio / height attributes. The component fixed this via imperative setAttribute in a useEffect, but React's repeated dangerouslySetInnerHTML re-injections (4-5 times during mount) wipe the imperative attrs because the useEffect's `svg` state dep does not change. Result: short flowcharts (e.g. `X --> Y --> Z`) render at their natural 85x66 px "dot" size, long flowcharts overflow vertically past their container (~1667 px), and drag feels broken because pan-on-clipped-svg has nowhere to go. Bake max-width:none, preserveAspectRatio="xMidYMid meet", and height="100%" into the SVG markup string itself before setSvg(). Since the attributes live in the markup, every re-injection carries them along. The imperative useEffect is left in place as defense-in-depth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After zooming a Mermaid diagram and toggling pinpoint mode on, hovering the viewer would reset the visible viewport back to the full diagram. The zoom ref / badge stayed at e.g. 150%, but the SVG snapped back to its natural extents. Root cause: the pinpoint hook calls setHoverTarget on every mousemove, which re-renders the parent Viewer, which re-runs MermaidBlock's dangerouslySetInnerHTML and replaces the live SVG with the cached markup string. The prior commit's markup normalization pins three sizing attrs, but viewBox is not in the markup -- zoom mutates viewBox via setAttribute, so each re-injection drops the zoomed value. Wrap MermaidBlock with React.memo and a custom comparator on block.id + block.content (matching HtmlBlock's convention). Upstream blocks come from useMemo([markdown]) in the editor App, so referential equality holds whenever content is unchanged -- memo skips the re-render and the live SVG keeps its zoomed viewBox across pinpoint mousemove storms. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
In pinpoint mode, releasing a Mermaid pan-drag created a whole-block annotation. mouseup is followed by a click event, and the pinpoint overlay's click handler caught it and called highlighter.fromRange() on the entire Mermaid block. Separate annoyance: the dashed hover border drawn by the pinpoint overlay was visually distracting while the user was clearly trying to drag the diagram. Add data-pinpoint-ignore="" to the grab container div. usePinpoint's existing SKIP_SELECTORS in blockTargeting.ts already matches that attribute, so this is a one-attribute opt-out using a public extension point -- no changes to the pinpoint hook itself. Trade-off: Mermaid blocks no longer support whole-block-annotation via pinpoint. Annotations on diagrams must go through standard selection. This is the deliberate behavior chosen for diagrams where drag/zoom ergonomics matter more than whole-block targeting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner
|
Thank you!! |
The MermaidBlock test imported the component, which runs mermaid.initialize() at module load. That throws 'Unsupported color format: "backnotprop#333"' in the headless CI environment, failing the test on import before any assertion runs. Move the pure SVG-normalization helper into mermaidSvg.ts (no React, no mermaid import) and have both the component and the test import it from there. App behavior is unchanged; the test no longer loads mermaid.
Owner
|
great work |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I've been a regular user of Plannotator and have been actively recommending it to my team as well. While using it, I came across a bug related to Mermaid, so I decided to submit a PR to fix it.
Summary
This PR bundles fixes for several regressions in
packages/ui/components/MermaidBlock.tsx. They share a single root cause — the parent component re-injects the rendered SVG viadangerouslySetInnerHTML4–5 times during mount and again on every parent re-render. Each re-injection wipes any imperative DOM attribute changes the component made after render (sizing, viewBox, etc.).Each scenario is shipped as its own commit so the bug → fix mapping is reviewable independently.
MermaidBlockinReact.memodata-pinpoint-ignore=""on the grab containerThe fixture diagrams used for testing can be reproduced via this shared link.
Scenario 1 — At-rest SVG sizing attrs wiped by re-injection
How to reproduce
flowchart LR X --> Y --> Z, a long top-down checkout flow, and a top-down signup flow.plannotator annotate <file>and wait for the editor to mount.As-is vs to-be
Fix
Mermaid 11.x emits the root
<svg>withstyle="max-width: <natural-px>;",width="100%", aviewBox, and nopreserveAspectRatio/height. The component previously corrected this in auseEffectviasetAttribute, but the parent'sdangerouslySetInnerHTMLre-injects the cached markup 4–5 times during mount; each re-injection replaces the live<svg>with the original Mermaid string, wiping the imperative attrs. TheuseEffectonly runs once because itssvgstate dep does not change between re-injections.The fix normalizes the rendered SVG string before storing it in state:
max-width: none,preserveAspectRatio="xMidYMid meet", andheight="100%"are baked into the markup itself, so every re-injection carries them along. The imperativeuseEffectis kept as defense-in-depth.A unit test (
packages/ui/components/MermaidBlock.test.ts, 7 cases) covers the regex normalization end-to-end including the edge cases (existing styles preserved, no style attr present, nested<svg>inside<marker>untouched, non-SVG input passes through).Commit
faad82c — Pin Mermaid SVG sizing attrs into markup so they survive re-injection
Scenario 2 — Parent-tree re-renders (Pinpoint-unrelated) reset the live Mermaid state
When the parent component (e.g. Viewer) re-renders for any reason, MermaidBlock's
dangerouslySetInnerHTMLis re-run against the cached markup string, and any state held only on the live DOM viasetAttribute(zoomed viewBox, pan position) snaps back to the natural Mermaid viewBox stored in the markup. The zoom ref is kept in React state so the badge still reads correctly, but the SVG attribute is silently reset on every re-injection.This scenario covers re-render triggers unrelated to Pinpoint — sidebar toggles, selecting another block, arbitrary parent state changes.
How to reproduce
As-is vs to-be
reset.mermaid.when.render.as-is.mov.mp4
reset.mermaid.when.render.to-be.mov.mp4
Fix
Wrap
MermaidBlockinReact.memowith a custom comparator onblock.id+block.content. This matches the conventionHtmlBlockalready uses in the same package. The upstreamblocksarray comes fromuseMemo([markdown])in the editor App (packages/editor/App.tsx), so referential equality holds as long as the markdown is unchanged — memo bails out of the re-render, thedangerouslySetInnerHTMLis not re-run, and the live SVG state is preserved regardless of the trigger.Commit
c9daf7e — Memoize MermaidBlock to preserve zoomed viewBox across pinpoint hover
Scenario 3 — Whole-block annotation on Pinpoint drag release
When Pinpoint mode is on and the user drags inside a Mermaid block, releasing the drag fires a synthetic
clickaftermouseup. The Pinpoint overlay's click handler catches the synthetic click and callshighlighter.fromRange()over the entire Mermaid block, creating an unwanted whole-block annotation.How to reproduce
As-is vs to-be
reset.when.pinpoint.as-is.mov.mp4
reset.when.pinpoint.to-be.mov.mp4
Fix
Add
data-pinpoint-ignore=""to the grab container<div>.usePinpoint's existingSKIP_SELECTORSinpackages/ui/utils/blockTargeting.tsalready matches that attribute for both hover and click — a one-attribute opt-out using a public extension point with no changes to the Pinpoint hook itself. The syntheticclickafter a short pan drag never reaches the Pinpoint overlay, so no unwanted whole-block annotation is created.Trade-off: Mermaid blocks no longer support whole-block annotation via Pinpoint. Annotations on diagrams go through standard text selection instead. The reasoning is that drag and zoom ergonomics on diagrams matter more than the ability to target the diagram as a whole, and the surrounding text blocks still accept Pinpoint clicks for context annotations.
Commit
8ad6c44 — Skip pinpoint targeting inside Mermaid grab area to keep drag clean