lite-image-preview 1.0.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/LICENSE +24 -0
- package/README.md +286 -0
- package/dist/main.cjs +722 -0
- package/dist/main.d.ts +116 -0
- package/dist/main.js +719 -0
- package/package.json +63 -0
- package/rolldown.config.js +18 -0
- package/src/main.ts +11 -0
- package/src/preview.ts +834 -0
- package/src/types.ts +98 -0
- package/src/util.ts +102 -0
- package/tsconfig.app.json +47 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +24 -0
package/src/preview.ts
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Point,
|
|
3
|
+
PreviewAdapter,
|
|
4
|
+
PreviewAdapterFactory,
|
|
5
|
+
PreviewCloseHandle,
|
|
6
|
+
TransformState,
|
|
7
|
+
ViewBox,
|
|
8
|
+
} from './types'
|
|
9
|
+
import {
|
|
10
|
+
clamp,
|
|
11
|
+
dist,
|
|
12
|
+
isFiniteNumber,
|
|
13
|
+
measureSvgBaseViewBox,
|
|
14
|
+
mid,
|
|
15
|
+
safeRect,
|
|
16
|
+
waitForImageLoad,
|
|
17
|
+
} from './util'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Open a modal preview dialog for arbitrary content.
|
|
21
|
+
*
|
|
22
|
+
* The dialog container handles lifecycle and gestures, while the adapter
|
|
23
|
+
* implements the actual rendering backend.
|
|
24
|
+
*
|
|
25
|
+
* @param content - The DOM element to preview.
|
|
26
|
+
* @param initAdapter - Factory that creates a preview adapter after the stage is mounted.
|
|
27
|
+
* @param dispose - Optional cleanup callback called after the dialog closes.
|
|
28
|
+
* @returns A function that closes the preview dialog.
|
|
29
|
+
*/
|
|
30
|
+
export function createPreview(
|
|
31
|
+
content: HTMLElement,
|
|
32
|
+
initAdapter: PreviewAdapterFactory,
|
|
33
|
+
dispose?: () => void
|
|
34
|
+
): PreviewCloseHandle {
|
|
35
|
+
const body = document.body
|
|
36
|
+
const previousOverflow = body.style.overflow
|
|
37
|
+
|
|
38
|
+
const dialog = document.createElement('dialog')
|
|
39
|
+
const stage = document.createElement('div')
|
|
40
|
+
const resetBtn = document.createElement('button')
|
|
41
|
+
const closeBtn = document.createElement('button')
|
|
42
|
+
|
|
43
|
+
let adapter: PreviewAdapter | null = null
|
|
44
|
+
let gestureBinder: { destroy: () => void } | null = null
|
|
45
|
+
let closed = false
|
|
46
|
+
|
|
47
|
+
;(dialog as any).closedBy = 'closeRequest'
|
|
48
|
+
|
|
49
|
+
dialog.style.cssText = `
|
|
50
|
+
width: 100%;
|
|
51
|
+
height: 100%;
|
|
52
|
+
max-width: 100%;
|
|
53
|
+
max-height: 100%;
|
|
54
|
+
box-sizing: border-box;
|
|
55
|
+
padding: 0;
|
|
56
|
+
border: none;
|
|
57
|
+
background: rgba(255, 255, 255, 0.8);
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
stage.style.cssText = `
|
|
62
|
+
position: relative;
|
|
63
|
+
width: 100%;
|
|
64
|
+
height: 100%;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
touch-action: none;
|
|
67
|
+
user-select: none;
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
content.style.cssText += `
|
|
71
|
+
position: absolute;
|
|
72
|
+
left: 0;
|
|
73
|
+
top: 0;
|
|
74
|
+
cursor: grab;
|
|
75
|
+
`
|
|
76
|
+
content.setAttribute('autofocus', '')
|
|
77
|
+
|
|
78
|
+
resetBtn.type = 'button'
|
|
79
|
+
resetBtn.textContent = 'Reset'
|
|
80
|
+
resetBtn.setAttribute('aria-label', 'Reset to fit')
|
|
81
|
+
resetBtn.style.cssText = `
|
|
82
|
+
position: absolute;
|
|
83
|
+
top: 16px;
|
|
84
|
+
right: 64px;
|
|
85
|
+
z-index: 2;
|
|
86
|
+
min-width: 72px;
|
|
87
|
+
height: 40px;
|
|
88
|
+
padding: 0 14px;
|
|
89
|
+
border: 0;
|
|
90
|
+
border-radius: 999px;
|
|
91
|
+
background: rgba(0, 0, 0, 0.5);
|
|
92
|
+
color: #fff;
|
|
93
|
+
font-size: 14px;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
`
|
|
96
|
+
resetBtn.disabled = true
|
|
97
|
+
|
|
98
|
+
closeBtn.type = 'button'
|
|
99
|
+
closeBtn.textContent = '×'
|
|
100
|
+
closeBtn.setAttribute('aria-label', 'Close')
|
|
101
|
+
closeBtn.style.cssText = `
|
|
102
|
+
position: absolute;
|
|
103
|
+
top: 16px;
|
|
104
|
+
right: 16px;
|
|
105
|
+
z-index: 2;
|
|
106
|
+
width: 40px;
|
|
107
|
+
height: 40px;
|
|
108
|
+
border: 0;
|
|
109
|
+
border-radius: 50%;
|
|
110
|
+
background: rgba(0, 0, 0, 0.5);
|
|
111
|
+
color: #fff;
|
|
112
|
+
font-size: 28px;
|
|
113
|
+
line-height: 40px;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
`
|
|
116
|
+
|
|
117
|
+
stage.appendChild(content)
|
|
118
|
+
stage.appendChild(resetBtn)
|
|
119
|
+
stage.appendChild(closeBtn)
|
|
120
|
+
dialog.appendChild(stage)
|
|
121
|
+
body.appendChild(dialog)
|
|
122
|
+
body.style.overflow = 'hidden'
|
|
123
|
+
|
|
124
|
+
const onWheel = (e: WheelEvent) => {
|
|
125
|
+
adapter?.zoomWithWheel(e)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cleanup = () => {
|
|
129
|
+
if (closed) return
|
|
130
|
+
closed = true
|
|
131
|
+
|
|
132
|
+
stage.removeEventListener('wheel', onWheel)
|
|
133
|
+
|
|
134
|
+
gestureBinder?.destroy()
|
|
135
|
+
gestureBinder = null
|
|
136
|
+
|
|
137
|
+
adapter?.destroy()
|
|
138
|
+
adapter?.resetStyle()
|
|
139
|
+
adapter = null
|
|
140
|
+
|
|
141
|
+
dialog.remove()
|
|
142
|
+
body.style.overflow = previousOverflow
|
|
143
|
+
dispose?.()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
closeBtn.addEventListener('click', (e) => {
|
|
147
|
+
e.stopPropagation()
|
|
148
|
+
dialog.close()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
resetBtn.addEventListener('click', (e) => {
|
|
152
|
+
e.preventDefault()
|
|
153
|
+
e.stopPropagation()
|
|
154
|
+
adapter?.fitToStage(stage)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
dialog.addEventListener('close', cleanup)
|
|
158
|
+
|
|
159
|
+
dialog.style.visibility = 'hidden'
|
|
160
|
+
dialog.showModal()
|
|
161
|
+
|
|
162
|
+
requestAnimationFrame(() => {
|
|
163
|
+
adapter = initAdapter(stage)
|
|
164
|
+
gestureBinder = bindGestures(content, adapter)
|
|
165
|
+
adapter.fitToStage(stage)
|
|
166
|
+
resetBtn.disabled = false
|
|
167
|
+
|
|
168
|
+
requestAnimationFrame(() => {
|
|
169
|
+
adapter?.fitToStage(stage)
|
|
170
|
+
dialog.style.visibility = 'visible'
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
stage.addEventListener('wheel', onWheel, { passive: false })
|
|
175
|
+
|
|
176
|
+
return () => dialog.close()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the default transform-based adapter used for raster images.
|
|
181
|
+
*/
|
|
182
|
+
function createTransformAdapter(
|
|
183
|
+
content: HTMLElement,
|
|
184
|
+
stage: HTMLElement,
|
|
185
|
+
baseWidth: number,
|
|
186
|
+
baseHeight: number,
|
|
187
|
+
options?: {
|
|
188
|
+
minScale?: number
|
|
189
|
+
maxScale?: number
|
|
190
|
+
fitPadding?: number
|
|
191
|
+
fitMaxScale?: number
|
|
192
|
+
}
|
|
193
|
+
): PreviewAdapter {
|
|
194
|
+
const minScale = options?.minScale ?? 0.1
|
|
195
|
+
const maxScale = options?.maxScale ?? 8
|
|
196
|
+
const fitPadding = options?.fitPadding ?? 32
|
|
197
|
+
const fitMaxScale = options?.fitMaxScale ?? 1
|
|
198
|
+
|
|
199
|
+
const prev = {
|
|
200
|
+
transform: content.style.transform,
|
|
201
|
+
transformOrigin: content.style.transformOrigin,
|
|
202
|
+
width: content.style.width,
|
|
203
|
+
height: content.style.height,
|
|
204
|
+
cursor: content.style.cursor,
|
|
205
|
+
touchAction: content.style.touchAction,
|
|
206
|
+
userSelect: content.style.userSelect,
|
|
207
|
+
display: content.style.display,
|
|
208
|
+
maxWidth: content.style.maxWidth,
|
|
209
|
+
maxHeight: content.style.maxHeight,
|
|
210
|
+
position: content.style.position,
|
|
211
|
+
left: content.style.left,
|
|
212
|
+
top: content.style.top,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
content.style.transformOrigin = '0 0'
|
|
216
|
+
content.style.touchAction = 'none'
|
|
217
|
+
content.style.userSelect = 'none'
|
|
218
|
+
content.style.cursor = 'grab'
|
|
219
|
+
content.style.display = 'block'
|
|
220
|
+
content.style.maxWidth = 'none'
|
|
221
|
+
content.style.maxHeight = 'none'
|
|
222
|
+
content.style.position = 'absolute'
|
|
223
|
+
content.style.left = '0'
|
|
224
|
+
content.style.top = '0'
|
|
225
|
+
content.style.width = `${baseWidth}px`
|
|
226
|
+
content.style.height = `${baseHeight}px`
|
|
227
|
+
|
|
228
|
+
const state: TransformState = {
|
|
229
|
+
scale: 1,
|
|
230
|
+
x: 0,
|
|
231
|
+
y: 0,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let pinchStart:
|
|
235
|
+
| {
|
|
236
|
+
state: TransformState
|
|
237
|
+
distance: number
|
|
238
|
+
anchor: Point
|
|
239
|
+
}
|
|
240
|
+
| null = null
|
|
241
|
+
|
|
242
|
+
function apply() {
|
|
243
|
+
content.style.transform = `translate3d(${state.x}px, ${state.y}px, 0) scale(${state.scale})`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function fitToStage() {
|
|
247
|
+
const rect = safeRect(stage)
|
|
248
|
+
const availW = Math.max(rect.width - fitPadding, 1)
|
|
249
|
+
const availH = Math.max(rect.height - fitPadding, 1)
|
|
250
|
+
|
|
251
|
+
const raw = Math.min(availW / baseWidth, availH / baseHeight, fitMaxScale)
|
|
252
|
+
const fitScale = clamp(isFiniteNumber(raw) && raw > 0 ? raw : 1, minScale, maxScale)
|
|
253
|
+
|
|
254
|
+
state.scale = fitScale
|
|
255
|
+
state.x = (rect.width - baseWidth * fitScale) / 2
|
|
256
|
+
state.y = (rect.height - baseHeight * fitScale) / 2
|
|
257
|
+
apply()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function panBy(dx: number, dy: number) {
|
|
261
|
+
if (!isFiniteNumber(dx) || !isFiniteNumber(dy)) return
|
|
262
|
+
state.x += dx
|
|
263
|
+
state.y += dy
|
|
264
|
+
apply()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function zoomAt(clientX: number, clientY: number, factor: number) {
|
|
268
|
+
if (!isFiniteNumber(factor) || factor <= 0) return
|
|
269
|
+
|
|
270
|
+
const nextScale = clamp(state.scale * factor, minScale, maxScale)
|
|
271
|
+
if (nextScale === state.scale) return
|
|
272
|
+
|
|
273
|
+
const contentX = (clientX - state.x) / state.scale
|
|
274
|
+
const contentY = (clientY - state.y) / state.scale
|
|
275
|
+
|
|
276
|
+
state.scale = nextScale
|
|
277
|
+
state.x = clientX - contentX * nextScale
|
|
278
|
+
state.y = clientY - contentY * nextScale
|
|
279
|
+
apply()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function beginPinch(points: [Point, Point]) {
|
|
283
|
+
const [p1, p2] = points
|
|
284
|
+
const midpoint = mid(p1, p2)
|
|
285
|
+
|
|
286
|
+
pinchStart = {
|
|
287
|
+
state: { ...state },
|
|
288
|
+
distance: dist(p1, p2),
|
|
289
|
+
anchor: {
|
|
290
|
+
x: (midpoint.x - state.x) / state.scale,
|
|
291
|
+
y: (midpoint.y - state.y) / state.scale,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function updatePinch(points: [Point, Point]) {
|
|
297
|
+
if (!pinchStart) return
|
|
298
|
+
|
|
299
|
+
const [p1, p2] = points
|
|
300
|
+
const currentDistance = dist(p1, p2)
|
|
301
|
+
if (!isFiniteNumber(currentDistance) || currentDistance <= 0) return
|
|
302
|
+
|
|
303
|
+
const currentMid = mid(p1, p2)
|
|
304
|
+
const factor = currentDistance / pinchStart.distance
|
|
305
|
+
const nextScale = clamp(pinchStart.state.scale * factor, minScale, maxScale)
|
|
306
|
+
|
|
307
|
+
state.scale = nextScale
|
|
308
|
+
state.x = currentMid.x - pinchStart.anchor.x * nextScale
|
|
309
|
+
state.y = currentMid.y - pinchStart.anchor.y * nextScale
|
|
310
|
+
apply()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function zoomWithWheel(e: WheelEvent) {
|
|
314
|
+
e.preventDefault()
|
|
315
|
+
|
|
316
|
+
// Wheel up (deltaY < 0) zooms in; wheel down zooms out.
|
|
317
|
+
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.0015))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function destroy() {
|
|
321
|
+
pinchStart = null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function resetStyle() {
|
|
325
|
+
content.style.transform = prev.transform
|
|
326
|
+
content.style.transformOrigin = prev.transformOrigin
|
|
327
|
+
content.style.width = prev.width
|
|
328
|
+
content.style.height = prev.height
|
|
329
|
+
content.style.cursor = prev.cursor
|
|
330
|
+
content.style.touchAction = prev.touchAction
|
|
331
|
+
content.style.userSelect = prev.userSelect
|
|
332
|
+
content.style.display = prev.display
|
|
333
|
+
content.style.maxWidth = prev.maxWidth
|
|
334
|
+
content.style.maxHeight = prev.maxHeight
|
|
335
|
+
content.style.position = prev.position
|
|
336
|
+
content.style.left = prev.left
|
|
337
|
+
content.style.top = prev.top
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
fitToStage,
|
|
342
|
+
panBy,
|
|
343
|
+
zoomAt,
|
|
344
|
+
beginPinch,
|
|
345
|
+
updatePinch,
|
|
346
|
+
zoomWithWheel,
|
|
347
|
+
destroy,
|
|
348
|
+
resetStyle,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Bind mouse, pen, and touch gestures to the preview content.
|
|
354
|
+
*
|
|
355
|
+
* Touch input is routed through Touch Events to avoid browser-specific
|
|
356
|
+
* pointer gesture quirks on some mobile browsers.
|
|
357
|
+
*/
|
|
358
|
+
function bindGestures(
|
|
359
|
+
content: HTMLElement,
|
|
360
|
+
adapter: PreviewAdapter
|
|
361
|
+
) {
|
|
362
|
+
const pointers = new Map<number, Point>()
|
|
363
|
+
const lastPointers = new Map<number, Point>()
|
|
364
|
+
|
|
365
|
+
let pinchActive = false
|
|
366
|
+
let pinchRafId: number | null = null
|
|
367
|
+
|
|
368
|
+
function getPrimaryTwoPoints(): [Point, Point] | null {
|
|
369
|
+
const pair = [...pointers.entries()]
|
|
370
|
+
.sort((a, b) => a[0] - b[0])
|
|
371
|
+
.slice(0, 2)
|
|
372
|
+
.map(([, p]) => p)
|
|
373
|
+
|
|
374
|
+
if (pair.length !== 2) return null
|
|
375
|
+
return [pair[0]!, pair[1]!]
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function ensurePinchStarted() {
|
|
379
|
+
const pair = getPrimaryTwoPoints()
|
|
380
|
+
if (!pair) return
|
|
381
|
+
adapter.beginPinch(pair)
|
|
382
|
+
pinchActive = true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function schedulePinchUpdate() {
|
|
386
|
+
if (pinchRafId !== null) return
|
|
387
|
+
|
|
388
|
+
pinchRafId = requestAnimationFrame(() => {
|
|
389
|
+
pinchRafId = null
|
|
390
|
+
|
|
391
|
+
if (pointers.size < 2) return
|
|
392
|
+
|
|
393
|
+
const pair = getPrimaryTwoPoints()
|
|
394
|
+
if (!pair) return
|
|
395
|
+
|
|
396
|
+
if (!pinchActive) {
|
|
397
|
+
adapter.beginPinch(pair)
|
|
398
|
+
pinchActive = true
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
adapter.updatePinch(pair)
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function stopPinchIfNeeded() {
|
|
406
|
+
if (pointers.size < 2) {
|
|
407
|
+
pinchActive = false
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function onPointerDown(e: PointerEvent) {
|
|
412
|
+
if (e.pointerType === 'touch') return
|
|
413
|
+
if (!content.isConnected) return
|
|
414
|
+
|
|
415
|
+
e.preventDefault()
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
content.setPointerCapture(e.pointerId)
|
|
419
|
+
} catch {
|
|
420
|
+
// ignore
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const point = { x: e.clientX, y: e.clientY }
|
|
424
|
+
pointers.set(e.pointerId, point)
|
|
425
|
+
lastPointers.set(e.pointerId, point)
|
|
426
|
+
|
|
427
|
+
if (pointers.size >= 2) {
|
|
428
|
+
ensurePinchStarted()
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function onPointerMove(e: PointerEvent) {
|
|
433
|
+
if (e.pointerType === 'touch') return
|
|
434
|
+
if (!pointers.has(e.pointerId)) return
|
|
435
|
+
if (!content.isConnected) return
|
|
436
|
+
|
|
437
|
+
e.preventDefault()
|
|
438
|
+
|
|
439
|
+
const current = { x: e.clientX, y: e.clientY }
|
|
440
|
+
const previous = lastPointers.get(e.pointerId) ?? current
|
|
441
|
+
|
|
442
|
+
pointers.set(e.pointerId, current)
|
|
443
|
+
lastPointers.set(e.pointerId, current)
|
|
444
|
+
|
|
445
|
+
if (pointers.size === 1) {
|
|
446
|
+
adapter.panBy(current.x - previous.x, current.y - previous.y)
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (pointers.size >= 2) {
|
|
451
|
+
if (!pinchActive) ensurePinchStarted()
|
|
452
|
+
schedulePinchUpdate()
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function onPointerUp(e: PointerEvent) {
|
|
457
|
+
if (e.pointerType === 'touch') return
|
|
458
|
+
|
|
459
|
+
pointers.delete(e.pointerId)
|
|
460
|
+
lastPointers.delete(e.pointerId)
|
|
461
|
+
|
|
462
|
+
if (pointers.size >= 2) {
|
|
463
|
+
ensurePinchStarted()
|
|
464
|
+
schedulePinchUpdate()
|
|
465
|
+
} else {
|
|
466
|
+
stopPinchIfNeeded()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (pinchRafId !== null) {
|
|
470
|
+
cancelAnimationFrame(pinchRafId)
|
|
471
|
+
pinchRafId = null
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function onTouchStart(e: TouchEvent) {
|
|
476
|
+
if (!content.isConnected) return
|
|
477
|
+
|
|
478
|
+
e.preventDefault()
|
|
479
|
+
|
|
480
|
+
for (const touch of Array.from(e.changedTouches)) {
|
|
481
|
+
const point = { x: touch.clientX, y: touch.clientY }
|
|
482
|
+
pointers.set(touch.identifier, point)
|
|
483
|
+
lastPointers.set(touch.identifier, point)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (pointers.size >= 2) {
|
|
487
|
+
ensurePinchStarted()
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function onTouchMove(e: TouchEvent) {
|
|
492
|
+
if (!content.isConnected) return
|
|
493
|
+
|
|
494
|
+
e.preventDefault()
|
|
495
|
+
|
|
496
|
+
for (const touch of Array.from(e.changedTouches)) {
|
|
497
|
+
const current = { x: touch.clientX, y: touch.clientY }
|
|
498
|
+
const previous = lastPointers.get(touch.identifier) ?? current
|
|
499
|
+
|
|
500
|
+
pointers.set(touch.identifier, current)
|
|
501
|
+
lastPointers.set(touch.identifier, current)
|
|
502
|
+
|
|
503
|
+
if (pointers.size === 1) {
|
|
504
|
+
adapter.panBy(current.x - previous.x, current.y - previous.y)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (pointers.size >= 2) {
|
|
509
|
+
if (!pinchActive) ensurePinchStarted()
|
|
510
|
+
schedulePinchUpdate()
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function onTouchEnd(e: TouchEvent) {
|
|
515
|
+
for (const touch of Array.from(e.changedTouches)) {
|
|
516
|
+
pointers.delete(touch.identifier)
|
|
517
|
+
lastPointers.delete(touch.identifier)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (pointers.size >= 2) {
|
|
521
|
+
ensurePinchStarted()
|
|
522
|
+
schedulePinchUpdate()
|
|
523
|
+
} else {
|
|
524
|
+
stopPinchIfNeeded()
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (pinchRafId !== null) {
|
|
528
|
+
cancelAnimationFrame(pinchRafId)
|
|
529
|
+
pinchRafId = null
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
content.addEventListener('pointerdown', onPointerDown)
|
|
534
|
+
content.addEventListener('pointermove', onPointerMove)
|
|
535
|
+
content.addEventListener('pointerup', onPointerUp)
|
|
536
|
+
content.addEventListener('pointercancel', onPointerUp)
|
|
537
|
+
content.addEventListener('lostpointercapture', onPointerUp)
|
|
538
|
+
|
|
539
|
+
content.addEventListener('touchstart', onTouchStart, { passive: false })
|
|
540
|
+
content.addEventListener('touchmove', onTouchMove, { passive: false })
|
|
541
|
+
content.addEventListener('touchend', onTouchEnd)
|
|
542
|
+
content.addEventListener('touchcancel', onTouchEnd)
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
destroy() {
|
|
546
|
+
content.removeEventListener('pointerdown', onPointerDown)
|
|
547
|
+
content.removeEventListener('pointermove', onPointerMove)
|
|
548
|
+
content.removeEventListener('pointerup', onPointerUp)
|
|
549
|
+
content.removeEventListener('pointercancel', onPointerUp)
|
|
550
|
+
content.removeEventListener('lostpointercapture', onPointerUp)
|
|
551
|
+
|
|
552
|
+
content.removeEventListener('touchstart', onTouchStart)
|
|
553
|
+
content.removeEventListener('touchmove', onTouchMove)
|
|
554
|
+
content.removeEventListener('touchend', onTouchEnd)
|
|
555
|
+
content.removeEventListener('touchcancel', onTouchEnd)
|
|
556
|
+
|
|
557
|
+
pointers.clear()
|
|
558
|
+
lastPointers.clear()
|
|
559
|
+
|
|
560
|
+
if (pinchRafId !== null) {
|
|
561
|
+
cancelAnimationFrame(pinchRafId)
|
|
562
|
+
pinchRafId = null
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Open an image preview dialog.
|
|
570
|
+
*
|
|
571
|
+
* The returned promise resolves to a close handle when the preview is ready.
|
|
572
|
+
* If the image fails to load, the promise resolves to null.
|
|
573
|
+
*/
|
|
574
|
+
export async function previewImage(
|
|
575
|
+
url: string,
|
|
576
|
+
dispose?: () => void
|
|
577
|
+
): Promise<PreviewCloseHandle | null> {
|
|
578
|
+
const img = document.createElement('img')
|
|
579
|
+
|
|
580
|
+
img.src = url
|
|
581
|
+
img.alt = ''
|
|
582
|
+
img.draggable = false
|
|
583
|
+
img.style.cssText = `
|
|
584
|
+
display: block;
|
|
585
|
+
max-width: none;
|
|
586
|
+
max-height: none;
|
|
587
|
+
`
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
await waitForImageLoad(img)
|
|
591
|
+
} catch {
|
|
592
|
+
dispose?.()
|
|
593
|
+
return null
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return createPreview(
|
|
597
|
+
img,
|
|
598
|
+
(stage: HTMLElement): PreviewAdapter => {
|
|
599
|
+
const w = img.naturalWidth || 1
|
|
600
|
+
const h = img.naturalHeight || 1
|
|
601
|
+
|
|
602
|
+
return createTransformAdapter(img, stage, w, h, {
|
|
603
|
+
minScale: 0.1,
|
|
604
|
+
maxScale: 8,
|
|
605
|
+
fitPadding: 32,
|
|
606
|
+
fitMaxScale: 1,
|
|
607
|
+
})
|
|
608
|
+
},
|
|
609
|
+
dispose
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Open an SVG preview dialog.
|
|
615
|
+
*
|
|
616
|
+
* The returned promise resolves to a close handle when the preview is ready.
|
|
617
|
+
*/
|
|
618
|
+
export async function previewSvg(
|
|
619
|
+
svg: SVGSVGElement,
|
|
620
|
+
dispose?: () => void
|
|
621
|
+
): Promise<PreviewCloseHandle> {
|
|
622
|
+
const cloned = svg.cloneNode(true) as SVGSVGElement
|
|
623
|
+
|
|
624
|
+
cloned.removeAttribute('width')
|
|
625
|
+
cloned.removeAttribute('height')
|
|
626
|
+
|
|
627
|
+
cloned.style.cssText = `
|
|
628
|
+
display: block;
|
|
629
|
+
max-width: none;
|
|
630
|
+
max-height: none;
|
|
631
|
+
overflow: visible;
|
|
632
|
+
`
|
|
633
|
+
|
|
634
|
+
return createPreview(
|
|
635
|
+
cloned as unknown as HTMLElement,
|
|
636
|
+
() => createSvgViewBoxAdapter(cloned),
|
|
637
|
+
dispose
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Build the SVG adapter that uses viewBox for crisp scaling.
|
|
643
|
+
*/
|
|
644
|
+
function createSvgViewBoxAdapter(svg: SVGSVGElement): PreviewAdapter {
|
|
645
|
+
const prev = {
|
|
646
|
+
transform: svg.style.transform,
|
|
647
|
+
transformOrigin: svg.style.transformOrigin,
|
|
648
|
+
width: svg.style.width,
|
|
649
|
+
height: svg.style.height,
|
|
650
|
+
cursor: svg.style.cursor,
|
|
651
|
+
touchAction: svg.style.touchAction,
|
|
652
|
+
userSelect: svg.style.userSelect,
|
|
653
|
+
display: svg.style.display,
|
|
654
|
+
maxWidth: svg.style.maxWidth,
|
|
655
|
+
maxHeight: svg.style.maxHeight,
|
|
656
|
+
position: svg.style.position,
|
|
657
|
+
left: svg.style.left,
|
|
658
|
+
top: svg.style.top,
|
|
659
|
+
overflow: svg.style.overflow,
|
|
660
|
+
preserveAspectRatio: svg.getAttribute('preserveAspectRatio'),
|
|
661
|
+
viewBox: svg.getAttribute('viewBox'),
|
|
662
|
+
widthAttr: svg.getAttribute('width'),
|
|
663
|
+
heightAttr: svg.getAttribute('height'),
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const baseViewBox = measureSvgBaseViewBox(svg)
|
|
667
|
+
let viewBox: ViewBox = { ...baseViewBox }
|
|
668
|
+
|
|
669
|
+
let pinchStart:
|
|
670
|
+
| {
|
|
671
|
+
viewBox: ViewBox
|
|
672
|
+
distance: number
|
|
673
|
+
midpoint: Point
|
|
674
|
+
}
|
|
675
|
+
| null = null
|
|
676
|
+
|
|
677
|
+
function applyViewBox() {
|
|
678
|
+
svg.setAttribute(
|
|
679
|
+
'viewBox',
|
|
680
|
+
`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function fitToStage() {
|
|
685
|
+
svg.style.width = '100%'
|
|
686
|
+
svg.style.height = '100%'
|
|
687
|
+
svg.style.display = 'block'
|
|
688
|
+
svg.style.maxWidth = 'none'
|
|
689
|
+
svg.style.maxHeight = 'none'
|
|
690
|
+
svg.style.overflow = 'visible'
|
|
691
|
+
svg.style.position = 'absolute'
|
|
692
|
+
svg.style.left = '0'
|
|
693
|
+
svg.style.top = '0'
|
|
694
|
+
|
|
695
|
+
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
|
696
|
+
viewBox = { ...baseViewBox }
|
|
697
|
+
applyViewBox()
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function getRectSafe() {
|
|
701
|
+
const rect = svg.getBoundingClientRect()
|
|
702
|
+
return {
|
|
703
|
+
left: rect.left,
|
|
704
|
+
top: rect.top,
|
|
705
|
+
width: rect.width || 1,
|
|
706
|
+
height: rect.height || 1,
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function panBy(dx: number, dy: number) {
|
|
711
|
+
if (!isFiniteNumber(dx) || !isFiniteNumber(dy)) return
|
|
712
|
+
|
|
713
|
+
const rect = getRectSafe()
|
|
714
|
+
const factor = Math.max(viewBox.w / rect.width, viewBox.h / rect.height)
|
|
715
|
+
|
|
716
|
+
viewBox.x -= dx * factor
|
|
717
|
+
viewBox.y -= dy * factor
|
|
718
|
+
applyViewBox()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function zoomAt(clientX: number, clientY: number, factor: number) {
|
|
722
|
+
if (!isFiniteNumber(factor) || factor <= 0) return
|
|
723
|
+
|
|
724
|
+
const rect = getRectSafe()
|
|
725
|
+
const cx = (clientX - rect.left) / rect.width
|
|
726
|
+
const cy = (clientY - rect.top) / rect.height
|
|
727
|
+
|
|
728
|
+
const newW = viewBox.w * factor
|
|
729
|
+
const newH = viewBox.h * factor
|
|
730
|
+
|
|
731
|
+
viewBox.x += (viewBox.w - newW) * cx
|
|
732
|
+
viewBox.y += (viewBox.h - newH) * cy
|
|
733
|
+
viewBox.w = newW
|
|
734
|
+
viewBox.h = newH
|
|
735
|
+
applyViewBox()
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function beginPinch(points: [Point, Point]) {
|
|
739
|
+
const [p1, p2] = points
|
|
740
|
+
const midpoint = mid(p1, p2)
|
|
741
|
+
|
|
742
|
+
pinchStart = {
|
|
743
|
+
viewBox: { ...viewBox },
|
|
744
|
+
distance: dist(p1, p2),
|
|
745
|
+
midpoint,
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function updatePinch(points: [Point, Point]) {
|
|
750
|
+
if (!pinchStart) return
|
|
751
|
+
|
|
752
|
+
const [p1, p2] = points
|
|
753
|
+
const currentDistance = dist(p1, p2)
|
|
754
|
+
if (!isFiniteNumber(currentDistance) || currentDistance <= 0) return
|
|
755
|
+
|
|
756
|
+
const currentMid = mid(p1, p2)
|
|
757
|
+
const scale = pinchStart.distance / currentDistance
|
|
758
|
+
|
|
759
|
+
const newW = pinchStart.viewBox.w * scale
|
|
760
|
+
const newH = pinchStart.viewBox.h * scale
|
|
761
|
+
|
|
762
|
+
const rect = getRectSafe()
|
|
763
|
+
const startCx = (pinchStart.midpoint.x - rect.left) / rect.width
|
|
764
|
+
const startCy = (pinchStart.midpoint.y - rect.top) / rect.height
|
|
765
|
+
const currentCx = (currentMid.x - rect.left) / rect.width
|
|
766
|
+
const currentCy = (currentMid.y - rect.top) / rect.height
|
|
767
|
+
|
|
768
|
+
const anchorX = pinchStart.viewBox.x + pinchStart.viewBox.w * startCx
|
|
769
|
+
const anchorY = pinchStart.viewBox.y + pinchStart.viewBox.h * startCy
|
|
770
|
+
|
|
771
|
+
viewBox.w = newW
|
|
772
|
+
viewBox.h = newH
|
|
773
|
+
viewBox.x = anchorX - newW * currentCx
|
|
774
|
+
viewBox.y = anchorY - newH * currentCy
|
|
775
|
+
applyViewBox()
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function zoomWithWheel(e: WheelEvent) {
|
|
779
|
+
e.preventDefault()
|
|
780
|
+
|
|
781
|
+
// Wheel up (deltaY < 0) zooms in; wheel down zooms out.
|
|
782
|
+
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.0015))
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function destroy() {
|
|
786
|
+
pinchStart = null
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function resetStyle() {
|
|
790
|
+
if (prev.transform !== undefined) svg.style.transform = prev.transform
|
|
791
|
+
if (prev.transformOrigin !== undefined) svg.style.transformOrigin = prev.transformOrigin
|
|
792
|
+
if (prev.width !== undefined) svg.style.width = prev.width
|
|
793
|
+
if (prev.height !== undefined) svg.style.height = prev.height
|
|
794
|
+
if (prev.cursor !== undefined) svg.style.cursor = prev.cursor
|
|
795
|
+
if (prev.touchAction !== undefined) svg.style.touchAction = prev.touchAction
|
|
796
|
+
if (prev.userSelect !== undefined) svg.style.userSelect = prev.userSelect
|
|
797
|
+
if (prev.display !== undefined) svg.style.display = prev.display
|
|
798
|
+
if (prev.maxWidth !== undefined) svg.style.maxWidth = prev.maxWidth
|
|
799
|
+
if (prev.maxHeight !== undefined) svg.style.maxHeight = prev.maxHeight
|
|
800
|
+
if (prev.position !== undefined) svg.style.position = prev.position
|
|
801
|
+
if (prev.left !== undefined) svg.style.left = prev.left
|
|
802
|
+
if (prev.top !== undefined) svg.style.top = prev.top
|
|
803
|
+
if (prev.overflow !== undefined) svg.style.overflow = prev.overflow
|
|
804
|
+
|
|
805
|
+
if (prev.preserveAspectRatio === null) {
|
|
806
|
+
svg.removeAttribute('preserveAspectRatio')
|
|
807
|
+
} else {
|
|
808
|
+
svg.setAttribute('preserveAspectRatio', prev.preserveAspectRatio)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (prev.viewBox === null) {
|
|
812
|
+
svg.removeAttribute('viewBox')
|
|
813
|
+
} else {
|
|
814
|
+
svg.setAttribute('viewBox', prev.viewBox)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (prev.widthAttr === null) svg.removeAttribute('width')
|
|
818
|
+
else svg.setAttribute('width', prev.widthAttr)
|
|
819
|
+
|
|
820
|
+
if (prev.heightAttr === null) svg.removeAttribute('height')
|
|
821
|
+
else svg.setAttribute('height', prev.heightAttr)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
fitToStage,
|
|
826
|
+
panBy,
|
|
827
|
+
zoomAt,
|
|
828
|
+
beginPinch,
|
|
829
|
+
updatePinch,
|
|
830
|
+
zoomWithWheel,
|
|
831
|
+
destroy,
|
|
832
|
+
resetStyle,
|
|
833
|
+
}
|
|
834
|
+
}
|