Skip to content

fix(components): stop Mermaid diagrams from capturing the wheel - #545

Merged
lodystage[bot] merged 4 commits into
mainfrom
fix/mermaid-diagram-gestures
Sep 10, 2026
Merged

fix(components): stop Mermaid diagrams from capturing the wheel#545
lodystage[bot] merged 4 commits into
mainfrom
fix/mermaid-diagram-gestures

Conversation

@lodystage

@lodystage lodystage Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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
wheel listener calls preventDefault() on each event. Scrolling the
conversation therefore stopped dead and zoomed the diagram whenever the pointer
happened to rest over one. controls.mermaid.panZoom: false — which this repo
already sets — only hides that canvas's buttons; the listener stays. The same
canvas also sets touch-action: none inline, so a finger landing on a diagram
could 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

  • A diagram in a message is a still preview. markdown-renderer.tsx intercepts
    wheel in the capture phase above the canvas and never calls
    preventDefault(), so the browser scrolls normally. Three !important
    overrides return touch-action, the pan transform, and the cursor to the page.
  • mermaid-diagram-viewer.tsx becomes the only canvas: a trackpad pinch
    (ctrl/meta-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.
  • Mermaid invariants moved to a scoped doc, which also drops
    ai-gui/AGENTS.md from 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"]
Loading

stopPropagation() alone would have hidden the gesture from the listeners above
the message, hence the re-dispatched copy from the markdown root, whose
propagation path excludes the canvas.

Where each gesture is handled now:

MarkdownRenderer (markdown-renderer.tsx)
└── [data-streamdown="mermaid"]        # still preview: click/Enter opens the viewer
    └── Streamdown pan/zoom canvas     # wheel taken back, transform + touch-action pinned

MermaidDiagramViewer (mermaid-diagram-viewer.tsx)
└── scroll surface                     # plain wheel + touch = native pan
    ├── ctrl/meta + wheel              # pinch: zoom anchored at the pointer
    └── mouse/pen drag on the diagram  # pan via scrollLeft/scrollTop

Before / after

Measured in Chromium (Playwright driving the real MermaidStyleReview story,
given a scroll container because the Storybook preview clips its own overflow):

Before After
300px wheel over the diagram: container scrollTop 0 -> 0 0 -> 300, matching the control wheel over prose beside it
touch-action: none on the diagram touch-action: auto
Drag left the preview at matrix(0.9, 0, 0, 0.9, 100, 60) — displaced and holding a zoom from the swallowed wheel Drag leaves transform: none; the release opens the viewer
Viewer: no pinch; ctrl+wheel reached the window Viewer: ctrl+wheel 121% -> 148%, anchored at the pointer
Viewer: pan by scrollbars or wheel only Viewer: drag pans (scrollTop 177 -> 297 for a 120px drag), stays open

Test plan

  • packages/components: vitest run tests/markdown-mermaid-fullscreen.test.tsx
    — 16 passed, including five new cases. The wheel case asserts
    defaultPrevented === false and that a listener above the message still
    receives one event of the same deltaY; removing the interceptor makes it
    fail, so it guards the reported defect rather than restating the code.
  • vitest run tests/markdown — 105 passed. tsgo --noEmit — clean.
    oxlint on the changed files — 0 warnings, 0 errors. prettier --check on
    every changed file — clean. pnpm run docs check — no errors.
  • Tailwind CLI over src/tailwind/index.css confirms the three overrides
    compile to !important declarations.
  • Chromium verification as tabulated above, including a baseline run with only
    markdown-renderer.tsx reverted, which reproduced the report.
  • Skipped: full pnpm check (this worktree needs its submodules initialized to
    install, and the suite reaches far past the changed files). Two full
    packages/components runs each failed one unrelated test, a different one
    each time (markdown-streaming-reparse, then avatar-cache); both pass in
    isolation, so they are load flakes. Touch was not exercised on a device: the
    touch-action fix is a computed-style observation.

Context handoff

Instructions for reviewing agents

  • Review focus: the capture-phase wheel interceptor in 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).
  • Decisions to challenge: neutralizing Streamdown's canvas from outside instead of patching the package or replacing the block with a custom renderer; pinning the canvas transform rather than intercepting pointerdown.
  • Plausible failures / evidence gaps: two-finger touch pinch is deliberately absent; the zoom-in cursor rule targets [data-streamdown="mermaid"] > div, so a Streamdown structural change would silently drop that affordance, though not the fix.

Authoring context

  • User goal / directives: stop a Mermaid diagram from capturing the wheel while the user scrolls the conversation, and support pinch/pan once the user focuses a diagram, as on a MacBook trackpad.
  • Constraints / non-goals: no dependency patch, no fork of Streamdown's mermaid block, and no reimplementation of its copy/download controls; the viewer's existing exits, safe-area padding, stacking, and natural-size opening are unchanged.
  • Risk-bearing decisions: the interceptor stops the original wheel and 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.
  • Destructive or irreversible behavior: none — renderer-only, no storage, protocol, or migration surface.
  • Deliberately not done or tested: two-finger touch pinch (it requires taking touch-action and reimplementing inertial panning); touch was not exercised on a device; full pnpm check was not run in this worktree.
  • Unknowns / confidence: high for desktop, verified in Chromium before and after; medium for touch, where the fix rests on a computed-style observation rather than a device test.

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

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>
zxch3n and others added 3 commits September 9, 2026 17:02
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>
@lodystage
lodystage Bot merged commit 7f58dec into main Sep 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant