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/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
+ }