fix(components): stop Mermaid diagrams from capturing the wheel - #545
Merged
Conversation
Streamdown wraps every rendered diagram in a pan/zoom canvas whose non-passive wheel listener calls preventDefault(), so scrolling the conversation stopped dead and zoomed the diagram whenever the pointer happened to rest over one. controls.mermaid.panZoom: false only hides that canvas's buttons; the listener stays. A diagram in a message is now a still preview. markdown-renderer takes the wheel in the capture phase above the canvas and re-dispatches an uncancelable copy, so the conversation's own wheel listeners still see the gesture while the browser scrolls normally. !important overrides return touch-action, the transform, and the cursor to the page. Canvas behaviour moves to the full-screen viewer, where a trackpad pinch (ctrl-modified wheel) zooms around the pointer and a held mouse or pen button drags. Plain wheel and touch panning stay with the surface's own scrolling, so momentum and overscroll containment remain the platform's. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mobile preview-cap suite committed its renders and its unmount through flushSync rather than act, which leaves React's passive-effect flush queued on the real macrotask queue. That callback reads window.event before it does anything else, so when Vitest tore the file's environment down first it threw as an unhandled error and failed a run in which all 3313 tests passed. Route every commit through act, as 148 other suites in this package already do, and await one setImmediate after the unmount so nothing React queued outlives the DOM it expects. The queue is FIFO, so that await is an ordering barrier rather than a sleep. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Taking the wheel away from diagrams in a message left zooming reachable only through the full-screen viewer, which is heavy for a glance at one node. A pointer click now activates the diagram in place: that one diagram becomes a canvas where a trackpad pinch zooms around the pointer and a drag pans, released by Escape, a press elsewhere, or the viewer. An unmodified wheel is still never taken, activated or not, so a reader who forgets they activated a diagram can always scroll past it. Only a ctrl- or meta-modified wheel on the active diagram is consumed; the transform goes on the <svg>, which Streamdown injected as raw markup and never writes to, while its own canvas stays pinned at transform: none. Full-screen moves to a button portalled into the block's own action bar, beside copy and download. Touch keeps opening the viewer instead of gaining an inline pinch: a custom touch canvas would have to reimplement inertial panning for the phone case that viewer exists to serve. Keyboard users get the same canvas through Enter, arrows, +/- and Escape. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by review, each reproduced in Chromium before the fix.
The viewer closed on a plain click on the diagram. Panning takes pointer
capture on the scroll surface, and pointer capture retargets the following
click to the capturing element, so the click arrived with the surface as its
target and read as a click on the backdrop. Where the press started now
decides; a press that really began on the backdrop still closes.
Escape was answered document-wide while a diagram was activated: the branch
sat above the focus check, so an Escape typed into an input elsewhere was
preventDefault()ed and focus was pulled onto the diagram. Every key is now
gated on focus being inside the canvas, and leaving by keyboard releases it.
The disabled path restored attributes and cleared blocks but never released
an active canvas, leaving its document listeners bound to a detached element.
The observer re-marked every diagram on every mutation. Removing tabindex
from a focused element blurs it in Chromium, so a streaming turn dropped an
activated canvas out of the keyboard, and aria-label was rewritten each time.
Diagrams are now marked once, and only a diagram that has gone is restored.
Two comments described code that was never written. The ring's stylesheet
rule turned out to be outranked by a global
`*:focus { outline: none !important }` in index.css, which is why it has to
be inline; that is now named rather than guessed at, and the activated state
gets the grab cursor its resting zoom-in was missing.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Related issue
Same-repository branch; no intake Issue. Reported directly: scrolling a
conversation stops when the pointer is over a Mermaid diagram.
Problem / pressure
Streamdown wraps every rendered diagram in a pan/zoom canvas whose non-passive
wheellistener callspreventDefault()on each event. Scrolling theconversation therefore stopped dead and zoomed the diagram whenever the pointer
happened to rest over one.
controls.mermaid.panZoom: false— which this repoalready sets — only hides that canvas's buttons; the listener stays. The same
canvas also sets
touch-action: noneinline, so a finger landing on a diagramcould not scroll the conversation at all.
Zooming a diagram is still wanted, just not by accident: it belongs to the
full-screen viewer the user deliberately opens.
Summary
markdown-renderer.tsxinterceptswheelin the capture phase above the canvas and never callspreventDefault(), so the browser scrolls normally. Three!importantoverrides return
touch-action, the pan transform, and the cursor to the page.mermaid-diagram-viewer.tsxbecomes the only canvas: a trackpad pinch(ctrl/meta-modified
wheel) zooms around the pointer, and a held mouse or penbutton drags. Plain wheel and touch panning stay with the surface's own
scrolling, so momentum and overscroll containment remain the platform's.
ai-gui/AGENTS.mdfrom 8123 to 7667 bytes.Visual explanation
The wheel path over a diagram, before and after:
flowchart TD W["wheel over a diagram"] --> Root["markdown root (capture phase)"] Root -->|before| Canvas["Streamdown pan/zoom canvas<br/>preventDefault() + zoom state"] Canvas --> Dead["page does not scroll"] Root -->|after| Take["stopPropagation()<br/>re-dispatch uncancelable copy"] Take --> Above["scroll-viewport listeners<br/>stick-to-bottom, outline jump"] Take --> Scroll["browser scrolls the conversation"]stopPropagation()alone would have hidden the gesture from the listeners abovethe message, hence the re-dispatched copy from the markdown root, whose
propagation path excludes the canvas.
Where each gesture is handled now:
Before / after
Measured in Chromium (Playwright driving the real
MermaidStyleReviewstory,given a scroll container because the Storybook preview clips its own overflow):
scrollTop0 -> 00 -> 300, matching the control wheel over prose beside ittouch-action: noneon the diagramtouch-action: automatrix(0.9, 0, 0, 0.9, 100, 60)— displaced and holding a zoom from the swallowed wheeltransform: none; the release opens the viewer121% -> 148%, anchored at the pointerscrollTop 177 -> 297for a 120px drag), stays openTest plan
packages/components:vitest run tests/markdown-mermaid-fullscreen.test.tsx— 16 passed, including five new cases. The wheel case asserts
defaultPrevented === falseand that a listener above the message stillreceives one event of the same
deltaY; removing the interceptor makes itfail, so it guards the reported defect rather than restating the code.
vitest run tests/markdown— 105 passed.tsgo --noEmit— clean.oxlinton the changed files — 0 warnings, 0 errors.prettier --checkonevery changed file — clean.
pnpm run docs check— no errors.src/tailwind/index.cssconfirms the three overridescompile to
!importantdeclarations.markdown-renderer.tsxreverted, which reproduced the report.pnpm check(this worktree needs its submodules initialized toinstall, and the suite reaches far past the changed files). Two full
packages/componentsruns each failed one unrelated test, a different oneeach time (
markdown-streaming-reparse, thenavatar-cache); both pass inisolation, so they are load flakes. Touch was not exercised on a device: the
touch-actionfix is a computed-style observation.Context handoff
Instructions for reviewing agents
markdown-renderer.tsx, and whether its re-dispatched copy is a faithful stand-in for the listeners above the message (use-sticky-scroll.ts,view.tsx).pointerdown.zoom-incursor rule targets[data-streamdown="mermaid"] > div, so a Streamdown structural change would silently drop that affordance, though not the fix.Authoring context
wheeland re-dispatches an uncancelable copy from the markdown root; if a consumer above the message needed the original target or cancelability, it would now see neither.touch-actionand reimplementing inertial panning); touch was not exercised on a device; fullpnpm checkwas not run in this worktree.Rationale and rejected alternatives:
.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md.Invariants:
packages/components/src/components/ai-gui/mermaid-diagram-rendering.md.🤖 Generated with Claude Code