astro-image-zoom 0.1.0-beta.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/zoom.ts ADDED
@@ -0,0 +1,713 @@
1
+ /**
2
+ * Zoom TypeScript Module
3
+ * Medium-style zoom with accessibility and performance optimizations
4
+ */
5
+
6
+ /// <reference path="./env.d.ts" />
7
+ import overlayStyles from './overlay.css?inline';
8
+
9
+ interface ZoomImage {
10
+ src: string;
11
+ alt: string;
12
+ caption?: string;
13
+ element: HTMLElement;
14
+ }
15
+
16
+ interface ZoomState {
17
+ isOpen: boolean;
18
+ currentIndex: number;
19
+ images: ZoomImage[];
20
+ }
21
+
22
+ // One slide of the carousel: the overlay holds a slide per image, scrolled and snapped natively
23
+ interface ZoomSlide {
24
+ figure: HTMLElement;
25
+ img: HTMLImageElement;
26
+ loaded?: Promise<void>;
27
+ }
28
+
29
+ // The custom properties of the overlay. When it opens, it takes the values set around the
30
+ // <astro-image-zoom> that opened it, so each gallery can have its own
31
+ const ZOOM_VARIABLES = [
32
+ '--zoom-bg',
33
+ '--zoom-close-color',
34
+ '--zoom-close-bg',
35
+ '--zoom-nav-color',
36
+ '--zoom-nav-bg',
37
+ '--zoom-caption-color',
38
+ '--zoom-caption-bg',
39
+ '--zoom-controls-offset',
40
+ '--zoom-caption-max-width',
41
+ '--zoom-caption-font',
42
+ '--zoom-caption-font-size',
43
+ '--zoom-caption-radius',
44
+ '--zoom-padding',
45
+ '--zoom-image-radius',
46
+ '--zoom-button-size',
47
+ '--zoom-button-radius',
48
+ '--zoom-animation-duration',
49
+ '--zoom-color-scheme'
50
+ ];
51
+
52
+ const CAPTION_POSITIONS = ['bottom', 'top'];
53
+ const NAVIGATION_LAYOUTS = ['bar', 'sides'];
54
+
55
+ // The CSS animations of the overlay that open() and close() wait for
56
+ const OPEN_ANIMATIONS = ['astro-image-zoom-in', 'astro-image-zoom-backdrop-in'];
57
+ const CLOSE_ANIMATIONS = ['astro-image-zoom-out', 'astro-image-zoom-backdrop-out'];
58
+ // How long (ms) a requested slide counts as the target while the smooth scroll runs
59
+ const SCROLL_TARGET_TTL = 500;
60
+ // Vertical wheel deltas below this many pixels do not close the overlay
61
+ const WHEEL_CLOSE_DELTA = 4;
62
+ // After a horizontal wheel event (ms), vertical jitter of the same touchpad swipe is ignored
63
+ const WHEEL_HORIZONTAL_GRACE = 250;
64
+
65
+ const OVERLAY_ID = 'astro-image-zoom-global-overlay';
66
+
67
+ const OVERLAY_HTML = `
68
+ <dialog class="astro-image-zoom-overlay" part="overlay" aria-label="Image zoom overlay">
69
+ <div class="astro-image-zoom-backdrop" part="backdrop" aria-hidden="true"></div>
70
+ <div class="astro-image-zoom-content" role="document">
71
+ <button class="astro-image-zoom-close" part="close" aria-label="Close zoom overlay" type="button">
72
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
73
+ <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
74
+ </svg>
75
+ </button>
76
+ <div class="astro-image-zoom-track" part="track" tabindex="0" role="group" aria-label="Images"></div>
77
+ <div class="astro-image-zoom-bottom">
78
+ <p class="astro-image-zoom-caption" part="caption" aria-live="polite"></p>
79
+ <div class="astro-image-zoom-toolbar" part="toolbar">
80
+ <button class="astro-image-zoom-nav astro-image-zoom-prev" part="nav prev" aria-label="Previous image" type="button">
81
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
82
+ <path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
83
+ </svg>
84
+ </button>
85
+ <span class="astro-image-zoom-counter" part="counter"></span>
86
+ <button class="astro-image-zoom-nav astro-image-zoom-next" part="nav next" aria-label="Next image" type="button">
87
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
88
+ <path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
89
+ </svg>
90
+ </button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </dialog>`;
95
+
96
+ // Resolves when the named CSS animations of the element or its descendants end or are cancelled,
97
+ // and at once when none runs. The durations live in CSS only: with reduced motion they are 0s.
98
+ function animationsFinished(element: Element, names: string[]): Promise<unknown> {
99
+ const animations = element
100
+ .getAnimations({ subtree: true })
101
+ .filter((animation) => animation instanceof CSSAnimation && names.includes(animation.animationName));
102
+ return Promise.allSettled(animations.map(({ finished }) => finished));
103
+ }
104
+
105
+ /**
106
+ * Returns the overlay shared by every zoom instance, creating it on first use.
107
+ * Built on the client so the page HTML never repeats its id, however many
108
+ * <ImageZoom> components it has; without JavaScript the links simply open the image.
109
+ */
110
+ function getOverlay(): HTMLDialogElement {
111
+ const existing = document.getElementById(OVERLAY_ID)?.shadowRoot?.querySelector('dialog');
112
+ if (existing) return existing;
113
+
114
+ // The dialog lives in the shadow root of a host element: the CSS of the page cannot reach it
115
+ const host = document.createElement('astro-image-zoom-overlay');
116
+ host.id = OVERLAY_ID;
117
+ const root = host.attachShadow({ mode: 'open' });
118
+ const style = document.createElement('style');
119
+ style.textContent = overlayStyles;
120
+ const template = document.createElement('template');
121
+ template.innerHTML = OVERLAY_HTML.trim();
122
+ root.append(style, template.content);
123
+ document.body.append(host);
124
+ return root.querySelector('dialog')!;
125
+ }
126
+
127
+ class Zoom {
128
+ private wrapper: HTMLElement;
129
+ // The element that holds the overlay's shadow root, and its --zoom-* variables
130
+ private host!: HTMLElement;
131
+ private overlay!: HTMLDialogElement;
132
+ private track!: HTMLElement;
133
+ private captionElement!: HTMLElement;
134
+ private closeButton!: HTMLButtonElement;
135
+ private toolbar!: HTMLElement;
136
+ private counter!: HTMLElement;
137
+ private prevButton!: HTMLButtonElement;
138
+ private nextButton!: HTMLButtonElement;
139
+ private slides: ZoomSlide[] = [];
140
+
141
+ private state: ZoomState = {
142
+ isOpen: false,
143
+ currentIndex: 0,
144
+ images: []
145
+ };
146
+
147
+ private options = {
148
+ keyboardNavigation: true,
149
+ closeOnBackdrop: true,
150
+ closeOnImage: true,
151
+ closeOnScroll: true,
152
+ showNavigation: true
153
+ };
154
+
155
+ private isClosing = false;
156
+ // True from open() until the image is ready and the opening animation starts
157
+ private openPending = false;
158
+ private previousFocus: HTMLElement | null = null;
159
+ private touchStartX = 0;
160
+ private touchStartY = 0;
161
+ private lastHorizontalWheel = 0;
162
+ private scrollTarget = 0;
163
+ private scrollTargetUntil = 0;
164
+ private openId = 0;
165
+
166
+ // Listeners on the page's links live as long as the instance
167
+ private controller = new AbortController();
168
+ // Listeners on the shared overlay live only while this instance has it open
169
+ private openController: AbortController | null = null;
170
+
171
+ constructor(wrapper: HTMLElement) {
172
+ this.wrapper = wrapper;
173
+
174
+ // Get configuration from data attributes
175
+ this.options.keyboardNavigation = wrapper.dataset.keyboard !== 'false';
176
+ this.options.closeOnBackdrop = wrapper.dataset.closeBackdrop !== 'false';
177
+ this.options.closeOnImage = wrapper.dataset.closeImage !== 'false';
178
+ this.options.closeOnScroll = wrapper.dataset.closeScroll !== 'false';
179
+ this.options.showNavigation = wrapper.dataset.showNav !== 'false';
180
+
181
+ // One overlay is shared by every zoom instance on the page
182
+ this.overlay = getOverlay();
183
+ this.host = (this.overlay.getRootNode() as ShadowRoot).host as HTMLElement;
184
+ this.track = this.overlay.querySelector('.astro-image-zoom-track')!;
185
+ this.captionElement = this.overlay.querySelector('.astro-image-zoom-caption')!;
186
+ this.closeButton = this.overlay.querySelector('.astro-image-zoom-close')!;
187
+ this.toolbar = this.overlay.querySelector('.astro-image-zoom-toolbar')!;
188
+ this.counter = this.overlay.querySelector('.astro-image-zoom-counter')!;
189
+ this.prevButton = this.overlay.querySelector('.astro-image-zoom-prev')!;
190
+ this.nextButton = this.overlay.querySelector('.astro-image-zoom-next')!;
191
+
192
+ this.setupEventListeners();
193
+ }
194
+
195
+ private collectImages(): void {
196
+ const links = this.wrapper.querySelectorAll<HTMLAnchorElement>(
197
+ 'a[data-zoom-generated], a[data-zoom], a:has(img)'
198
+ );
199
+
200
+ this.state.images = Array.from(links)
201
+ .map((anchor) => {
202
+ const img = anchor.querySelector('img');
203
+ return {
204
+ src: anchor.href,
205
+ alt: img?.alt || '',
206
+ caption: anchor.dataset.zoomCaption || anchor.title || '',
207
+ element: anchor
208
+ };
209
+ })
210
+ .filter(({ src }) => src && src !== window.location.origin + '/' && src !== 'about:blank');
211
+ }
212
+
213
+ private setupEventListeners(): void {
214
+ // One delegated listener: links added later are picked up when clicked.
215
+ // Links already handle keyboard navigation natively (Enter key)
216
+ this.wrapper.addEventListener(
217
+ 'click',
218
+ (e) => {
219
+ // Let the browser handle "open in new tab" and similar clicks
220
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
221
+
222
+ this.collectImages();
223
+ const link = (e.target as Element).closest('a');
224
+ const index = this.state.images.findIndex(({ element }) => element === link);
225
+ if (index === -1) return;
226
+
227
+ e.preventDefault();
228
+ this.open(index);
229
+ },
230
+ { signal: this.controller.signal }
231
+ );
232
+ }
233
+
234
+ // The overlay is shared, so only the instance that has it open may listen to it
235
+ private bindOverlayListeners(signal: AbortSignal): void {
236
+ // Handle native dialog cancel (Escape key), also while the image is loading
237
+ this.overlay.addEventListener('cancel', this.handleCancel, { signal });
238
+
239
+ this.closeButton.addEventListener('click', () => this.close(), { signal });
240
+
241
+ if (this.options.showNavigation) {
242
+ this.prevButton.addEventListener('click', () => this.prev(), { signal });
243
+ this.nextButton.addEventListener('click', () => this.next(), { signal });
244
+ }
245
+
246
+ // The slides cover the backdrop, so they receive its clicks. Click on the image closes
247
+ // (like Medium), click beside it counts as a backdrop click
248
+ this.track.addEventListener(
249
+ 'click',
250
+ (e) => {
251
+ const onImage = (e.target as Element).closest('.astro-image-zoom-image') !== null;
252
+ if (onImage ? this.options.closeOnImage : this.options.closeOnBackdrop) this.close();
253
+ },
254
+ { signal }
255
+ );
256
+ }
257
+
258
+ // The image of the slide on screen
259
+ private get imageElement(): HTMLImageElement {
260
+ return this.slides[this.state.currentIndex].img;
261
+ }
262
+
263
+ private async open(index: number): Promise<void> {
264
+ if (this.state.isOpen) return;
265
+
266
+ // Identifies this opening; close() increments it to cancel a pending open
267
+ const currentOpenId = ++this.openId;
268
+
269
+ // Store current focused element
270
+ this.previousFocus = document.activeElement as HTMLElement;
271
+
272
+ // Update state
273
+ this.state.isOpen = true;
274
+ this.state.currentIndex = index;
275
+
276
+ // The variables of this gallery (the theme and animationDuration props among them)
277
+ this.inheritVariables();
278
+
279
+ // Get source image and position
280
+ const sourceImg = this.state.images[index].element.querySelector('img')!;
281
+ const sourceRect = sourceImg.getBoundingClientRect();
282
+
283
+ this.buildSlides();
284
+ this.renderActive(index);
285
+ // Hidden until the image is ready, so the FLIP animation starts from a clean frame. Before any
286
+ // layout, so its opacity transition does not run: it would show the image for a frame
287
+ this.imageElement.style.opacity = '0';
288
+
289
+ // Listen to the overlay from now until close(), also while the image is loading
290
+ this.openController = new AbortController();
291
+ const { signal } = this.openController;
292
+ this.bindOverlayListeners(signal);
293
+
294
+ // Show overlay and prevent body scroll
295
+ this.overlay.showModal();
296
+ this.overlay.setAttribute('data-close-backdrop', String(this.options.closeOnBackdrop));
297
+ this.overlay.setAttribute('data-close-image', String(this.options.closeOnImage));
298
+ // Read on each opening, so a page can change them after load
299
+ const captionPosition = this.wrapper.dataset.captionPosition ?? '';
300
+ this.overlay.setAttribute(
301
+ 'data-caption-position',
302
+ CAPTION_POSITIONS.includes(captionPosition) ? captionPosition : 'bottom'
303
+ );
304
+ this.overlay.setAttribute('data-show-caption', String(this.wrapper.dataset.showCaption !== 'false'));
305
+ this.overlay.setAttribute('data-show-counter', String(this.wrapper.dataset.showCounter !== 'false'));
306
+ const navigationLayout = this.wrapper.dataset.navigationLayout ?? '';
307
+ this.overlay.setAttribute(
308
+ 'data-navigation-layout',
309
+ NAVIGATION_LAYOUTS.includes(navigationLayout) ? navigationLayout : 'bar'
310
+ );
311
+ document.body.style.overflow = 'hidden';
312
+
313
+ // Show the slide of the image, without letting a swipe move it away while it loads
314
+ this.track.style.overflowX = 'hidden';
315
+ this.jumpToSlide(index);
316
+
317
+ this.openPending = true;
318
+ await this.loadSlide(index);
319
+
320
+ // Closed while loading: close() already restored everything
321
+ if (currentOpenId !== this.openId) return;
322
+ this.openPending = false;
323
+ this.track.style.overflowX = '';
324
+ this.imageElement.style.opacity = '1';
325
+ this.preloadNeighbors(index);
326
+
327
+ // FLIP Animation
328
+ const finalRect = this.imageElement.getBoundingClientRect();
329
+ const transform = this.calculateFlipTransform(sourceRect, finalRect);
330
+
331
+ // Set CSS variables for animation
332
+ this.setAnimationVariables(transform);
333
+
334
+ // Hide thumbnail instantly and trigger CSS animation simultaneously
335
+ this.hideThumbnail(sourceImg);
336
+ this.overlay.classList.add('is-opening');
337
+
338
+ // After the animation, switch to the is-open state
339
+ void animationsFinished(this.overlay, OPEN_ANIMATIONS).then(() => {
340
+ if (currentOpenId !== this.openId) return;
341
+ this.overlay.classList.remove('is-opening');
342
+ this.overlay.classList.add('is-open');
343
+ });
344
+
345
+ // Add keyboard listener
346
+ if (this.options.keyboardNavigation) {
347
+ document.addEventListener('keydown', this.handleKeydown, { signal });
348
+ }
349
+
350
+ // Horizontal swipes scroll the track natively; the slide on screen follows the scroll
351
+ this.track.addEventListener('scroll', this.handleScroll, { passive: true, signal });
352
+
353
+ // Smooth close on vertical scroll/wheel (like Medium - non-blocking)
354
+ if (this.options.closeOnScroll) {
355
+ this.overlay.addEventListener('touchstart', this.handleTouchStart, { passive: true, signal });
356
+ this.overlay.addEventListener('touchmove', this.handleTouchMove, { passive: true, signal });
357
+ this.overlay.addEventListener('wheel', this.handleWheel, { passive: true, signal });
358
+ }
359
+
360
+ // The modal dialog traps the focus natively
361
+ this.closeButton.focus();
362
+ }
363
+
364
+ private close(byScroll = false): void {
365
+ if (!this.state.isOpen || this.isClosing) return;
366
+
367
+ this.isClosing = true;
368
+ this.state.isOpen = false;
369
+
370
+ // Cancel a pending open() that is still waiting for the image to load
371
+ this.openId++;
372
+ const openWasPending = this.openPending;
373
+ this.openPending = false;
374
+
375
+ // Release the shared overlay: no listener of this instance survives the close
376
+ this.openController?.abort();
377
+ this.openController = null;
378
+
379
+ // Get source image BEFORE any DOM changes
380
+ const sourceElement = this.state.images[this.state.currentIndex].element;
381
+ const sourceImg = sourceElement.querySelector('img')!;
382
+
383
+ // Closed while loading: the opening animation never ran, so close without animating
384
+ if (openWasPending) {
385
+ this.finalizeClose(sourceImg);
386
+ return;
387
+ }
388
+
389
+ // Stop a smooth scroll still running, so the image closes from a still position
390
+ this.scrollTargetUntil = 0;
391
+ this.jumpToSlide(this.state.currentIndex);
392
+
393
+ this.overlay.classList.remove('is-open', 'is-opening');
394
+ this.overlay.classList.add('is-closing');
395
+
396
+ const startRect = this.imageElement.getBoundingClientRect();
397
+ const targetRect = sourceImg.getBoundingClientRect();
398
+
399
+ // Calculate transform for closing animation
400
+ const transform = this.calculateFlipTransform(targetRect, startRect);
401
+
402
+ // Set CSS variables for closing animation
403
+ this.setAnimationVariables(transform);
404
+
405
+ // If closed by scroll, unlock scroll immediately and use special animation
406
+ if (byScroll) {
407
+ document.body.style.overflow = '';
408
+ this.animateScrollClose(startRect, sourceImg);
409
+ } else {
410
+ void animationsFinished(this.overlay, CLOSE_ANIMATIONS).then(() => this.finalizeClose(sourceImg));
411
+ }
412
+ }
413
+
414
+ private next(): void {
415
+ this.scrollToSlide(this.slideIndex + 1);
416
+ }
417
+
418
+ private prev(): void {
419
+ this.scrollToSlide(this.slideIndex - 1);
420
+ }
421
+
422
+ // Where the slide on screen is heading: quick presses keep adding up while the smooth
423
+ // scroll of the previous one still runs
424
+ private get slideIndex(): number {
425
+ return performance.now() < this.scrollTargetUntil ? this.scrollTarget : this.state.currentIndex;
426
+ }
427
+
428
+ private scrollToSlide(index: number): void {
429
+ if (!this.slides[index]) return;
430
+
431
+ this.scrollTarget = index;
432
+ this.scrollTargetUntil = performance.now() + SCROLL_TARGET_TTL;
433
+ // Smooth, unless the user prefers reduced motion: the track's scroll-behavior decides
434
+ this.track.scrollTo({ left: index * this.track.clientWidth });
435
+ }
436
+
437
+ // Shows a slide without the smooth scroll
438
+ private jumpToSlide(index: number): void {
439
+ this.track.scrollTo({ left: index * this.track.clientWidth, behavior: 'instant' });
440
+ }
441
+
442
+ private buildSlides(): void {
443
+ this.slides = this.state.images.map(({ alt }) => {
444
+ const figure = document.createElement('figure');
445
+ figure.className = 'astro-image-zoom-slide';
446
+ figure.part.add('slide');
447
+ const img = document.createElement('img');
448
+ img.className = 'astro-image-zoom-image';
449
+ img.part.add('image');
450
+ img.alt = alt;
451
+ figure.append(img);
452
+ return { figure, img };
453
+ });
454
+
455
+ this.track.replaceChildren(...this.slides.map(({ figure }) => figure));
456
+ }
457
+
458
+ // Loads the image of a slide once; the spinner appears only if the wait is noticeable.
459
+ // Resolves when the image is decoded, or failed: a broken image is shown as it is.
460
+ private loadSlide(index: number): Promise<void> {
461
+ const slide = this.slides[index];
462
+
463
+ slide.loaded ??= (async () => {
464
+ slide.img.src = this.state.images[index].src;
465
+ // The CSS shows the spinner only if the wait lasts
466
+ slide.figure.classList.add('is-loading');
467
+
468
+ try {
469
+ await slide.img.decode();
470
+ } catch {
471
+ // Load error: nothing to do
472
+ }
473
+
474
+ slide.figure.classList.remove('is-loading');
475
+ })();
476
+
477
+ return slide.loaded;
478
+ }
479
+
480
+ private preloadNeighbors(index: number): void {
481
+ for (const neighbor of [index - 1, index + 1]) {
482
+ if (this.slides[neighbor]) void this.loadSlide(neighbor);
483
+ }
484
+ }
485
+
486
+ // The scroll moved another slide to the center of the overlay
487
+ private handleScroll = (): void => {
488
+ const width = this.track.clientWidth;
489
+ if (!width) return;
490
+
491
+ const index = Math.round(this.track.scrollLeft / width);
492
+ if (index === this.state.currentIndex || !this.slides[index]) return;
493
+
494
+ // Only the thumbnail of the image on screen stays hidden
495
+ this.showThumbnail(this.getThumbnail(this.state.currentIndex));
496
+ this.hideThumbnail(this.getThumbnail(index));
497
+
498
+ this.renderActive(index);
499
+ void this.loadSlide(index);
500
+ this.preloadNeighbors(index);
501
+ }
502
+
503
+ // Marks the slide on screen and updates what depends on it: caption and buttons
504
+ private renderActive(index: number): void {
505
+ this.state.currentIndex = index;
506
+
507
+ this.slides.forEach(({ figure }, i) => {
508
+ figure.classList.toggle('is-active', i === index);
509
+ figure.setAttribute('aria-hidden', String(i !== index));
510
+ });
511
+
512
+ this.captionElement.textContent = this.state.images[index].caption || '';
513
+ this.updateNavigationButtons();
514
+ }
515
+
516
+ // The navigation bar: arrows and counter, only for galleries
517
+ private updateNavigationButtons(): void {
518
+ const total = this.state.images.length;
519
+ this.toolbar.hidden = !this.options.showNavigation || total < 2;
520
+ if (this.toolbar.hidden) return;
521
+
522
+ const index = this.state.currentIndex;
523
+ this.counter.textContent = `${index + 1} / ${total}`;
524
+ this.prevButton.setAttribute('aria-disabled', String(index === 0));
525
+ this.nextButton.setAttribute('aria-disabled', String(index === total - 1));
526
+ }
527
+
528
+ private handleKeydown = (e: KeyboardEvent): void => {
529
+ if (!this.state.isOpen) return;
530
+
531
+ switch (e.key) {
532
+ case 'ArrowLeft':
533
+ e.preventDefault();
534
+ this.prev();
535
+ break;
536
+ case 'ArrowRight':
537
+ e.preventDefault();
538
+ this.next();
539
+ break;
540
+ }
541
+ }
542
+
543
+ private handleCancel = (e: Event): void => {
544
+ e.preventDefault(); // Prevent immediate closing
545
+ this.close(); // Trigger animated close
546
+ }
547
+
548
+ private handleTouchStart = (e: TouchEvent): void => {
549
+ // Ignore multi-touch (pinch to zoom)
550
+ if (e.touches.length > 1) return;
551
+
552
+ this.touchStartX = e.touches[0].clientX;
553
+ this.touchStartY = e.touches[0].clientY;
554
+ }
555
+
556
+ private handleWheel = (e: WheelEvent): void => {
557
+ // Horizontal gestures scroll the track natively; the vertical jitter of a touchpad
558
+ // swipe must not close the overlay
559
+ if (Math.abs(e.deltaX) * 2 >= Math.abs(e.deltaY)) {
560
+ this.lastHorizontalWheel = e.timeStamp;
561
+ return;
562
+ }
563
+
564
+ if (Math.abs(e.deltaY) < WHEEL_CLOSE_DELTA) return;
565
+ if (e.timeStamp - this.lastHorizontalWheel < WHEEL_HORIZONTAL_GRACE) return;
566
+ this.close(true);
567
+ }
568
+
569
+ private handleTouchMove = (e: TouchEvent): void => {
570
+ // Ignore multi-touch (pinch to zoom)
571
+ if (e.touches.length > 1) return;
572
+
573
+ const touch = e.touches[0];
574
+ const deltaX = Math.abs(touch.clientX - this.touchStartX);
575
+ const deltaY = Math.abs(touch.clientY - this.touchStartY);
576
+
577
+ // Only trigger close on vertical scroll (not horizontal swipes)
578
+ if (deltaY > deltaX && deltaY > 10) {
579
+ this.close(true);
580
+ }
581
+ }
582
+
583
+ private calculateFlipTransform(sourceRect: DOMRect, finalRect: DOMRect): { x: number, y: number, scale: number, clipPath: string } {
584
+ const scaleX = sourceRect.width / finalRect.width;
585
+ const scaleY = sourceRect.height / finalRect.height;
586
+ const scale = Math.max(scaleX, scaleY); // Use max to fill thumbnail
587
+
588
+ const translateX = sourceRect.left + sourceRect.width / 2 - (finalRect.left + finalRect.width / 2);
589
+ const translateY = sourceRect.top + sourceRect.height / 2 - (finalRect.top + finalRect.height / 2);
590
+
591
+ // Calculate clip-path to hide parts that extend beyond the thumbnail
592
+ // The scaled image is larger than the thumbnail, so we clip the excess
593
+ const scaledWidth = finalRect.width * scale;
594
+ const scaledHeight = finalRect.height * scale;
595
+
596
+ const clipX = (scaledWidth - sourceRect.width) / 2;
597
+ const clipY = (scaledHeight - sourceRect.height) / 2;
598
+
599
+ // Inset values relative to the element's own dimensions
600
+ const insetTop = clipY / scale;
601
+ const insetRight = clipX / scale;
602
+ const insetBottom = clipY / scale;
603
+ const insetLeft = clipX / scale;
604
+
605
+ const clipPath = `inset(${insetTop}px ${insetRight}px ${insetBottom}px ${insetLeft}px)`;
606
+
607
+ return { x: translateX, y: translateY, scale, clipPath };
608
+ }
609
+
610
+
611
+ public destroy(): void {
612
+ // Remove the listeners on the page's links
613
+ this.controller.abort();
614
+
615
+ // Close if open (also releases the overlay listeners)
616
+ if (this.state.isOpen) {
617
+ this.close();
618
+ }
619
+ }
620
+
621
+ // Copies the --zoom-* values around the wrapper to the overlay's host, which lives in <body> and
622
+ // would otherwise only see the ones set on :root. A previous opening's values are cleared too.
623
+ // On the host, they also reach the image that leaves the dialog when a scroll closes it.
624
+ private inheritVariables(): void {
625
+ const styles = getComputedStyle(this.wrapper);
626
+
627
+ for (const name of ZOOM_VARIABLES) {
628
+ const value = styles.getPropertyValue(name).trim();
629
+ if (value) {
630
+ this.host.style.setProperty(name, value);
631
+ } else {
632
+ this.host.style.removeProperty(name);
633
+ }
634
+ }
635
+ }
636
+
637
+ // The image shrinks back to the thumbnail with the closing animation (its variables are set),
638
+ // but out of the dialog and positioned on the page, so it scrolls away with it
639
+ private animateScrollClose(startRect: DOMRect, sourceImg: HTMLImageElement): void {
640
+ const image = this.imageElement;
641
+ image.style.top = `${startRect.top + window.scrollY}px`;
642
+ image.style.left = `${startRect.left + window.scrollX}px`;
643
+ image.style.width = `${startRect.width}px`;
644
+ image.style.height = `${startRect.height}px`;
645
+ image.classList.add('is-detached');
646
+
647
+ // It stays in the shadow root: it keeps the overlay styles and the CSS of the page still
648
+ // cannot reach it
649
+ this.overlay.getRootNode().appendChild(image);
650
+
651
+ void animationsFinished(image, CLOSE_ANIMATIONS).then(() => {
652
+ // finalizeClose() discards the slides, so the image does not go back to its slide
653
+ image.remove();
654
+ this.finalizeClose(sourceImg);
655
+ });
656
+ }
657
+
658
+ private setAnimationVariables(transform: { x: number, y: number, scale: number, clipPath: string }): void {
659
+ this.imageElement.style.setProperty('--tx-from', `${transform.x}px`);
660
+ this.imageElement.style.setProperty('--ty-from', `${transform.y}px`);
661
+ this.imageElement.style.setProperty('--scale-from', transform.scale.toString());
662
+ this.imageElement.style.setProperty('--clip-from', transform.clipPath);
663
+ this.imageElement.style.setProperty('--clip-to', 'inset(0px)');
664
+ }
665
+
666
+ private getThumbnail(index: number): HTMLImageElement | null {
667
+ return this.state.images[index]?.element.querySelector('img') ?? null;
668
+ }
669
+
670
+ // The thumbnail is hidden while its image is shown in the overlay
671
+ private hideThumbnail(img: HTMLImageElement | null): void {
672
+ if (!img) return;
673
+ img.style.transition = 'none';
674
+ img.style.opacity = '0';
675
+ img.style.pointerEvents = 'none';
676
+ }
677
+
678
+ private showThumbnail(img: HTMLImageElement | null): void {
679
+ if (!img) return;
680
+ img.style.opacity = '';
681
+ img.style.pointerEvents = '';
682
+
683
+ // Force reflow then restore transition, so the thumbnail reappears without fading
684
+ void img.offsetHeight;
685
+ img.style.transition = '';
686
+ }
687
+
688
+ private finalizeClose(sourceImg: HTMLImageElement): void {
689
+ // Restore the thumbnail as the overlay goes away - same frame
690
+ this.showThumbnail(sourceImg);
691
+
692
+ this.overlay.close();
693
+ this.overlay.classList.remove('is-closing');
694
+ document.body.style.overflow = '';
695
+
696
+ // The slides belong to the instance that opened the overlay: discard them
697
+ this.track.replaceChildren();
698
+ this.track.style.overflowX = '';
699
+ this.captionElement.textContent = '';
700
+ this.slides = [];
701
+
702
+ // Restore focus
703
+ if (this.previousFocus) {
704
+ this.previousFocus.focus({ preventScroll: true });
705
+ this.previousFocus = null;
706
+ }
707
+
708
+ this.isClosing = false;
709
+ }
710
+ }
711
+
712
+ // Export class for advanced usage
713
+ export { Zoom };