From e7c058b1f9ba706aeb0056335402f2c893a55295 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 9 Sep 2026 07:16:40 +0000 Subject: [PATCH 1/4] fix(components): stop Mermaid diagrams from capturing the wheel 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 --- .../2026-09-09-mermaid-diagram-gestures.md | 110 +++++++++ .../src/components/ai-gui/AGENTS.md | 12 +- .../src/components/ai-gui/README.md | 14 +- .../components/ai-gui/markdown-renderer.tsx | 61 +++++ .../ai-gui/mermaid-diagram-rendering.md | 56 +++++ .../ai-gui/mermaid-diagram-viewer.tsx | 215 +++++++++++++++++- .../markdown-mermaid-fullscreen.test.tsx | 121 ++++++++++ 7 files changed, 573 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md create mode 100644 packages/components/src/components/ai-gui/mermaid-diagram-rendering.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md b/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md new file mode 100644 index 000000000..427a8a089 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md @@ -0,0 +1,110 @@ +# Give the page back the wheel over a Mermaid diagram + +Status: implemented +Translation: pending + +## Abstract + +Streamdown wraps every rendered Mermaid diagram in a pan/zoom canvas whose +non-passive `wheel` listener calls `preventDefault()`, so scrolling a conversation +stopped dead and zoomed the diagram whenever the pointer happened to rest over +one; `controls.mermaid.panZoom: false` hides that canvas's buttons but not its +listener. A diagram in a message is now a still preview: `markdown-renderer.tsx` +takes the wheel in the capture phase and re-dispatches an uncancelable copy so the +conversation's own wheel listeners still see the gesture, and `!important` +overrides return `touch-action` and the cursor to the page. Canvas behaviour moved +to the full-screen viewer, where a trackpad pinch (ctrl-modified wheel) zooms +around the pointer and a held mouse button drags. Touch panning stays with the +browser's scrolling, which is why two-finger pinch on a touch screen is +deliberately absent rather than partly implemented. + +## Decision + +Two surfaces, one rule each. A diagram in a message opens the viewer and does +nothing else; the viewer is the only canvas. + +Streamdown's canvas is neutralized from outside rather than removed, because the +package offers no way to turn it off: + +- `wheel` is intercepted on the markdown root in the capture phase, above the + canvas element that carries the listener. The interceptor never calls + `preventDefault()` — the browser's own scrolling is the behaviour being restored. +- `stopPropagation()` alone would also hide the gesture from the conversation's + wheel listeners further up: releasing stick-to-bottom (`use-sticky-scroll.ts`) + and abandoning an outline jump (`view.tsx`) both listen on the scroll viewport. + An uncancelable `WheelEvent` copy is therefore re-dispatched from the markdown + root, whose propagation path excludes the canvas. +- `touch-action: none`, the pan transform, and the `grab` cursor are inline styles + on the canvas, so `MARKDOWN_BASE_CLASSNAME` overrides all three with + `!important`. Pinning the transform makes the canvas's remaining pointer drag + visually inert without intercepting `pointerdown`, which would have hidden that + event from outside-dismiss and selection handlers above the markdown. + +In the viewer, a ctrl- or meta-modified wheel is taken (Chromium spends it on +zooming the window otherwise) and zooms around the pointer. The anchored point is +restored by scrolling the surface, measured from the diagram's box rather than +from scroll offsets, because the surface centres a diagram that fits and that +offset is not proportional to the zoom. A held mouse or pen button pans; a release +that moved the diagram does not count as the click off the diagram that closes. + +Two-finger touch pinch is not implemented. Custom pinch requires taking +`touch-action` from the browser, which means reimplementing inertial panning for +the phone case this viewer exists to serve. Touch zooms with the control bar +instead. Invariants: `packages/components/src/components/ai-gui/mermaid-diagram-rendering.md`. + +## Alternatives + +- **Patch `streamdown`.** Honouring `panZoom: false` in the package would be the + semantically correct fix, and the repository already carries eleven patches. Its + only build artifact is a single-line minified `dist` chunk, so a unified diff + would restate the whole bundle and could not be reviewed. +- **Replace the mermaid block with a custom `plugins.renderers` entry.** Full + control, but it also takes over lazy rendering, the streaming and error paths, + and the diagram copy/download menu, none of which are exported. +- **`pointer-events: none` on the canvas.** Does not help: pointer events only + affect hit-testing, while the propagation path of an event targeted at a + descendant still runs through the canvas. + +## Evidence and limits + +`tests/markdown-mermaid-fullscreen.test.tsx` renders the real Streamdown block in +jsdom. The new wheel case asserts `defaultPrevented === false` and that a listener +above the message still receives one event of the same `deltaY`; with the +interceptor removed from `markdown-renderer.tsx`, that case fails on +`defaultPrevented`, so it guards the reported defect rather than restating the +implementation. The viewer cases assert that a plain wheel is left alone while a +ctrl-modified one is taken and moves the zoom readout from 100% to 122%, that a +drag moves `scrollLeft`/`scrollTop`, and that the release does not close the +viewer. `computePinchZoomFactor` and `computeAnchoredScrollCorrection` are pure +and unit-tested for reversibility, notch bounding, and the anchored point. + +All 16 tests in that file pass, as do the 105 in `tests/markdown*`, and +`packages/components` typechecks. The three CSS overrides compile to `!important` +declarations, checked by running the Tailwind CLI over `src/tailwind/index.css`. + +Chromium drove the real `MermaidStyleReview` story through Playwright against a +local Storybook, with the story given a scroll container because the Storybook +preview clips its own overflow. With the fix, a 300px wheel over the diagram moved +that container by 300px — the same as the control wheel over prose beside it — and +the diagram reported `touch-action: auto`, no transform, and a `zoom-in` cursor. A +drag across the preview left its transform at `none` and its release opened the +viewer. In the viewer, a ctrl-modified wheel moved the zoom readout from 121% to +148% without zooming the window, a plain wheel panned the surface from 69 to 177 +and left the zoom alone, and a 150 x 120 drag panned to the horizontal limit and by +exactly 120px vertically while leaving the viewer open, which Escape then closed. + +Reverting only `markdown-renderer.tsx` in the same session reproduced the report: +the wheel over the diagram left the container at 0 while the control still scrolled +300, `touch-action` was `none`, and the drag left the preview at +`matrix(0.9, 0, 0, 0.9, 100, 60)` — displaced, and still holding a zoom from the +swallowed wheel. + +Limits: touch was not exercised; the `touch-action` fix is a computed-style +observation, not a finger on a phone, and two-finger pinch is absent by design. +The full `pnpm check` was not run — this worktree needs its submodules initialized +to install at all, and the suite reaches far past the changed files. Two full +`packages/components` runs each failed one unrelated test, and a different one each +time (`markdown-streaming-reparse`, then `avatar-cache`); both pass in isolation, +so they are load flakes rather than regressions. `NODE_ENV=production` in the +environment resolves React to its production build, where `act` is missing; the +suite was run with `NODE_ENV=test`. diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index 849b9bd0f..f1c3b7ca2 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -94,15 +94,9 @@ File-by-file ownership and coverage pointers: [README.md](README.md). dense monospace, terminal output, and collapsed height through `conversation-font-size-classes.ts`; settings own legacy preset migration. Keep Streamdown in streaming mode, but never enable word-level `animated`. -- A Mermaid diagram opens in `mermaid-diagram-viewer.tsx`, never Streamdown's own - full-screen overlay (`controls.mermaid.fullscreen` stays off). Keep three - properties: controls padded by the `--safe-area-*` variables rather than a fixed - viewport offset and at least 44px; never a single exit (close button, click off - the diagram, Escape); and `--z-image-viewer` stacking, so a diagram opened - inside a dialog lands above that dialog. Open the diagram at NATURAL size when - it does not fit and pan, instead of scaling it down. `markdown-renderer.tsx` - applies the click target and its `role`/`tabindex` by observer; the block's own - copy/download controls must stay reachable without hover. +- A Mermaid diagram in a message is a still preview that NEVER captures a scroll, + and `mermaid-diagram-viewer.tsx` is the only canvas and the only full-screen + surface. Invariants: [mermaid-diagram-rendering.md](mermaid-diagram-rendering.md). - `chat_failed` raw errors use a modal; extraction/copy live in `chat-failed-error-report.ts`. - Capacity retry targets only the latest notice: the first click consents, and diff --git a/packages/components/src/components/ai-gui/README.md b/packages/components/src/components/ai-gui/README.md index 2561d152e..50413cd90 100644 --- a/packages/components/src/components/ai-gui/README.md +++ b/packages/components/src/components/ai-gui/README.md @@ -20,7 +20,9 @@ the reasoning behind those rules. work) and a hover preview; `conversation-outline-arrival-intent.ts` decides when a pointer heading for a tick counts as arrival. - `markdown-renderer.tsx` wraps Streamdown; `mermaid-diagram-viewer.tsx` is the - full-screen diagram surface, and `markdown-diff-block.tsx` the inline diff. + full-screen diagram surface and `markdown-diff-block.tsx` the inline diff. + Diagram invariants live in + [mermaid-diagram-rendering.md](mermaid-diagram-rendering.md). - `message-content-guards.ts` gates which shared `MessageContent` variants render. - `chat-failed-error-report.ts` / `chat-failed-detail-dialog.tsx` own raw error extraction and its modal; `terminal-component.tsx` / `terminal-preview.ts` own @@ -62,11 +64,11 @@ the reasoning behind those rules. cost is unbounded on a long turn. - **The gutter rule.** Virtua rows are absolutely positioned and ignore scroller padding, so the rail has to come from `ConversationColumn`. -- **The Mermaid viewer replacement.** Streamdown's own overlay put its only exit at - a raw `top-4 right-4` — inside a phone's status-bar inset — while its content - layer covered the backdrop and swallowed every tap, so a touch user could not - leave it. An agent's sequence diagram scaled to a phone screen is also - unreadable, which is why the replacement opens at natural size and pans. +- **The Mermaid viewer replacement, and the still preview in a message.** + Streamdown's own overlay could not be left on touch, and the pan/zoom canvas it + wraps every diagram in swallowed page scrolls that merely passed under one. + Both, and the gestures that replaced them, are in + [mermaid-diagram-rendering.md](mermaid-diagram-rendering.md). ## Creation progress diff --git a/packages/components/src/components/ai-gui/markdown-renderer.tsx b/packages/components/src/components/ai-gui/markdown-renderer.tsx index 2446221fa..c4a796116 100644 --- a/packages/components/src/components/ai-gui/markdown-renderer.tsx +++ b/packages/components/src/components/ai-gui/markdown-renderer.tsx @@ -178,6 +178,17 @@ const MARKDOWN_BASE_CLASSNAME = '[&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-muted-foreground/40 [&_a:hover]:decoration-muted-foreground ' + '[&_.katex-display]:!my-5 [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:py-1 ' + '[&_[data-streamdown="mermaid-block"]]:!my-5 ' + + // Streamdown wraps every diagram in a pan/zoom canvas that claims the gesture + // through inline styles: `touch-action: none` stops a finger resting on a + // diagram from scrolling the conversation, and a `grab` cursor advertises a + // drag that only shifts the preview inside its own frame. A diagram in a + // message is a still preview that opens `MermaidDiagramViewer`; panning and + // zooming belong to that viewer, so the canvas transform is pinned and the + // gesture handed back to the page. The canvas also takes the wheel from a + // listener, which CSS cannot reach — see `releaseDiagramWheelToPage` below. + '[&_[data-streamdown="mermaid"]_[role="application"]]:!touch-auto ' + + '[&_[data-streamdown="mermaid"]_[role="application"]]:!transform-none ' + + '[&_[data-streamdown="mermaid"]>div]:!cursor-zoom-in ' + '[&_[data-streamdown="code-block"]]:!my-4 ' + '[&_table]:!my-0 [&_table]:w-full [&_table]:border-collapse [&_table]:text-[0.92em] [&_table]:leading-[1.5] ' + '[&_th]:border-b [&_th]:border-border/70 [&_th]:bg-muted/45 [&_th]:px-2.5 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground/80 ' + @@ -1234,6 +1245,56 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ clearMarkedDiagrams(); }; }, [hasMermaidBlock, openDiagramLabel]); + + // Streamdown's pan/zoom canvas listens for `wheel` non-passively and calls + // `preventDefault()` on every one of them, so a page scroll that merely passes + // under a diagram is swallowed and becomes a zoom instead. Turning + // `controls.mermaid.panZoom` off only hides that canvas's buttons — the + // listener stays, and it sits on Streamdown's own element, so the gesture has + // to be taken from it in the capture phase above. + // + // The interceptor never calls `preventDefault()`: the browser's own scrolling + // is exactly what is being handed back. `stopPropagation()` alone would also + // hide the gesture from the conversation's wheel listeners further up + // (releasing stick-to-bottom, abandoning an outline jump), so an uncancelable + // copy is re-dispatched from the markdown root, whose path excludes the canvas. + useEffect(() => { + const root = containerRef.current; + if (!root || !hasMermaidBlock) { + return undefined; + } + + const releaseDiagramWheelToPage = (event: WheelEvent) => { + const target = event.target; + if (!(target instanceof Element) || !target.closest(MERMAID_DIAGRAM_SELECTOR)) { + return; + } + event.stopPropagation(); + root.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: false, + composed: true, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaZ: event.deltaZ, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }) + ); + }; + + const options = { capture: true, passive: true } as const; + root.addEventListener('wheel', releaseDiagramWheelToPage, options); + return () => { + root.removeEventListener('wheel', releaseDiagramWheelToPage, { capture: true }); + }; + }, [hasMermaidBlock]); const components = useMemo( () => createMarkdownComponents({ diff --git a/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md b/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md new file mode 100644 index 000000000..cb1644feb --- /dev/null +++ b/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md @@ -0,0 +1,56 @@ +# Mermaid diagram rendering + +Streamdown renders the diagram; `markdown-renderer.tsx` decides what a diagram in +a message may do, and `mermaid-diagram-viewer.tsx` owns the full-screen surface. +Binding rules live in [AGENTS.md](AGENTS.md); this file holds the invariants and +why they read the way they do. Coverage: +`tests/markdown-mermaid-fullscreen.test.tsx`. + +## A diagram in a message is a still preview + +- It NEVER captures or cancels a scroll. Streamdown wraps every diagram in a + pan/zoom canvas that listens for `wheel` non-passively and calls + `preventDefault()` on each one, so a page scroll passing under a diagram became + a zoom. `controls.mermaid.panZoom: false` only hides that canvas's buttons — the + listener stays — so `markdown-renderer.tsx` takes the gesture in the capture + phase above the canvas and hands it back to the page. +- The interceptor must not call `preventDefault()`; the browser's own scrolling is + the behaviour being restored. It re-dispatches an uncancelable copy from the + markdown root, because `stopPropagation()` alone would also hide the gesture + from the conversation's wheel listeners further up — releasing stick-to-bottom + (`use-sticky-scroll.ts`) and abandoning an outline jump (`view.tsx`). +- The same canvas claims touch and the cursor through inline styles. + `MARKDOWN_BASE_CLASSNAME` overrides all three with `!important`: an automatic + `touch-action`, so a finger resting on a diagram still scrolls the conversation; + no transform, so a stray drag cannot leave the preview displaced inside its own + frame; and a `zoom-in` cursor for the click that opens the viewer. +- Streamdown owns the markup, so the click target and its `role`/`tabindex` are + applied by a `MutationObserver` — a diagram appears only after the lazily + imported runtime resolves, long after the component commits. The block's own + copy/download controls stay reachable without hover. + +## The viewer is the only canvas + +- It replaces Streamdown's own full-screen overlay (`controls.mermaid.fullscreen` + stays off), whose only exit sat at a raw `top-4 right-4` — inside a phone's + status-bar inset — while its content layer covered the backdrop and swallowed + every tap, leaving a touch user no way out. +- Controls are at least 44px and padded by the `--safe-area-*` variables, never at + a fixed viewport offset. There is always more than one exit: the close button, a + click off the diagram, and Escape. Stacking comes from `--z-image-viewer`, so a + diagram opened inside a dialog lands above that dialog. +- A diagram that does not fit opens at NATURAL size and is panned. Scaling an + agent's sequence diagram down to a phone screen turns readable labels into a + grey texture; only a diagram that already fits is scaled up. +- A trackpad pinch arrives as a ctrl-modified `wheel`, which Chromium would spend + on zooming the whole window, so the viewer takes that default and zooms the + diagram around the pointer instead. The anchored point is restored by scrolling + the surface, measured from the diagram's own box: the surface centres a diagram + that fits, and that offset is not proportional to the zoom. +- Plain wheel and touch panning stay with the surface's own scrolling. A pan + driven from pointer deltas cannot reproduce touch momentum or rubber-banding, so + only a held mouse or pen button pans by hand, and a release that moved the + diagram does not read as the click off the diagram that closes the viewer. +- Two-finger pinch on a touch screen is deliberately absent: implementing it means + taking `touch-action` from the browser and reimplementing inertial panning. + Touch zooms with the control bar's buttons instead. diff --git a/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx b/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx index 6143ba0cc..875179feb 100644 --- a/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx +++ b/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, } from 'react'; import { createPortal } from 'react-dom'; import { Maximize, X, ZoomIn, ZoomOut } from 'lucide-react'; @@ -29,6 +30,13 @@ import { cn } from '@/lib/utils'; * off the diagram, and Escape. * 3. The stacking order comes from the app's z-index scale, so the viewer * lands above dialogs and popovers rather than under them. + * + * The viewer is also the only place a diagram behaves like a canvas. A diagram + * sitting in a message never takes the wheel (see `markdown-renderer.tsx`); + * here, where the user asked for the diagram and nothing else is on screen, a + * trackpad pinch zooms around the pointer and a held button drags the diagram. + * Plain wheel and touch panning stay with the browser's own scrolling, so + * momentum and overscroll containment are the platform's, not a reimplementation. */ export const MERMAID_DIAGRAM_MIN_ZOOM = 0.25; @@ -40,6 +48,18 @@ export const MERMAID_DIAGRAM_MAX_ZOOM = 4; const MERMAID_DIAGRAM_MAX_INITIAL_ZOOM = 3; const MERMAID_DIAGRAM_ZOOM_STEP = 1.25; +/** + * A trackpad pinch reaches the page as a ctrl-modified wheel event carrying a + * few pixels per frame, while one notch of a mouse wheel carries around a + * hundred. Bounding the delta keeps a single step of either device to a + * comparable jump instead of throwing the diagram to a zoom limit. + */ +const MERMAID_DIAGRAM_PINCH_MAX_DELTA = 25; +const MERMAID_DIAGRAM_PINCH_SENSITIVITY = 0.01; + +/** A drag this short is a click that wobbled, not a pan. */ +const MERMAID_DIAGRAM_PAN_SLOP_PX = 3; + /** * `size="icon"` is 36px square. The viewer's controls sit at the top edge of a * phone screen, where the shared size is under the 44px touch-target floor @@ -72,6 +92,58 @@ export type MermaidDiagramSelection = { const clampZoom = (zoom: number): number => Math.min(MERMAID_DIAGRAM_MAX_ZOOM, Math.max(MERMAID_DIAGRAM_MIN_ZOOM, zoom)); +const clampRatio = (ratio: number): number => Math.min(1, Math.max(0, ratio)); + +/** + * The zoom multiplier for one pinch (or ctrl-wheel) step. Exponential rather + * than additive so the gesture feels the same at every zoom level and so + * pinching apart exactly undoes pinching together by the same amount. + */ +export function computePinchZoomFactor(deltaY: number): number { + const bounded = Math.max( + -MERMAID_DIAGRAM_PINCH_MAX_DELTA, + Math.min(MERMAID_DIAGRAM_PINCH_MAX_DELTA, deltaY) + ); + return Math.exp(-bounded * MERMAID_DIAGRAM_PINCH_SENSITIVITY); +} + +/** + * The point of the diagram the pointer sat over when a zoom started, kept as a + * fraction of the diagram's box plus the viewport coordinate it has to return + * to once the new size is laid out. + */ +export type MermaidDiagramZoomAnchor = { + readonly clientX: number; + readonly clientY: number; + readonly ratioX: number; + readonly ratioY: number; +}; + +/** + * How far the scroll surface has to move for the anchored point of the resized + * diagram to sit back under the pointer. Measured from the diagram's own box + * rather than from scroll offsets, because the surface centres a diagram that + * fits and that offset is not proportional to the zoom. + */ +export function computeAnchoredScrollCorrection({ + anchor, + diagramLeft, + diagramTop, + diagramWidth, + diagramHeight, +}: { + anchor: MermaidDiagramZoomAnchor; + diagramLeft: number; + diagramTop: number; + diagramWidth: number; + diagramHeight: number; +}): { left: number; top: number } { + return { + left: diagramLeft + anchor.ratioX * diagramWidth - anchor.clientX, + top: diagramTop + anchor.ratioY * diagramHeight - anchor.clientY, + }; +} + /** Falls back to the window while the scroll surface is still unmeasured. */ const measureViewport = (surface: HTMLElement | null) => ({ containerWidth: surface && surface.clientWidth > 0 ? surface.clientWidth : window.innerWidth, @@ -146,6 +218,19 @@ function OpenMermaidDiagramViewer({ const hostRef = useRef(null); const closeRef = useRef(null); const [zoom, setZoom] = useState(null); + // Set by a pinch and consumed by the layout effect that applies the new size, + // which is the first moment the resized diagram can be measured. + const zoomAnchorRef = useRef(null); + const panRef = useRef<{ + pointerId: number; + originX: number; + originY: number; + lastX: number; + lastY: number; + } | null>(null); + // A pan that ends off the diagram would otherwise read as a click on the + // backdrop, which closes the viewer. + const pannedRef = useRef(false); // The diagram is a live node, not markup: hand it to the DOM directly rather // than re-serializing it through `dangerouslySetInnerHTML`. @@ -184,6 +269,27 @@ function OpenMermaidDiagramViewer({ svg.style.width = `${selection.naturalWidth * zoom}px`; svg.style.height = `${selection.naturalHeight * zoom}px`; } + + // A pinch has to keep the point it started on under the fingers, so the + // surface is scrolled by whatever the resize moved that point. Reading the + // box here flushes the layout the assignments above just invalidated. + const anchor = zoomAnchorRef.current; + zoomAnchorRef.current = null; + const surface = scrollRef.current; + const host = hostRef.current; + if (!anchor || !surface || !host) { + return; + } + const rect = host.getBoundingClientRect(); + const correction = computeAnchoredScrollCorrection({ + anchor, + diagramLeft: rect.left, + diagramTop: rect.top, + diagramWidth: rect.width, + diagramHeight: rect.height, + }); + surface.scrollLeft += correction.left; + surface.scrollTop += correction.top; }, [selection, zoom]); useEffect(() => { @@ -223,20 +329,123 @@ function OpenMermaidDiagramViewer({ // diagram live, which on a wide phone is most of the screen. const handleSurfaceClick = useCallback( (event: ReactMouseEvent) => { + const panned = pannedRef.current; + pannedRef.current = false; const target = event.target; if (target instanceof Node && hostRef.current?.contains(target)) { return; } + // Letting go after dragging the diagram out from under the pointer is not + // a click on the backdrop. + if (panned) { + return; + } onClose(); }, [onClose] ); + const zoomAtPoint = useCallback((clientX: number, clientY: number, factor: number) => { + const host = hostRef.current; + const rect = host?.getBoundingClientRect(); + zoomAnchorRef.current = + rect && rect.width > 0 && rect.height > 0 + ? { + clientX, + clientY, + ratioX: clampRatio((clientX - rect.left) / rect.width), + ratioY: clampRatio((clientY - rect.top) / rect.height), + } + : null; + setZoom((current) => clampZoom((current ?? 1) * factor)); + }, []); + + // A trackpad pinch arrives as a ctrl-modified wheel event, and Chromium zooms + // the whole window with it unless the default is taken. An unmodified wheel is + // left alone: it is the surface's own scrolling, which is how the diagram pans. + useEffect(() => { + const surface = scrollRef.current; + if (!surface) { + return undefined; + } + const handleWheel = (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) { + return; + } + event.preventDefault(); + zoomAtPoint(event.clientX, event.clientY, computePinchZoomFactor(event.deltaY)); + }; + surface.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + surface.removeEventListener('wheel', handleWheel); + }; + }, [zoomAtPoint]); + + // Dragging the diagram itself pans it. Touch keeps the browser's own panning, + // whose momentum and rubber-banding a scroll driven from pointer deltas cannot + // reproduce, so only a held mouse or pen button pans by hand. + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + // Whatever the last gesture left behind, this one starts as a click. + pannedRef.current = false; + const surface = scrollRef.current; + const target = event.target; + if ( + !surface || + event.pointerType === 'touch' || + event.button !== 0 || + !(target instanceof Node) || + !hostRef.current?.contains(target) + ) { + return; + } + // Otherwise the drag paints a text selection across the diagram's labels. + event.preventDefault(); + panRef.current = { + pointerId: event.pointerId, + originX: event.clientX, + originY: event.clientY, + lastX: event.clientX, + lastY: event.clientY, + }; + surface.setPointerCapture?.(event.pointerId); + }, []); + + const handlePointerMove = useCallback((event: ReactPointerEvent) => { + const pan = panRef.current; + const surface = scrollRef.current; + if (!pan || !surface || pan.pointerId !== event.pointerId) { + return; + } + // Measured from where the drag started, so a slow pan of many small moves + // still counts as one. + if ( + Math.abs(event.clientX - pan.originX) >= MERMAID_DIAGRAM_PAN_SLOP_PX || + Math.abs(event.clientY - pan.originY) >= MERMAID_DIAGRAM_PAN_SLOP_PX + ) { + pannedRef.current = true; + } + surface.scrollLeft -= event.clientX - pan.lastX; + surface.scrollTop -= event.clientY - pan.lastY; + pan.lastX = event.clientX; + pan.lastY = event.clientY; + }, []); + + const handlePointerEnd = useCallback((event: ReactPointerEvent) => { + const pan = panRef.current; + if (!pan || pan.pointerId !== event.pointerId) { + return; + } + panRef.current = null; + scrollRef.current?.releasePointerCapture?.(event.pointerId); + }, []); + const zoomBy = useCallback((factor: number) => { + zoomAnchorRef.current = null; setZoom((current) => clampZoom((current ?? 1) * factor)); }, []); const resetZoom = useCallback(() => { + zoomAnchorRef.current = null; setZoom( computeInitialDiagramZoom({ ...measureViewport(scrollRef.current), @@ -330,9 +539,13 @@ function OpenMermaidDiagramViewer({ paddingRight: SAFE_AREA_RIGHT, }} onClick={handleSurfaceClick} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerEnd} + onPointerCancel={handlePointerEnd} >
-
+
diff --git a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx index 251107a0d..e1a7be677 100644 --- a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx +++ b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx @@ -5,7 +5,9 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + computeAnchoredScrollCorrection, computeInitialDiagramZoom, + computePinchZoomFactor, MERMAID_DIAGRAM_MAX_ZOOM, } from '../src/components/ai-gui/mermaid-diagram-viewer'; @@ -142,6 +144,46 @@ describe('computeInitialDiagramZoom', () => { }); }); +describe('diagram pinch arithmetic', () => { + it('undoes itself when the pinch reverses, at any zoom level', () => { + expect(computePinchZoomFactor(-12) * computePinchZoomFactor(12)).toBeCloseTo(1, 10); + expect(computePinchZoomFactor(-4)).toBeGreaterThan(1); + expect(computePinchZoomFactor(4)).toBeLessThan(1); + }); + + it('bounds a mouse notch so one step cannot throw the diagram to a zoom limit', () => { + // A wheel notch reports ~100 where a trackpad pinch reports a few pixels. + expect(computePinchZoomFactor(-400)).toBe(computePinchZoomFactor(-25)); + expect(computePinchZoomFactor(-400)).toBeLessThan(1.3); + }); + + it('scrolls the pinched point back under the pointer', () => { + // A diagram that doubled around the point under the pointer: what was at its + // centre now sits 500px further right, so the surface follows by 500px. + expect( + computeAnchoredScrollCorrection({ + anchor: { clientX: 500, clientY: 300, ratioX: 0.5, ratioY: 0.5 }, + diagramLeft: 0, + diagramTop: 0, + diagramWidth: 2000, + diagramHeight: 1200, + }) + ).toEqual({ left: 500, top: 300 }); + }); + + it('leaves the surface alone when the resize kept the point in place', () => { + expect( + computeAnchoredScrollCorrection({ + anchor: { clientX: 400, clientY: 200, ratioX: 0.25, ratioY: 0.5 }, + diagramLeft: 300, + diagramTop: 100, + diagramWidth: 400, + diagramHeight: 200, + }) + ).toEqual({ left: 0, top: 0 }); + }); +}); + describe('mermaid full-screen viewer', () => { let root: Root | undefined; let container: HTMLDivElement | undefined; @@ -265,4 +307,83 @@ describe('mermaid full-screen viewer', () => { expect(container?.querySelector('[data-streamdown="mermaid"]')).toBeNull(); expect(container?.querySelector('[aria-label="Open diagram"]')).toBeNull(); }); + + it('leaves a wheel over a diagram in a message to the page', async () => { + const diagram = await renderMarkdown(); + const svg = diagram.querySelector('svg') as SVGSVGElement; + + // Stands in for the conversation's own wheel listeners, which sit on the + // scroll viewport above the message. + const abovePage: number[] = []; + const listener = (event: Event) => abovePage.push((event as WheelEvent).deltaY); + container?.addEventListener('wheel', listener); + + const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); + await act(async () => { + svg.dispatchEvent(wheel); + }); + container?.removeEventListener('wheel', listener); + + // Streamdown's pan/zoom canvas would have taken this one and zoomed instead. + expect(wheel.defaultPrevented).toBe(false); + expect(abovePage).toEqual([120]); + }); + + it('zooms the open viewer on a pinch and leaves a plain wheel to scrolling', async () => { + const diagram = await renderMarkdown(); + await clickOn(diagram); + const surface = viewerSurface() as HTMLElement; + const zoomLabel = () => document.body.querySelector('[title="Reset zoom"]')?.textContent; + expect(zoomLabel()).toBe('100%'); + + const scroll = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: -40 }); + await act(async () => { + surface.dispatchEvent(scroll); + }); + // An unmodified wheel is the surface's own scrolling, which is how it pans. + expect(scroll.defaultPrevented).toBe(false); + expect(zoomLabel()).toBe('100%'); + + // A trackpad pinch: a ctrl-modified wheel, which would otherwise zoom the + // whole window. + const pinch = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -20, + }); + await act(async () => { + surface.dispatchEvent(pinch); + }); + expect(pinch.defaultPrevented).toBe(true); + expect(zoomLabel()).toBe('122%'); + }); + + it('pans the open viewer by dragging the diagram, without closing on release', async () => { + const diagram = await renderMarkdown(); + await clickOn(diagram); + const surface = viewerSurface() as HTMLElement; + const svg = surface.querySelector('svg[data-diagram="sequence"]') as Element; + surface.scrollLeft = 100; + surface.scrollTop = 100; + + const pointer = (type: string, clientX: number, clientY: number) => + new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY }); + await act(async () => { + svg.dispatchEvent(pointer('pointerdown', 200, 200)); + surface.dispatchEvent(pointer('pointermove', 180, 170)); + surface.dispatchEvent(pointer('pointerup', 180, 170)); + }); + + expect(surface.scrollLeft).toBe(120); + expect(surface.scrollTop).toBe(130); + + // The drag ended over the backdrop, but letting go of a pan is not a click + // off the diagram. + await clickOn(surface); + expect(viewer()).toBeTruthy(); + // The next real click still closes. + await clickOn(surface); + expect(viewer()).toBeNull(); + }); }); From de22178fe8ef17f8f25141e77d429e69dcd1bb11 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 9 Sep 2026 17:02:33 +0000 Subject: [PATCH 2/4] test(components): stop a React commit outliving its jsdom environment 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 --- .../2026-09-09-mermaid-diagram-gestures.md | 5 ++ .../2026-09-09-react-commit-teardown-leak.md | 57 +++++++++++++++++++ .../mobile-chat-list-preview-cap.test.tsx | 34 +++++++---- 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-09-react-commit-teardown-leak.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md b/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md index 427a8a089..0d397b38a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md +++ b/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md @@ -99,6 +99,11 @@ the wheel over the diagram left the container at 0 while the control still scrol `matrix(0.9, 0, 0, 0.9, 100, 60)` — displaced, and still holding a zoom from the swallowed wheel. +The first CI run failed without a failing test: shifting test timing exposed a +latent teardown race in an unrelated suite, recorded in +[a React commit outside act](../testing/2026-09-09-react-commit-teardown-leak.md) +and fixed in the same pull request. + Limits: touch was not exercised; the `touch-action` fix is a computed-style observation, not a finger on a phone, and two-finger pinch is absent by design. The full `pnpm check` was not run — this worktree needs its submodules initialized diff --git a/.agents/notes/implemented/testing/2026-09-09-react-commit-teardown-leak.md b/.agents/notes/implemented/testing/2026-09-09-react-commit-teardown-leak.md new file mode 100644 index 000000000..31cc784ea --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-09-react-commit-teardown-leak.md @@ -0,0 +1,57 @@ +# A React commit outside act can fail the run from the next test file + +Status: implemented +Translation: pending + +## Abstract + +`tests/mobile-chat-list-preview-cap.test.tsx` 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 jsdom +environment down first, it threw as an unhandled error and failed a run in which +all 3313 tests passed. Every commit in the file now goes through `act`, and its +teardown awaits one `setImmediate` so nothing React queued outlives the DOM it +expects. The same `flushSync` pattern appears in dozens of other suites, so this +fixes the file that lost the race rather than the class; a suite-wide sweep is +still open. + +## Decision + +Route every commit through `act` and set `IS_REACT_ACT_ENVIRONMENT`, as 148 +other suites in this package already do. `act` drains passive effects inside the +test, so no scheduler callback is created for them in the first place. + +That alone was not enough: one commit per test still escaped, leaving a single +queued callback. Rather than keep hunting a stray update inside a component tree +this file only observes, teardown awaits one `setImmediate` after the unmount. +The macrotask queue is FIFO, so every callback queued before it has run by the +time it resolves — an ordering barrier, not a sleep, and not a wall-clock race. +Both parts are needed: `act` removes the bulk deterministically, and the barrier +closes whatever remains. + +## Evidence and limits + +The failure mode was reproduced deliberately: a copy of the file with an +`afterAll` that deletes `globalThis.window` and then lets the macrotask queue run +— standing in for Vitest's teardown winning the race — reported three +`ReferenceError: window is not defined` unhandled errors, the same error and the +same file as +[CI run 34323149523](https://github.com/LodyAI/Lody/actions/runs/34323149523). +Moving the commits into `act` took that to one; adding the teardown barrier took +it to zero, with all nine tests still passing. The same probe against +`tests/markdown-mermaid-fullscreen.test.tsx`, which already awaits `act`, +reported nothing, and a minimal render-and-unmount-inside-`act` fixture also +reported nothing — so the leak is a property of commits made outside `act`, not +of React's unmount. + +Limits: the CI failure itself never reproduced locally, on eight workers or on +two, pinned to a single core or not; the probe is a deliberate simulation of the +race, not the race. The stray per-test commit that survives `act` was not traced +to its source — the scheduler captures `setImmediate` before a test file can +instrument it — so the barrier is what covers it. Dozens of other suites commit +through `flushSync` and remain latent; they are green today only because their +queued callbacks normally run before teardown. + +Related: [Mermaid diagram gestures](../bug-fix/2026-09-09-mermaid-diagram-gestures.md), +whose pull request surfaced this by shifting test timing. diff --git a/packages/components/tests/mobile-chat-list-preview-cap.test.tsx b/packages/components/tests/mobile-chat-list-preview-cap.test.tsx index efb52df58..28e18b3fb 100644 --- a/packages/components/tests/mobile-chat-list-preview-cap.test.tsx +++ b/packages/components/tests/mobile-chat-list-preview-cap.test.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; -import { flushSync } from 'react-dom'; import { createRoot, type Root } from 'react-dom/client'; import { createStore, Provider } from 'jotai'; import { @@ -50,6 +49,17 @@ function makeProjectItems( ); } +/** + * Every commit goes through `act`, including the render and the unmount. A + * commit outside it leaves React's passive-effect flush queued on the real + * macrotask queue, and that callback reads `window.event` before it does + * anything else — so when this file finishes first, teardown removes `window` + * and the queued callback throws into the run as an unhandled error. + */ +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + let container: HTMLDivElement; let root: Root; let store: ReturnType; @@ -62,16 +72,25 @@ beforeEach(async () => { store = createStore(); }); -afterEach(() => { - flushSync(() => root.unmount()); +afterEach(async () => { + act(() => { + root.unmount(); + }); container.remove(); + // Then let the macrotask queue run out. Anything React still had queued when + // this file ends fires into the next file, after Vitest has torn this jsdom + // environment down — and the first thing such a callback reads is + // `window.event`. Awaiting one `setImmediate` is an ordering barrier, not a + // sleep: the queue is FIFO, so every callback queued before this one has run + // by the time it resolves, while the DOM it expects is still here. + await new Promise((resolve) => setImmediate(resolve)); }); function render( chats: MobileConversationItem[], props: Partial> = {} ) { - flushSync(() => { + act(() => { root.render( @@ -116,7 +135,6 @@ describe('mobile chat list group preview cap', () => { expect(toggleLabels()).toEqual(['Show all (9)']); }); - it('previews five rows per overflowing bucket and leaves the rest alone', () => { // Three boundary points in one list: over the cap (trimmed, toggle), // exactly at it (untouched, no toggle — an off-by-one in the `>` would @@ -222,11 +240,7 @@ describe('mobile chat list group preview cap', () => { act(() => { toggles()[0]!.click(); }); - expect(titles().slice(5)).toEqual([ - 'Session opener', - 'Session opened-a', - 'Session opened-b', - ]); + expect(titles().slice(5)).toEqual(['Session opener', 'Session opened-a', 'Session opened-b']); }); it('truncates the pinned-first order rather than reshuffling it', () => { From 6ff9f9b357e2838f5f60f27b75db052db3933251 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 10 Sep 2026 02:53:55 +0000 Subject: [PATCH 3/4] feat(components): activate a Mermaid diagram into a canvas on click 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 , 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 --- .../2026-09-09-mermaid-diagram-gestures.md | 5 + .../2026-09-10-mermaid-click-to-activate.md | 75 +++ locales/en.json | 1 + locales/zh_CN.json | 1 + .../src/components/ai-gui/AGENTS.md | 8 +- .../src/components/ai-gui/README.md | 15 +- .../components/ai-gui/markdown-renderer.tsx | 214 ++----- .../ai-gui/mermaid-diagram-rendering.md | 61 +- .../ai-gui/mermaid-inline-canvas.ts | 191 ++++++ .../ai-gui/use-mermaid-diagram-canvas.tsx | 572 ++++++++++++++++++ .../markdown-mermaid-fullscreen.test.tsx | 321 +++++++++- 11 files changed, 1230 insertions(+), 234 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md create mode 100644 packages/components/src/components/ai-gui/mermaid-inline-canvas.ts create mode 100644 packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md b/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md index 0d397b38a..9fc62f919 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md +++ b/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md @@ -104,6 +104,11 @@ latent teardown race in an unrelated suite, recorded in [a React commit outside act](../testing/2026-09-09-react-commit-teardown-leak.md) and fixed in the same pull request. +A later decision partly supersedes this one: a diagram in a message can now be +activated into a canvas by clicking it, and full-screen moved to the block's +action bar. The wheel rule below is unchanged — see +[click to turn a Mermaid diagram into a canvas](../feature/2026-09-10-mermaid-click-to-activate.md). + Limits: touch was not exercised; the `touch-action` fix is a computed-style observation, not a finger on a phone, and two-finger pinch is absent by design. The full `pnpm check` was not run — this worktree needs its submodules initialized diff --git a/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md b/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md new file mode 100644 index 000000000..080880f65 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md @@ -0,0 +1,75 @@ +# Click to turn a Mermaid diagram into a canvas + +Status: implemented +Translation: pending + +## Abstract + +Taking the wheel away from diagrams in a message +([earlier note](../bug-fix/2026-09-09-mermaid-diagram-gestures.md)) 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 — the trap the earlier note removed cannot return. +Full-screen moves to a button in the block's own action bar, and touch keeps +opening the viewer rather than gaining an inline pinch, because a custom touch +canvas would have to reimplement inertial panning for the phone case that viewer +exists to serve. + +## Decision + +Activation is a pointer affordance with an explicit release, the pattern embedded +maps use: the reader opts into the canvas, so capturing gestures inside it is +honest, and nothing is captured before they do. + +- Only a ctrl- or meta-modified wheel on the ACTIVE diagram is consumed. Every + other wheel keeps the previous behaviour — intercepted above Streamdown's + canvas, never `preventDefault()`ed, and re-dispatched as an uncancelable copy so + the conversation's own wheel listeners still see it. +- The transform is written to the ``, which Streamdown injected as raw markup + and never touches. Streamdown's own pan/zoom canvas stays pinned at + `transform: none`, so its still-live pointer handlers cannot move anything and + cannot fight ours. +- Geometry works in viewport coordinates (`mermaid-inline-canvas.ts`): the frame + rectangle and the diagram's current rectangle are enough to anchor a pinch and + to clamp a pan, and the layout offset cancels out. Modelling Streamdown's + layout instead was tried first and was wrong — it centres a narrow diagram in a + frame the full width of the message, which no transform can reconstruct. +- Releasing resets the transform. The copy in the conversation is a preview, not a + saved view, and a diagram left zoomed would read as a rendering bug. +- Touch does not activate. `pointerType` is recorded from the `pointerdown` that + precedes the click, because `click` does not carry it in every engine. + +Keyboard users get the same canvas: Enter toggles it, arrows pan, `+`/`-` zoom, +Escape releases. Without that, activation would be a pointer-only capability +behind a control that is focusable. + +## Evidence and limits + +`tests/markdown-mermaid-fullscreen.test.tsx` covers the pure geometry (anchored +pinch, re-centring, edge clamping, the zoom ceiling, pinch reversibility) and the +DOM behaviour: a mouse click activates and rings the diagram without opening the +viewer, a pinch scales it, an unmodified wheel over an ACTIVE diagram is still +handed to the page, a drag pans, Escape restores the preview, a press elsewhere +releases it, and a touch tap opens the viewer instead. 27 tests pass; +`packages/components` typechecks and lints clean. + +Chromium drove the real `MermaidStyleReview` story through Playwright. Resting: no +ring, and a 300px wheel scrolled the container by 300. Activated: the ring +appeared (`outline: rgb(255, 199, 153) solid 2px`), the same wheel still scrolled +by 300, a ctrl-modified wheel scaled the diagram to 1.284 without zooming the +window, a 60px drag moved it by exactly 60, Escape cleared both transform and +ring, and the action-bar button opened the viewer at 121%. + +Limits: after a pinch, Chromium keeps routing the rest of that wheel gesture +stream to the handler that consumed it, so an immediate follow-up scroll in the +same stream does not move the page; a real trackpad gesture ends when the fingers +lift, and the same probe scrolls normally before any pinch. Touch was not +exercised on a device. The activated ring had to be written inline with +`important` after a stylesheet rule — verified correct in isolation and present in +the served CSS — was still outranked by something already applying to Streamdown's +canvas in the Storybook page; the cause was not identified, and the inline write +sidesteps it. The resting cursor comes from that same unidentified rule rather +than from this change. diff --git a/locales/en.json b/locales/en.json index 43d0186d0..56e567489 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1170,6 +1170,7 @@ "sharing.privateHelpTitle": "Private resources are only visible to you", "sharing.privateOnlyYou": "Private · Only you", "sessions.addImage": "Add image", + "sessions.diagram.canvas": "Zoom and pan diagram", "sessions.diagramViewer.close": "Close diagram", "sessions.diagramViewer.open": "Open diagram", "sessions.diagramViewer.resetZoom": "Reset zoom", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8417d0186..997b96448 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1170,6 +1170,7 @@ "sharing.privateHelpTitle": "私密资源仅你可见", "sharing.privateOnlyYou": "私密 · 仅你可见", "sessions.addImage": "添加图片", + "sessions.diagram.canvas": "缩放和平移图表", "sessions.diagramViewer.close": "关闭图表", "sessions.diagramViewer.open": "打开图表", "sessions.diagramViewer.resetZoom": "重置缩放", diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index f1c3b7ca2..c970f8980 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -94,9 +94,11 @@ File-by-file ownership and coverage pointers: [README.md](README.md). dense monospace, terminal output, and collapsed height through `conversation-font-size-classes.ts`; settings own legacy preset migration. Keep Streamdown in streaming mode, but never enable word-level `animated`. -- A Mermaid diagram in a message is a still preview that NEVER captures a scroll, - and `mermaid-diagram-viewer.tsx` is the only canvas and the only full-screen - surface. Invariants: [mermaid-diagram-rendering.md](mermaid-diagram-rendering.md). +- A Mermaid diagram in a message is a still preview until a pointer click + activates it, and an unmodified wheel is NEVER taken — activated or not. + `mermaid-diagram-viewer.tsx` stays the only full-screen surface, reached from + the block's action bar. Invariants: + [mermaid-diagram-rendering.md](mermaid-diagram-rendering.md). - `chat_failed` raw errors use a modal; extraction/copy live in `chat-failed-error-report.ts`. - Capacity retry targets only the latest notice: the first click consents, and diff --git a/packages/components/src/components/ai-gui/README.md b/packages/components/src/components/ai-gui/README.md index 50413cd90..df7128247 100644 --- a/packages/components/src/components/ai-gui/README.md +++ b/packages/components/src/components/ai-gui/README.md @@ -19,9 +19,11 @@ the reasoning behind those rules. - `conversation-outline-rail.tsx` renders one tick per round (a user turn plus its work) and a hover preview; `conversation-outline-arrival-intent.ts` decides when a pointer heading for a tick counts as arrival. -- `markdown-renderer.tsx` wraps Streamdown; `mermaid-diagram-viewer.tsx` is the - full-screen diagram surface and `markdown-diff-block.tsx` the inline diff. - Diagram invariants live in +- `markdown-renderer.tsx` wraps Streamdown; `markdown-diff-block.tsx` is the + inline diff. Diagrams are split three ways: `use-mermaid-diagram-canvas.tsx` + owns activation and the gestures that follow it, `mermaid-inline-canvas.ts` the + pure zoom/pan geometry, and `mermaid-diagram-viewer.tsx` the full-screen + surface. Invariants live in [mermaid-diagram-rendering.md](mermaid-diagram-rendering.md). - `message-content-guards.ts` gates which shared `MessageContent` variants render. - `chat-failed-error-report.ts` / `chat-failed-detail-dialog.tsx` own raw error @@ -64,10 +66,11 @@ the reasoning behind those rules. cost is unbounded on a long turn. - **The gutter rule.** Virtua rows are absolutely positioned and ignore scroller padding, so the rail has to come from `ConversationColumn`. -- **The Mermaid viewer replacement, and the still preview in a message.** +- **The Mermaid viewer replacement, and click-to-activate in a message.** Streamdown's own overlay could not be left on touch, and the pan/zoom canvas it - wraps every diagram in swallowed page scrolls that merely passed under one. - Both, and the gestures that replaced them, are in + wraps every diagram in swallowed page scrolls that merely passed under one. A + diagram now becomes a canvas only when the reader asks for one, and an + unmodified wheel is never taken either way: [mermaid-diagram-rendering.md](mermaid-diagram-rendering.md). ## Creation progress diff --git a/packages/components/src/components/ai-gui/markdown-renderer.tsx b/packages/components/src/components/ai-gui/markdown-renderer.tsx index c4a796116..9be09fc45 100644 --- a/packages/components/src/components/ai-gui/markdown-renderer.tsx +++ b/packages/components/src/components/ai-gui/markdown-renderer.tsx @@ -1,17 +1,15 @@ import { type ComponentPropsWithoutRef, type CSSProperties, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, type ReactNode, useState, useCallback, - useEffect, useMemo, useLayoutEffect, useRef, memo, } from 'react'; +import { createPortal } from 'react-dom'; import { createMathPlugin } from '@streamdown/math'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; @@ -59,7 +57,8 @@ import type { ConversationFontSize } from '@/atoms/settings'; import { useTaskImageUrl } from '@/hooks/use-task-image'; import { MarkdownDiffBlock } from './markdown-diff-block'; import { createMarkdownMermaidConfig, createMarkdownMermaidPlugin } from './markdown-mermaid'; -import { MermaidDiagramViewer, type MermaidDiagramSelection } from './mermaid-diagram-viewer'; +import { MermaidDiagramViewer } from './mermaid-diagram-viewer'; +import { MermaidFullscreenButton, useMermaidDiagramCanvas } from './use-mermaid-diagram-canvas'; export { createMarkdownMermaidConfig } from './markdown-mermaid'; @@ -188,7 +187,11 @@ const MARKDOWN_BASE_CLASSNAME = // listener, which CSS cannot reach — see `releaseDiagramWheelToPage` below. '[&_[data-streamdown="mermaid"]_[role="application"]]:!touch-auto ' + '[&_[data-streamdown="mermaid"]_[role="application"]]:!transform-none ' + - '[&_[data-streamdown="mermaid"]>div]:!cursor-zoom-in ' + + // The cursor and the activated ring live in `tailwind/index.css` under + // `.markdown-renderer`: they key off `data-lody-canvas`, and an arbitrary + // variant carrying both an attribute selector and `:not()` is not reliably + // compiled by the pinned Tailwind. + '[&_[data-streamdown="mermaid"]]:overflow-hidden ' + '[&_[data-streamdown="code-block"]]:!my-4 ' + '[&_table]:!my-0 [&_table]:w-full [&_table]:border-collapse [&_table]:text-[0.92em] [&_table]:leading-[1.5] ' + '[&_th]:border-b [&_th]:border-border/70 [&_th]:bg-muted/45 [&_th]:px-2.5 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground/80 ' + @@ -892,9 +895,6 @@ const STREAMDOWN_CONTROLS = { table: false, } satisfies ControlsConfig; -/** Streamdown's wrapper around one rendered diagram, inside a `mermaid-block`. */ -const MERMAID_DIAGRAM_SELECTOR = '[data-streamdown="mermaid"]'; - /** Matches a fenced ```mermaid block, so blocks without one skip the observer. */ const MERMAID_FENCE_PATTERN = /^[ \t]{0,3}(?:`{3,}|~{3,})[ \t]*mermaid\b/mu; @@ -1124,177 +1124,23 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ const copyCodeLabel = t('common.copyCode', 'Copy code'); const copyAgentFileLabel = t('sessions.copyAgentFilePath', 'Copy agent file path'); const openAgentFileLabel = t('sessions.openAgentFile', 'Open agent file'); + const canvasLabel = t('sessions.diagram.canvas', 'Zoom and pan diagram'); const openDiagramLabel = t('sessions.diagramViewer.open', 'Open diagram'); - const [diagramSelection, setDiagramSelection] = useState(null); const hasMermaidBlock = useMemo(() => MERMAID_FENCE_PATTERN.test(text), [text]); const normalizedText = useMemo(() => normalizeTexMathDelimiters(text), [text]); + const { + blocks: mermaidBlocks, + selection: diagramSelection, + closeDiagram, + openDiagram, + handleContainerClick, + handleContainerKeyDown, + } = useMermaidDiagramCanvas({ + containerRef, + enabled: hasMermaidBlock, + canvasLabel, + }); - const closeDiagram = useCallback(() => setDiagramSelection(null), []); - - const openDiagram = useCallback((diagram: Element) => { - const svg = diagram.querySelector('svg'); - if (!svg) { - return; - } - // The rendered size of the copy in the message is the diagram's natural - // size, and the viewer's opening zoom is expressed against it. - const rect = svg.getBoundingClientRect(); - setDiagramSelection({ - svg: svg.cloneNode(true) as SVGSVGElement, - naturalWidth: rect.width, - naturalHeight: rect.height, - }); - }, []); - - const handleMarkdownClick = useCallback( - (event: ReactMouseEvent) => { - if (!(event.target instanceof Element)) { - return; - } - const diagram = event.target.closest(MERMAID_DIAGRAM_SELECTOR); - if (!diagram) { - return; - } - // Releasing a text selection over a diagram label is not a request to - // open it. - if (window.getSelection()?.toString()) { - return; - } - openDiagram(diagram); - }, - [openDiagram] - ); - - const handleMarkdownKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - if (!(event.target instanceof Element)) { - return; - } - const diagram = event.target.closest(MERMAID_DIAGRAM_SELECTOR); - if (!diagram) { - return; - } - event.preventDefault(); - openDiagram(diagram); - }, - [openDiagram] - ); - - // Streamdown owns the diagram markup, so the affordance that replaces its - // removed full-screen button is applied to that markup here. A diagram - // appears only after the lazily imported Mermaid runtime resolves — long - // after this component commits — so a one-shot pass would miss it; the - // observer is installed only for text that actually fences a diagram. - useEffect(() => { - const root = containerRef.current; - if (!root) { - return undefined; - } - - const markedDiagrams = new Map< - HTMLElement, - { role: string | null; tabIndex: string | null; ariaLabel: string | null } - >(); - const clearMarkedDiagrams = () => { - for (const [diagram, attributes] of markedDiagrams) { - if (attributes.role == null) { - diagram.removeAttribute('role'); - } else { - diagram.setAttribute('role', attributes.role); - } - if (attributes.tabIndex == null) { - diagram.removeAttribute('tabindex'); - } else { - diagram.setAttribute('tabindex', attributes.tabIndex); - } - if (attributes.ariaLabel == null) { - diagram.removeAttribute('aria-label'); - } else { - diagram.setAttribute('aria-label', attributes.ariaLabel); - } - } - markedDiagrams.clear(); - }; - if (!hasMermaidBlock) { - clearMarkedDiagrams(); - return undefined; - } - - const markDiagramsOpenable = () => { - clearMarkedDiagrams(); - root.querySelectorAll(MERMAID_DIAGRAM_SELECTOR).forEach((diagram) => { - markedDiagrams.set(diagram, { - role: diagram.getAttribute('role'), - tabIndex: diagram.getAttribute('tabindex'), - ariaLabel: diagram.getAttribute('aria-label'), - }); - diagram.setAttribute('role', 'button'); - diagram.setAttribute('tabindex', '0'); - diagram.setAttribute('aria-label', openDiagramLabel); - }); - }; - - markDiagramsOpenable(); - const observer = new MutationObserver(markDiagramsOpenable); - observer.observe(root, { childList: true, subtree: true }); - return () => { - observer.disconnect(); - clearMarkedDiagrams(); - }; - }, [hasMermaidBlock, openDiagramLabel]); - - // Streamdown's pan/zoom canvas listens for `wheel` non-passively and calls - // `preventDefault()` on every one of them, so a page scroll that merely passes - // under a diagram is swallowed and becomes a zoom instead. Turning - // `controls.mermaid.panZoom` off only hides that canvas's buttons — the - // listener stays, and it sits on Streamdown's own element, so the gesture has - // to be taken from it in the capture phase above. - // - // The interceptor never calls `preventDefault()`: the browser's own scrolling - // is exactly what is being handed back. `stopPropagation()` alone would also - // hide the gesture from the conversation's wheel listeners further up - // (releasing stick-to-bottom, abandoning an outline jump), so an uncancelable - // copy is re-dispatched from the markdown root, whose path excludes the canvas. - useEffect(() => { - const root = containerRef.current; - if (!root || !hasMermaidBlock) { - return undefined; - } - - const releaseDiagramWheelToPage = (event: WheelEvent) => { - const target = event.target; - if (!(target instanceof Element) || !target.closest(MERMAID_DIAGRAM_SELECTOR)) { - return; - } - event.stopPropagation(); - root.dispatchEvent( - new WheelEvent('wheel', { - bubbles: true, - cancelable: false, - composed: true, - deltaX: event.deltaX, - deltaY: event.deltaY, - deltaZ: event.deltaZ, - deltaMode: event.deltaMode, - clientX: event.clientX, - clientY: event.clientY, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - }) - ); - }; - - const options = { capture: true, passive: true } as const; - root.addEventListener('wheel', releaseDiagramWheelToPage, options); - return () => { - root.removeEventListener('wheel', releaseDiagramWheelToPage, { capture: true }); - }; - }, [hasMermaidBlock]); const components = useMemo( () => createMarkdownComponents({ @@ -1462,8 +1308,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ data-search-block-id={searchBlockId} className={cn(MARKDOWN_BASE_CLASSNAME, MARKDOWN_SIZE_CLASSNAME, className)} style={markdownFontSizeStyle(normalizedSize)} - onClick={handleMarkdownClick} - onKeyDown={handleMarkdownKeyDown} + onClick={handleContainerClick} + onKeyDown={handleContainerKeyDown} > {normalizedText} + {/* Streamdown's own action bar, filled by portal: its full-screen + control is off (its overlay is unusable on touch), and this one + opens `MermaidDiagramViewer` from the same always-visible row as + copy and download. */} + {mermaidBlocks.map((block) => + createPortal( + openDiagram(block.diagram)} + />, + block.actions, + block.id + ) + )} {/* A sibling of the markdown, not a child: a portal's events bubble through the React tree, and inside the container the viewer's own diff --git a/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md b/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md index cb1644feb..ce9a0efe5 100644 --- a/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md +++ b/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md @@ -6,35 +6,48 @@ Binding rules live in [AGENTS.md](AGENTS.md); this file holds the invariants and why they read the way they do. Coverage: `tests/markdown-mermaid-fullscreen.test.tsx`. -## A diagram in a message is a still preview +## A diagram in a message is a still preview until it is clicked -- It NEVER captures or cancels a scroll. Streamdown wraps every diagram in a - pan/zoom canvas that listens for `wheel` non-passively and calls +- It NEVER captures an unmodified wheel, activated or not. Streamdown wraps every + diagram in a pan/zoom canvas that listens for `wheel` non-passively and calls `preventDefault()` on each one, so a page scroll passing under a diagram became a zoom. `controls.mermaid.panZoom: false` only hides that canvas's buttons — the - listener stays — so `markdown-renderer.tsx` takes the gesture in the capture - phase above the canvas and hands it back to the page. -- The interceptor must not call `preventDefault()`; the browser's own scrolling is - the behaviour being restored. It re-dispatches an uncancelable copy from the - markdown root, because `stopPropagation()` alone would also hide the gesture - from the conversation's wheel listeners further up — releasing stick-to-bottom - (`use-sticky-scroll.ts`) and abandoning an outline jump (`view.tsx`). -- The same canvas claims touch and the cursor through inline styles. - `MARKDOWN_BASE_CLASSNAME` overrides all three with `!important`: an automatic - `touch-action`, so a finger resting on a diagram still scrolls the conversation; - no transform, so a stray drag cannot leave the preview displaced inside its own - frame; and a `zoom-in` cursor for the click that opens the viewer. -- Streamdown owns the markup, so the click target and its `role`/`tabindex` are - applied by a `MutationObserver` — a diagram appears only after the lazily - imported runtime resolves, long after the component commits. The block's own - copy/download controls stay reachable without hover. + listener stays — so `use-mermaid-diagram-canvas.tsx` takes the gesture in the + capture phase above the canvas and hands it back to the page. +- The interceptor must not call `preventDefault()` except for a pinch on the + activated diagram; the browser's own scrolling is the behaviour being restored. + It re-dispatches an uncancelable copy from the markdown root, because + `stopPropagation()` alone would also hide the gesture from the conversation's + wheel listeners further up — releasing stick-to-bottom (`use-sticky-scroll.ts`) + and abandoning an outline jump (`view.tsx`). +- Clicking a diagram with a mouse, pen, or the keyboard ACTIVATES it: that one + diagram becomes a canvas, where a trackpad pinch (a ctrl- or meta-modified + wheel) zooms around the pointer and a held button drags. Escape, a press + anywhere else, or the full-screen viewer releases it, and releasing resets the + transform — the copy in the conversation is a preview, not a saved view. +- Touch never activates: inline pinch would mean taking `touch-action` from the + browser and reimplementing inertial panning for the phone case the viewer + exists to serve. A tap opens the viewer, where the control bar's buttons zoom. +- The transform goes on the ``, which Streamdown injected as raw markup and + never writes to. Its own canvas stays pinned at `transform: none` with + `touch-action: auto`, so its remaining handlers cannot move anything and a + finger resting on a diagram still scrolls the conversation. +- The activated ring is written inline with `important` rather than through a + stylesheet: the diagram is Streamdown's element, and an ordinary rule of ours + does not reliably outrank what already applies to it. +- Streamdown owns the markup, so the click target, its `role`/`tabindex`, and the + full-screen button's host are all found by a `MutationObserver` — a diagram + appears only after the lazily imported runtime resolves, long after the + component commits. The block's own copy/download controls stay reachable + without hover, and the full-screen button joins them there. -## The viewer is the only canvas +## The viewer is the only full-screen surface - It replaces Streamdown's own full-screen overlay (`controls.mermaid.fullscreen` stays off), whose only exit sat at a raw `top-4 right-4` — inside a phone's status-bar inset — while its content layer covered the backdrop and swallowed - every tap, leaving a touch user no way out. + every tap, leaving a touch user no way out. Its entry point is a button + portalled into Streamdown's action bar, beside copy and download. - Controls are at least 44px and padded by the `--safe-area-*` variables, never at a fixed viewport offset. There is always more than one exit: the close button, a click off the diagram, and Escape. Stacking comes from `--z-image-viewer`, so a @@ -51,6 +64,6 @@ why they read the way they do. Coverage: driven from pointer deltas cannot reproduce touch momentum or rubber-banding, so only a held mouse or pen button pans by hand, and a release that moved the diagram does not read as the click off the diagram that closes the viewer. -- Two-finger pinch on a touch screen is deliberately absent: implementing it means - taking `touch-action` from the browser and reimplementing inertial panning. - Touch zooms with the control bar's buttons instead. +- Two-finger pinch on a touch screen is deliberately absent, here and inline: + implementing it means taking `touch-action` from the browser and reimplementing + inertial panning. Touch zooms with the control bar's buttons instead. diff --git a/packages/components/src/components/ai-gui/mermaid-inline-canvas.ts b/packages/components/src/components/ai-gui/mermaid-inline-canvas.ts new file mode 100644 index 000000000..a5a0a9585 --- /dev/null +++ b/packages/components/src/components/ai-gui/mermaid-inline-canvas.ts @@ -0,0 +1,191 @@ +/** + * The geometry of an activated Mermaid diagram in a message. + * + * A diagram is a still preview until the reader clicks it. Activation turns + * that one diagram into a canvas: a trackpad pinch zooms around the pointer and + * a drag pans, both by transforming the rendered `` inside the frame + * Streamdown already clips. An unmodified wheel is never taken, so the page + * scrolls whether or not a diagram happens to be active — the reader cannot get + * stuck in a canvas they forgot they opened. + * + * The transform goes on the ``, not on Streamdown's own pan/zoom canvas: + * that canvas stays pinned at `transform: none` (see `markdown-renderer.tsx`), + * so its handlers cannot fight ours, and Streamdown never writes to the `` + * it injected as raw markup. + */ + +export type MermaidCanvasTransform = { + readonly scale: number; + readonly x: number; + readonly y: number; +}; + +export type MermaidCanvasRect = { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +}; + +/** + * What the reader can see right now, in viewport coordinates: the frame that + * clips the diagram, and the diagram as it is currently drawn — transform + * included. Everything below works from these two rectangles rather than from + * Streamdown's layout, which centres the diagram in a frame far wider than it + * and cannot be reconstructed from a transform. + */ +export type MermaidCanvasView = { + readonly frame: MermaidCanvasRect; + readonly content: MermaidCanvasRect; +}; + +export const MERMAID_CANVAS_IDENTITY: MermaidCanvasTransform = { scale: 1, x: 0, y: 0 }; + +export const MERMAID_CANVAS_MIN_SCALE = 0.25; +export const MERMAID_CANVAS_MAX_SCALE = 4; + +/** One press of a zoom key, and one notch of a keyboard-driven pan. */ +export const MERMAID_CANVAS_KEY_ZOOM_STEP = 1.25; +export const MERMAID_CANVAS_KEY_PAN_STEP_PX = 48; + +/** + * A trackpad pinch reaches the page as a ctrl-modified wheel carrying a few + * pixels per frame, while one notch of a mouse wheel carries around a hundred. + * Bounding the delta keeps one step of either device to a comparable jump. + */ +const MERMAID_CANVAS_PINCH_MAX_DELTA = 25; +const MERMAID_CANVAS_PINCH_SENSITIVITY = 0.01; + +/** + * The zoom multiplier for one pinch step. Exponential so the gesture feels the + * same at every scale, and so pinching back by the same amount undoes it. + */ +export function computeCanvasPinchFactor(deltaY: number): number { + const bounded = Math.max( + -MERMAID_CANVAS_PINCH_MAX_DELTA, + Math.min(MERMAID_CANVAS_PINCH_MAX_DELTA, deltaY) + ); + return Math.exp(-bounded * MERMAID_CANVAS_PINCH_SENSITIVITY); +} + +const clampScale = (scale: number): number => + Math.min(MERMAID_CANVAS_MAX_SCALE, Math.max(MERMAID_CANVAS_MIN_SCALE, scale)); + +/** + * Where an axis of the diagram is allowed to end up: an axis smaller than the + * frame is centred, and a larger one may only travel as far as its own edges, + * so a reader can never push the diagram out of the message and lose it. + */ +export function clampCanvasAxis( + start: number, + size: number, + frameStart: number, + frameSize: number +): number { + if (!(size > 0) || !(frameSize > 0)) { + return start; + } + if (size <= frameSize) { + return frameStart + (frameSize - size) / 2; + } + return Math.min(frameStart, Math.max(frameStart + frameSize - size, start)); +} + +/** + * Both gestures move the diagram in viewport space and then express that move + * as a change to the translation, which is why neither needs to know where the + * untransformed diagram sits: the layout offset cancels out. + */ +const withVisualPosition = ( + transform: MermaidCanvasTransform, + view: MermaidCanvasView, + scale: number, + left: number, + top: number, + width: number, + height: number +): MermaidCanvasTransform => ({ + scale, + x: + transform.x + + (clampCanvasAxis(left, width, view.frame.left, view.frame.width) - view.content.left), + y: + transform.y + + (clampCanvasAxis(top, height, view.frame.top, view.frame.height) - view.content.top), +}); + +/** Scales by `factor` while the diagram point under the pointer stays under it. */ +export function zoomCanvasTransform( + transform: MermaidCanvasTransform, + { clientX, clientY, factor }: { clientX: number; clientY: number; factor: number }, + view: MermaidCanvasView +): MermaidCanvasTransform { + const scale = clampScale(transform.scale * factor); + const ratio = scale / transform.scale; + return withVisualPosition( + transform, + view, + scale, + clientX - (clientX - view.content.left) * ratio, + clientY - (clientY - view.content.top) * ratio, + view.content.width * ratio, + view.content.height * ratio + ); +} + +export function panCanvasTransform( + transform: MermaidCanvasTransform, + { deltaX, deltaY }: { deltaX: number; deltaY: number }, + view: MermaidCanvasView +): MermaidCanvasTransform { + return withVisualPosition( + transform, + view, + transform.scale, + view.content.left + deltaX, + view.content.top + deltaY, + view.content.width, + view.content.height + ); +} + +export const isIdentityCanvasTransform = (transform: MermaidCanvasTransform): boolean => + transform.scale === 1 && transform.x === 0 && transform.y === 0; + +/** Reads both rectangles as the browser currently draws them. */ +export function measureCanvasView( + frame: HTMLElement, + svg: SVGSVGElement +): MermaidCanvasView | null { + const frameRect = frame.getBoundingClientRect(); + const contentRect = svg.getBoundingClientRect(); + if (!(frameRect.width > 0) || !(contentRect.width > 0) || !(contentRect.height > 0)) { + return null; + } + return { + frame: { + left: frameRect.left, + top: frameRect.top, + width: frameRect.width, + height: frameRect.height, + }, + content: { + left: contentRect.left, + top: contentRect.top, + width: contentRect.width, + height: contentRect.height, + }, + }; +} + +export function applyCanvasTransform(svg: SVGSVGElement, transform: MermaidCanvasTransform): void { + if (isIdentityCanvasTransform(transform)) { + svg.style.removeProperty('transform'); + svg.style.removeProperty('transform-origin'); + svg.style.removeProperty('will-change'); + return; + } + svg.style.transformOrigin = '0 0'; + svg.style.transform = `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`; + svg.style.willChange = 'transform'; +} diff --git a/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx b/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx new file mode 100644 index 000000000..947e09cf8 --- /dev/null +++ b/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx @@ -0,0 +1,572 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; +import { Maximize2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { MermaidDiagramSelection } from './mermaid-diagram-viewer'; +import { + applyCanvasTransform, + computeCanvasPinchFactor, + measureCanvasView, + panCanvasTransform, + zoomCanvasTransform, + MERMAID_CANVAS_IDENTITY, + MERMAID_CANVAS_KEY_PAN_STEP_PX, + MERMAID_CANVAS_KEY_ZOOM_STEP, + type MermaidCanvasTransform, +} from './mermaid-inline-canvas'; + +/** + * Diagram interaction for `markdown-renderer.tsx`. + * + * Streamdown owns the diagram markup, so everything here is applied to nodes it + * rendered: the click target and its `role`/`tabindex` by observer, the canvas + * transform on the ``, and the full-screen button by portal into the + * block's own action bar. + * + * A diagram in a message is a still preview. Clicking one with a pointer that + * can pinch ACTIVATES it: that one diagram becomes a canvas until Escape, a + * click elsewhere, or the full-screen viewer takes over. Touch never activates — + * inline pinch would mean taking `touch-action` from the browser and + * reimplementing inertial panning — so a tap opens the viewer instead, where the + * control bar's buttons zoom. + */ + +/** Streamdown's wrapper around one rendered diagram, inside a `mermaid-block`. */ +export const MERMAID_DIAGRAM_SELECTOR = '[data-streamdown="mermaid"]'; +const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]'; +const MERMAID_BLOCK_ACTIONS_SELECTOR = '[data-streamdown="mermaid-block-actions"]'; + +/** Marks the activated diagram for tests and for anything styling it. */ +const CANVAS_STATE_ATTRIBUTE = 'data-lody-canvas'; + +/** + * The ring is the only sign that a click did anything, so it is written inline + * with `important` rather than through a stylesheet: the diagram is Streamdown's + * element, sitting under utilities in a cascade layer, and an ordinary rule of + * ours does not reliably outrank what is already on it. The element carries no + * React-managed `style`, so nothing overwrites these. + */ +const CANVAS_ACTIVE_STYLE = [ + ['outline', '2px solid hsl(var(--ring))'], + ['outline-offset', '2px'], + ['border-radius', 'var(--radius-md)'], +] as const; +const BLOCK_ID_ATTRIBUTE = 'data-lody-diagram-id'; + +/** A drag this short is a click that wobbled, not a pan. */ +const CANVAS_PAN_SLOP_PX = 3; + +export type MermaidDiagramBlock = { + readonly id: string; + readonly diagram: HTMLElement; + readonly actions: HTMLElement; +}; + +type ActiveCanvas = { + readonly diagram: HTMLElement; + readonly svg: SVGSVGElement; + transform: MermaidCanvasTransform; +}; + +let nextBlockId = 0; + +const sameBlocks = ( + a: readonly MermaidDiagramBlock[], + b: readonly MermaidDiagramBlock[] +): boolean => + a.length === b.length && + a.every( + (block, index) => + block.diagram === b[index]?.diagram && + block.actions === b[index]?.actions && + block.id === b[index]?.id + ); + +export function useMermaidDiagramCanvas({ + containerRef, + enabled, + canvasLabel, +}: { + readonly containerRef: RefObject; + /** False for markdown with no fenced diagram: no observer, no listeners. */ + readonly enabled: boolean; + readonly canvasLabel: string; +}) { + const [blocks, setBlocks] = useState([]); + const [activeDiagram, setActiveDiagram] = useState(null); + const [selection, setSelection] = useState(null); + // Written before the state commit so the listeners below, which are not + // re-registered per activation, always read the current canvas. + const canvasRef = useRef(null); + const panRef = useRef<{ pointerId: number; lastX: number; lastY: number } | null>(null); + const pannedRef = useRef(false); + // A `click` does not say which device produced it in every engine, so the + // pointer that started it is remembered instead. + const pointerTypeRef = useRef('mouse'); + + const deactivate = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + applyCanvasTransform(canvas.svg, MERMAID_CANVAS_IDENTITY); + for (const [property] of CANVAS_ACTIVE_STYLE) { + canvas.diagram.style.removeProperty(property); + } + canvas.diagram.removeAttribute(CANVAS_STATE_ATTRIBUTE); + canvasRef.current = null; + panRef.current = null; + setActiveDiagram(null); + }, []); + + const activate = useCallback( + (diagram: HTMLElement) => { + if (canvasRef.current?.diagram === diagram) { + return; + } + const svg = diagram.querySelector('svg'); + if (!svg) { + return; + } + deactivate(); + diagram.setAttribute(CANVAS_STATE_ATTRIBUTE, 'active'); + for (const [property, value] of CANVAS_ACTIVE_STYLE) { + diagram.style.setProperty(property, value, 'important'); + } + canvasRef.current = { diagram, svg, transform: MERMAID_CANVAS_IDENTITY }; + setActiveDiagram(diagram); + }, + [deactivate] + ); + + const updateTransform = useCallback( + (next: (canvas: ActiveCanvas) => MermaidCanvasTransform | null) => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const transform = next(canvas); + if (!transform) { + return; + } + canvas.transform = transform; + applyCanvasTransform(canvas.svg, transform); + }, + [] + ); + + const zoomAt = useCallback( + (clientX: number, clientY: number, factor: number) => { + updateTransform((canvas) => { + const view = measureCanvasView(canvas.diagram, canvas.svg); + return view + ? zoomCanvasTransform(canvas.transform, { clientX, clientY, factor }, view) + : null; + }); + }, + [updateTransform] + ); + + const panBy = useCallback( + (deltaX: number, deltaY: number) => { + updateTransform((canvas) => { + const view = measureCanvasView(canvas.diagram, canvas.svg); + return view ? panCanvasTransform(canvas.transform, { deltaX, deltaY }, view) : null; + }); + }, + [updateTransform] + ); + + const openDiagram = useCallback( + (diagram: Element) => { + const svg = diagram.querySelector('svg'); + if (!svg) { + return; + } + // The rendered size of the copy in the message is the diagram's natural + // size, so an activated canvas is reset before it is measured. + deactivate(); + const rect = svg.getBoundingClientRect(); + setSelection({ + svg: svg.cloneNode(true) as SVGSVGElement, + naturalWidth: rect.width, + naturalHeight: rect.height, + }); + }, + [deactivate] + ); + + const closeDiagram = useCallback(() => setSelection(null), []); + + // Streamdown renders a diagram only after its lazily imported runtime + // resolves — long after this component commits — so the click target, the + // block ids, and the action-bar hosts are all applied by observer. + useEffect(() => { + const root = containerRef.current; + if (!root) { + return undefined; + } + + const marked = new Map< + HTMLElement, + { role: string | null; tabIndex: string | null; ariaLabel: string | null } + >(); + const restoreMarked = () => { + for (const [diagram, attributes] of marked) { + for (const [name, value] of [ + ['role', attributes.role], + ['tabindex', attributes.tabIndex], + ['aria-label', attributes.ariaLabel], + ] as const) { + if (value == null) { + diagram.removeAttribute(name); + } else { + diagram.setAttribute(name, value); + } + } + } + marked.clear(); + }; + + if (!enabled) { + restoreMarked(); + setBlocks((current) => (current.length === 0 ? current : [])); + return undefined; + } + + const scan = () => { + restoreMarked(); + const found: MermaidDiagramBlock[] = []; + root.querySelectorAll(MERMAID_BLOCK_SELECTOR).forEach((block) => { + const diagram = block.querySelector(MERMAID_DIAGRAM_SELECTOR); + const actions = block.querySelector(MERMAID_BLOCK_ACTIONS_SELECTOR); + if (!diagram) { + return; + } + marked.set(diagram, { + role: diagram.getAttribute('role'), + tabIndex: diagram.getAttribute('tabindex'), + ariaLabel: diagram.getAttribute('aria-label'), + }); + diagram.setAttribute('role', 'button'); + diagram.setAttribute('tabindex', '0'); + diagram.setAttribute('aria-label', canvasLabel); + if (!actions) { + return; + } + let id = block.getAttribute(BLOCK_ID_ATTRIBUTE); + if (!id) { + nextBlockId += 1; + id = `mermaid-block-${nextBlockId}`; + block.setAttribute(BLOCK_ID_ATTRIBUTE, id); + } + found.push({ id, diagram, actions }); + }); + // The portalled button below is itself a child-list mutation, so an + // unconditional update would re-enter this observer forever. + setBlocks((current) => (sameBlocks(current, found) ? current : found)); + if (canvasRef.current && !root.contains(canvasRef.current.diagram)) { + deactivate(); + } + }; + + scan(); + const observer = new MutationObserver(scan); + observer.observe(root, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + restoreMarked(); + }; + }, [canvasLabel, containerRef, deactivate, enabled]); + + // Streamdown's pan/zoom canvas listens for `wheel` non-passively and calls + // `preventDefault()` on every one of them, so a page scroll that merely passes + // under a diagram is swallowed and becomes a zoom instead. Turning + // `controls.mermaid.panZoom` off only hides that canvas's buttons — the + // listener stays, and it sits on Streamdown's own element, so the gesture has + // to be taken from it in the capture phase above. + // + // Only a pinch over the ACTIVE diagram is consumed here. Everything else is + // handed back: the interceptor never calls `preventDefault()`, because the + // browser's own scrolling is the behaviour being restored. `stopPropagation()` + // alone would also hide the gesture from the conversation's wheel listeners + // further up (releasing stick-to-bottom, abandoning an outline jump), so an + // uncancelable copy is re-dispatched from the markdown root, whose path + // excludes the canvas. + useEffect(() => { + const root = containerRef.current; + if (!root || !enabled) { + return undefined; + } + + const handleWheel = (event: WheelEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const diagram = target.closest(MERMAID_DIAGRAM_SELECTOR); + if (!diagram) { + return; + } + event.stopPropagation(); + + if (canvasRef.current?.diagram === diagram && (event.ctrlKey || event.metaKey)) { + // A trackpad pinch, which Chromium would otherwise spend on zooming the + // whole window. + event.preventDefault(); + zoomAt(event.clientX, event.clientY, computeCanvasPinchFactor(event.deltaY)); + return; + } + + root.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: false, + composed: true, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaZ: event.deltaZ, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }) + ); + }; + + root.addEventListener('wheel', handleWheel, { capture: true, passive: false }); + return () => { + root.removeEventListener('wheel', handleWheel, { capture: true }); + }; + }, [containerRef, enabled, zoomAt]); + + // Which device is asking decides what a click means, so the pointer is + // recorded before the click arrives. Capture phase: Streamdown's canvas calls + // `setPointerCapture` on its own element for a drag it can no longer perform. + useEffect(() => { + const root = containerRef.current; + if (!root || !enabled) { + return undefined; + } + const rememberPointer = (event: PointerEvent) => { + pointerTypeRef.current = event.pointerType || 'mouse'; + }; + root.addEventListener('pointerdown', rememberPointer, { capture: true, passive: true }); + return () => { + root.removeEventListener('pointerdown', rememberPointer, { capture: true }); + }; + }, [containerRef, enabled]); + + // Dragging an activated diagram pans it. Bound to the diagram itself rather + // than the container, so an inactive one keeps every gesture it had. + useEffect(() => { + const diagram = activeDiagram; + if (!diagram) { + return undefined; + } + + const handlePointerDown = (event: PointerEvent) => { + pannedRef.current = false; + if (event.pointerType === 'touch' || event.button !== 0) { + return; + } + // Otherwise the drag paints a text selection across the diagram's labels. + event.preventDefault(); + panRef.current = { pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY }; + diagram.setPointerCapture?.(event.pointerId); + }; + + const handlePointerMove = (event: PointerEvent) => { + const pan = panRef.current; + if (!pan || pan.pointerId !== event.pointerId) { + return; + } + const deltaX = event.clientX - pan.lastX; + const deltaY = event.clientY - pan.lastY; + if (Math.abs(deltaX) >= CANVAS_PAN_SLOP_PX || Math.abs(deltaY) >= CANVAS_PAN_SLOP_PX) { + pannedRef.current = true; + } + pan.lastX = event.clientX; + pan.lastY = event.clientY; + panBy(deltaX, deltaY); + }; + + const handlePointerEnd = (event: PointerEvent) => { + const pan = panRef.current; + if (!pan || pan.pointerId !== event.pointerId) { + return; + } + panRef.current = null; + diagram.releasePointerCapture?.(event.pointerId); + }; + + diagram.addEventListener('pointerdown', handlePointerDown); + diagram.addEventListener('pointermove', handlePointerMove); + diagram.addEventListener('pointerup', handlePointerEnd); + diagram.addEventListener('pointercancel', handlePointerEnd); + return () => { + diagram.removeEventListener('pointerdown', handlePointerDown); + diagram.removeEventListener('pointermove', handlePointerMove); + diagram.removeEventListener('pointerup', handlePointerEnd); + diagram.removeEventListener('pointercancel', handlePointerEnd); + }; + }, [activeDiagram, panBy]); + + // A canvas the reader has moved on from stops being one: any press outside it + // releases it, and so does Escape. Both listen on the document, because the + // next click is rarely inside this message. + useEffect(() => { + if (!activeDiagram) { + return undefined; + } + + const handlePointerDown = (event: Event) => { + const target = event.target; + if (target instanceof Node && activeDiagram.contains(target)) { + return; + } + deactivate(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + deactivate(); + activeDiagram.focus?.(); + return; + } + // Pinch and drag have no keyboard equivalent, so the activated canvas + // carries its own. Only while it is activated, so ordinary scrolling and + // typing keep every key. + if (document.activeElement !== activeDiagram) { + return; + } + const frame = activeDiagram.getBoundingClientRect(); + switch (event.key) { + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': + case 'ArrowDown': { + event.preventDefault(); + const step = MERMAID_CANVAS_KEY_PAN_STEP_PX; + panBy( + event.key === 'ArrowLeft' ? step : event.key === 'ArrowRight' ? -step : 0, + event.key === 'ArrowUp' ? step : event.key === 'ArrowDown' ? -step : 0 + ); + return; + } + case '+': + case '=': + case '-': + case '_': { + event.preventDefault(); + const zoomIn = event.key === '+' || event.key === '='; + zoomAt( + frame.left + frame.width / 2, + frame.top + frame.height / 2, + zoomIn ? MERMAID_CANVAS_KEY_ZOOM_STEP : 1 / MERMAID_CANVAS_KEY_ZOOM_STEP + ); + return; + } + default: + return; + } + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [activeDiagram, deactivate, panBy, zoomAt]); + + useEffect(() => deactivate, [deactivate]); + + const handleContainerClick = useCallback( + (event: { target: EventTarget | null }) => { + if (!(event.target instanceof Element)) { + return; + } + const diagram = event.target.closest(MERMAID_DIAGRAM_SELECTOR); + if (!diagram) { + deactivate(); + return; + } + // Releasing a text selection over a diagram label is not a request to + // activate it, and neither is letting go of a pan. + if (window.getSelection()?.toString() || pannedRef.current) { + pannedRef.current = false; + return; + } + if (pointerTypeRef.current === 'touch') { + openDiagram(diagram); + return; + } + activate(diagram); + }, + [activate, deactivate, openDiagram] + ); + + const handleContainerKeyDown = useCallback( + (event: { key: string; target: EventTarget | null; preventDefault: () => void }) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + if (!(event.target instanceof Element)) { + return; + } + const diagram = event.target.closest(MERMAID_DIAGRAM_SELECTOR); + if (!diagram) { + return; + } + event.preventDefault(); + if (canvasRef.current?.diagram === diagram) { + deactivate(); + return; + } + activate(diagram); + }, + [activate, deactivate] + ); + + return { + blocks, + activeDiagram, + selection, + closeDiagram, + openDiagram, + handleContainerClick, + handleContainerKeyDown, + }; +} + +/** + * Sits in Streamdown's own action bar beside copy and download, which is + * always visible rather than revealed on hover. It replaces the bundled + * full-screen control, whose overlay a touch user cannot leave. + */ +export function MermaidFullscreenButton({ + label, + onOpen, +}: { + readonly label: string; + readonly onOpen: () => void; +}) { + return ( + + ); +} diff --git a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx index e1a7be677..4db32a539 100644 --- a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx +++ b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx @@ -10,6 +10,13 @@ import { computePinchZoomFactor, MERMAID_DIAGRAM_MAX_ZOOM, } from '../src/components/ai-gui/mermaid-diagram-viewer'; +import { + computeCanvasPinchFactor, + panCanvasTransform, + zoomCanvasTransform, + MERMAID_CANVAS_MAX_SCALE, + type MermaidCanvasView, +} from '../src/components/ai-gui/mermaid-inline-canvas'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -184,6 +191,80 @@ describe('diagram pinch arithmetic', () => { }); }); +describe('inline canvas geometry', () => { + /** + * A 400x300 frame, drawn at the viewport origin, holding a 200x150 diagram + * that Streamdown has centred inside it. + */ + const resting: MermaidCanvasView = { + frame: { left: 0, top: 0, width: 400, height: 300 }, + content: { left: 100, top: 75, width: 200, height: 150 }, + }; + const identity = { scale: 1, x: 0, y: 0 }; + + it('keeps the pinched point under the pointer', () => { + // The pointer sits on the diagram's centre, so doubling the diagram must + // leave that centre exactly where it was. + const zoomed = zoomCanvasTransform( + identity, + { clientX: 200, clientY: 150, factor: 2 }, + resting + ); + expect(zoomed.scale).toBe(2); + // 400x300 now fills the frame exactly: its left edge moves from 100 to 0. + expect(zoomed).toEqual({ scale: 2, x: -100, y: -75 }); + }); + + it('re-centres a diagram smaller than its frame instead of leaving it adrift', () => { + // Pinching in from a diagram that was dragged off-centre still ends centred, + // because there is nothing left to pan. + const off = { scale: 1, x: 90, y: 40 }; + const zoomed = zoomCanvasTransform( + off, + { clientX: 0, clientY: 0, factor: 0.5 }, + { ...resting, content: { left: 190, top: 115, width: 200, height: 150 } } + ); + // Halved to 100x75, it lands back in the middle of the 400x300 frame. + expect(zoomed).toEqual({ scale: 0.5, x: 50, y: 37.5 }); + }); + + it('stops a larger diagram at its own edges', () => { + // 800x600 inside 400x300: it may travel 400 left and 300 up, no further. + const large: MermaidCanvasView = { + frame: resting.frame, + content: { left: 0, top: 0, width: 800, height: 600 }, + }; + expect( + panCanvasTransform({ scale: 2, x: 0, y: 0 }, { deltaX: -10_000, deltaY: -10_000 }, large) + ).toEqual({ scale: 2, x: -400, y: -300 }); + expect( + panCanvasTransform({ scale: 2, x: 0, y: 0 }, { deltaX: 10_000, deltaY: 10_000 }, large) + ).toEqual({ scale: 2, x: 0, y: 0 }); + }); + + it('pans by the drag when there is room for it', () => { + const large: MermaidCanvasView = { + frame: resting.frame, + content: { left: -100, top: -100, width: 800, height: 600 }, + }; + expect( + panCanvasTransform({ scale: 2, x: -100, y: -100 }, { deltaX: -30, deltaY: -20 }, large) + ).toEqual({ scale: 2, x: -130, y: -120 }); + }); + + it('never scales past the ceiling, however hard the pinch', () => { + expect( + zoomCanvasTransform(identity, { clientX: 200, clientY: 150, factor: 1000 }, resting).scale + ).toBe(MERMAID_CANVAS_MAX_SCALE); + }); + + it('undoes itself when the pinch reverses', () => { + expect(computeCanvasPinchFactor(-12) * computeCanvasPinchFactor(12)).toBeCloseTo(1, 10); + // A mouse notch reports ~100 where a trackpad pinch reports a few pixels. + expect(computeCanvasPinchFactor(-400)).toBe(computeCanvasPinchFactor(-25)); + }); +}); + describe('mermaid full-screen viewer', () => { let root: Root | undefined; let container: HTMLDivElement | undefined; @@ -201,10 +282,67 @@ describe('mermaid full-screen viewer', () => { return diagram as HTMLElement; }; - const clickOn = async (element: Element) => { + const clickOn = async (element: Element, init: MouseEventInit = {}) => { + await act(async () => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init })); + }); + }; + + /** The pointer that started a click decides what it means, so it is replayed. */ + const pressWith = async (element: Element, pointerType: string) => { await act(async () => { - element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + element.dispatchEvent( + Object.assign(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }), { + pointerType, + pointerId: 1, + isPrimary: true, + }) + ); }); + await clickOn(element); + }; + + const readScale = (svg: SVGSVGElement) => + Number(svg.style.transform.match(/scale\(([-\d.]+)\)/)?.[1] ?? 1); + const readTranslate = (svg: SVGSVGElement) => { + const match = svg.style.transform.match(/translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/); + return { x: Number(match?.[1] ?? 0), y: Number(match?.[2] ?? 0) }; + }; + + /** + * jsdom lays nothing out and applies no transform, so the frame gets a fixed + * size and the diagram is measured the way a browser would: through whatever + * transform is on it right now. + */ + const stubCanvasRects = (diagram: HTMLElement, svg: SVGSVGElement) => { + const asRect = (left: number, top: number, width: number, height: number) => { + const rect = { + x: left, + y: top, + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; + return { ...rect, toJSON: () => rect } as DOMRect; + }; + vi.spyOn(diagram, 'getBoundingClientRect').mockReturnValue(asRect(0, 0, 400, 300)); + vi.spyOn(svg, 'getBoundingClientRect').mockImplementation(() => { + const { x, y } = readTranslate(svg); + const scale = readScale(svg); + return asRect(x, y, 400 * scale, 300 * scale); + }); + }; + + const fullscreenButton = () => + container?.querySelector('[data-testid="mermaid-fullscreen-button"]') ?? null; + + const openViewer = async () => { + const button = fullscreenButton(); + expect(button).toBeTruthy(); + await clickOn(button as Element); }; beforeEach(() => { @@ -233,9 +371,11 @@ describe('mermaid full-screen viewer', () => { expect(diagram.getAttribute('role')).toBe('button'); expect(diagram.getAttribute('tabindex')).toBe('0'); - expect(diagram.getAttribute('aria-label')).toBe('Open diagram'); + expect(diagram.getAttribute('aria-label')).toBe('Zoom and pan diagram'); - await clickOn(diagram); + // The replacement sits in the block's own always-visible action bar. + expect(fullscreenButton()?.closest('[data-streamdown="mermaid-block-actions"]')).toBeTruthy(); + await openViewer(); expect(viewer()).toBeTruthy(); // The copy in the conversation stays where it was. @@ -244,8 +384,8 @@ describe('mermaid full-screen viewer', () => { }); it('keeps its controls clear of the top safe-area inset', async () => { - const diagram = await renderMarkdown(); - await clickOn(diagram); + await renderMarkdown(); + await openViewer(); const close = viewerClose(); expect(close).toBeTruthy(); @@ -260,14 +400,14 @@ describe('mermaid full-screen viewer', () => { }); it('closes from the button, from a click off the diagram, and from Escape', async () => { - const diagram = await renderMarkdown(); + await renderMarkdown(); - await clickOn(diagram); + await openViewer(); expect(viewer()).toBeTruthy(); await clickOn(viewerClose() as Element); expect(viewer()).toBeNull(); - await clickOn(diagram); + await openViewer(); const surface = viewerSurface() as HTMLElement; // A click on the diagram itself must NOT close: panning it is the point. await clickOn(surface.querySelector('svg[data-diagram="sequence"]') as Element); @@ -275,7 +415,7 @@ describe('mermaid full-screen viewer', () => { await clickOn(surface); expect(viewer()).toBeNull(); - await clickOn(diagram); + await openViewer(); expect(viewer()).toBeTruthy(); await act(async () => { document.body.dispatchEvent( @@ -285,27 +425,160 @@ describe('mermaid full-screen viewer', () => { expect(viewer()).toBeNull(); }); - it('opens from the keyboard, since the diagram replaced a focusable button', async () => { + it('activates from the keyboard, since the diagram is a focusable control', async () => { const diagram = await renderMarkdown(); - await act(async () => { - diagram.focus(); - diagram.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) - ); - }); + const pressEnter = async () => { + await act(async () => { + diagram.focus(); + diagram.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ); + }); + }; + + await pressEnter(); + expect(diagram.getAttribute('data-lody-canvas')).toBe('active'); + // The viewer stays out of the way: it has its own control. + expect(viewer()).toBeNull(); - expect(viewer()).toBeTruthy(); + await pressEnter(); + expect(diagram.getAttribute('data-lody-canvas')).toBeNull(); }); it('removes diagram button semantics when the markdown rerenders without Mermaid', async () => { await renderMarkdown(); - expect(container?.querySelector('[aria-label="Open diagram"]')).toBeTruthy(); + expect(container?.querySelector('[aria-label="Zoom and pan diagram"]')).toBeTruthy(); await renderMarkdown(PLAIN_MARKDOWN); expect(container?.querySelector('[data-streamdown="mermaid"]')).toBeNull(); - expect(container?.querySelector('[aria-label="Open diagram"]')).toBeNull(); + expect(container?.querySelector('[aria-label="Zoom and pan diagram"]')).toBeNull(); + expect(fullscreenButton()).toBeNull(); + }); + + it('turns a clicked diagram into a canvas that pinches where it stands', async () => { + const diagram = await renderMarkdown(); + const svg = diagram.querySelector('svg') as SVGSVGElement; + stubCanvasRects(diagram, svg); + + await pressWith(diagram, 'mouse'); + + expect(diagram.getAttribute('data-lody-canvas')).toBe('active'); + // The ring is the only sign that the click did anything. + expect(diagram.style.outline).toContain('2px solid'); + // Activation is not the viewer: the diagram stays in the conversation. + expect(viewer()).toBeNull(); + + const pinch = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -20, + clientX: 200, + clientY: 150, + }); + await act(async () => { + svg.dispatchEvent(pinch); + }); + + // Taken, so Chromium does not spend the pinch on zooming the whole window. + expect(pinch.defaultPrevented).toBe(true); + expect(readScale(svg)).toBeCloseTo(computeCanvasPinchFactor(-20), 5); + }); + + it('keeps the page scrolling even while a diagram is activated', async () => { + const diagram = await renderMarkdown(); + const svg = diagram.querySelector('svg') as SVGSVGElement; + stubCanvasRects(diagram, svg); + await pressWith(diagram, 'mouse'); + + const abovePage: number[] = []; + const listener = (event: Event) => abovePage.push((event as WheelEvent).deltaY); + container?.addEventListener('wheel', listener); + const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); + await act(async () => { + svg.dispatchEvent(wheel); + }); + container?.removeEventListener('wheel', listener); + + // An unmodified wheel is never the canvas's: a reader who forgot they + // activated a diagram must still be able to scroll past it. + expect(wheel.defaultPrevented).toBe(false); + expect(abovePage).toEqual([120]); + expect(readScale(svg)).toBe(1); + }); + + it('drags the activated diagram, and releases it on Escape', async () => { + const diagram = await renderMarkdown(); + const svg = diagram.querySelector('svg') as SVGSVGElement; + stubCanvasRects(diagram, svg); + await pressWith(diagram, 'mouse'); + // Zoom in first: a diagram that fits its frame has nowhere to pan. + await act(async () => { + svg.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -25, + clientX: 0, + clientY: 0, + }) + ); + }); + const zoomed = readTranslate(svg); + + const pointer = (type: string, clientX: number, clientY: number) => + Object.assign(new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY }), { + pointerType: 'mouse', + pointerId: 7, + isPrimary: true, + }); + await act(async () => { + diagram.dispatchEvent(pointer('pointerdown', 200, 150)); + diagram.dispatchEvent(pointer('pointermove', 170, 130)); + diagram.dispatchEvent(pointer('pointerup', 170, 130)); + }); + + expect(readTranslate(svg).x).toBe(zoomed.x - 30); + expect(readTranslate(svg).y).toBe(zoomed.y - 20); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + }); + + // Escape hands the diagram back as the still preview it was. + expect(diagram.getAttribute('data-lody-canvas')).toBeNull(); + expect(svg.style.transform).toBe(''); + expect(diagram.style.outline).toBe(''); + }); + + it('opens the viewer instead of activating when the tap came from touch', async () => { + const diagram = await renderMarkdown(); + + await pressWith(diagram, 'touch'); + + // Inline pinch would mean taking `touch-action` from the browser; the + // viewer's control bar zooms instead. + expect(viewer()).toBeTruthy(); + expect(diagram.getAttribute('data-lody-canvas')).toBeNull(); + }); + + it('releases the canvas when the reader presses somewhere else', async () => { + const diagram = await renderMarkdown(); + const svg = diagram.querySelector('svg') as SVGSVGElement; + stubCanvasRects(diagram, svg); + await pressWith(diagram, 'mouse'); + expect(diagram.getAttribute('data-lody-canvas')).toBe('active'); + + await act(async () => { + document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + }); + + expect(diagram.getAttribute('data-lody-canvas')).toBeNull(); }); it('leaves a wheel over a diagram in a message to the page', async () => { @@ -330,8 +603,8 @@ describe('mermaid full-screen viewer', () => { }); it('zooms the open viewer on a pinch and leaves a plain wheel to scrolling', async () => { - const diagram = await renderMarkdown(); - await clickOn(diagram); + await renderMarkdown(); + await openViewer(); const surface = viewerSurface() as HTMLElement; const zoomLabel = () => document.body.querySelector('[title="Reset zoom"]')?.textContent; expect(zoomLabel()).toBe('100%'); @@ -360,8 +633,8 @@ describe('mermaid full-screen viewer', () => { }); it('pans the open viewer by dragging the diagram, without closing on release', async () => { - const diagram = await renderMarkdown(); - await clickOn(diagram); + await renderMarkdown(); + await openViewer(); const surface = viewerSurface() as HTMLElement; const svg = surface.querySelector('svg[data-diagram="sequence"]') as Element; surface.scrollLeft = 100; From 63bb8e8fcd6d4ab2e810ff6642aa73c1121023b4 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 10 Sep 2026 06:26:49 +0000 Subject: [PATCH 4/4] fix(components): correct four defects in the Mermaid diagram canvas 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 --- .../2026-09-10-mermaid-click-to-activate.md | 38 +++++++ .../components/ai-gui/markdown-renderer.tsx | 17 ++- .../ai-gui/mermaid-diagram-rendering.md | 27 ++++- .../ai-gui/mermaid-diagram-viewer.tsx | 24 ++-- .../ai-gui/use-mermaid-diagram-canvas.tsx | 106 +++++++++++++----- packages/components/src/tailwind/index.css | 16 ++- .../markdown-mermaid-fullscreen.test.tsx | 102 +++++++++++++++++ 7 files changed, 273 insertions(+), 57 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md b/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md index 080880f65..5576a855d 100644 --- a/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md +++ b/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md @@ -73,3 +73,41 @@ the served CSS — was still outranked by something already applying to Streamdo canvas in the Storybook page; the cause was not identified, and the inline write sidesteps it. The resting cursor comes from that same unidentified rule rather than from this change. + +## Corrections + +A review of this change found four defects and one stale comment; all are fixed +in the same pull request, and the cause left unidentified above is now named. + +The ring's stylesheet rule was outranked by `tailwind/index.css`'s global +`*:focus, *:focus-visible { outline: none !important }` — activating focuses the +diagram, and specificity does not beat `important`. Inline `important` was +therefore necessary, not merely expedient. Moving the ring back to a stylesheet +was tried and reproduced the failure in Chromium (`outline: 0px none`) before the +rule was found. The resting `zoom-in` cursor was likewise not unexplained: it +comes from a pre-existing `.markdown-renderer [data-streamdown='mermaid'] > div` +rule in the same file, which now carries a `grab` companion for the activated +state. + +The defects, each reproduced in Chromium before the fix and re-checked after: + +- 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 is now what + decides, and 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 (checked directly), so a streaming + turn dropped an activated canvas out of the keyboard; `aria-label` was also + rewritten each time. Diagrams are now marked once. + +Four tests cover the behaviours and were each confirmed to fail against the +unfixed code. The Chromium blur is outside what jsdom models — it does not +implement the blur — so that test observes the attribute mutations instead, and +the blur itself was verified in the browser. diff --git a/packages/components/src/components/ai-gui/markdown-renderer.tsx b/packages/components/src/components/ai-gui/markdown-renderer.tsx index 9be09fc45..8a88d9af6 100644 --- a/packages/components/src/components/ai-gui/markdown-renderer.tsx +++ b/packages/components/src/components/ai-gui/markdown-renderer.tsx @@ -179,18 +179,15 @@ const MARKDOWN_BASE_CLASSNAME = '[&_[data-streamdown="mermaid-block"]]:!my-5 ' + // Streamdown wraps every diagram in a pan/zoom canvas that claims the gesture // through inline styles: `touch-action: none` stops a finger resting on a - // diagram from scrolling the conversation, and a `grab` cursor advertises a - // drag that only shifts the preview inside its own frame. A diagram in a - // message is a still preview that opens `MermaidDiagramViewer`; panning and - // zooming belong to that viewer, so the canvas transform is pinned and the - // gesture handed back to the page. The canvas also takes the wheel from a - // listener, which CSS cannot reach — see `releaseDiagramWheelToPage` below. + // diagram from scrolling the conversation, and its transform moves the preview + // inside its own frame. Panning and zooming belong to the canvas + // `use-mermaid-diagram-canvas.tsx` activates on the ``, so Streamdown's + // own transform is pinned and touch is handed back to the page. The wheel it + // takes from a listener is out of CSS's reach and is intercepted there too. '[&_[data-streamdown="mermaid"]_[role="application"]]:!touch-auto ' + '[&_[data-streamdown="mermaid"]_[role="application"]]:!transform-none ' + - // The cursor and the activated ring live in `tailwind/index.css` under - // `.markdown-renderer`: they key off `data-lody-canvas`, and an arbitrary - // variant carrying both an attribute selector and `:not()` is not reliably - // compiled by the pinned Tailwind. + // The cursor and the activated ring are in `tailwind/index.css` under + // `.markdown-renderer`, beside the rest of the diagram's frame. '[&_[data-streamdown="mermaid"]]:overflow-hidden ' + '[&_[data-streamdown="code-block"]]:!my-4 ' + '[&_table]:!my-0 [&_table]:w-full [&_table]:border-collapse [&_table]:text-[0.92em] [&_table]:leading-[1.5] ' + diff --git a/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md b/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md index ce9a0efe5..4e84c8d66 100644 --- a/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md +++ b/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md @@ -32,14 +32,27 @@ why they read the way they do. Coverage: never writes to. Its own canvas stays pinned at `transform: none` with `touch-action: auto`, so its remaining handlers cannot move anything and a finger resting on a diagram still scrolls the conversation. -- The activated ring is written inline with `important` rather than through a - stylesheet: the diagram is Streamdown's element, and an ordinary rule of ours - does not reliably outrank what already applies to it. +- The activated ring is written inline with `important` because it cannot come + from a stylesheet: activating focuses the diagram, and `tailwind/index.css` + carries a global `*:focus, *:focus-visible { outline: none !important }`. + Specificity does not beat `important`, so only an inline `important` of our own + wins. The grab cursor, which is not focus-gated, does live in that stylesheet + beside the resting `zoom-in`. +- Every key the canvas answers — Escape included — is read only while focus is + inside the activated diagram. An activated diagram sitting further up the + scrollback must not swallow the Escape that dismisses a dialog, nor pull the + caret out of the composer. Leaving by keyboard releases the canvas, so + "activated" and "focused" never drift apart. - Streamdown owns the markup, so the click target, its `role`/`tabindex`, and the full-screen button's host are all found by a `MutationObserver` — a diagram appears only after the lazily imported runtime resolves, long after the component commits. The block's own copy/download controls stay reachable without hover, and the full-screen button joins them there. +- The observer marks a diagram ONCE. It re-runs on every mutation a streaming + turn makes, and removing `tabindex` from a focused element blurs it in + Chromium — which would drop an activated canvas out of the keyboard mid-turn — + while rewriting `aria-label` re-announces it. Only a diagram that has + disappeared is restored. ## The viewer is the only full-screen surface @@ -62,8 +75,12 @@ why they read the way they do. Coverage: that fits, and that offset is not proportional to the zoom. - Plain wheel and touch panning stay with the surface's own scrolling. A pan driven from pointer deltas cannot reproduce touch momentum or rubber-banding, so - only a held mouse or pen button pans by hand, and a release that moved the - diagram does not read as the click off the diagram that closes the viewer. + only a held mouse or pen button pans by hand. +- Whether a click closes the viewer is decided by where the press STARTED, never + by the click's target. Panning takes pointer capture on the surface, and pointer + capture retargets the following `click` to the capturing element — so a plain + click on the diagram arrives with the surface as its target and would otherwise + dismiss the viewer the reader just opened. - Two-finger pinch on a touch screen is deliberately absent, here and inline: implementing it means taking `touch-action` from the browser and reimplementing inertial panning. Touch zooms with the control bar's buttons instead. diff --git a/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx b/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx index 875179feb..91c19f957 100644 --- a/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx +++ b/packages/components/src/components/ai-gui/mermaid-diagram-viewer.tsx @@ -231,6 +231,10 @@ function OpenMermaidDiagramViewer({ // A pan that ends off the diagram would otherwise read as a click on the // backdrop, which closes the viewer. const pannedRef = useRef(false); + // Pointer capture retargets the following `click` to the capturing element, + // so a press on the diagram arrives at the surface with the surface as its + // target. Where the press STARTED is the only reliable question to ask. + const pressedOnDiagramRef = useRef(false); // The diagram is a live node, not markup: hand it to the DOM directly rather // than re-serializing it through `dangerouslySetInnerHTML`. @@ -330,13 +334,18 @@ function OpenMermaidDiagramViewer({ const handleSurfaceClick = useCallback( (event: ReactMouseEvent) => { const panned = pannedRef.current; + const pressedOnDiagram = pressedOnDiagramRef.current; pannedRef.current = false; + pressedOnDiagramRef.current = false; + // A press that began on the diagram is never a click on the backdrop, + // however the click was retargeted and however far it travelled. + if (pressedOnDiagram) { + return; + } const target = event.target; if (target instanceof Node && hostRef.current?.contains(target)) { return; } - // Letting go after dragging the diagram out from under the pointer is not - // a click on the backdrop. if (panned) { return; } @@ -389,13 +398,10 @@ function OpenMermaidDiagramViewer({ pannedRef.current = false; const surface = scrollRef.current; const target = event.target; - if ( - !surface || - event.pointerType === 'touch' || - event.button !== 0 || - !(target instanceof Node) || - !hostRef.current?.contains(target) - ) { + const onDiagram = target instanceof Node && Boolean(hostRef.current?.contains(target)); + // Recorded for every pointer type, including the touch that never pans. + pressedOnDiagramRef.current = onDiagram; + if (!surface || event.pointerType === 'touch' || event.button !== 0 || !onDiagram) { return; } // Otherwise the drag paints a text selection across the diagram's labels. diff --git a/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx b/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx index 947e09cf8..5b4009457 100644 --- a/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx +++ b/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx @@ -35,21 +35,23 @@ export const MERMAID_DIAGRAM_SELECTOR = '[data-streamdown="mermaid"]'; const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]'; const MERMAID_BLOCK_ACTIONS_SELECTOR = '[data-streamdown="mermaid-block-actions"]'; -/** Marks the activated diagram for tests and for anything styling it. */ +/** Marks the activated diagram; the grab cursor hangs off it in `index.css`. */ const CANVAS_STATE_ATTRIBUTE = 'data-lody-canvas'; /** - * The ring is the only sign that a click did anything, so it is written inline - * with `important` rather than through a stylesheet: the diagram is Streamdown's - * element, sitting under utilities in a cascade layer, and an ordinary rule of - * ours does not reliably outrank what is already on it. The element carries no - * React-managed `style`, so nothing overwrites these. + * The ring is the only sign that a click did anything, and it cannot come from + * a stylesheet: activating focuses the diagram, and `tailwind/index.css` carries + * a global `*:focus, *:focus-visible { outline: none !important }`. No rule of + * ours can outrank that — specificity does not beat `important` — so the ring is + * written inline with `important` of its own. The element carries no + * React-managed `style`, so nothing overwrites it. */ const CANVAS_ACTIVE_STYLE = [ ['outline', '2px solid hsl(var(--ring))'], ['outline-offset', '2px'], ['border-radius', 'var(--radius-md)'], ] as const; + const BLOCK_ID_ATTRIBUTE = 'data-lody-diagram-id'; /** A drag this short is a click that wobbled, not a pan. */ @@ -210,46 +212,64 @@ export function useMermaidDiagramCanvas({ HTMLElement, { role: string | null; tabIndex: string | null; ariaLabel: string | null } >(); - const restoreMarked = () => { - for (const [diagram, attributes] of marked) { - for (const [name, value] of [ - ['role', attributes.role], - ['tabindex', attributes.tabIndex], - ['aria-label', attributes.ariaLabel], - ] as const) { - if (value == null) { - diagram.removeAttribute(name); - } else { - diagram.setAttribute(name, value); - } + const restoreOne = (diagram: HTMLElement) => { + const attributes = marked.get(diagram); + if (!attributes) { + return; + } + for (const [name, value] of [ + ['role', attributes.role], + ['tabindex', attributes.tabIndex], + ['aria-label', attributes.ariaLabel], + ] as const) { + if (value == null) { + diagram.removeAttribute(name); + } else { + diagram.setAttribute(name, value); } } - marked.clear(); + marked.delete(diagram); + }; + const restoreMarked = () => { + for (const diagram of [...marked.keys()]) { + restoreOne(diagram); + } }; if (!enabled) { + // The markdown no longer fences a diagram, so any canvas it was holding + // is gone with it — including the document listeners keyed to it. + deactivate(); restoreMarked(); setBlocks((current) => (current.length === 0 ? current : [])); return undefined; } const scan = () => { - restoreMarked(); const found: MermaidDiagramBlock[] = []; + const present = new Set(); root.querySelectorAll(MERMAID_BLOCK_SELECTOR).forEach((block) => { const diagram = block.querySelector(MERMAID_DIAGRAM_SELECTOR); const actions = block.querySelector(MERMAID_BLOCK_ACTIONS_SELECTOR); if (!diagram) { return; } - marked.set(diagram, { - role: diagram.getAttribute('role'), - tabIndex: diagram.getAttribute('tabindex'), - ariaLabel: diagram.getAttribute('aria-label'), - }); - diagram.setAttribute('role', 'button'); - diagram.setAttribute('tabindex', '0'); - diagram.setAttribute('aria-label', canvasLabel); + present.add(diagram); + // Only a diagram seen for the first time is written to. Re-marking one + // that is already correct runs on every streamed mutation, and removing + // `tabindex` from a focused element blurs it — which would drop an + // activated canvas out of the keyboard mid-stream — while rewriting + // `aria-label` re-announces it. + if (!marked.has(diagram)) { + marked.set(diagram, { + role: diagram.getAttribute('role'), + tabIndex: diagram.getAttribute('tabindex'), + ariaLabel: diagram.getAttribute('aria-label'), + }); + diagram.setAttribute('role', 'button'); + diagram.setAttribute('tabindex', '0'); + diagram.setAttribute('aria-label', canvasLabel); + } if (!actions) { return; } @@ -261,6 +281,11 @@ export function useMermaidDiagramCanvas({ } found.push({ id, diagram, actions }); }); + for (const diagram of [...marked.keys()]) { + if (!present.has(diagram)) { + restoreOne(diagram); + } + } // The portalled button below is itself a child-list mutation, so an // unconditional update would re-enter this observer forever. setBlocks((current) => (sameBlocks(current, found) ? current : found)); @@ -430,7 +455,27 @@ export function useMermaidDiagramCanvas({ deactivate(); }; + // Tabbing away is the other way to leave without pressing anything. + const handleFocusOut = (event: FocusEvent) => { + const next = event.relatedTarget; + if (next instanceof Node && activeDiagram.contains(next)) { + return; + } + deactivate(); + }; + + // Every key below belongs to the activated diagram, so none of them is read + // unless focus is actually inside it. An activated diagram sitting in the + // scrollback must not answer the Escape that dismisses a dialog, nor pull + // the caret out of the composer. const handleKeyDown = (event: KeyboardEvent) => { + const focused = document.activeElement; + if ( + focused !== activeDiagram && + !(focused instanceof Node && activeDiagram.contains(focused)) + ) { + return; + } if (event.key === 'Escape') { event.preventDefault(); deactivate(); @@ -440,9 +485,6 @@ export function useMermaidDiagramCanvas({ // Pinch and drag have no keyboard equivalent, so the activated canvas // carries its own. Only while it is activated, so ordinary scrolling and // typing keep every key. - if (document.activeElement !== activeDiagram) { - return; - } const frame = activeDiagram.getBoundingClientRect(); switch (event.key) { case 'ArrowLeft': @@ -477,9 +519,11 @@ export function useMermaidDiagramCanvas({ document.addEventListener('pointerdown', handlePointerDown, true); document.addEventListener('keydown', handleKeyDown); + activeDiagram.addEventListener('focusout', handleFocusOut); return () => { document.removeEventListener('pointerdown', handlePointerDown, true); document.removeEventListener('keydown', handleKeyDown); + activeDiagram.removeEventListener('focusout', handleFocusOut); }; }, [activeDiagram, deactivate, panBy, zoomAt]); diff --git a/packages/components/src/tailwind/index.css b/packages/components/src/tailwind/index.css index 6ff55678d..2aafd6d30 100644 --- a/packages/components/src/tailwind/index.css +++ b/packages/components/src/tailwind/index.css @@ -487,8 +487,8 @@ pre, overflow-y: visible !important; overscroll-behavior-x: contain; padding: 1rem; - /* Overrides Streamdown's own grab cursor: the block does not pan in place, - it opens `MermaidDiagramViewer` (markdown-renderer.tsx tags it + /* Overrides Streamdown's own grab cursor: an inactive preview does not pan, + it activates on click (`use-mermaid-diagram-canvas.tsx` tags it `role="button"` once the diagram renders). */ cursor: zoom-in !important; scrollbar-gutter: stable; @@ -502,6 +502,18 @@ pre, border-radius: 0.375rem; } + /* The activated canvas stops promising a zoom it has already performed. Its + ring cannot live here: activating focuses the diagram, and the global + `*:focus { outline: none !important }` below outranks any rule of ours, so + `use-mermaid-diagram-canvas.tsx` writes it inline with `important`. */ + .markdown-renderer [data-streamdown='mermaid'][data-lody-canvas='active'] > div { + cursor: grab !important; + } + + .markdown-renderer [data-streamdown='mermaid'][data-lody-canvas='active'] > div:active { + cursor: grabbing !important; + } + .markdown-renderer [data-streamdown='mermaid'] > div::-webkit-scrollbar { width: 10px; height: 10px; diff --git a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx index 4db32a539..d8dae8317 100644 --- a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx +++ b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx @@ -544,7 +544,27 @@ describe('mermaid full-screen viewer', () => { expect(readTranslate(svg).x).toBe(zoomed.x - 30); expect(readTranslate(svg).y).toBe(zoomed.y - 20); + // Escape belongs to the canvas only while the canvas has focus. An + // activated diagram sitting in the scrollback must not answer the Escape + // that dismisses a dialog, nor prevent its default. + const elsewhere = document.createElement('input'); + document.body.appendChild(elsewhere); + elsewhere.focus(); + const ignored = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + await act(async () => { + document.dispatchEvent(ignored); + }); + expect(ignored.defaultPrevented).toBe(false); + expect(diagram.getAttribute('data-lody-canvas')).toBe('active'); + expect(document.activeElement).toBe(elsewhere); + elsewhere.remove(); + await act(async () => { + diagram.focus(); document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) ); @@ -556,6 +576,88 @@ describe('mermaid full-screen viewer', () => { expect(diagram.style.outline).toBe(''); }); + it('keeps the viewer open when a press that began on the diagram is retargeted', async () => { + await renderMarkdown(); + await openViewer(); + const surface = viewerSurface() as HTMLElement; + const svg = surface.querySelector('svg[data-diagram="sequence"]') as Element; + + // Pointer capture retargets the `click` that follows a press on the diagram + // to the capturing element, which is the surface itself. Without the press + // being remembered, that click reads as one on the backdrop and closes the + // viewer the user just opened. + await act(async () => { + svg.dispatchEvent( + Object.assign( + new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }), + { pointerType: 'mouse', pointerId: 4, isPrimary: true } + ) + ); + }); + await clickOn(surface); + expect(viewer()).toBeTruthy(); + + // A press that really did start on the backdrop still closes it. + await act(async () => { + surface.dispatchEvent( + Object.assign( + new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }), + { pointerType: 'mouse', pointerId: 5, isPrimary: true } + ) + ); + }); + await clickOn(surface); + expect(viewer()).toBeNull(); + }); + + it('releases an activated diagram when the markdown stops containing one', async () => { + const diagram = await renderMarkdown(); + stubCanvasRects(diagram, diagram.querySelector('svg') as SVGSVGElement); + await pressWith(diagram, 'mouse'); + expect(diagram.getAttribute('data-lody-canvas')).toBe('active'); + + await renderMarkdown(PLAIN_MARKDOWN); + + // The element is detached by now, so its own state is what proves the + // canvas was released rather than left holding document listeners. + expect(diagram.getAttribute('data-lody-canvas')).toBeNull(); + expect(diagram.style.outline).toBe(''); + }); + + it('does not rewrite a diagram it has already marked while the turn streams', async () => { + const diagram = await renderMarkdown(); + stubCanvasRects(diagram, diagram.querySelector('svg') as SVGSVGElement); + await pressWith(diagram, 'mouse'); + await act(async () => { + diagram.focus(); + }); + + // Every streamed mutation re-runs the observer. Re-marking a diagram that is + // already marked removes `tabindex` from a focused element, which blurs it + // in a browser and drops the canvas out of the keyboard mid-turn, and + // rewriting `aria-label` re-announces it. Watching the attributes is the + // part of that jsdom can prove; the blur itself was checked in Chromium. + const rewrites: string[] = []; + const watcher = new MutationObserver((records) => { + for (const record of records) { + if (record.attributeName) rewrites.push(record.attributeName); + } + }); + watcher.observe(diagram, { attributes: true }); + + await renderMarkdown(`${MERMAID_MARKDOWN}\n\nAnd then it finished.`); + watcher.takeRecords().forEach((record) => { + if (record.attributeName) rewrites.push(record.attributeName); + }); + watcher.disconnect(); + + expect(container?.querySelector('[data-streamdown="mermaid"]')).toBe(diagram); + expect(rewrites).toEqual([]); + expect(document.activeElement).toBe(diagram); + expect(diagram.getAttribute('data-lody-canvas')).toBe('active'); + expect(diagram.getAttribute('tabindex')).toBe('0'); + }); + it('opens the viewer instead of activating when the tap came from touch', async () => { const diagram = await renderMarkdown();