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/overlay.css
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles of the zoom overlay, applied inside its shadow root: the CSS of the page cannot reach
|
|
3
|
+
* them, and they cannot leak out. zoom.ts imports this file with ?inline, so Vite hands it over
|
|
4
|
+
* as a string, minified in production builds. Sites customize the overlay through the --zoom-* custom
|
|
5
|
+
* properties, which cross the shadow boundary, and through the parts exposed with ::part().
|
|
6
|
+
*
|
|
7
|
+
* Lengths are in px, not rem: rem follows the font size of the page's <html>.
|
|
8
|
+
*/
|
|
9
|
+
/*
|
|
10
|
+
* The host resets every inherited property, so the page cannot change the overlay through
|
|
11
|
+
* inheritance either. Only the font family comes through: the caption uses the site's font.
|
|
12
|
+
*/
|
|
13
|
+
:host {
|
|
14
|
+
all: initial;
|
|
15
|
+
display: contents;
|
|
16
|
+
font-family: inherit;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Overlay container: a modal <dialog>, so it lives in the top layer */
|
|
20
|
+
.astro-image-zoom-overlay {
|
|
21
|
+
color-scheme: var(--zoom-color-scheme, light dark);
|
|
22
|
+
position: fixed;
|
|
23
|
+
inset: 0;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
max-width: 100%;
|
|
27
|
+
max-height: 100%;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
background-color: transparent;
|
|
32
|
+
border: none;
|
|
33
|
+
padding: 0;
|
|
34
|
+
margin: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.astro-image-zoom-overlay[open] {
|
|
38
|
+
display: flex;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Native backdrop - make transparent to use our custom one */
|
|
42
|
+
.astro-image-zoom-overlay::backdrop {
|
|
43
|
+
background: transparent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Backdrop */
|
|
47
|
+
.astro-image-zoom-backdrop {
|
|
48
|
+
position: absolute;
|
|
49
|
+
inset: 0;
|
|
50
|
+
background-color: var(--zoom-bg, light-dark(rgba(255, 255, 255, 0.98), rgba(0, 0, 0, 0.95)));
|
|
51
|
+
opacity: 0;
|
|
52
|
+
will-change: opacity;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* The slides cover the backdrop, so they show its cursor: default when closing is disabled */
|
|
56
|
+
.astro-image-zoom-overlay[data-close-backdrop="false"] .astro-image-zoom-slide,
|
|
57
|
+
.astro-image-zoom-overlay[data-close-image="false"] .astro-image-zoom-image {
|
|
58
|
+
cursor: default;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Nothing can be swiped or clicked while the overlay closes */
|
|
62
|
+
.astro-image-zoom-overlay.is-closing {
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.astro-image-zoom-overlay.is-opening .astro-image-zoom-backdrop {
|
|
67
|
+
animation: astro-image-zoom-backdrop-in var(--zoom-animation-duration, 300ms) ease-out forwards;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.astro-image-zoom-overlay.is-open .astro-image-zoom-backdrop {
|
|
71
|
+
opacity: 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.astro-image-zoom-overlay.is-closing .astro-image-zoom-backdrop {
|
|
75
|
+
animation: astro-image-zoom-backdrop-out calc(var(--zoom-animation-duration, 300ms) * 0.75) ease-out forwards;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@keyframes astro-image-zoom-backdrop-in {
|
|
79
|
+
from {
|
|
80
|
+
opacity: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
to {
|
|
84
|
+
opacity: 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@keyframes astro-image-zoom-backdrop-out {
|
|
89
|
+
from {
|
|
90
|
+
opacity: 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
to {
|
|
94
|
+
opacity: 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Content container: lets clicks reach the backdrop, interactive children opt back in */
|
|
99
|
+
.astro-image-zoom-content {
|
|
100
|
+
position: relative;
|
|
101
|
+
width: 100%;
|
|
102
|
+
height: 100%;
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.astro-image-zoom-content>* {
|
|
110
|
+
pointer-events: auto;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/*
|
|
114
|
+
* Carousel: one slide per image, scrolled and snapped natively, so touchpad and touch swipes
|
|
115
|
+
* come with inertia for free. overscroll-behavior-x stops the browser's swipe-back gesture.
|
|
116
|
+
* The vertical axis stays free: a vertical swipe closes the overlay (closeOnScroll) and the
|
|
117
|
+
* same gesture, inertia included, must go on scrolling the page. Containing it would hold the
|
|
118
|
+
* page still until the next gesture.
|
|
119
|
+
*/
|
|
120
|
+
.astro-image-zoom-track {
|
|
121
|
+
/* Scrolls from left to right whatever the page direction, like the nav buttons */
|
|
122
|
+
direction: ltr;
|
|
123
|
+
width: 100%;
|
|
124
|
+
height: 100%;
|
|
125
|
+
display: flex;
|
|
126
|
+
overflow-x: auto;
|
|
127
|
+
overflow-y: hidden;
|
|
128
|
+
scroll-snap-type: x mandatory;
|
|
129
|
+
overscroll-behavior-x: contain;
|
|
130
|
+
scrollbar-width: none;
|
|
131
|
+
touch-action: pan-x pan-y pinch-zoom;
|
|
132
|
+
/* The arrows and keys glide to the next slide; swipes keep their own inertia */
|
|
133
|
+
scroll-behavior: smooth;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* The track scrolls, so keyboard users can focus it (WCAG 2.1.1); the ring sits inside the edges */
|
|
137
|
+
.astro-image-zoom-track:focus-visible {
|
|
138
|
+
outline: 2px solid currentColor;
|
|
139
|
+
outline-offset: -4px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.astro-image-zoom-track::-webkit-scrollbar {
|
|
143
|
+
display: none;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.astro-image-zoom-slide {
|
|
147
|
+
position: relative;
|
|
148
|
+
flex: 0 0 100%;
|
|
149
|
+
height: 100%;
|
|
150
|
+
/* Border box: the padding must not widen the slide, or the carousel loses its step */
|
|
151
|
+
box-sizing: border-box;
|
|
152
|
+
margin: 0;
|
|
153
|
+
padding: var(--zoom-padding, 0);
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: center;
|
|
157
|
+
cursor: zoom-out;
|
|
158
|
+
scroll-snap-align: center;
|
|
159
|
+
/* A fast swipe cannot skip images */
|
|
160
|
+
scroll-snap-stop: always;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Image - animated from the thumbnail position */
|
|
164
|
+
.astro-image-zoom-image {
|
|
165
|
+
display: block;
|
|
166
|
+
max-width: 100%;
|
|
167
|
+
max-height: 100%;
|
|
168
|
+
width: auto;
|
|
169
|
+
height: auto;
|
|
170
|
+
object-fit: contain;
|
|
171
|
+
border-radius: var(--zoom-image-radius, 0);
|
|
172
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
173
|
+
user-select: none;
|
|
174
|
+
-webkit-user-select: none;
|
|
175
|
+
-webkit-user-drag: none;
|
|
176
|
+
cursor: zoom-out;
|
|
177
|
+
transition: opacity 0.15s ease;
|
|
178
|
+
will-change: transform, opacity;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* Loading state: after 200ms, the partly loaded image is hidden and the spinner shows */
|
|
182
|
+
.astro-image-zoom-slide.is-loading .astro-image-zoom-image {
|
|
183
|
+
animation: astro-image-zoom-hide 0s 200ms forwards;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@keyframes astro-image-zoom-hide {
|
|
187
|
+
to {
|
|
188
|
+
opacity: 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.astro-image-zoom-slide.is-loading::after {
|
|
193
|
+
content: '';
|
|
194
|
+
position: absolute;
|
|
195
|
+
top: 50%;
|
|
196
|
+
left: 50%;
|
|
197
|
+
transform: translate(-50%, -50%);
|
|
198
|
+
width: 48px;
|
|
199
|
+
height: 48px;
|
|
200
|
+
border: 4px solid light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1));
|
|
201
|
+
border-left-color: light-dark(rgba(0, 0, 0, 0.8), rgba(255, 255, 255, 0.9));
|
|
202
|
+
border-radius: 50%;
|
|
203
|
+
visibility: hidden;
|
|
204
|
+
animation:
|
|
205
|
+
astro-image-zoom-spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite,
|
|
206
|
+
astro-image-zoom-reveal 0s 200ms forwards;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@keyframes astro-image-zoom-reveal {
|
|
210
|
+
to {
|
|
211
|
+
visibility: visible;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@keyframes astro-image-zoom-spin {
|
|
216
|
+
from {
|
|
217
|
+
transform: translate(-50%, -50%) rotate(0deg);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
to {
|
|
221
|
+
transform: translate(-50%, -50%) rotate(360deg);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* Opening animation - zoom in from thumbnail */
|
|
226
|
+
.astro-image-zoom-overlay.is-opening .astro-image-zoom-slide.is-active .astro-image-zoom-image {
|
|
227
|
+
animation: astro-image-zoom-in var(--zoom-animation-duration, 300ms) cubic-bezier(0.2, 0, 0.2, 1) forwards;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* Closing animation - zoom out to thumbnail */
|
|
231
|
+
.astro-image-zoom-overlay.is-closing .astro-image-zoom-slide.is-active .astro-image-zoom-image {
|
|
232
|
+
animation: astro-image-zoom-out calc(var(--zoom-animation-duration, 300ms) * 0.75) cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/*
|
|
236
|
+
* Closed by a scroll: the image leaves the dialog for the shadow root, positioned on the page
|
|
237
|
+
* (zoom.ts sets its box), so it scrolls away with the page while it shrinks to the thumbnail
|
|
238
|
+
*/
|
|
239
|
+
.astro-image-zoom-image.is-detached {
|
|
240
|
+
position: absolute;
|
|
241
|
+
z-index: 9999999;
|
|
242
|
+
margin: 0;
|
|
243
|
+
animation: astro-image-zoom-out calc(var(--zoom-animation-duration, 300ms) * 0.75) cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@keyframes astro-image-zoom-in {
|
|
247
|
+
from {
|
|
248
|
+
transform: translate(var(--tx-from, 0), var(--ty-from, 0)) scale(var(--scale-from, 0.5));
|
|
249
|
+
clip-path: var(--clip-from, inset(0));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
to {
|
|
253
|
+
transform: translate(0, 0) scale(1);
|
|
254
|
+
clip-path: var(--clip-to, inset(0));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@keyframes astro-image-zoom-out {
|
|
259
|
+
from {
|
|
260
|
+
transform: translate(0, 0) scale(1);
|
|
261
|
+
clip-path: var(--clip-to, inset(0));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
to {
|
|
265
|
+
transform: translate(var(--tx-from, 0), var(--ty-from, 0)) scale(var(--scale-from, 0.5));
|
|
266
|
+
clip-path: var(--clip-from, inset(0));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/*
|
|
271
|
+
* Bottom stack: the caption and, below it, the navigation bar. Clicks beside them reach the
|
|
272
|
+
* slides, so they still close the zoom.
|
|
273
|
+
*/
|
|
274
|
+
.astro-image-zoom-bottom {
|
|
275
|
+
position: fixed;
|
|
276
|
+
inset-inline: 0;
|
|
277
|
+
bottom: var(--zoom-controls-offset, 20px);
|
|
278
|
+
z-index: 11;
|
|
279
|
+
display: flex;
|
|
280
|
+
flex-direction: column;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 12px;
|
|
283
|
+
padding-inline: 16px;
|
|
284
|
+
pointer-events: none;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.astro-image-zoom-bottom > * {
|
|
288
|
+
pointer-events: auto;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* Caption - above the navigation bar, or at the top of the screen; hidden while empty */
|
|
292
|
+
.astro-image-zoom-caption {
|
|
293
|
+
max-width: var(--zoom-caption-max-width, 70%);
|
|
294
|
+
margin: 0;
|
|
295
|
+
padding: 14px 20px;
|
|
296
|
+
color: var(--zoom-caption-color, #fff);
|
|
297
|
+
font-family: var(--zoom-caption-font, inherit);
|
|
298
|
+
font-size: var(--zoom-caption-font-size, 13px);
|
|
299
|
+
font-weight: 500;
|
|
300
|
+
line-height: 1.6;
|
|
301
|
+
letter-spacing: 0.01em;
|
|
302
|
+
text-align: center;
|
|
303
|
+
background-color: var(--zoom-caption-bg, rgba(0, 0, 0, 0.9));
|
|
304
|
+
border-radius: var(--zoom-caption-radius, 6px);
|
|
305
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
306
|
+
opacity: 0;
|
|
307
|
+
animation: astro-image-zoom-caption-in 0.3s ease 0.1s forwards;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.astro-image-zoom-caption:empty,
|
|
311
|
+
.astro-image-zoom-overlay[data-show-caption="false"] .astro-image-zoom-caption {
|
|
312
|
+
display: none;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* At the top, the caption stays clear of the close button in the corner */
|
|
316
|
+
.astro-image-zoom-overlay[data-caption-position="top"] .astro-image-zoom-caption {
|
|
317
|
+
position: fixed;
|
|
318
|
+
top: var(--zoom-controls-offset, 20px);
|
|
319
|
+
inset-inline: 0;
|
|
320
|
+
width: fit-content;
|
|
321
|
+
max-width: min(
|
|
322
|
+
var(--zoom-caption-max-width, 70%),
|
|
323
|
+
100% - 2 * (var(--zoom-button-size, 44px) + 2 * var(--zoom-controls-offset, 20px))
|
|
324
|
+
);
|
|
325
|
+
margin-inline: auto;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
@keyframes astro-image-zoom-caption-in {
|
|
329
|
+
from {
|
|
330
|
+
opacity: 0;
|
|
331
|
+
translate: 0 10px;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
to {
|
|
335
|
+
opacity: 1;
|
|
336
|
+
translate: 0 0;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/*
|
|
341
|
+
* Controls: the close button in the corner, and the navigation bar (previous, counter, next).
|
|
342
|
+
* Every color, size and shape comes from a --zoom-* variable.
|
|
343
|
+
*/
|
|
344
|
+
.astro-image-zoom-close,
|
|
345
|
+
.astro-image-zoom-nav {
|
|
346
|
+
display: flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
justify-content: center;
|
|
349
|
+
flex: none;
|
|
350
|
+
width: var(--zoom-button-size, 44px);
|
|
351
|
+
height: var(--zoom-button-size, 44px);
|
|
352
|
+
padding: 0;
|
|
353
|
+
color: inherit;
|
|
354
|
+
background: transparent;
|
|
355
|
+
border: 0;
|
|
356
|
+
border-radius: var(--zoom-button-radius, 999px);
|
|
357
|
+
cursor: pointer;
|
|
358
|
+
transition: background-color 0.15s ease;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.astro-image-zoom-close svg,
|
|
362
|
+
.astro-image-zoom-nav svg {
|
|
363
|
+
display: block;
|
|
364
|
+
width: 22px;
|
|
365
|
+
height: 22px;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.astro-image-zoom-close:focus-visible,
|
|
369
|
+
.astro-image-zoom-nav:focus-visible {
|
|
370
|
+
outline: 2px solid currentColor;
|
|
371
|
+
outline-offset: 2px;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* The surfaces: the close button, the bar, and in "sides" layout each arrow and the counter */
|
|
375
|
+
.astro-image-zoom-close,
|
|
376
|
+
.astro-image-zoom-toolbar,
|
|
377
|
+
.astro-image-zoom-overlay[data-navigation-layout="sides"] :is(.astro-image-zoom-nav, .astro-image-zoom-counter) {
|
|
378
|
+
border: 1px solid light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.14));
|
|
379
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.astro-image-zoom-close {
|
|
383
|
+
position: absolute;
|
|
384
|
+
top: var(--zoom-controls-offset, 20px);
|
|
385
|
+
right: var(--zoom-controls-offset, 20px);
|
|
386
|
+
z-index: 11;
|
|
387
|
+
color: var(--zoom-close-color, light-dark(rgba(0, 0, 0, 0.9), rgba(255, 255, 255, 0.92)));
|
|
388
|
+
background-color: var(--zoom-close-bg, light-dark(rgba(255, 255, 255, 0.9), rgba(30, 30, 32, 0.85)));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.astro-image-zoom-toolbar {
|
|
392
|
+
display: flex;
|
|
393
|
+
align-items: center;
|
|
394
|
+
gap: 2px;
|
|
395
|
+
padding: 4px;
|
|
396
|
+
color: var(--zoom-nav-color, light-dark(rgba(0, 0, 0, 0.9), rgba(255, 255, 255, 0.92)));
|
|
397
|
+
background-color: var(--zoom-nav-bg, light-dark(rgba(255, 255, 255, 0.9), rgba(30, 30, 32, 0.85)));
|
|
398
|
+
border-radius: calc(var(--zoom-button-radius, 999px) + 4px);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* display: flex above would beat the [hidden] of the user agent */
|
|
402
|
+
.astro-image-zoom-toolbar[hidden] {
|
|
403
|
+
display: none;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* Hover tints whatever background the button has, so custom colors keep working */
|
|
407
|
+
.astro-image-zoom-close:hover,
|
|
408
|
+
.astro-image-zoom-nav:hover {
|
|
409
|
+
--astro-image-zoom-tint: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.16));
|
|
410
|
+
background-image: linear-gradient(var(--astro-image-zoom-tint), var(--astro-image-zoom-tint));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.astro-image-zoom-nav[aria-disabled="true"] {
|
|
414
|
+
opacity: 0.35;
|
|
415
|
+
pointer-events: none;
|
|
416
|
+
cursor: default;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.astro-image-zoom-counter {
|
|
420
|
+
min-width: 4.5ch;
|
|
421
|
+
padding-inline: 6px;
|
|
422
|
+
font-size: 13px;
|
|
423
|
+
font-variant-numeric: tabular-nums;
|
|
424
|
+
line-height: 1;
|
|
425
|
+
text-align: center;
|
|
426
|
+
white-space: nowrap;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.astro-image-zoom-overlay[data-show-counter="false"] .astro-image-zoom-counter {
|
|
430
|
+
display: none;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* "sides" layout: the arrows move to the screen edges, the counter stays at the bottom */
|
|
434
|
+
.astro-image-zoom-overlay[data-navigation-layout="sides"] .astro-image-zoom-toolbar {
|
|
435
|
+
display: contents;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.astro-image-zoom-overlay[data-navigation-layout="sides"] .astro-image-zoom-toolbar[hidden] {
|
|
439
|
+
display: none;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.astro-image-zoom-overlay[data-navigation-layout="sides"] .astro-image-zoom-nav {
|
|
443
|
+
position: fixed;
|
|
444
|
+
top: 50%;
|
|
445
|
+
translate: 0 -50%;
|
|
446
|
+
color: var(--zoom-nav-color, light-dark(rgba(0, 0, 0, 0.9), rgba(255, 255, 255, 0.92)));
|
|
447
|
+
background-color: var(--zoom-nav-bg, light-dark(rgba(255, 255, 255, 0.9), rgba(30, 30, 32, 0.85)));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.astro-image-zoom-overlay[data-navigation-layout="sides"] .astro-image-zoom-prev {
|
|
451
|
+
left: var(--zoom-controls-offset, 20px);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.astro-image-zoom-overlay[data-navigation-layout="sides"] .astro-image-zoom-next {
|
|
455
|
+
right: var(--zoom-controls-offset, 20px);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.astro-image-zoom-overlay[data-navigation-layout="sides"] .astro-image-zoom-counter {
|
|
459
|
+
padding: 8px 12px;
|
|
460
|
+
color: var(--zoom-nav-color, light-dark(rgba(0, 0, 0, 0.9), rgba(255, 255, 255, 0.92)));
|
|
461
|
+
background-color: var(--zoom-nav-bg, light-dark(rgba(255, 255, 255, 0.9), rgba(30, 30, 32, 0.85)));
|
|
462
|
+
border-radius: var(--zoom-button-radius, 999px);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* Windows high contrast: the surfaces lose their backgrounds, so they get an outline */
|
|
466
|
+
@media (forced-colors: active) {
|
|
467
|
+
.astro-image-zoom-close,
|
|
468
|
+
.astro-image-zoom-toolbar,
|
|
469
|
+
.astro-image-zoom-caption {
|
|
470
|
+
border: 1px solid CanvasText;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* Mobile Responsive */
|
|
475
|
+
@media (max-width: 768px) {
|
|
476
|
+
.astro-image-zoom-caption {
|
|
477
|
+
max-width: var(--zoom-caption-max-width, 90%);
|
|
478
|
+
padding: 12px 16px;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/* Accessibility - Reduced Motion */
|
|
483
|
+
@media (prefers-reduced-motion: reduce) {
|
|
484
|
+
|
|
485
|
+
.astro-image-zoom-overlay,
|
|
486
|
+
.astro-image-zoom-image {
|
|
487
|
+
transition: none;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* The zoom animations run in no time: zoom.ts still waits for their end, which comes at once */
|
|
491
|
+
.astro-image-zoom-backdrop,
|
|
492
|
+
.astro-image-zoom-image {
|
|
493
|
+
animation-duration: 0s !important;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.astro-image-zoom-track {
|
|
497
|
+
scroll-behavior: auto;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* The entrance animation is off, so show the caption in its final state */
|
|
501
|
+
.astro-image-zoom-caption {
|
|
502
|
+
animation: none;
|
|
503
|
+
opacity: 1;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* Print - hide zoom */
|
|
508
|
+
@media print {
|
|
509
|
+
.astro-image-zoom-overlay {
|
|
510
|
+
display: none !important;
|
|
511
|
+
}
|
|
512
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-image-zoom",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Medium-style image zoom for Astro: accessible, animated from the page, with swipeable galleries",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Antonio (https://antoniohg.com)",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/antoniohg/astro-image-zoom.git",
|
|
11
|
+
"directory": "packages/astro-image-zoom"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/antoniohg/astro-image-zoom#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/antoniohg/astro-image-zoom/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"astro-component",
|
|
19
|
+
"withastro",
|
|
20
|
+
"zoom",
|
|
21
|
+
"image-zoom",
|
|
22
|
+
"image-gallery",
|
|
23
|
+
"a11y",
|
|
24
|
+
"performance"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./index.js",
|
|
28
|
+
"./ImageZoom.astro": "./ImageZoom.astro",
|
|
29
|
+
"./zoom.css": "./zoom.css"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"index.js",
|
|
33
|
+
"ImageZoom.astro",
|
|
34
|
+
"zoom.ts",
|
|
35
|
+
"overlay.css",
|
|
36
|
+
"env.d.ts",
|
|
37
|
+
"wrapImages.ts",
|
|
38
|
+
"zoom.css",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"astro": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"astro": "^7.3.3"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/wrapImages.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps the images of a rendered HTML string in accessible zoom links.
|
|
3
|
+
* Runs on the server, so without JavaScript the links still open the full-size image.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Comments (skipped) or tags; quoted attribute values may contain ">"
|
|
7
|
+
const TOKEN = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/g;
|
|
8
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
9
|
+
|
|
10
|
+
type Attributes = Map<string, string>;
|
|
11
|
+
|
|
12
|
+
function parseAttributes(source: string): Attributes {
|
|
13
|
+
const attributes: Attributes = new Map();
|
|
14
|
+
for (const [, name, double, single, bare] of source.matchAll(ATTRIBUTE)) {
|
|
15
|
+
// First occurrence wins, like in a browser
|
|
16
|
+
const key = name.toLowerCase();
|
|
17
|
+
if (!attributes.has(key)) attributes.set(key, double ?? single ?? bare ?? '');
|
|
18
|
+
}
|
|
19
|
+
return attributes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Values keep their original entities; only the quote that delimits the new attribute needs escaping
|
|
23
|
+
const escapeQuotes = (value: string): string => value.replaceAll('"', '"');
|
|
24
|
+
|
|
25
|
+
// The image URL becomes a link href, so only schemes that cannot run code are allowed:
|
|
26
|
+
// relative URLs, http(s), blob: and data:image/. javascript:, vbscript: and the like are dropped.
|
|
27
|
+
function isSafeUrl(url: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
// The URL parser strips the whitespace and control characters browsers ignore in schemes
|
|
30
|
+
const { protocol } = new URL(url, 'https://astro-image-zoom.invalid/');
|
|
31
|
+
if (protocol === 'data:') return /^[\s\0-\x1f]*data:image\//i.test(url);
|
|
32
|
+
return protocol === 'http:' || protocol === 'https:' || protocol === 'blob:';
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The zoom URL of an image: data-zoom-src wins over src, and unsafe URLs are ignored
|
|
39
|
+
const getSource = (image: Attributes): string | undefined => {
|
|
40
|
+
const src = image.get('data-zoom-src') || image.get('src');
|
|
41
|
+
return src && isSafeUrl(src) ? src : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// The link is named after the image so screen readers announce what opens
|
|
45
|
+
function wrap(html: string, image: Attributes): string {
|
|
46
|
+
const src = getSource(image);
|
|
47
|
+
if (!src) return html;
|
|
48
|
+
|
|
49
|
+
const caption = image.get('data-zoom-caption');
|
|
50
|
+
const alt = image.get('alt');
|
|
51
|
+
|
|
52
|
+
const captionAttribute = caption ? ` data-zoom-caption="${escapeQuotes(caption)}"` : '';
|
|
53
|
+
const label = alt ? `Enlarge image: ${alt}` : 'Enlarge image';
|
|
54
|
+
|
|
55
|
+
return `<a href="${escapeQuotes(src)}" data-zoom-generated${captionAttribute} aria-label="${escapeQuotes(label)}">${html}</a>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function wrapImages(html: string): string {
|
|
59
|
+
let output = '';
|
|
60
|
+
let cursor = 0;
|
|
61
|
+
let anchorDepth = 0;
|
|
62
|
+
// A <picture> is wrapped as a whole: <a> is not valid inside it
|
|
63
|
+
let picture: { start: number; image?: Attributes } | null = null;
|
|
64
|
+
|
|
65
|
+
for (const match of html.matchAll(TOKEN)) {
|
|
66
|
+
const [tag, closing, rawName, rawAttributes] = match;
|
|
67
|
+
if (!rawName) continue; // comment
|
|
68
|
+
|
|
69
|
+
const name = rawName.toLowerCase();
|
|
70
|
+
const start = match.index;
|
|
71
|
+
const end = start + tag.length;
|
|
72
|
+
|
|
73
|
+
if (name === 'a') {
|
|
74
|
+
anchorDepth = Math.max(0, anchorDepth + (closing ? -1 : 1));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Images that already live inside a link are left alone
|
|
79
|
+
if (anchorDepth > 0) continue;
|
|
80
|
+
|
|
81
|
+
if (name === 'picture') {
|
|
82
|
+
if (!closing) {
|
|
83
|
+
picture = { start };
|
|
84
|
+
} else if (picture) {
|
|
85
|
+
const { start: pictureStart, image } = picture;
|
|
86
|
+
picture = null;
|
|
87
|
+
if (image && getSource(image)) {
|
|
88
|
+
output += html.slice(cursor, pictureStart) + wrap(html.slice(pictureStart, end), image);
|
|
89
|
+
cursor = end;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (name === 'img' && !closing) {
|
|
93
|
+
const image = parseAttributes(rawAttributes);
|
|
94
|
+
if (picture) {
|
|
95
|
+
picture.image ??= image;
|
|
96
|
+
} else if (getSource(image)) {
|
|
97
|
+
output += html.slice(cursor, start) + wrap(tag, image);
|
|
98
|
+
cursor = end;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return output + html.slice(cursor);
|
|
104
|
+
}
|
package/zoom.css
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* astro-image-zoom page styles: the images on the page, not the zoom overlay.
|
|
3
|
+
*
|
|
4
|
+
* The overlay lives in a shadow root and brings its own styles (overlay.css), so no CSS of
|
|
5
|
+
* the page can reach it. Style it with the --zoom-* custom properties or with ::part().
|
|
6
|
+
*
|
|
7
|
+
* These page styles live in the `astro-image-zoom` cascade layer, so any unlayered CSS on the page
|
|
8
|
+
* overrides them without specificity fights. To control their priority among your own layers,
|
|
9
|
+
* name the layer in your layer order, e.g. `@layer reset, astro-image-zoom, components;`.
|
|
10
|
+
*/
|
|
11
|
+
@layer astro-image-zoom {
|
|
12
|
+
/* The element only groups its content, it must not add a box */
|
|
13
|
+
astro-image-zoom {
|
|
14
|
+
display: contents;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Links wrapping images: the image is the click target */
|
|
18
|
+
.astro-image-zoom-wrapper a img {
|
|
19
|
+
cursor: zoom-in;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Focus ring on the image, not on the generated link: the link is inline, so when the image
|
|
24
|
+
* is display: block its outline does not wrap the image. Customize it with
|
|
25
|
+
* --zoom-focus-outline and --zoom-focus-offset (a negative offset draws it inside the image,
|
|
26
|
+
* useful when a parent with overflow: hidden would clip it).
|
|
27
|
+
*/
|
|
28
|
+
.astro-image-zoom-wrapper a[data-zoom-generated]:focus-visible {
|
|
29
|
+
outline: none;
|
|
30
|
+
|
|
31
|
+
img {
|
|
32
|
+
outline: var(--zoom-focus-outline, 2px solid currentColor);
|
|
33
|
+
outline-offset: var(--zoom-focus-offset, 3px);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|