@seed-ship/mcp-ui-solid 6.20.0 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +120 -3
  3. package/dist/components/ChartJSRenderer.cjs +244 -68
  4. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  5. package/dist/components/ChartJSRenderer.d.ts +9 -0
  6. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  7. package/dist/components/ChartJSRenderer.js +246 -70
  8. package/dist/components/ChartJSRenderer.js.map +1 -1
  9. package/dist/components/ExpandableWrapper.cjs +9 -1
  10. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  11. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  12. package/dist/components/ExpandableWrapper.js +9 -1
  13. package/dist/components/ExpandableWrapper.js.map +1 -1
  14. package/dist/components/IframeFallbackLink.cjs +37 -0
  15. package/dist/components/IframeFallbackLink.cjs.map +1 -0
  16. package/dist/components/IframeFallbackLink.d.ts +23 -0
  17. package/dist/components/IframeFallbackLink.d.ts.map +1 -0
  18. package/dist/components/IframeFallbackLink.js +37 -0
  19. package/dist/components/IframeFallbackLink.js.map +1 -0
  20. package/dist/components/LightboxOverlay.cjs +139 -16
  21. package/dist/components/LightboxOverlay.cjs.map +1 -1
  22. package/dist/components/LightboxOverlay.d.ts +6 -0
  23. package/dist/components/LightboxOverlay.d.ts.map +1 -1
  24. package/dist/components/LightboxOverlay.js +141 -18
  25. package/dist/components/LightboxOverlay.js.map +1 -1
  26. package/dist/components/UIResourceRenderer.cjs +150 -127
  27. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  28. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  29. package/dist/components/UIResourceRenderer.js +151 -128
  30. package/dist/components/UIResourceRenderer.js.map +1 -1
  31. package/dist/components/VideoRenderer.cjs +45 -19
  32. package/dist/components/VideoRenderer.cjs.map +1 -1
  33. package/dist/components/VideoRenderer.d.ts.map +1 -1
  34. package/dist/components/VideoRenderer.js +45 -19
  35. package/dist/components/VideoRenderer.js.map +1 -1
  36. package/dist/context/MCPUIConfigContext.cjs +30 -0
  37. package/dist/context/MCPUIConfigContext.cjs.map +1 -0
  38. package/dist/context/MCPUIConfigContext.d.ts +173 -0
  39. package/dist/context/MCPUIConfigContext.d.ts.map +1 -0
  40. package/dist/context/MCPUIConfigContext.js +30 -0
  41. package/dist/context/MCPUIConfigContext.js.map +1 -0
  42. package/dist/context/MCPUIStringsContext.cjs +8 -1
  43. package/dist/context/MCPUIStringsContext.cjs.map +1 -1
  44. package/dist/context/MCPUIStringsContext.d.ts +29 -0
  45. package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
  46. package/dist/context/MCPUIStringsContext.js +8 -1
  47. package/dist/context/MCPUIStringsContext.js.map +1 -1
  48. package/dist/hooks/index.d.ts +2 -0
  49. package/dist/hooks/index.d.ts.map +1 -1
  50. package/dist/hooks.cjs +2 -0
  51. package/dist/hooks.cjs.map +1 -1
  52. package/dist/hooks.js +2 -0
  53. package/dist/hooks.js.map +1 -1
  54. package/dist/index.cjs +12 -0
  55. package/dist/index.cjs.map +1 -1
  56. package/dist/index.d.cts +5 -1
  57. package/dist/index.d.ts +5 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/services/index.d.ts +1 -1
  62. package/dist/services/index.d.ts.map +1 -1
  63. package/dist/services/validation.cjs +11 -10
  64. package/dist/services/validation.cjs.map +1 -1
  65. package/dist/services/validation.d.ts +20 -0
  66. package/dist/services/validation.d.ts.map +1 -1
  67. package/dist/services/validation.js +11 -10
  68. package/dist/services/validation.js.map +1 -1
  69. package/dist/utils/iframe-coep.cjs +30 -0
  70. package/dist/utils/iframe-coep.cjs.map +1 -0
  71. package/dist/utils/iframe-coep.d.ts +53 -0
  72. package/dist/utils/iframe-coep.d.ts.map +1 -0
  73. package/dist/utils/iframe-coep.js +30 -0
  74. package/dist/utils/iframe-coep.js.map +1 -0
  75. package/dist/utils/pan-zoom.cjs +312 -0
  76. package/dist/utils/pan-zoom.cjs.map +1 -0
  77. package/dist/utils/pan-zoom.d.ts +235 -0
  78. package/dist/utils/pan-zoom.d.ts.map +1 -0
  79. package/dist/utils/pan-zoom.js +312 -0
  80. package/dist/utils/pan-zoom.js.map +1 -0
  81. package/dist/validation.cjs +2 -0
  82. package/dist/validation.cjs.map +1 -1
  83. package/dist/validation.d.cts +1 -1
  84. package/dist/validation.d.ts +1 -1
  85. package/dist/validation.d.ts.map +1 -1
  86. package/dist/validation.js +3 -1
  87. package/package.json +5 -1
  88. package/src/components/ChartJSRenderer.tsx +396 -1
  89. package/src/components/ExpandableWrapper.tsx +26 -0
  90. package/src/components/IframeFallbackLink.tsx +57 -0
  91. package/src/components/LightboxOverlay.tsx +280 -4
  92. package/src/components/UIResourceRenderer.tsx +31 -4
  93. package/src/components/VideoRenderer.tsx +20 -0
  94. package/src/context/MCPUIConfigContext.tsx +202 -0
  95. package/src/context/MCPUIStringsContext.tsx +40 -0
  96. package/src/hooks/index.ts +13 -0
  97. package/src/index.ts +31 -0
  98. package/src/services/index.ts +1 -0
  99. package/src/services/validation.ts +38 -16
  100. package/src/solid-jsx-attrs.d.ts +24 -0
  101. package/src/utils/iframe-coep.ts +81 -0
  102. package/src/utils/pan-zoom.ts +779 -0
  103. package/src/validation.ts +4 -0
