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..9fc62f919 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-mermaid-diagram-gestures.md @@ -0,0 +1,120 @@ +# 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. + +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. + +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 +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/.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..5576a855d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-mermaid-click-to-activate.md @@ -0,0 +1,113 @@ +# 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. + +## 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/.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/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 849b9bd0f..c970f8980 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -94,15 +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 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 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 2561d152e..df7128247 100644 --- a/packages/components/src/components/ai-gui/README.md +++ b/packages/components/src/components/ai-gui/README.md @@ -19,8 +19,12 @@ 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. +- `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 extraction and its modal; `terminal-component.tsx` / `terminal-preview.ts` own @@ -62,11 +66,12 @@ 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 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. 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 2446221fa..8a88d9af6 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'; @@ -178,6 +177,18 @@ 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 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 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] ' + '[&_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 ' + @@ -881,9 +892,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; @@ -1113,127 +1121,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]); const components = useMemo( () => createMarkdownComponents({ @@ -1401,8 +1305,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 new file mode 100644 index 000000000..4e84c8d66 --- /dev/null +++ b/packages/components/src/components/ai-gui/mermaid-diagram-rendering.md @@ -0,0 +1,86 @@ +# 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 until it is clicked + +- 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 `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` 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 + +- 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. 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 + 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. +- 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 6143ba0cc..91c19f957 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,23 @@ 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); + // 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`. @@ -184,6 +273,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 +333,125 @@ function OpenMermaidDiagramViewer({ // diagram live, which on a wide phone is most of the screen. 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; } + 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; + 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. + 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 +545,13 @@ function OpenMermaidDiagramViewer({ paddingRight: SAFE_AREA_RIGHT, }} onClick={handleSurfaceClick} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerEnd} + onPointerCancel={handlePointerEnd} >
-
+
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..5b4009457 --- /dev/null +++ b/packages/components/src/components/ai-gui/use-mermaid-diagram-canvas.tsx @@ -0,0 +1,616 @@ +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; 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, 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. */ +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 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.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 = () => { + 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; + } + 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; + } + 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 }); + }); + 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)); + 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(); + }; + + // 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(); + 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. + 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); + activeDiagram.addEventListener('focusout', handleFocusOut); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + document.removeEventListener('keydown', handleKeyDown); + activeDiagram.removeEventListener('focusout', handleFocusOut); + }; + }, [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/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 251107a0d..d8dae8317 100644 --- a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx +++ b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx @@ -5,9 +5,18 @@ 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'; +import { + computeCanvasPinchFactor, + panCanvasTransform, + zoomCanvasTransform, + MERMAID_CANVAS_MAX_SCALE, + type MermaidCanvasView, +} from '../src/components/ai-gui/mermaid-inline-canvas'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -142,6 +151,120 @@ 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('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; @@ -159,12 +282,69 @@ 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(() => { vi.useFakeTimers(); container = document.createElement('div'); @@ -191,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. @@ -202,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(); @@ -218,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); @@ -233,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( @@ -243,26 +425,340 @@ 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(); + + 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(); + + 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="Zoom and pan diagram"]')).toBeTruthy(); + + await renderMarkdown(PLAIN_MARKDOWN); + + expect(container?.querySelector('[data-streamdown="mermaid"]')).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); + + // 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(); - diagram.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) ); }); - expect(viewer()).toBeTruthy(); + // 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('removes diagram button semantics when the markdown rerenders without Mermaid', async () => { + it('keeps the viewer open when a press that began on the diagram is retargeted', async () => { await renderMarkdown(); - expect(container?.querySelector('[aria-label="Open diagram"]')).toBeTruthy(); + 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); - expect(container?.querySelector('[data-streamdown="mermaid"]')).toBeNull(); - expect(container?.querySelector('[aria-label="Open diagram"]')).toBeNull(); + // 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(); + + 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 () => { + 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 () => { + await renderMarkdown(); + await openViewer(); + 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 () => { + await renderMarkdown(); + await openViewer(); + 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(); }); }); 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', () => {