@motion-proto/live-tokens 0.32.0 → 0.33.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/package.json
CHANGED
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
fit?: 'contain' | 'cover';
|
|
27
27
|
/** When true, shows a bottom toolbar (zoom in/out + percent) and a top-right close button, and enables wheel/drag zoom inside the open modal. When false, click anywhere closes. */
|
|
28
28
|
extended?: boolean;
|
|
29
|
+
/** Maximum zoom, as a multiple of the image's natural resolution: `1` = 100%
|
|
30
|
+
of the source's real pixels (1 source px = 1 screen px), `2` = 200%. The
|
|
31
|
+
modal always opens fitted to the viewport; this only caps how far the
|
|
32
|
+
`extended` zoom controls can magnify. Unset = the default 5x-the-fit cap.
|
|
33
|
+
An image whose fitted size already exceeds the cap simply can't be zoomed
|
|
34
|
+
in. Needs the natural pixel size (from `width`/`height`, or the loaded
|
|
35
|
+
image); until that's known the default cap applies. */
|
|
36
|
+
maxZoom?: number | undefined;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
let {
|
|
@@ -37,6 +45,7 @@
|
|
|
37
45
|
maxWidth = undefined,
|
|
38
46
|
fit = 'contain',
|
|
39
47
|
extended = false,
|
|
48
|
+
maxZoom = undefined,
|
|
40
49
|
}: Props = $props();
|
|
41
50
|
|
|
42
51
|
const items = $derived(
|
|
@@ -59,7 +68,7 @@
|
|
|
59
68
|
);
|
|
60
69
|
|
|
61
70
|
const MIN_SCALE = 1;
|
|
62
|
-
const
|
|
71
|
+
const DEFAULT_MAX_SCALE = 5;
|
|
63
72
|
const ZOOM_STEP = 1.5;
|
|
64
73
|
const TRANSITION_MS = 350;
|
|
65
74
|
const TRANSITION_EASE = 'cubic-bezier(0.65, 0, 0.35, 1)';
|
|
@@ -98,25 +107,45 @@
|
|
|
98
107
|
let outgoing = $state<GalleryImage | null>(null);
|
|
99
108
|
let navigating = false;
|
|
100
109
|
|
|
101
|
-
//
|
|
102
|
-
// measured from the loaded <img>. The thumbnail reads the cover's entry and
|
|
103
|
-
// modal reads the current image's, so they are never confused for each other.
|
|
104
|
-
|
|
110
|
+
// Natural pixel size per image, keyed by src: explicit width/height when given,
|
|
111
|
+
// else measured from the loaded <img>. The thumbnail reads the cover's entry and
|
|
112
|
+
// the modal reads the current image's, so they are never confused for each other.
|
|
113
|
+
// Aspect ratio derives from it; natural width drives the `maxZoom` cap.
|
|
114
|
+
let measured = $state<Record<string, { w: number; h: number }>>({});
|
|
105
115
|
let reducedMotion = $state(false);
|
|
106
116
|
const dur = (ms = TRANSITION_MS) => (reducedMotion ? 0 : ms);
|
|
107
117
|
|
|
118
|
+
// Reactive viewport so the fitted-stage geometry and the maxZoom cap recompute
|
|
119
|
+
// on resize (set in onMount + the resize handler).
|
|
120
|
+
let vw = $state(0);
|
|
121
|
+
let vh = $state(0);
|
|
122
|
+
|
|
108
123
|
// Pointer drag for pan (extended + zoomed only).
|
|
109
124
|
let dragState: { startX: number; startY: number; baseX: number; baseY: number; pointerId: number } | null = null;
|
|
110
125
|
let didDrag = false;
|
|
111
126
|
|
|
112
|
-
const aspectOf = (it?: GalleryImage) =>
|
|
113
|
-
|
|
127
|
+
const aspectOf = (it?: GalleryImage) => {
|
|
128
|
+
if (!it) return undefined;
|
|
129
|
+
if (it.width && it.height) return it.width / it.height;
|
|
130
|
+
const m = measured[it.src];
|
|
131
|
+
return m ? m.w / m.h : undefined;
|
|
132
|
+
};
|
|
133
|
+
const naturalWidthOf = (it?: GalleryImage) => (it ? (it.width ?? measured[it.src]?.w) : undefined);
|
|
114
134
|
const coverAspect = $derived(aspectOf(cover)); // inline thumbnail box
|
|
115
135
|
const aspect = $derived(aspectOf(current)); // open modal box
|
|
116
136
|
|
|
137
|
+
// Internal `scale` is relative to the fitted stage, so a natural-size cap is
|
|
138
|
+
// converted: maxZoom × naturalWidth ÷ fittedWidth. Floored at MIN_SCALE so an
|
|
139
|
+
// image already displayed larger than the cap just can't be zoomed in.
|
|
140
|
+
const maxScale = $derived.by(() => {
|
|
141
|
+
if (maxZoom == null) return DEFAULT_MAX_SCALE;
|
|
142
|
+
const nw = naturalWidthOf(current);
|
|
143
|
+
const fitW = viewportTarget().width;
|
|
144
|
+
if (!nw || !fitW) return DEFAULT_MAX_SCALE;
|
|
145
|
+
return Math.max(MIN_SCALE, (maxZoom * nw) / fitW);
|
|
146
|
+
});
|
|
147
|
+
|
|
117
148
|
function viewportTarget() {
|
|
118
|
-
const vw = window.innerWidth;
|
|
119
|
-
const vh = window.innerHeight;
|
|
120
149
|
const capW = vw * 0.94;
|
|
121
150
|
const capH = vh * 0.92;
|
|
122
151
|
if (!aspect) {
|
|
@@ -317,6 +346,7 @@
|
|
|
317
346
|
height: `${target.height}px`,
|
|
318
347
|
});
|
|
319
348
|
}
|
|
349
|
+
if (scale > maxScale) scale = maxScale; // a larger viewport lowers the natural-size cap
|
|
320
350
|
offset = clampOffset(offset.x, offset.y, scale);
|
|
321
351
|
applyTransform(scale, offset);
|
|
322
352
|
}
|
|
@@ -375,7 +405,7 @@
|
|
|
375
405
|
|
|
376
406
|
function record(src: string, e: Event) {
|
|
377
407
|
const img = e.currentTarget as HTMLImageElement;
|
|
378
|
-
if (img.naturalWidth && img.naturalHeight) measured[src] = img.naturalWidth
|
|
408
|
+
if (img.naturalWidth && img.naturalHeight) measured[src] = { w: img.naturalWidth, h: img.naturalHeight };
|
|
379
409
|
}
|
|
380
410
|
|
|
381
411
|
function onCoverLoad(e: Event) {
|
|
@@ -396,7 +426,7 @@
|
|
|
396
426
|
});
|
|
397
427
|
|
|
398
428
|
function zoomTo(nextScale: number, anchor?: { x: number; y: number }) {
|
|
399
|
-
const s = Math.max(MIN_SCALE, Math.min(
|
|
429
|
+
const s = Math.max(MIN_SCALE, Math.min(maxScale, nextScale));
|
|
400
430
|
if (s <= MIN_SCALE) {
|
|
401
431
|
scale = MIN_SCALE;
|
|
402
432
|
offset = { x: 0, y: 0 };
|
|
@@ -502,7 +532,13 @@
|
|
|
502
532
|
prev();
|
|
503
533
|
}
|
|
504
534
|
};
|
|
505
|
-
|
|
535
|
+
vw = window.innerWidth;
|
|
536
|
+
vh = window.innerHeight;
|
|
537
|
+
const onResize = () => {
|
|
538
|
+
vw = window.innerWidth;
|
|
539
|
+
vh = window.innerHeight;
|
|
540
|
+
fitToViewport(false);
|
|
541
|
+
};
|
|
506
542
|
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
507
543
|
reducedMotion = motionQuery.matches;
|
|
508
544
|
const onMotionChange = (e: MediaQueryListEvent) => { reducedMotion = e.matches; };
|
|
@@ -646,7 +682,7 @@
|
|
|
646
682
|
class="image-lightbox-chrome-button"
|
|
647
683
|
type="button"
|
|
648
684
|
aria-label="Zoom in"
|
|
649
|
-
disabled={scale >=
|
|
685
|
+
disabled={scale >= maxScale - 0.001}
|
|
650
686
|
onclick={() => zoomTo(scale * ZOOM_STEP)}
|
|
651
687
|
>
|
|
652
688
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|