@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -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 MAX_SCALE = 5;
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
- // Aspect ratio per image, keyed by src: explicit width/height when given, else
102
- // measured from the loaded <img>. The thumbnail reads the cover's entry and the
103
- // modal reads the current image's, so they are never confused for each other.
104
- let measured = $state<Record<string, number>>({});
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
- it ? (it.width && it.height ? it.width / it.height : measured[it.src]) : undefined;
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 / img.naturalHeight;
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(MAX_SCALE, nextScale));
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
- const onResize = () => fitToViewport(false);
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 >= MAX_SCALE - 0.001}
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">