@seed-ship/mcp-ui-solid 6.21.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.
- package/CHANGELOG.md +79 -0
- package/dist/components/ChartJSRenderer.cjs +244 -68
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts +9 -0
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +246 -70
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/ExpandableWrapper.cjs +9 -1
- package/dist/components/ExpandableWrapper.cjs.map +1 -1
- package/dist/components/ExpandableWrapper.d.ts.map +1 -1
- package/dist/components/ExpandableWrapper.js +9 -1
- package/dist/components/ExpandableWrapper.js.map +1 -1
- package/dist/components/LightboxOverlay.cjs +139 -16
- package/dist/components/LightboxOverlay.cjs.map +1 -1
- package/dist/components/LightboxOverlay.d.ts +6 -0
- package/dist/components/LightboxOverlay.d.ts.map +1 -1
- package/dist/components/LightboxOverlay.js +141 -18
- package/dist/components/LightboxOverlay.js.map +1 -1
- package/dist/context/MCPUIConfigContext.cjs +2 -1
- package/dist/context/MCPUIConfigContext.cjs.map +1 -1
- package/dist/context/MCPUIConfigContext.d.ts +60 -11
- package/dist/context/MCPUIConfigContext.d.ts.map +1 -1
- package/dist/context/MCPUIConfigContext.js +2 -1
- package/dist/context/MCPUIConfigContext.js.map +1 -1
- package/dist/context/MCPUIStringsContext.cjs +6 -1
- package/dist/context/MCPUIStringsContext.cjs.map +1 -1
- package/dist/context/MCPUIStringsContext.d.ts +22 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
- package/dist/context/MCPUIStringsContext.js +6 -1
- package/dist/context/MCPUIStringsContext.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks.cjs +2 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.js +2 -0
- package/dist/hooks.js.map +1 -1
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/iframe-coep.cjs.map +1 -1
- package/dist/utils/iframe-coep.d.ts +9 -2
- package/dist/utils/iframe-coep.d.ts.map +1 -1
- package/dist/utils/iframe-coep.js.map +1 -1
- package/dist/utils/pan-zoom.cjs +312 -0
- package/dist/utils/pan-zoom.cjs.map +1 -0
- package/dist/utils/pan-zoom.d.ts +235 -0
- package/dist/utils/pan-zoom.d.ts.map +1 -0
- package/dist/utils/pan-zoom.js +312 -0
- package/dist/utils/pan-zoom.js.map +1 -0
- package/package.json +5 -1
- package/src/components/ChartJSRenderer.tsx +396 -1
- package/src/components/ExpandableWrapper.tsx +26 -0
- package/src/components/LightboxOverlay.tsx +280 -4
- package/src/context/MCPUIConfigContext.tsx +62 -11
- package/src/context/MCPUIStringsContext.tsx +30 -0
- package/src/hooks/index.ts +13 -0
- package/src/index.ts +16 -0
- package/src/utils/iframe-coep.ts +9 -2
- package/src/utils/pan-zoom.ts +779 -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
|
+
}
|