Skip to content

Fix Mermaid block sizing, zoom retention, and pinpoint drag#819

Merged
backnotprop merged 4 commits into
backnotprop:mainfrom
HyunmoAhn:fix/planotator-mermaid-zoom-height
May 29, 2026
Merged

Fix Mermaid block sizing, zoom retention, and pinpoint drag#819
backnotprop merged 4 commits into
backnotprop:mainfrom
HyunmoAhn:fix/planotator-mermaid-zoom-height

Conversation

@HyunmoAhn
Copy link
Copy Markdown
Contributor

@HyunmoAhn HyunmoAhn commented May 29, 2026

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 via dangerouslySetInnerHTML 4–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.

# Scenario Fix Commit
1 At-rest SVG sizing attrs wiped by re-injection (short diagrams render as a "dot", long diagrams overflow vertically with the wrong height) Bake sizing attrs into the SVG markup string faad82c
2 Parent-tree re-renders unrelated to Pinpoint (sidebar toggle, selecting another block, etc.) reset the live Mermaid state (zoomed viewBox, pan, etc.) to the natural viewBox Wrap MermaidBlock in React.memo c9daf7e
3 In Pinpoint mode, releasing a pan-drag inside a Mermaid block creates an unwanted whole-block annotation data-pinpoint-ignore="" on the grab container 8ad6c44

The 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

  1. Open the shared link, or any equivalent markdown with three Mermaid blocks: a short flowchart LR X --> Y --> Z, a long top-down checkout flow, and a top-down signup flow.
  2. Run plannotator annotate <file> and wait for the editor to mount.
  3. Observe the three blocks at rest (no interaction) — the short diagram renders as a tiny "dot" (85 × 66 px), and the long diagram overflows its container vertically with the wrong height (498 × 1667 px).

As-is vs to-be

As-is (pre-fix) To-be (post-fix)
wrong height as-is wrong height to-be

Fix

Mermaid 11.x emits the root <svg> with style="max-width: <natural-px>;", width="100%", a viewBox, and no preserveAspectRatio / height. The component previously corrected this in a useEffect via setAttribute, but the parent's dangerouslySetInnerHTML re-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. The useEffect only runs once because its svg state 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", and height="100%" are baked into the markup itself, so every re-injection carries them along. The imperative useEffect is 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 dangerouslySetInnerHTML is re-run against the cached markup string, and any state held only on the live DOM via setAttribute (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

  1. Open the shared link and zoom in (or pan) a Mermaid block so its live viewBox differs from the natural one. Pinpoint mode is left off.
  2. Trigger any action that causes the parent Viewer to re-render — toggle the sidebar, select another block, etc.
  3. Observe that the Mermaid block snaps back to the natural viewBox.

As-is vs to-be

As-is (pre-fix) To-be (post-fix)
reset.mermaid.when.render.as-is.mov.mp4
reset.mermaid.when.render.to-be.mov.mp4

Fix

Wrap MermaidBlock in React.memo with a custom comparator on block.id + block.content. This matches the convention HtmlBlock already uses in the same package. The upstream blocks array comes from useMemo([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, the dangerouslySetInnerHTML is 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 click after mouseup. The Pinpoint overlay's click handler catches the synthetic click and calls highlighter.fromRange() over the entire Mermaid block, creating an unwanted whole-block annotation.

How to reproduce

  1. Open the shared link and zoom in × 2 (or pan) a Mermaid block.
  2. Toggle Pinpoint mode ON.
  3. mousedown inside the diagram → drag (pan) → release → a whole-block annotation is created.

As-is vs to-be

As-is (pre-fix) To-be (post-fix)
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 existing SKIP_SELECTORS in packages/ui/utils/blockTargeting.ts already 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 synthetic click after 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

HyunmoAhn and others added 3 commits May 29, 2026 12:41
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>
@HyunmoAhn HyunmoAhn marked this pull request as ready for review May 29, 2026 05:05
@backnotprop
Copy link
Copy Markdown
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.
@backnotprop
Copy link
Copy Markdown
Owner

great work

@backnotprop backnotprop merged commit b00ddfa into backnotprop:main May 29, 2026
7 checks passed
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