@@ -0,0 +1,779 @@
1
+ /**
2
+ * `createPanZoom` — cursor-anchored zoom + drag-pan for one CSS-transformed
3
+ * element. Dependency-free, SSR-safe.
4
+ *
5
+ * @since v6.22.0
6
+ *
7
+ * ## Why a primitive and not a library
8
+ *
9
+ * The surfaces that need this (the image lightbox, the expanded modal) already
10
+ * own their viewport and their own event wiring; what they lack is the small
11
+ * amount of arithmetic that keeps the point under the cursor *under the
12
+ * cursor* while the scale changes. A pan/zoom library would bring its own
13
+ * DOM ownership, its own listeners and its own lifecycle into a tree where
14
+ * `ExpandableWrapper` physically reparents nodes between an inline slot and a
15
+ * modal slot — so this module owns numbers only, never nodes: it holds a
16
+ * scale + a translation and hands them back as one `transform` string. It
17
+ * attaches no listener of its own; the caller binds the handlers in JSX,
18
+ * which is what makes it survive a reparent.
19
+ *
20
+ * ## The contract with the DOM
21
+ *
22
+ * The element that carries the `transform` MUST also carry
23
+ * `transform-origin: 0 0` — {@link PanZoomController.contentStyle} sets both,
24
+ * and the anchoring maths assumes it. With that origin, `translate(t) scale(s)`
25
+ * maps a point `p` of the element's own (untransformed) coordinate space to
26
+ * `t + s·p`, measured from the element's untransformed top-left corner. The
27
+ * two spaces this module talks about are therefore:
28
+ *
29
+ * - a **content point** — px from that corner, in the element's own
30
+ * untransformed space. It names a pixel of the image/chart/graph itself and
31
+ * does not move when the user zooms.
32
+ * - a **viewport point** — px from that same corner, in the *parent's*
33
+ * unscaled space. `event.clientX - originX` is one. It names a place on
34
+ * screen and does not move when the user zooms.
35
+ *
36
+ * Zooming "at the cursor" is then one line: take the content point currently
37
+ * under a viewport point, and choose the translation that puts it back under
38
+ * the same viewport point at the new scale ({@link zoomAtAnchor}).
39
+ *
40
+ * ## Gestures
41
+ *
42
+ * One pointer drags (only while zoomed — at the fitted size there is nothing
43
+ * to move); two pointers pinch. The pinch is served here rather than left to
44
+ * the browser because the surface must set `touch-action: none` for the
45
+ * one-finger drag to work at all: a UA that is allowed to claim the contact
46
+ * for its own visual-viewport zoom would claim it for scrolling too. Taking
47
+ * `none` therefore obliges this module to give the second finger back, which
48
+ * it does through the same anchored maths — the content point under the
49
+ * contacts' midpoint stays under that midpoint, so a pinch zooms and pans in
50
+ * one motion.
51
+ *
52
+ * ## Gesture lifetime
53
+ *
54
+ * Nothing here listens on `window` or `document`, so a gesture's lifetime is
55
+ * the SURFACE's, not the controller's. A surface that can disappear mid-drag —
56
+ * a lightbox whose `<Show>` unmounts while a button is still held — takes the
57
+ * `pointerup` with it, and the gesture would otherwise never end. Two things
58
+ * close that hole: the caller calls {@link PanZoomController.cancelGesture}
59
+ * when its surface goes away, and the handlers themselves drop a gesture whose
60
+ * surface has left the document. `onCleanup` only covers the narrower case
61
+ * where the whole component is disposed.
62
+ *
63
+ * ## SSR
64
+ *
65
+ * Creating the controller touches no browser global — it only creates signals.
66
+ * Every DOM read (`getBoundingClientRect`, pointer capture) happens inside an
67
+ * event handler, i.e. never on the server and never during render, so SSR
68
+ * markup and the hydrated markup agree: both start at `min` scale, no offset.
69
+ */
70
+
71
+ import { createSignal, onCleanup, untrack, type Accessor, type JSX } from 'solid-js'
72
+
73
+ /** A point in one of the two spaces described above. Plain data, no units. */
74
+ export interface PanZoomPoint {
75
+ x: number
76
+ y: number
77
+ }
78
+
79
+ /**
80
+ * The whole state of a pan/zoom surface: a scale, and a translation in the
81
+ * parent's unscaled pixels (the translation is NOT multiplied by the scale —
82
+ * in `translate(t) scale(s)` the translate is applied to the unscaled
83
+ * coordinate system, which is why a drag adds screen pixels to it directly).
84
+ */
85
+ export interface PanZoomState {
86
+ scale: number
87
+ x: number
88
+ y: number
89
+ }
90
+
91
+ export interface PanZoomOptions {
92
+ /** Lowest scale, and the scale `reset()` returns to. Default `1`. */
93
+ min?: number
94
+ /** Highest scale. Default `8`. */
95
+ max?: number
96
+ /**
97
+ * Wheel sensitivity. One wheel event multiplies the scale by
98
+ * `exp(-deltaY * step)`, so the zoom is exponential (every notch is the same
99
+ * *ratio*, whatever the current scale) and symmetric (a notch back undoes a
100
+ * notch forward exactly). Default `0.0015` — one 100px notch is ≈ ±16%.
101
+ */
102
+ step?: number
103
+ /**
104
+ * The element that carries the transform, when the handlers are bound
105
+ * somewhere else — typically a fullscreen backdrop wrapping the `<img>`.
106
+ * Without it the handlers use their own `currentTarget`, which is then
107
+ * assumed to be the transformed element.
108
+ *
109
+ * An accessor, not an element: the ref is still `undefined` when the
110
+ * controller is created.
111
+ */
112
+ target?: () => HTMLElement | null | undefined
113
+ }
114
+
115
+ /** Cursor for the surface the handlers are bound to. */
116
+ export type PanZoomCursor = 'auto' | 'grab' | 'grabbing'
117
+
118
+ export interface PanZoomController {
119
+ /** Current scale, clamped to `[min, max]`. Starts at `min`. */
120
+ scale: Accessor<number>
121
+ /** Current translation, in the parent's unscaled pixels. Starts at `{0, 0}`. */
122
+ offset: Accessor<PanZoomPoint>
123
+ /** `{ scale, x, y }` in one read — the shape the pure helpers take. */
124
+ state: Accessor<PanZoomState>
125
+ /** Value for CSS `transform`: `translate(Xpx, Ypx) scale(S)`. */
126
+ transform: Accessor<string>
127
+ /**
128
+ * Style for the TRANSFORMED element: the transform, the `0 0` origin the
129
+ * maths depends on, and `will-change` while a gesture is worth promoting.
130
+ */
131
+ contentStyle: Accessor<JSX.CSSProperties>
132
+ /**
133
+ * Style for the element the handlers are bound to: the grab cursor, and
134
+ * `touch-action: none`.
135
+ *
136
+ * `none` is not about page scrolling — the surfaces that use this own their
137
+ * viewport and have already locked the document. It is about the contact
138
+ * itself: anything other than `none` lets the UA claim a touch for its own
139
+ * pan or pinch and send `pointercancel` instead of the move stream, which
140
+ * would leave one-finger panning dead. The price is that the browser's own
141
+ * pinch-zoom is off, which is why {@link PanZoomController.onPointerDown}
142
+ * implements the two-finger gesture rather than leaving it to the platform.
143
+ */
144
+ surfaceStyle: Accessor<JSX.CSSProperties>
145
+ /** `'grabbing'` mid-drag, `'grab'` while pannable, `'auto'` otherwise. */
146
+ cursor: Accessor<PanZoomCursor>
147
+ /** `scale() > min` — the surface is showing something other than its reset state. */
148
+ isZoomed: Accessor<boolean>
149
+ /** A drag is in progress right now. */
150
+ isPanning: Accessor<boolean>
151
+ /**
152
+ * The pointer travelled since the last `pointerdown` — a drag, not a click.
153
+ * Set whether or not the surface could actually pan, and stays true until
154
+ * the next `pointerdown`, so a `click` handler that fires after a drag —
155
+ * the lightbox backdrop's "click to close", typically — can bail out on it.
156
+ */
157
+ wasDragged: Accessor<boolean>
158
+ /** `scale()` as a whole percentage, for `strings.zoomLevel`. */
159
+ percent: Accessor<number>
160
+ /** The configured bounds, so callers can disable a button at the limit. */
161
+ min: number
162
+ max: number
163
+ /**
164
+ * Multiplies the scale by `factor` (`> 1` zooms in), clamped to the bounds.
165
+ * Without an anchor the element's own centre stays put, which is what a
166
+ * toolbar button should do.
167
+ */
168
+ zoomBy: (factor: number, anchor?: PanZoomPoint) => void
169
+ /** Same, with an absolute target scale. */
170
+ zoomTo: (scale: number, anchor?: PanZoomPoint) => void
171
+ /** Back to `min` scale and no offset. */
172
+ reset: () => void
173
+ /**
174
+ * Drops every gesture in flight — the drag, the pinch, the contacts they are
175
+ * made of — and releases any pointer capture, WITHOUT touching the transform.
176
+ *
177
+ * Call it when the surface the handlers are bound to goes away while a
178
+ * pointer may still be down: `<Show>` disposing a portal, a modal closing on
179
+ * Escape mid-drag. The `pointerup` that would normally end the gesture is
180
+ * delivered to the element under the pointer, so once the surface is
181
+ * detached it never arrives — {@link PanZoomController.isPanning} would stay
182
+ * `true` for good, the cursor would stay `grabbing`, and the next press
183
+ * would be read as the second finger of a pinch with a phantom first one.
184
+ */
185
+ cancelGesture: () => void
186
+ /** Cursor-anchored wheel zoom. Calls `preventDefault()` — bind it only on a surface that owns its viewport. */
187
+ onWheel: (event: WheelEvent) => void
188
+ /**
189
+ * Starts a drag-pan (if the surface is zoomed), or — on the second
190
+ * simultaneous contact — a pinch, which zooms whatever the current scale.
191
+ */
192
+ onPointerDown: (event: PointerEvent) => void
193
+ onPointerMove: (event: PointerEvent) => void
194
+ onPointerUp: (event: PointerEvent) => void
195
+ onPointerCancel: (event: PointerEvent) => void
196
+ }
197
+
198
+ /**
199
+ * How far a pointer must travel (px, Euclidean) before it counts as a drag
200
+ * rather than a click — for a mouse or a pen, which land where they are aimed.
201
+ */
202
+ const DRAG_THRESHOLD_PX = 3
203
+
204
+ /**
205
+ * The same budget for a finger, which does not.
206
+ *
207
+ * The contact centroid of a tap rolls by several pixels between press and
208
+ * lift, and the platforms allow for it: a browser lets a touch travel of the
209
+ * order of 10px and still fires the `click`. Sharing the mouse's 3px would put
210
+ * this module an order of magnitude under its own platform's tap tolerance —
211
+ * an ordinary tap on the lightbox backdrop would read as a drag, the "click to
212
+ * close" would refuse it, and the overlay would look intermittently deaf.
213
+ */
214
+ const TOUCH_DRAG_THRESHOLD_PX = 10
215
+
216
+ /** The travel budget for the device that produced a contact. */
217
+ const dragThresholdFor = (pointerType: string): number =>
218
+ pointerType === 'touch' ? TOUCH_DRAG_THRESHOLD_PX : DRAG_THRESHOLD_PX
219
+
220
+ /** `deltaMode` is in lines or pages on some browsers; normalise to pixels. */
221
+ const LINE_HEIGHT_PX = 16
222
+ const PAGE_HEIGHT_PX = 400
223
+
224
+ const DEFAULTS = { min: 1, max: 8, step: 0.0015 } as const
225
+
226
+ /**
227
+ * Clamps a scale into `[min, max]`. `NaN` — the result of dividing by a zero
228
+ * scale somewhere upstream — resolves to `min` rather than poisoning the
229
+ * transform; `±Infinity` simply hits the nearer bound.
230
+ */
231
+ export function clampScale(scale: number, min: number, max: number): number {
232
+ if (Number.isNaN(scale)) return min
233
+ return Math.min(max, Math.max(min, scale))
234
+ }
235
+
236
+ /**
237
+ * The content point currently shown at `viewport` — the inverse of
238
+ * {@link viewportPointOf}. Pure; exported for the anchoring tests.
239
+ */
240
+ export function contentPointAt(state: PanZoomState, viewport: PanZoomPoint): PanZoomPoint {
241
+ return {
242
+ x: (viewport.x - state.x) / state.scale,
243
+ y: (viewport.y - state.y) / state.scale,
244
+ }
245
+ }
246
+
247
+ /** Where `content` currently shows up, as a viewport point. Pure. */
248
+ export function viewportPointOf(state: PanZoomState, content: PanZoomPoint): PanZoomPoint {
249
+ return {
250
+ x: state.x + state.scale * content.x,
251
+ y: state.y + state.scale * content.y,
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Changes the scale while keeping whatever is under `anchor` under `anchor`.
257
+ *
258
+ * This is the whole feature: `viewportPointOf(zoomAtAnchor(s, k, a), contentPointAt(s, a))`
259
+ * is `a` again, for every scale, every offset and every anchor. Pure — no
260
+ * clamping, no DOM; the controller clamps around it.
261
+ */
262
+ export function zoomAtAnchor(
263
+ state: PanZoomState,
264
+ nextScale: number,
265
+ anchor: PanZoomPoint
266
+ ): PanZoomState {
267
+ const content = contentPointAt(state, anchor)
268
+ return {
269
+ scale: nextScale,
270
+ x: anchor.x - nextScale * content.x,
271
+ y: anchor.y - nextScale * content.y,
272
+ }
273
+ }
274
+
275
+ /**
276
+ * One contact currently on the surface, in client coordinates.
277
+ *
278
+ * Tracked apart from {@link PanGesture} because a pinch needs TWO at once, and
279
+ * because a gesture can end while the fingers are still down — the second
280
+ * finger supersedes the drag the first one armed.
281
+ */
282
+ interface PanZoomContact extends PanZoomPoint {
283
+ /** `'mouse' | 'pen' | 'touch'`, as reported at `pointerdown`. */
284
+ pointerType: string
285
+ /** The element the handlers are bound to, so a detached surface is noticeable. */
286
+ surface: Element | null
287
+ }
288
+
289
+ /** The in-flight drag. `null` between gestures. */
290
+ interface PanGesture {
291
+ pointerId: number
292
+ /** Where the pointer went down, in client coordinates. */
293
+ startX: number
294
+ startY: number
295
+ /** The offset when it went down — the drag is added to this, never accumulated. */
296
+ origin: PanZoomPoint
297
+ /** Was the surface zoomed when the gesture started? Only then does it pan. */
298
+ pannable: boolean
299
+ /** Travel this pointer needs before it stops being a click. */
300
+ threshold: number
301
+ /** The surface the gesture was armed on; if it leaves the DOM the gesture dies with it. */
302
+ surface: Element | null
303
+ /** The element that captured the pointer, so the same one releases it. */
304
+ captured: Element | null
305
+ }
306
+
307
+ /**
308
+ * The in-flight two-finger pinch. `null` unless exactly two contacts are down.
309
+ *
310
+ * Everything in it is a snapshot from the moment the pinch STARTED: each move
311
+ * recomputes the whole state from those, never from the previous frame, so a
312
+ * pinch that goes out and back lands exactly where it began and rounding never
313
+ * accumulates.
314
+ */
315
+ interface PinchGesture {
316
+ /** The two contacts it is made of, in the order they went down. */
317
+ a: number
318
+ b: number
319
+ /** Their separation at the start — the denominator of the scale ratio. */
320
+ startDistance: number
321
+ /** The transform at the start; the scale is multiplied out of this one. */
322
+ startState: PanZoomState
323
+ /**
324
+ * The content point that was under the contacts' midpoint. Keeping THAT
325
+ * pixel under the (moving) midpoint is what makes a pinch feel like it has
326
+ * hold of the image: the gesture zooms and pans in a single motion.
327
+ */
328
+ anchorContent: PanZoomPoint
329
+ /**
330
+ * The transformed element's untransformed top-left corner, in client px,
331
+ * measured once. It is invariant under the transform (this module only moves
332
+ * content INSIDE the layout box), so re-measuring mid-pinch would feed the
333
+ * translation already applied back into the anchor and the image would run
334
+ * away from the fingers.
335
+ */
336
+ origin: PanZoomPoint
337
+ }
338
+
339
+ /**
340
+ * Creates a pan/zoom controller. Call it in a component body (it registers an
341
+ * `onCleanup`); bind the handlers on the surface that owns the viewport.
342
+ *
343
+ * @example
344
+ * ```tsx
345
+ * let image: HTMLImageElement | undefined
346
+ * const panZoom = createPanZoom({ min: 1, max: 8, target: () => image })
347
+ *
348
+ * <div
349
+ * style={panZoom.surfaceStyle()}
350
+ * onWheel={panZoom.onWheel}
351
+ * onPointerDown={panZoom.onPointerDown}
352
+ * onPointerMove={panZoom.onPointerMove}
353
+ * onPointerUp={panZoom.onPointerUp}
354
+ * onPointerCancel={panZoom.onPointerCancel}
355
+ * onClick={() => { if (!panZoom.wasDragged()) close() }}
356
+ * >
357
+ * <img ref={image} style={panZoom.contentStyle()} src={src} />
358
+ * </div>
359
+ * ```
360
+ */
361
+ export function createPanZoom(options: PanZoomOptions = {}): PanZoomController {
362
+ const min = options.min ?? DEFAULTS.min
363
+ // A `max` below `min` would make every clamp pick the wrong bound; the
364
+ // surface then simply does not zoom.
365
+ const max = Math.max(options.max ?? DEFAULTS.max, min)
366
+ const step = options.step ?? DEFAULTS.step
367
+
368
+ const [scale, setScale] = createSignal(min)
369
+ const [offset, setOffset] = createSignal<PanZoomPoint>({ x: 0, y: 0 })
370
+ const [isPanning, setIsPanning] = createSignal(false)
371
+ const [wasDragged, setWasDragged] = createSignal(false)
372
+
373
+ let gesture: PanGesture | null = null
374
+ let pinch: PinchGesture | null = null
375
+ /** Every pointer currently down on the surface, newest last. */
376
+ const contacts = new Map<number, PanZoomContact>()
377
+
378
+ const state: Accessor<PanZoomState> = () => ({ scale: scale(), ...offset() })
379
+ const isZoomed = () => scale() > min
380
+
381
+ /**
382
+ * Reads the state WITHOUT subscribing: these helpers run from event handlers
383
+ * but also from a caller's `createEffect` (resetting the zoom when the
384
+ * displayed image changes, say), and such an effect must not re-run on its
385
+ * own writes.
386
+ */
387
+ const snapshot = (): PanZoomState => untrack(state)
388
+
389
+ const apply = (next: PanZoomState) => {
390
+ setScale(next.scale)
391
+ setOffset({ x: next.x, y: next.y })
392
+ }
393
+
394
+ /** The transformed element — explicit `target`, else the handler's own target. */
395
+ const elementFor = (event?: { currentTarget?: unknown }): HTMLElement | null => {
396
+ const explicit = options.target?.()
397
+ if (explicit) return explicit
398
+ const current = event?.currentTarget
399
+ return current && typeof (current as HTMLElement).getBoundingClientRect === 'function'
400
+ ? (current as HTMLElement)
401
+ : null
402
+ }
403
+
404
+ /**
405
+ * Viewport point of a pointer event: its client position relative to the
406
+ * transformed element's UNTRANSFORMED origin. That origin is not directly
407
+ * observable — the rect already includes the transform — but with
408
+ * `transform-origin: 0 0` the rendered left edge is exactly `origin + offset`,
409
+ * so subtracting the current offset recovers it whatever the layout.
410
+ */
411
+ const anchorFromEvent = (
412
+ event: { clientX: number; clientY: number; currentTarget?: unknown },
413
+ current: PanZoomState
414
+ ): PanZoomPoint | undefined => {
415
+ const element = elementFor(event)
416
+ if (!element) return undefined
417
+ const rect = element.getBoundingClientRect()
418
+ return {
419
+ x: event.clientX - (rect.left - current.x),
420
+ y: event.clientY - (rect.top - current.y),
421
+ }
422
+ }
423
+
424
+ /**
425
+ * The transformed element's UNTRANSFORMED top-left corner, in client px.
426
+ *
427
+ * Same recovery as {@link anchorFromEvent} — the rendered left edge is
428
+ * `corner + offset` under a `0 0` origin — but expressed once, for gestures
429
+ * that need to convert several client points against a corner measured a
430
+ * while ago. With nothing to measure (no ref yet, no explicit `target` on a
431
+ * surface that binds its handlers elsewhere) it degrades to the client
432
+ * origin, which keeps the arithmetic self-consistent even if the anchor is
433
+ * then off by the element's position.
434
+ */
435
+ const untransformedOrigin = (current: PanZoomState): PanZoomPoint => {
436
+ const element = elementFor()
437
+ if (!element) return { x: 0, y: 0 }
438
+ const rect = element.getBoundingClientRect()
439
+ return { x: rect.left - current.x, y: rect.top - current.y }
440
+ }
441
+
442
+ /**
443
+ * Fallback anchor for the toolbar buttons: the element's own visible centre,
444
+ * which is where a user looks. With no element to measure (SSR, no ref yet)
445
+ * it degrades to the content's top-left corner.
446
+ */
447
+ const centreAnchor = (current: PanZoomState): PanZoomPoint => {
448
+ const element = elementFor()
449
+ if (!element) return { x: current.x, y: current.y }
450
+ const rect = element.getBoundingClientRect()
451
+ return { x: current.x + rect.width / 2, y: current.y + rect.height / 2 }
452
+ }
453
+
454
+ const zoomTo = (target: number, anchor?: PanZoomPoint) => {
455
+ const current = snapshot()
456
+ const next = clampScale(target, min, max)
457
+ if (next === current.scale) return
458
+ // At `min` nothing can be panned out of view, so the offset snaps back to
459
+ // zero: zooming all the way out always lands on the same, centred frame
460
+ // rather than on whatever translation the anchored maths left behind.
461
+ if (next === min) {
462
+ apply({ scale: min, x: 0, y: 0 })
463
+ return
464
+ }
465
+ apply(zoomAtAnchor(current, next, anchor ?? centreAnchor(current)))
466
+ }
467
+
468
+ const zoomBy = (factor: number, anchor?: PanZoomPoint) => {
469
+ if (!Number.isFinite(factor) || factor <= 0) return
470
+ zoomTo(snapshot().scale * factor, anchor)
471
+ }
472
+
473
+ const reset = () => {
474
+ apply({ scale: min, x: 0, y: 0 })
475
+ }
476
+
477
+ const onWheel = (event: WheelEvent) => {
478
+ if (max === min) return
479
+ // The surface owns its viewport (a fullscreen overlay, the expanded
480
+ // modal), so the page must not scroll underneath the zoom.
481
+ event.preventDefault()
482
+ const delta =
483
+ event.deltaY *
484
+ (event.deltaMode === 1 ? LINE_HEIGHT_PX : event.deltaMode === 2 ? PAGE_HEIGHT_PX : 1)
485
+ if (delta === 0) return
486
+ const current = snapshot()
487
+ zoomTo(current.scale * Math.exp(-delta * step), anchorFromEvent(event, current))
488
+ }
489
+
490
+ const releaseCapture = () => {
491
+ const captured = gesture?.captured
492
+ const pointerId = gesture?.pointerId
493
+ if (!captured || pointerId === undefined) return
494
+ try {
495
+ if (captured.hasPointerCapture?.(pointerId)) captured.releasePointerCapture(pointerId)
496
+ } catch {
497
+ // Releasing a capture the browser already dropped (the pointer left the
498
+ // window, the node was reparented) throws; there is nothing to do about it.
499
+ }
500
+ }
501
+
502
+ const endGesture = () => {
503
+ releaseCapture()
504
+ gesture = null
505
+ setIsPanning(false)
506
+ }
507
+
508
+ const cancelGesture = () => {
509
+ endGesture()
510
+ pinch = null
511
+ contacts.clear()
512
+ }
513
+
514
+ /**
515
+ * Has this surface left the document?
516
+ *
517
+ * Only an explicit `false` counts: `isConnected` is a boolean on a real
518
+ * node, and `undefined` on the plain objects the unit tests hand to the
519
+ * handlers as `currentTarget` — those must not be pruned.
520
+ */
521
+ const isDetached = (surface: Element | null | undefined): boolean =>
522
+ surface?.isConnected === false
523
+
524
+ /**
525
+ * Forgets contacts whose surface has left the document, and drops the
526
+ * gesture built on one.
527
+ *
528
+ * Run at every `pointerdown`, because a stranded contact is worse than a
529
+ * stuck flag: it would make the next single press look like the second
530
+ * finger of a pinch, against a phantom first finger somewhere off screen.
531
+ */
532
+ const pruneDetachedContacts = () => {
533
+ for (const [pointerId, contact] of contacts) {
534
+ if (isDetached(contact.surface)) contacts.delete(pointerId)
535
+ }
536
+ if (isDetached(gesture?.surface)) endGesture()
537
+ if (pinch && (!contacts.has(pinch.a) || !contacts.has(pinch.b))) pinch = null
538
+ }
539
+
540
+ /** Arms a drag on `contact`, capturing the pointer when the caller asks. */
541
+ const beginPan = (pointerId: number, contact: PanZoomContact, capture: boolean): boolean => {
542
+ // The gesture is tracked even when nothing can be panned: `wasDragged` is
543
+ // what a backdrop's "click to close" tests, and a drag across an
544
+ // unzoomed surface is still a drag, not a click.
545
+ const pannable = untrack(isZoomed)
546
+ gesture = {
547
+ pointerId,
548
+ startX: contact.x,
549
+ startY: contact.y,
550
+ origin: untrack(offset),
551
+ pannable,
552
+ threshold: dragThresholdFor(contact.pointerType),
553
+ surface: contact.surface,
554
+ captured: null,
555
+ }
556
+ if (!pannable) return false
557
+ setIsPanning(true)
558
+ if (!capture) return true
559
+ const element =
560
+ contact.surface && typeof contact.surface.setPointerCapture === 'function'
561
+ ? contact.surface
562
+ : null
563
+ try {
564
+ element?.setPointerCapture(pointerId)
565
+ gesture.captured = element
566
+ } catch {
567
+ // jsdom, and browsers whose pointer is already gone, throw here. The
568
+ // gesture still works through the handlers bound on the surface.
569
+ }
570
+ return true
571
+ }
572
+
573
+ /**
574
+ * Turns the two live contacts into a pinch, replacing whatever the first
575
+ * finger had armed.
576
+ *
577
+ * Does nothing while the two contacts sit on the same pixel: there is no
578
+ * ratio to grow from yet, and the next move tries again.
579
+ */
580
+ const beginPinch = () => {
581
+ const ids = [...contacts.keys()]
582
+ if (ids.length !== 2) return
583
+ const first = contacts.get(ids[0])!
584
+ const second = contacts.get(ids[1])!
585
+ const startDistance = Math.hypot(first.x - second.x, first.y - second.y)
586
+ if (startDistance === 0) return
587
+ // Two fingers drive the translation together from here; the single-pointer
588
+ // drag the first one armed would fight them for the same offset.
589
+ endGesture()
590
+ const startState = snapshot()
591
+ const origin = untransformedOrigin(startState)
592
+ const mid = { x: (first.x + second.x) / 2 - origin.x, y: (first.y + second.y) / 2 - origin.y }
593
+ pinch = {
594
+ a: ids[0],
595
+ b: ids[1],
596
+ startDistance,
597
+ startState,
598
+ anchorContent: contentPointAt(startState, mid),
599
+ origin,
600
+ }
601
+ // Two fingers on the glass are never the click that closes a backdrop.
602
+ setWasDragged(true)
603
+ }
604
+
605
+ /** Re-derives the transform from the contacts' current separation and midpoint. */
606
+ const updatePinch = () => {
607
+ if (!pinch) return
608
+ const first = contacts.get(pinch.a)
609
+ const second = contacts.get(pinch.b)
610
+ if (!first || !second) {
611
+ pinch = null
612
+ return
613
+ }
614
+ const distance = Math.hypot(first.x - second.x, first.y - second.y)
615
+ if (distance === 0) return
616
+ const next = clampScale(pinch.startState.scale * (distance / pinch.startDistance), min, max)
617
+ // Same rule as `zoomTo`: at `min` nothing can be out of view, so pinching
618
+ // all the way out always lands on the same centred frame.
619
+ if (next === min) {
620
+ apply({ scale: min, x: 0, y: 0 })
621
+ return
622
+ }
623
+ const mid = {
624
+ x: (first.x + second.x) / 2 - pinch.origin.x,
625
+ y: (first.y + second.y) / 2 - pinch.origin.y,
626
+ }
627
+ apply({
628
+ scale: next,
629
+ x: mid.x - next * pinch.anchorContent.x,
630
+ y: mid.y - next * pinch.anchorContent.y,
631
+ })
632
+ }
633
+
634
+ const onPointerDown = (event: PointerEvent) => {
635
+ pruneDetachedContacts()
636
+ // Cleared once per gesture — on the FIRST contact, not on the second
637
+ // finger of a pinch, which would tell the backdrop that the pinch it is
638
+ // in the middle of was a click.
639
+ if (contacts.size === 0) setWasDragged(false)
640
+ const surface = (event.currentTarget ?? null) as Element | null
641
+ contacts.set(event.pointerId, {
642
+ x: event.clientX,
643
+ y: event.clientY,
644
+ pointerType: event.pointerType || 'mouse',
645
+ surface,
646
+ })
647
+
648
+ if (contacts.size === 2) {
649
+ beginPinch()
650
+ // The second finger must not reach the browser either: it would start a
651
+ // native selection or image drag over a surface that is now pinching.
652
+ event.preventDefault()
653
+ return
654
+ }
655
+ if (contacts.size > 2) {
656
+ // A third contact is not a gesture this module knows; stop rather than
657
+ // pinch on an arbitrary pair.
658
+ pinch = null
659
+ endGesture()
660
+ return
661
+ }
662
+
663
+ if (!beginPan(event.pointerId, contacts.get(event.pointerId)!, true)) return
664
+ // Stops the browser's native image drag, which would otherwise swallow
665
+ // the pointer stream mid-pan. Only while panning: an unzoomed surface
666
+ // must keep its ordinary click/focus behaviour.
667
+ event.preventDefault()
668
+ }
669
+
670
+ const onPointerMove = (event: PointerEvent) => {
671
+ const contact = contacts.get(event.pointerId)
672
+ if (contact) {
673
+ contact.x = event.clientX
674
+ contact.y = event.clientY
675
+ }
676
+
677
+ if (pinch) {
678
+ updatePinch()
679
+ return
680
+ }
681
+ // Two fingers that went down on the same pixel only become a pinch once
682
+ // they part, which is a move, not a press.
683
+ if (contacts.size === 2) {
684
+ beginPinch()
685
+ if (pinch) {
686
+ updatePinch()
687
+ return
688
+ }
689
+ }
690
+
691
+ if (!gesture || event.pointerId !== gesture.pointerId) return
692
+ // A surface that left the document mid-gesture can never deliver the
693
+ // `pointerup` that would end this; drop it rather than pan towards a
694
+ // pointer that is no longer talking to us.
695
+ if (isDetached(gesture.surface)) {
696
+ endGesture()
697
+ return
698
+ }
699
+ const dx = event.clientX - gesture.startX
700
+ const dy = event.clientY - gesture.startY
701
+ if (Math.hypot(dx, dy) > gesture.threshold) setWasDragged(true)
702
+ // `pannable` is the state at `pointerdown`; `isZoomed` is the state now.
703
+ // A reset that lands mid-drag (the `0` key, the reset button) must stop
704
+ // the pan rather than drag a fitted image around.
705
+ if (!gesture.pannable || !untrack(isZoomed)) return
706
+ setOffset({ x: gesture.origin.x + dx, y: gesture.origin.y + dy })
707
+ }
708
+
709
+ /**
710
+ * A contact left the surface. Ends what it was driving — and, when it was
711
+ * half of a pinch, hands the surviving finger back to the drag from its
712
+ * CURRENT position, so the image does not freeze under a finger still on
713
+ * the glass.
714
+ */
715
+ const endContact = (pointerId: number) => {
716
+ contacts.delete(pointerId)
717
+ if (pinch && (pinch.a === pointerId || pinch.b === pointerId)) {
718
+ pinch = null
719
+ const survivor = [...contacts.entries()][0]
720
+ if (survivor) beginPan(survivor[0], survivor[1], false)
721
+ return
722
+ }
723
+ if (gesture && gesture.pointerId === pointerId) endGesture()
724
+ }
725
+
726
+ const onPointerUp = (event: PointerEvent) => {
727
+ endContact(event.pointerId)
728
+ }
729
+
730
+ const onPointerCancel = (event: PointerEvent) => {
731
+ endContact(event.pointerId)
732
+ }
733
+
734
+ // No global listener is ever registered — the handlers are bound in JSX and
735
+ // die with the element. What can outlive the component is a pointer capture
736
+ // taken mid-drag and the gesture state behind it, so that is what cleanup
737
+ // gives back. It only covers a disposed OWNER, though: a surface that
738
+ // unmounts under a still-living controller is the caller's `cancelGesture`.
739
+ onCleanup(cancelGesture)
740
+
741
+ const cursor = (): PanZoomCursor => (isPanning() ? 'grabbing' : isZoomed() ? 'grab' : 'auto')
742
+
743
+ const transform = () => {
744
+ const current = state()
745
+ return `translate(${current.x}px, ${current.y}px) scale(${current.scale})`
746
+ }
747
+
748
+ return {
749
+ scale,
750
+ offset,
751
+ state,
752
+ transform,
753
+ contentStyle: () => ({
754
+ transform: transform(),
755
+ 'transform-origin': '0 0',
756
+ 'will-change': isZoomed() || isPanning() ? 'transform' : 'auto',
757
+ }),
758
+ surfaceStyle: () => ({
759
+ cursor: cursor(),
760
+ 'touch-action': 'none',
761
+ }),
762
+ cursor,
763
+ isZoomed,
764
+ isPanning,
765
+ wasDragged,
766
+ percent: () => Math.round(scale() * 100),
767
+ min,
768
+ max,
769
+ zoomBy,
770
+ zoomTo,
771
+ reset,
772
+ cancelGesture,
773
+ onWheel,
774
+ onPointerDown,
775
+ onPointerMove,
776
+ onPointerUp,
777
+ onPointerCancel,
778
+ }
779
+ }