@classic-homes/theme-svelte 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +43 -0
  2. package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
  3. package/dist/__tests__/media-components.test.js +206 -0
  4. package/dist/__tests__/media-hls.test.js +273 -0
  5. package/dist/__tests__/media-matterport.test.js +296 -0
  6. package/dist/__tests__/media-players.test.js +244 -0
  7. package/dist/__tests__/media-scrubber.test.js +44 -0
  8. package/dist/__tests__/media-utils.test.js +427 -0
  9. package/dist/__tests__/setup.js +50 -0
  10. package/dist/lib/components/FileUpload.svelte +221 -4
  11. package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
  12. package/dist/lib/components/media/AspectRatio.svelte +47 -0
  13. package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
  14. package/dist/lib/components/media/Audio.svelte +247 -0
  15. package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
  16. package/dist/lib/components/media/Image.svelte +209 -0
  17. package/dist/lib/components/media/Image.svelte.d.ts +52 -0
  18. package/dist/lib/components/media/Lightbox.svelte +449 -0
  19. package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
  20. package/dist/lib/components/media/MediaCarousel.svelte +359 -0
  21. package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
  22. package/dist/lib/components/media/MediaEmbed.svelte +314 -0
  23. package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
  24. package/dist/lib/components/media/MediaGrid.svelte +327 -0
  25. package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
  26. package/dist/lib/components/media/Video.svelte +455 -0
  27. package/dist/lib/components/media/Video.svelte.d.ts +81 -0
  28. package/dist/lib/components/media/index.d.ts +14 -0
  29. package/dist/lib/components/media/index.js +14 -0
  30. package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
  31. package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
  32. package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
  33. package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
  34. package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
  35. package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
  36. package/dist/lib/composables/index.d.ts +3 -0
  37. package/dist/lib/composables/index.js +4 -0
  38. package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
  39. package/dist/lib/composables/useHlsSource.svelte.js +119 -0
  40. package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
  41. package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
  42. package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
  43. package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
  44. package/dist/lib/index.d.ts +8 -2
  45. package/dist/lib/index.js +14 -1
  46. package/dist/lib/types/components.d.ts +172 -0
  47. package/dist/lib/utils/hls.d.ts +76 -0
  48. package/dist/lib/utils/hls.js +83 -0
  49. package/dist/lib/utils/media.d.ts +379 -0
  50. package/dist/lib/utils/media.js +715 -0
  51. package/package.json +6 -2
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Composable that mirrors an `HTMLMediaElement`'s state into runes and exposes
3
+ * imperative playback commands.
4
+ *
5
+ * Drives both `Video` and `Audio`, and is exported so applications can build
6
+ * their own player chrome on top of the same behaviour.
7
+ *
8
+ * The element is passed as a getter rather than a value, because the element
9
+ * does not exist on the first call — `bind:this` populates it after mount.
10
+ * Reading it inside the effect keeps the subscription reactive.
11
+ *
12
+ * @example
13
+ * ```svelte
14
+ * <script lang="ts">
15
+ * import { useMediaPlayer } from '@classic-homes/theme-svelte';
16
+ *
17
+ * let el = $state<HTMLVideoElement | null>(null);
18
+ * const player = useMediaPlayer(() => el);
19
+ * </script>
20
+ *
21
+ * <video bind:this={el} src="/clip.mp4"></video>
22
+ * <button onclick={() => player.toggle()}>
23
+ * {player.playing ? 'Pause' : 'Play'}
24
+ * </button>
25
+ * ```
26
+ */
27
+ import { clamp, timeRangesToArray } from '../utils/media.js';
28
+ export function useMediaPlayer(getElement, options = {}) {
29
+ let element = $state(null);
30
+ let playing = $state(false);
31
+ let paused = $state(true);
32
+ let ended = $state(false);
33
+ let currentTime = $state(0);
34
+ let duration = $state(0);
35
+ let buffered = $state([]);
36
+ let volume = $state(1);
37
+ let muted = $state(false);
38
+ let playbackRate = $state(1);
39
+ let waiting = $state(false);
40
+ let loading = $state(true);
41
+ let error = $state(null);
42
+ let fullscreen = $state(false);
43
+ let pip = $state(false);
44
+ let textTracks = $state([]);
45
+ let activeTextTrack = $state(null);
46
+ function readTextTracks(media) {
47
+ const list = media.textTracks;
48
+ const tracks = [];
49
+ let active = null;
50
+ for (let i = 0; i < list.length; i += 1) {
51
+ const track = list[i];
52
+ // `metadata` and `chapters` tracks are not user-facing captions
53
+ if (track.kind !== 'subtitles' && track.kind !== 'captions')
54
+ continue;
55
+ const isActive = track.mode === 'showing';
56
+ if (isActive)
57
+ active = i;
58
+ tracks.push({
59
+ index: i,
60
+ label: track.label || track.language || `Track ${i + 1}`,
61
+ language: track.language,
62
+ kind: track.kind,
63
+ active: isActive,
64
+ });
65
+ }
66
+ textTracks = tracks;
67
+ activeTextTrack = active;
68
+ }
69
+ $effect(() => {
70
+ const media = getElement();
71
+ if (!media) {
72
+ element = null;
73
+ return;
74
+ }
75
+ element = media;
76
+ const syncTime = () => {
77
+ currentTime = media.currentTime;
78
+ options.onTimeUpdate?.(media.currentTime);
79
+ };
80
+ const syncDuration = () => {
81
+ duration = Number.isFinite(media.duration) ? media.duration : 0;
82
+ loading = false;
83
+ readTextTracks(media);
84
+ };
85
+ const syncBuffered = () => {
86
+ buffered = timeRangesToArray(media.buffered);
87
+ };
88
+ const syncVolume = () => {
89
+ volume = media.volume;
90
+ muted = media.muted;
91
+ };
92
+ const handlePlay = () => {
93
+ playing = true;
94
+ paused = false;
95
+ ended = false;
96
+ };
97
+ const handlePause = () => {
98
+ playing = false;
99
+ paused = true;
100
+ // A paused element is not buffering, whatever it reported a moment ago.
101
+ waiting = false;
102
+ };
103
+ const handleEnded = () => {
104
+ playing = false;
105
+ paused = true;
106
+ ended = true;
107
+ waiting = false;
108
+ options.onEnded?.();
109
+ };
110
+ const handleWaiting = () => {
111
+ waiting = true;
112
+ };
113
+ const handlePlaying = () => {
114
+ waiting = false;
115
+ playing = true;
116
+ paused = false;
117
+ };
118
+ /**
119
+ * Any event that means the element has enough data again clears the stall.
120
+ * Listening only for `playing` leaves a paused element stuck reporting a
121
+ * stall forever, because `playing` never fires until playback resumes.
122
+ */
123
+ const handleReady = () => {
124
+ waiting = false;
125
+ };
126
+ const handleError = () => {
127
+ error = media.error;
128
+ loading = false;
129
+ waiting = false;
130
+ options.onError?.(media.error);
131
+ };
132
+ const handleRateChange = () => {
133
+ playbackRate = media.playbackRate;
134
+ };
135
+ const handleSeeking = () => {
136
+ currentTime = media.currentTime;
137
+ };
138
+ const handleFullscreenChange = () => {
139
+ fullscreen = document.fullscreenElement !== null;
140
+ };
141
+ const handleEnterPip = () => {
142
+ pip = true;
143
+ };
144
+ const handleLeavePip = () => {
145
+ pip = false;
146
+ };
147
+ const handleTrackChange = () => readTextTracks(media);
148
+ media.addEventListener('timeupdate', syncTime);
149
+ media.addEventListener('durationchange', syncDuration);
150
+ media.addEventListener('loadedmetadata', syncDuration);
151
+ media.addEventListener('progress', syncBuffered);
152
+ media.addEventListener('volumechange', syncVolume);
153
+ media.addEventListener('play', handlePlay);
154
+ media.addEventListener('pause', handlePause);
155
+ media.addEventListener('ended', handleEnded);
156
+ media.addEventListener('waiting', handleWaiting);
157
+ media.addEventListener('playing', handlePlaying);
158
+ media.addEventListener('canplay', handleReady);
159
+ media.addEventListener('canplaythrough', handleReady);
160
+ media.addEventListener('loadeddata', handleReady);
161
+ media.addEventListener('error', handleError);
162
+ media.addEventListener('ratechange', handleRateChange);
163
+ media.addEventListener('seeking', handleSeeking);
164
+ media.addEventListener('seeked', handleSeeking);
165
+ media.addEventListener('enterpictureinpicture', handleEnterPip);
166
+ media.addEventListener('leavepictureinpicture', handleLeavePip);
167
+ media.textTracks?.addEventListener?.('change', handleTrackChange);
168
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
169
+ // The element may already be past these events when the effect runs
170
+ // (cached media, or an `autoplay` that resolved during hydration).
171
+ syncVolume();
172
+ syncBuffered();
173
+ if (media.readyState >= 1)
174
+ syncDuration();
175
+ paused = media.paused;
176
+ if (!media.paused)
177
+ handlePlay();
178
+ return () => {
179
+ media.removeEventListener('timeupdate', syncTime);
180
+ media.removeEventListener('durationchange', syncDuration);
181
+ media.removeEventListener('loadedmetadata', syncDuration);
182
+ media.removeEventListener('progress', syncBuffered);
183
+ media.removeEventListener('volumechange', syncVolume);
184
+ media.removeEventListener('play', handlePlay);
185
+ media.removeEventListener('pause', handlePause);
186
+ media.removeEventListener('ended', handleEnded);
187
+ media.removeEventListener('waiting', handleWaiting);
188
+ media.removeEventListener('playing', handlePlaying);
189
+ media.removeEventListener('canplay', handleReady);
190
+ media.removeEventListener('canplaythrough', handleReady);
191
+ media.removeEventListener('loadeddata', handleReady);
192
+ media.removeEventListener('error', handleError);
193
+ media.removeEventListener('ratechange', handleRateChange);
194
+ media.removeEventListener('seeking', handleSeeking);
195
+ media.removeEventListener('seeked', handleSeeking);
196
+ media.removeEventListener('enterpictureinpicture', handleEnterPip);
197
+ media.removeEventListener('leavepictureinpicture', handleLeavePip);
198
+ media.textTracks?.removeEventListener?.('change', handleTrackChange);
199
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
200
+ };
201
+ });
202
+ // Volume and mute live in their own effect so changing them does not tear
203
+ // down and re-register every media listener.
204
+ $effect(() => {
205
+ const media = getElement();
206
+ if (!media)
207
+ return;
208
+ const nextVolume = options.volume?.();
209
+ if (nextVolume !== undefined)
210
+ media.volume = clamp(nextVolume, 0, 1);
211
+ const nextMuted = options.muted?.();
212
+ if (nextMuted !== undefined)
213
+ media.muted = nextMuted;
214
+ });
215
+ async function play() {
216
+ const media = getElement();
217
+ if (!media)
218
+ return;
219
+ try {
220
+ await media.play();
221
+ }
222
+ catch (err) {
223
+ // A play() promise rejects when autoplay is blocked, or when the call is
224
+ // superseded by a pause(). Neither is an error worth surfacing.
225
+ if (err instanceof DOMException &&
226
+ (err.name === 'AbortError' || err.name === 'NotAllowedError')) {
227
+ return;
228
+ }
229
+ throw err;
230
+ }
231
+ }
232
+ function pause() {
233
+ getElement()?.pause();
234
+ }
235
+ async function toggle() {
236
+ const media = getElement();
237
+ if (!media)
238
+ return;
239
+ if (media.paused)
240
+ await play();
241
+ else
242
+ media.pause();
243
+ }
244
+ function seek(seconds) {
245
+ const media = getElement();
246
+ if (!media)
247
+ return;
248
+ const max = Number.isFinite(media.duration) ? media.duration : seconds;
249
+ media.currentTime = clamp(seconds, 0, max);
250
+ currentTime = media.currentTime;
251
+ }
252
+ function seekBy(delta) {
253
+ const media = getElement();
254
+ if (!media)
255
+ return;
256
+ seek(media.currentTime + delta);
257
+ }
258
+ function seekToFraction(fraction) {
259
+ const media = getElement();
260
+ if (!media || !Number.isFinite(media.duration))
261
+ return;
262
+ seek(clamp(fraction, 0, 1) * media.duration);
263
+ }
264
+ function setVolume(next) {
265
+ const media = getElement();
266
+ if (!media)
267
+ return;
268
+ media.volume = clamp(next, 0, 1);
269
+ // Raising the volume from a muted state should unmute, matching how
270
+ // native players and YouTube behave.
271
+ if (media.volume > 0 && media.muted)
272
+ media.muted = false;
273
+ }
274
+ function adjustVolume(delta) {
275
+ const media = getElement();
276
+ if (!media)
277
+ return;
278
+ setVolume(media.volume + delta);
279
+ }
280
+ function toggleMute() {
281
+ const media = getElement();
282
+ if (!media)
283
+ return;
284
+ media.muted = !media.muted;
285
+ }
286
+ function setPlaybackRate(rate) {
287
+ const media = getElement();
288
+ if (!media)
289
+ return;
290
+ media.playbackRate = rate;
291
+ }
292
+ async function toggleFullscreen(container) {
293
+ const target = container ?? getElement();
294
+ if (!target)
295
+ return;
296
+ if (document.fullscreenElement) {
297
+ await document.exitFullscreen();
298
+ return;
299
+ }
300
+ if (typeof target.requestFullscreen === 'function') {
301
+ await target.requestFullscreen();
302
+ return;
303
+ }
304
+ // iOS Safari exposes fullscreen only on the video element itself.
305
+ const media = getElement();
306
+ media?.webkitEnterFullscreen?.();
307
+ }
308
+ async function togglePictureInPicture() {
309
+ const media = getElement();
310
+ if (!media || !(media instanceof HTMLVideoElement))
311
+ return;
312
+ if (!document.pictureInPictureEnabled)
313
+ return;
314
+ if (document.pictureInPictureElement) {
315
+ await document.exitPictureInPicture();
316
+ }
317
+ else {
318
+ await media.requestPictureInPicture();
319
+ }
320
+ }
321
+ function setTextTrack(index) {
322
+ const media = getElement();
323
+ if (!media)
324
+ return;
325
+ for (let i = 0; i < media.textTracks.length; i += 1) {
326
+ const track = media.textTracks[i];
327
+ if (track.kind !== 'subtitles' && track.kind !== 'captions')
328
+ continue;
329
+ track.mode = i === index ? 'showing' : 'disabled';
330
+ }
331
+ readTextTracks(media);
332
+ }
333
+ function toggleCaptions() {
334
+ if (activeTextTrack !== null) {
335
+ setTextTrack(null);
336
+ }
337
+ else if (textTracks.length > 0) {
338
+ setTextTrack(textTracks[0].index);
339
+ }
340
+ }
341
+ return {
342
+ get element() {
343
+ return element;
344
+ },
345
+ get playing() {
346
+ return playing;
347
+ },
348
+ get paused() {
349
+ return paused;
350
+ },
351
+ get ended() {
352
+ return ended;
353
+ },
354
+ get currentTime() {
355
+ return currentTime;
356
+ },
357
+ get duration() {
358
+ return duration;
359
+ },
360
+ get progress() {
361
+ return duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
362
+ },
363
+ get buffered() {
364
+ return buffered;
365
+ },
366
+ get bufferedProgress() {
367
+ if (duration <= 0)
368
+ return 0;
369
+ // Only the range containing the playhead matters for the scrub bar.
370
+ const active = buffered.find((r) => r.start <= currentTime && r.end >= currentTime);
371
+ const end = active?.end ?? (buffered.length > 0 ? buffered[buffered.length - 1].end : 0);
372
+ return clamp(end / duration, 0, 1);
373
+ },
374
+ get volume() {
375
+ return volume;
376
+ },
377
+ get muted() {
378
+ return muted;
379
+ },
380
+ get playbackRate() {
381
+ return playbackRate;
382
+ },
383
+ get waiting() {
384
+ return waiting;
385
+ },
386
+ get buffering() {
387
+ return waiting && !paused;
388
+ },
389
+ get loading() {
390
+ return loading;
391
+ },
392
+ get error() {
393
+ return error;
394
+ },
395
+ get fullscreen() {
396
+ return fullscreen;
397
+ },
398
+ get pip() {
399
+ return pip;
400
+ },
401
+ get textTracks() {
402
+ return textTracks;
403
+ },
404
+ get activeTextTrack() {
405
+ return activeTextTrack;
406
+ },
407
+ play,
408
+ pause,
409
+ toggle,
410
+ seek,
411
+ seekBy,
412
+ seekToFraction,
413
+ setVolume,
414
+ adjustVolume,
415
+ toggleMute,
416
+ setPlaybackRate,
417
+ toggleFullscreen,
418
+ togglePictureInPicture,
419
+ setTextTrack,
420
+ toggleCaptions,
421
+ };
422
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Composable providing zoom and pan for a single element, used by the
3
+ * Lightbox image viewer.
4
+ *
5
+ * Deliberately avoids `setPointerCapture`: capturing on a container swallows
6
+ * click events destined for buttons rendered over it, which would break the
7
+ * lightbox close and navigation controls. Dragging is tracked with plain
8
+ * pointer events plus an explicit pointer-id check instead.
9
+ *
10
+ * @example
11
+ * ```svelte
12
+ * <script lang="ts">
13
+ * import { useMediaZoom } from '@classic-homes/theme-svelte';
14
+ *
15
+ * const zoom = useMediaZoom();
16
+ * </script>
17
+ *
18
+ * <div use:zoom.action class="overflow-hidden">
19
+ * <img src={src} alt={alt} style={zoom.transform} />
20
+ * </div>
21
+ * ```
22
+ */
23
+ /** Options accepted by {@link useMediaZoom}. */
24
+ export interface UseMediaZoomOptions {
25
+ /** Smallest allowed scale (default `1`) */
26
+ minScale?: number;
27
+ /** Largest allowed scale (default `4`) */
28
+ maxScale?: number;
29
+ /** Multiplier applied by the zoom-in and zoom-out commands (default `1.5`) */
30
+ step?: number;
31
+ /** Scale jumped to by a double-click or double-tap (default `2.5`) */
32
+ doubleTapScale?: number;
33
+ /** Called whenever the scale changes */
34
+ onScaleChange?: (scale: number) => void;
35
+ }
36
+ /** Reactive state and commands returned by {@link useMediaZoom}. */
37
+ export interface MediaZoomApi {
38
+ readonly scale: number;
39
+ readonly x: number;
40
+ readonly y: number;
41
+ /** True while the user is dragging the content */
42
+ readonly panning: boolean;
43
+ /** True when the content is zoomed in past its natural size */
44
+ readonly zoomed: boolean;
45
+ /** Ready-to-use CSS `transform` value */
46
+ readonly transform: string;
47
+ zoomIn(): void;
48
+ zoomOut(): void;
49
+ /** Reset scale and offset back to their defaults */
50
+ reset(): void;
51
+ /** Toggle between reset and the double-tap scale */
52
+ toggle(): void;
53
+ panBy(dx: number, dy: number): void;
54
+ /** Svelte action applied to the element that should receive gestures */
55
+ action(node: HTMLElement): {
56
+ destroy(): void;
57
+ };
58
+ }
59
+ export declare function useMediaZoom(options?: UseMediaZoomOptions): MediaZoomApi;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Composable providing zoom and pan for a single element, used by the
3
+ * Lightbox image viewer.
4
+ *
5
+ * Deliberately avoids `setPointerCapture`: capturing on a container swallows
6
+ * click events destined for buttons rendered over it, which would break the
7
+ * lightbox close and navigation controls. Dragging is tracked with plain
8
+ * pointer events plus an explicit pointer-id check instead.
9
+ *
10
+ * @example
11
+ * ```svelte
12
+ * <script lang="ts">
13
+ * import { useMediaZoom } from '@classic-homes/theme-svelte';
14
+ *
15
+ * const zoom = useMediaZoom();
16
+ * </script>
17
+ *
18
+ * <div use:zoom.action class="overflow-hidden">
19
+ * <img src={src} alt={alt} style={zoom.transform} />
20
+ * </div>
21
+ * ```
22
+ */
23
+ import { clamp } from '../utils/media.js';
24
+ export function useMediaZoom(options = {}) {
25
+ // Plain locals: only read imperatively from event handlers, never tracked
26
+ const minScale = options.minScale ?? 1;
27
+ const maxScale = options.maxScale ?? 4;
28
+ const step = options.step ?? 1.5;
29
+ const doubleTapScale = options.doubleTapScale ?? 2.5;
30
+ const onScaleChange = options.onScaleChange;
31
+ let scale = $state(1);
32
+ let x = $state(0);
33
+ let y = $state(0);
34
+ let panning = $state(false);
35
+ /** Active pointers, so a two-finger pinch can be measured. */
36
+ const pointers = new Map();
37
+ let dragPointerId = null;
38
+ let dragOrigin = { x: 0, y: 0, offsetX: 0, offsetY: 0 };
39
+ let pinchStartDistance = 0;
40
+ let pinchStartScale = 1;
41
+ function setScale(next) {
42
+ const clamped = clamp(next, minScale, maxScale);
43
+ if (clamped === scale)
44
+ return;
45
+ scale = clamped;
46
+ if (clamped === minScale) {
47
+ x = 0;
48
+ y = 0;
49
+ }
50
+ onScaleChange?.(clamped);
51
+ }
52
+ function zoomIn() {
53
+ setScale(scale * step);
54
+ }
55
+ function zoomOut() {
56
+ setScale(scale / step);
57
+ }
58
+ function reset() {
59
+ x = 0;
60
+ y = 0;
61
+ if (scale !== 1) {
62
+ scale = 1;
63
+ onScaleChange?.(1);
64
+ }
65
+ }
66
+ function toggle() {
67
+ if (scale > minScale)
68
+ reset();
69
+ else
70
+ setScale(doubleTapScale);
71
+ }
72
+ function panBy(dx, dy) {
73
+ if (scale <= minScale)
74
+ return;
75
+ x += dx;
76
+ y += dy;
77
+ }
78
+ function distanceBetweenPointers() {
79
+ const [a, b] = [...pointers.values()];
80
+ if (!a || !b)
81
+ return 0;
82
+ return Math.hypot(a.x - b.x, a.y - b.y);
83
+ }
84
+ function action(node) {
85
+ let lastTapTime = 0;
86
+ function handlePointerDown(event) {
87
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
88
+ if (pointers.size === 2) {
89
+ pinchStartDistance = distanceBetweenPointers();
90
+ pinchStartScale = scale;
91
+ dragPointerId = null;
92
+ panning = false;
93
+ return;
94
+ }
95
+ if (scale > minScale) {
96
+ dragPointerId = event.pointerId;
97
+ panning = true;
98
+ dragOrigin = { x: event.clientX, y: event.clientY, offsetX: x, offsetY: y };
99
+ }
100
+ }
101
+ function handlePointerMove(event) {
102
+ if (!pointers.has(event.pointerId))
103
+ return;
104
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
105
+ if (pointers.size === 2 && pinchStartDistance > 0) {
106
+ event.preventDefault();
107
+ const ratio = distanceBetweenPointers() / pinchStartDistance;
108
+ setScale(pinchStartScale * ratio);
109
+ return;
110
+ }
111
+ if (dragPointerId !== event.pointerId || !panning)
112
+ return;
113
+ event.preventDefault();
114
+ x = dragOrigin.offsetX + (event.clientX - dragOrigin.x);
115
+ y = dragOrigin.offsetY + (event.clientY - dragOrigin.y);
116
+ }
117
+ function endPointer(event) {
118
+ pointers.delete(event.pointerId);
119
+ if (dragPointerId === event.pointerId) {
120
+ dragPointerId = null;
121
+ panning = false;
122
+ }
123
+ if (pointers.size < 2)
124
+ pinchStartDistance = 0;
125
+ }
126
+ function handleWheel(event) {
127
+ // Only intercept deliberate zoom gestures; a plain wheel should still
128
+ // scroll whatever is underneath.
129
+ if (!event.ctrlKey && !event.metaKey && scale === minScale)
130
+ return;
131
+ event.preventDefault();
132
+ setScale(scale * (event.deltaY < 0 ? 1.1 : 1 / 1.1));
133
+ }
134
+ function handleDoubleClick(event) {
135
+ event.preventDefault();
136
+ toggle();
137
+ }
138
+ /** Touch devices do not fire dblclick, so tap timing is measured directly. */
139
+ function handlePointerUpTap(event) {
140
+ if (event.pointerType !== 'touch')
141
+ return;
142
+ const now = event.timeStamp;
143
+ if (now - lastTapTime < 300) {
144
+ toggle();
145
+ lastTapTime = 0;
146
+ }
147
+ else {
148
+ lastTapTime = now;
149
+ }
150
+ }
151
+ node.addEventListener('pointerdown', handlePointerDown);
152
+ node.addEventListener('pointermove', handlePointerMove);
153
+ node.addEventListener('pointerup', endPointer);
154
+ node.addEventListener('pointerup', handlePointerUpTap);
155
+ node.addEventListener('pointercancel', endPointer);
156
+ node.addEventListener('pointerleave', endPointer);
157
+ node.addEventListener('wheel', handleWheel, { passive: false });
158
+ node.addEventListener('dblclick', handleDoubleClick);
159
+ return {
160
+ destroy() {
161
+ node.removeEventListener('pointerdown', handlePointerDown);
162
+ node.removeEventListener('pointermove', handlePointerMove);
163
+ node.removeEventListener('pointerup', endPointer);
164
+ node.removeEventListener('pointerup', handlePointerUpTap);
165
+ node.removeEventListener('pointercancel', endPointer);
166
+ node.removeEventListener('pointerleave', endPointer);
167
+ node.removeEventListener('wheel', handleWheel);
168
+ node.removeEventListener('dblclick', handleDoubleClick);
169
+ pointers.clear();
170
+ },
171
+ };
172
+ }
173
+ return {
174
+ get scale() {
175
+ return scale;
176
+ },
177
+ get x() {
178
+ return x;
179
+ },
180
+ get y() {
181
+ return y;
182
+ },
183
+ get panning() {
184
+ return panning;
185
+ },
186
+ get zoomed() {
187
+ return scale > minScale;
188
+ },
189
+ get transform() {
190
+ return `translate(${x}px, ${y}px) scale(${scale})`;
191
+ },
192
+ zoomIn,
193
+ zoomOut,
194
+ reset,
195
+ toggle,
196
+ panBy,
197
+ action,
198
+ };
199
+ }