Files
HyunmoAhn b00ddfa16d Fix Mermaid block sizing, zoom retention, and pinpoint drag (#819)
* Pin Mermaid SVG sizing attrs into markup so they survive re-injection

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>

* Memoize MermaidBlock to preserve zoomed viewBox across pinpoint hover

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>

* Skip pinpoint targeting inside Mermaid grab area to keep drag clean

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>

* Extract normalizeMermaidSvgMarkup to a pure module

The MermaidBlock test imported the component, which runs mermaid.initialize()
at module load. That throws 'Unsupported color format: "#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.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-05-29 15:58:04 -07:00

34 lines
1.2 KiB
TypeScript

// Pure SVG-markup helpers for MermaidBlock. Kept free of React and the
// mermaid library so they can be unit-tested without loading mermaid's
// browser-only `initialize()` (which throws in headless test environments).
// Bake sizing attrs into the SVG markup so they survive repeated
// dangerouslySetInnerHTML re-injection — imperative setAttribute gets wiped.
export function normalizeMermaidSvgMarkup(markup: string): string {
return markup.replace(/<svg\b([^>]*)>/i, (_match, attrs: string) => {
let next = attrs;
if (/\bstyle\s*=\s*"/i.test(next)) {
next = next.replace(/\bstyle\s*=\s*"([^"]*)"/i, (_m, styleVal: string) => {
const rules = styleVal
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0 && !/^max-width\s*:/i.test(s));
rules.push('max-width: none');
return `style="${rules.join('; ')}"`;
});
} else {
next += ' style="max-width: none"';
}
if (!/\bpreserveAspectRatio\s*=/i.test(next)) {
next += ' preserveAspectRatio="xMidYMid meet"';
}
if (!/\bheight\s*=/i.test(next)) {
next += ' height="100%"';
}
return `<svg${next}>`;
});
}