{
- if (event.target === event.currentTarget) onClose();
- }}
+ ref={viewportRef}
+ className={["mermaid-zoom-viewport", dragging ? "is-dragging" : ""].filter(Boolean).join(" ")}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerCancel}
+ onLostPointerCapture={handlePointerCancel}
+ onDragStart={(event) => event.preventDefault()}
>
diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md
index e99fbb77b6..d15d0e75ea 100644
--- a/docs/agents/sessions.md
+++ b/docs/agents/sessions.md
@@ -200,6 +200,12 @@ Files: `lib/markdown.ts` (`markdownClobberPrefix`, `rehypeMatchClobberedIds`, `j
- The pieces are plain divs, not `memo`'d: React already leaves a div whose text is unchanged alone. A memo bailout keeps the old props, and each old piece is a V8 slice pinning the whole thinking string of the chunk it last changed in: streaming 2M Chinese characters retained 995 MB until the reply ended, 9 MB without memo (React 19 in headless Chrome).
- Expanding a finished block still lays out all of it once, as before (about 500 ms for that text at 4x). `content-visibility: auto` on the pieces would skip the ones off screen (70 ms to the first frame), but its placeholder heights, about half the real ones in the bench, change the scroll height as pieces come into view; it is not used.
+## Mermaid viewer: zoom at the pointer, drag to pan (`MermaidZoomDialog` in `components/MermaidBlock.tsx`)
+- Mermaid emits an svg sized to its container (`width="100%"`, a `max-width` style), so there is nothing to scale and no natural size to pan over. The viewer pins the svg to the pixels of its own `viewBox` (inline `width`/`height` marked important, `max-width: none`) and moves the wrapper with `transform: translate(x, y) scale(zoom)`, `transform-origin: 0 0`. 100% is therefore the diagram's own pixel size, and fit-to-width (the toolbar's reset) never enlarges past it.
+- Anchored zoom is one line of math: the content point under the pointer is `(pointer - offset) / zoom`, so the next offset is `pointer - (pointer - offset) * (next / current)`. The wheel listener is added by hand in an effect with `{ passive: false }`: React's own `wheel` handler is passive, so `preventDefault()` (the page behind the modal must not scroll) would be ignored.
+- Left-drag pans with pointer capture, so a drag that leaves the viewport keeps panning. The capture also makes every `click` land on the viewport, so background-click-to-close is decided in `pointerup`: the press must have started on the viewport itself (`event.target === viewport`) and moved less than 3 px — otherwise a pan that ends over empty space would dismiss the diagram.
+- `showModal()` and the measuring/fitting belong to the same `useLayoutEffect`: a closed `