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/ImageZoom.astro +150 -0
- package/LICENSE +21 -0
- package/README.md +467 -0
- package/env.d.ts +6 -0
- package/index.js +7 -0
- package/overlay.css +512 -0
- package/package.json +47 -0
- package/wrapImages.ts +104 -0
- package/zoom.css +36 -0
- package/zoom.ts +713 -0
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 };
|