@hyperframes/studio 0.6.2 → 0.6.4

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.
@@ -334,24 +334,43 @@ export function Section({
334
334
  icon,
335
335
  children,
336
336
  accessory,
337
+ defaultCollapsed = false,
337
338
  }: {
338
339
  title: string;
339
340
  icon: ReactNode;
340
341
  children: ReactNode;
341
342
  accessory?: ReactNode;
343
+ defaultCollapsed?: boolean;
342
344
  }) {
345
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
346
+
343
347
  return (
344
- <section className="min-w-0 border-t border-neutral-800/80 px-4 py-4">
345
- <div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-2">
348
+ <section className="min-w-0 border-t border-neutral-800/80">
349
+ <button
350
+ type="button"
351
+ onClick={() => setCollapsed((v) => !v)}
352
+ className="flex w-full items-center justify-between gap-2 px-4 py-3"
353
+ >
346
354
  <div className="flex min-w-0 items-center gap-2.5">
347
355
  <span className="flex-shrink-0 text-neutral-500">{icon}</span>
348
356
  <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
349
357
  {title}
350
358
  </h3>
351
359
  </div>
352
- {accessory}
353
- </div>
354
- {children}
360
+ <div className="flex items-center gap-2">
361
+ {accessory}
362
+ <svg
363
+ width="10"
364
+ height="10"
365
+ viewBox="0 0 10 10"
366
+ fill="currentColor"
367
+ className={`flex-shrink-0 text-neutral-500 transition-transform ${collapsed ? "-rotate-90" : ""}`}
368
+ >
369
+ <path d="M2 3l3 4 3-4z" />
370
+ </svg>
371
+ </div>
372
+ </button>
373
+ {!collapsed && <div className="px-4 pb-4">{children}</div>}
355
374
  </section>
356
375
  );
357
376
  }
@@ -109,7 +109,7 @@ export function StyleSections({
109
109
  return (
110
110
  <>
111
111
  {isFlex && (
112
- <Section title="Flex" icon={<Layers size={15} />}>
112
+ <Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
113
113
  <div className="space-y-4">
114
114
  <SegmentedControl
115
115
  disabled={styleEditingDisabled}
@@ -154,7 +154,7 @@ export function StyleSections({
154
154
  )}
155
155
 
156
156
  {hasVisualBackground && (
157
- <Section title="Radius" icon={<Settings size={15} />}>
157
+ <Section title="Radius" icon={<Settings size={15} />} defaultCollapsed>
158
158
  <SliderControl
159
159
  value={radiusValue}
160
160
  min={0}
@@ -168,7 +168,7 @@ export function StyleSections({
168
168
  </Section>
169
169
  )}
170
170
 
171
- <Section title="Stroke" icon={<Square size={15} />}>
171
+ <Section title="Stroke" icon={<Square size={15} />} defaultCollapsed>
172
172
  <div className="space-y-4">
173
173
  <div className={RESPONSIVE_GRID}>
174
174
  <MetricField
@@ -226,7 +226,7 @@ export function StyleSections({
226
226
  </div>
227
227
  </Section>
228
228
 
229
- <Section title="Effects" icon={<Zap size={15} />}>
229
+ <Section title="Effects" icon={<Zap size={15} />} defaultCollapsed>
230
230
  <div className="space-y-4">
231
231
  <SelectField
232
232
  label="Shadow"
@@ -279,7 +279,7 @@ export function StyleSections({
279
279
  </div>
280
280
  </Section>
281
281
 
282
- <Section title="Clip" icon={<Layers size={15} />}>
282
+ <Section title="Clip" icon={<Layers size={15} />} defaultCollapsed>
283
283
  <div className="space-y-4">
284
284
  <div className={RESPONSIVE_GRID}>
285
285
  <SelectField
@@ -325,7 +325,7 @@ export function StyleSections({
325
325
  </div>
326
326
  </Section>
327
327
 
328
- <Section title="Transparency" icon={<Eye size={15} />}>
328
+ <Section title="Transparency" icon={<Eye size={15} />} defaultCollapsed>
329
329
  <div className="space-y-4">
330
330
  <SliderControl
331
331
  value={opacityValue}
@@ -23,12 +23,7 @@ import {
23
23
  restoreStudioPathOffset,
24
24
  restoreStudioRotation,
25
25
  } from "./manualEdits";
26
- import {
27
- type GroupOverlayItem,
28
- type OverlayRect,
29
- groupOverlayItemsEqual,
30
- rectsEqual,
31
- } from "./domEditOverlayGeometry";
26
+ import { type GroupOverlayItem, type OverlayRect } from "./domEditOverlayGeometry";
32
27
  import {
33
28
  BLOCKED_MOVE_THRESHOLD_PX,
34
29
  type BlockedMoveState,
@@ -87,8 +82,6 @@ export type UseDomEditOverlayGesturesOptions = {
87
82
 
88
83
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
89
84
  const setDraftOverlayRect = (next: OverlayRect) => {
90
- if (rectsEqual(opts.overlayRectRef.current, next)) return;
91
- opts.overlayRectRef.current = next;
92
85
  opts.setOverlayRect(next);
93
86
  };
94
87
  const restoreGestureOverlayRect = (g: GestureState) => {
@@ -102,8 +95,6 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
102
95
  });
103
96
  };
104
97
  const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
105
- if (groupOverlayItemsEqual(opts.groupOverlayItemsRef.current, next)) return;
106
- opts.groupOverlayItemsRef.current = next;
107
98
  opts.setGroupOverlayItems(next);
108
99
  };
109
100
 
@@ -247,9 +247,11 @@ export const NLELayout = memo(function NLELayout({
247
247
  const currentLevel = compositionStack[compositionStack.length - 1];
248
248
  const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
249
249
 
250
+ const onIframeRefStable = useRef(onIframeRef);
251
+ onIframeRefStable.current = onIframeRef;
250
252
  useEffect(() => {
251
- onIframeRef?.(iframeRef.current);
252
- }, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
253
+ onIframeRefStable.current?.(iframeRef.current);
254
+ }, [compositionStack.length, refreshKey, iframeRef]);
253
255
 
254
256
  // Resize divider handlers
255
257
  const handleDividerPointerDown = useCallback(
@@ -1,5 +1,14 @@
1
- import { memo, useRef, useState, type Ref } from "react";
1
+ import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
2
2
  import { Player } from "../../player";
3
+ import {
4
+ DEFAULT_PREVIEW_ZOOM,
5
+ clampPreviewPan,
6
+ clampPreviewZoomPercent,
7
+ resolvePreviewWheelZoom,
8
+ toDomPrecision,
9
+ type PreviewZoomState,
10
+ } from "./previewZoom";
11
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
3
12
 
4
13
  interface NLEPreviewProps {
5
14
  projectId: string;
@@ -23,17 +32,20 @@ export function getPreviewPlayerKey({
23
32
  return directUrl ?? projectId;
24
33
  }
25
34
 
26
- /**
27
- * Manages the composition preview with crossfade on reload.
28
- *
29
- * When refreshKey changes, a new Player is mounted alongside the old one.
30
- * The old Player stays visible (opacity 1) until the new one fires onLoad,
31
- * at which point the old is removed. This avoids the flash that a simple
32
- * key-swap remount would cause.
33
- *
34
- * Uses the render-time state adjustment pattern (React-sanctioned) to detect
35
- * refreshKey changes — no useEffect needed.
36
- */
35
+ const ZOOM_HUD_TIMEOUT_MS = 1200;
36
+ const ZOOM_SETTLE_MS = 200;
37
+
38
+ function loadInitialZoom(): PreviewZoomState {
39
+ const stored = readStudioUiPreferences().previewZoom;
40
+ return stored
41
+ ? {
42
+ zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
43
+ panX: stored.panX,
44
+ panY: stored.panY,
45
+ }
46
+ : DEFAULT_PREVIEW_ZOOM;
47
+ }
48
+
37
49
  export const NLEPreview = memo(function NLEPreview({
38
50
  projectId,
39
51
  iframeRef,
@@ -46,12 +58,78 @@ export const NLEPreview = memo(function NLEPreview({
46
58
  }: NLEPreviewProps) {
47
59
  const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
48
60
  const prevRefreshKeyRef = useRef(refreshKey);
61
+ const viewportRef = useRef<HTMLDivElement>(null);
62
+ const stageRef = useRef<HTMLDivElement>(null);
49
63
  const [retiringKey, setRetiringKey] = useState<string | null>(null);
50
64
  const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
51
65
 
52
- // Detect refreshKey change during render (React-sanctioned derived state pattern).
53
- // When the key changes, the current active player becomes the retiring player
54
- // and a new active player is mounted alongside it.
66
+ const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
67
+ const hudRef = useRef<HTMLDivElement>(null);
68
+ const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
69
+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
+ const zoomingRef = useRef(false);
71
+ const dragRef = useRef<{
72
+ pointerId: number;
73
+ startX: number;
74
+ startY: number;
75
+ originX: number;
76
+ originY: number;
77
+ } | null>(null);
78
+
79
+ useEffect(() => {
80
+ return () => {
81
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
82
+ if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
83
+ if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
84
+ };
85
+ }, []);
86
+
87
+ const writeTransform = useCallback((state: PreviewZoomState) => {
88
+ const stage = stageRef.current;
89
+ if (!stage) return;
90
+ const s = toDomPrecision(state.zoomPercent / 100);
91
+ const px = toDomPrecision(state.panX);
92
+ const py = toDomPrecision(state.panY);
93
+ stage.style.zoom = String(s);
94
+ stage.style.transform = `translate(${px}px, ${py}px)`;
95
+ }, []);
96
+
97
+ const applyZoom = useCallback(
98
+ (next: PreviewZoomState) => {
99
+ const clamped: PreviewZoomState = {
100
+ zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
101
+ panX: Number.isFinite(next.panX) ? next.panX : 0,
102
+ panY: Number.isFinite(next.panY) ? next.panY : 0,
103
+ };
104
+ zoomRef.current = clamped;
105
+
106
+ if (!zoomingRef.current) {
107
+ zoomingRef.current = true;
108
+ const hud = hudRef.current;
109
+ if (hud) hud.style.opacity = "1";
110
+ }
111
+
112
+ writeTransform(clamped);
113
+
114
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
115
+ settleTimerRef.current = setTimeout(() => {
116
+ zoomingRef.current = false;
117
+ const final = zoomRef.current;
118
+ writeStudioUiPreferences({ previewZoom: final });
119
+ const hud = hudRef.current;
120
+ if (hud) {
121
+ const zoomed = Math.abs(final.zoomPercent - 100) > 0.5;
122
+ hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit";
123
+ if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
124
+ hudTimerRef.current = setTimeout(() => {
125
+ if (hudRef.current) hudRef.current.style.opacity = "0";
126
+ }, ZOOM_HUD_TIMEOUT_MS);
127
+ }
128
+ }, ZOOM_SETTLE_MS);
129
+ },
130
+ [writeTransform],
131
+ );
132
+
55
133
  if (refreshKey !== prevRefreshKeyRef.current) {
56
134
  const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
57
135
  prevRefreshKeyRef.current = refreshKey;
@@ -60,8 +138,16 @@ export const NLEPreview = memo(function NLEPreview({
60
138
 
61
139
  const activeKey = `${baseKey}:${refreshKey ?? 0}`;
62
140
 
141
+ const applyInitialZoom = useCallback(() => {
142
+ const z = zoomRef.current;
143
+ if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
144
+ writeTransform(z);
145
+ }
146
+ }, [writeTransform]);
147
+
63
148
  const handleNewPlayerLoad = () => {
64
149
  onIframeLoad();
150
+ applyInitialZoom();
65
151
  if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
66
152
  retiringTimerRef.current = setTimeout(() => {
67
153
  setRetiringKey(null);
@@ -69,33 +155,167 @@ export const NLEPreview = memo(function NLEPreview({
69
155
  }, 160);
70
156
  };
71
157
 
158
+ useEffect(() => {
159
+ const viewport = viewportRef.current;
160
+ if (!viewport) return;
161
+
162
+ let lastZoomTime = 0;
163
+
164
+ const handleWheel = (event: WheelEvent) => {
165
+ const rect = viewport.getBoundingClientRect();
166
+ if (
167
+ event.clientX < rect.left ||
168
+ event.clientX > rect.right ||
169
+ event.clientY < rect.top ||
170
+ event.clientY > rect.bottom
171
+ ) {
172
+ return;
173
+ }
174
+
175
+ const isZoomGesture = event.ctrlKey || event.metaKey;
176
+
177
+ if (isZoomGesture) {
178
+ lastZoomTime = Date.now();
179
+ event.preventDefault();
180
+ event.stopPropagation();
181
+
182
+ const next = resolvePreviewWheelZoom({
183
+ state: zoomRef.current,
184
+ deltaY: event.deltaY,
185
+ viewportWidth: rect.width,
186
+ viewportHeight: rect.height,
187
+ });
188
+ applyZoom(next);
189
+ return;
190
+ }
191
+
192
+ if (Date.now() - lastZoomTime < 400) {
193
+ event.preventDefault();
194
+ event.stopPropagation();
195
+ }
196
+ };
197
+
198
+ document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
199
+ return () => document.removeEventListener("wheel", handleWheel, { capture: true });
200
+ }, [applyZoom]);
201
+
202
+ useEffect(() => {
203
+ const viewport = viewportRef.current;
204
+ if (!viewport) return;
205
+
206
+ const handleDblClick = (event: MouseEvent) => {
207
+ if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return;
208
+ const rect = viewport.getBoundingClientRect();
209
+ if (
210
+ event.clientX < rect.left ||
211
+ event.clientX > rect.right ||
212
+ event.clientY < rect.top ||
213
+ event.clientY > rect.bottom
214
+ ) {
215
+ return;
216
+ }
217
+ applyZoom(DEFAULT_PREVIEW_ZOOM);
218
+ };
219
+
220
+ document.addEventListener("dblclick", handleDblClick, { capture: true });
221
+ return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
222
+ }, [applyZoom]);
223
+
224
+ const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
225
+ if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return;
226
+ event.currentTarget.setPointerCapture(event.pointerId);
227
+ dragRef.current = {
228
+ pointerId: event.pointerId,
229
+ startX: event.clientX,
230
+ startY: event.clientY,
231
+ originX: zoomRef.current.panX,
232
+ originY: zoomRef.current.panY,
233
+ };
234
+ }, []);
235
+
236
+ const handlePointerMove = useCallback(
237
+ (event: React.PointerEvent<HTMLDivElement>) => {
238
+ const drag = dragRef.current;
239
+ const viewport = viewportRef.current;
240
+ if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
241
+ event.preventDefault();
242
+ const rect = viewport.getBoundingClientRect();
243
+ const pan = clampPreviewPan({
244
+ panX: drag.originX + event.clientX - drag.startX,
245
+ panY: drag.originY + event.clientY - drag.startY,
246
+ zoomPercent: zoomRef.current.zoomPercent,
247
+ viewportWidth: rect.width,
248
+ viewportHeight: rect.height,
249
+ });
250
+ applyZoom({ ...zoomRef.current, ...pan });
251
+ },
252
+ [applyZoom],
253
+ );
254
+
255
+ const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
256
+ if (dragRef.current?.pointerId === event.pointerId) {
257
+ dragRef.current = null;
258
+ }
259
+ }, []);
260
+
261
+ const initial = zoomRef.current;
262
+
72
263
  return (
73
264
  <div className="flex flex-col h-full min-h-0">
74
265
  <div
266
+ ref={viewportRef}
75
267
  className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
76
268
  tabIndex={0}
77
269
  aria-label="Composition preview"
270
+ onPointerDown={handlePointerDown}
271
+ onPointerMove={handlePointerMove}
272
+ onPointerUp={finishDrag}
273
+ onPointerCancel={finishDrag}
78
274
  >
79
- {retiringKey && (
275
+ <div
276
+ ref={stageRef}
277
+ className="absolute inset-2"
278
+ style={{
279
+ zoom: toDomPrecision(initial.zoomPercent / 100),
280
+ transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
281
+ transformOrigin: "0 0",
282
+ }}
283
+ data-testid="preview-zoom-stage"
284
+ >
285
+ {retiringKey && (
286
+ <Player
287
+ key={retiringKey}
288
+ projectId={directUrl ? undefined : projectId}
289
+ directUrl={directUrl}
290
+ onLoad={() => {}}
291
+ portrait={portrait}
292
+ style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
293
+ />
294
+ )}
80
295
  <Player
81
- key={retiringKey}
296
+ key={activeKey}
297
+ ref={iframeRef}
82
298
  projectId={directUrl ? undefined : projectId}
83
299
  directUrl={directUrl}
84
- onLoad={() => {}}
300
+ onLoad={
301
+ retiringKey
302
+ ? handleNewPlayerLoad
303
+ : () => {
304
+ onIframeLoad();
305
+ applyInitialZoom();
306
+ }
307
+ }
308
+ onCompositionLoadingChange={onCompositionLoadingChange}
85
309
  portrait={portrait}
86
- style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
310
+ style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
311
+ suppressLoadingOverlay={suppressLoadingOverlay}
87
312
  />
88
- )}
89
- <Player
90
- key={activeKey}
91
- ref={iframeRef}
92
- projectId={directUrl ? undefined : projectId}
93
- directUrl={directUrl}
94
- onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
95
- onCompositionLoadingChange={onCompositionLoadingChange}
96
- portrait={portrait}
97
- style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
98
- suppressLoadingOverlay={suppressLoadingOverlay}
313
+ </div>
314
+ <div
315
+ ref={hudRef}
316
+ className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
317
+ style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
318
+ aria-live="polite"
99
319
  />
100
320
  </div>
101
321
  </div>
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULT_PREVIEW_ZOOM,
4
+ MAX_PREVIEW_ZOOM_PERCENT,
5
+ MIN_PREVIEW_ZOOM_PERCENT,
6
+ clampPreviewPan,
7
+ clampPreviewZoomPercent,
8
+ getNextPreviewZoomPercent,
9
+ getPreviewWheelZoomPercent,
10
+ resolvePreviewWheelZoom,
11
+ toDomPrecision,
12
+ } from "./previewZoom";
13
+
14
+ describe("toDomPrecision", () => {
15
+ it("rounds to 4 decimal places", () => {
16
+ expect(toDomPrecision(1.23456789)).toBe(1.2346);
17
+ });
18
+
19
+ it("preserves zero", () => {
20
+ expect(toDomPrecision(0)).toBe(0);
21
+ });
22
+
23
+ it("handles negative values", () => {
24
+ expect(toDomPrecision(-3.14159)).toBe(-3.1416);
25
+ });
26
+ });
27
+
28
+ describe("clampPreviewZoomPercent", () => {
29
+ it("falls back to fit zoom for invalid input", () => {
30
+ expect(clampPreviewZoomPercent(Number.NaN)).toBe(100);
31
+ });
32
+
33
+ it("clamps to supported preview zoom bounds", () => {
34
+ expect(clampPreviewZoomPercent(1)).toBe(MIN_PREVIEW_ZOOM_PERCENT);
35
+ expect(clampPreviewZoomPercent(5000)).toBe(MAX_PREVIEW_ZOOM_PERCENT);
36
+ });
37
+ });
38
+
39
+ describe("getPreviewWheelZoomPercent", () => {
40
+ it("zooms in on negative deltaY (scroll up / pinch out)", () => {
41
+ expect(getPreviewWheelZoomPercent(-5, 100)).toBeGreaterThan(100);
42
+ });
43
+
44
+ it("zooms out on positive deltaY (scroll down / pinch in)", () => {
45
+ expect(getPreviewWheelZoomPercent(5, 200)).toBeLessThan(200);
46
+ });
47
+
48
+ it("clamps large deltas to prevent overshoot", () => {
49
+ const small = getPreviewWheelZoomPercent(-5, 100);
50
+ const large = getPreviewWheelZoomPercent(-50, 100);
51
+ expect(large).toBeLessThan(small * 2);
52
+ });
53
+
54
+ it("preserves the current zoom for invalid input", () => {
55
+ expect(getPreviewWheelZoomPercent(Number.NaN, 180)).toBe(180);
56
+ });
57
+ });
58
+
59
+ describe("getNextPreviewZoomPercent", () => {
60
+ it("steps preview zoom in and out", () => {
61
+ expect(getNextPreviewZoomPercent("in", 100)).toBe(125);
62
+ expect(getNextPreviewZoomPercent("out", 125)).toBe(100);
63
+ });
64
+ });
65
+
66
+ describe("clampPreviewPan", () => {
67
+ it("centers the preview when fit or zoomed out", () => {
68
+ expect(
69
+ clampPreviewPan({
70
+ panX: 120,
71
+ panY: -90,
72
+ zoomPercent: 100,
73
+ viewportWidth: 800,
74
+ viewportHeight: 600,
75
+ }),
76
+ ).toEqual({ panX: 0, panY: 0 });
77
+ });
78
+
79
+ it("keeps pan within the zoomed preview bounds", () => {
80
+ expect(
81
+ clampPreviewPan({
82
+ panX: 900,
83
+ panY: -900,
84
+ zoomPercent: 200,
85
+ viewportWidth: 800,
86
+ viewportHeight: 600,
87
+ }),
88
+ ).toEqual({ panX: 400, panY: -300 });
89
+ });
90
+ });
91
+
92
+ describe("resolvePreviewWheelZoom", () => {
93
+ it("zooms in from center without shifting pan", () => {
94
+ const next = resolvePreviewWheelZoom({
95
+ state: DEFAULT_PREVIEW_ZOOM,
96
+ deltaY: -5,
97
+ viewportWidth: 800,
98
+ viewportHeight: 600,
99
+ });
100
+
101
+ expect(next.zoomPercent).toBeGreaterThan(100);
102
+ expect(next.panX).toBe(0);
103
+ expect(next.panY).toBe(0);
104
+ });
105
+
106
+ it("clamps pan when zooming out past minimum", () => {
107
+ const next = resolvePreviewWheelZoom({
108
+ state: { zoomPercent: 26, panX: 20, panY: 20 },
109
+ deltaY: 500,
110
+ viewportWidth: 800,
111
+ viewportHeight: 600,
112
+ });
113
+
114
+ expect(next.zoomPercent).toBeCloseTo(MIN_PREVIEW_ZOOM_PERCENT, 0);
115
+ expect(next.panX).toBe(0);
116
+ expect(next.panY).toBe(0);
117
+ });
118
+ });
@@ -0,0 +1,84 @@
1
+ export interface PreviewZoomState {
2
+ zoomPercent: number;
3
+ panX: number;
4
+ panY: number;
5
+ }
6
+
7
+ export const MIN_PREVIEW_ZOOM_PERCENT = 25;
8
+ export const MAX_PREVIEW_ZOOM_PERCENT = 400;
9
+ export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = {
10
+ zoomPercent: 100,
11
+ panX: 0,
12
+ panY: 0,
13
+ };
14
+
15
+ const ZOOM_SENSITIVITY = 0.007;
16
+ const MAX_DELTA = 10;
17
+
18
+ export function toDomPrecision(value: number): number {
19
+ return Math.round(value * 10000) / 10000;
20
+ }
21
+
22
+ export function clampPreviewZoomPercent(percent: number): number {
23
+ if (!Number.isFinite(percent)) return 100;
24
+ return Math.min(MAX_PREVIEW_ZOOM_PERCENT, Math.max(MIN_PREVIEW_ZOOM_PERCENT, percent));
25
+ }
26
+
27
+ export function getPreviewWheelZoomPercent(deltaY: number, currentZoomPercent: number): number {
28
+ if (!Number.isFinite(deltaY)) return clampPreviewZoomPercent(currentZoomPercent);
29
+ const clamped = Math.abs(deltaY) > MAX_DELTA ? MAX_DELTA * Math.sign(deltaY) : deltaY;
30
+ const step = -clamped * ZOOM_SENSITIVITY;
31
+ const current = clampPreviewZoomPercent(currentZoomPercent);
32
+ return clampPreviewZoomPercent(current * Math.exp(step));
33
+ }
34
+
35
+ export function getNextPreviewZoomPercent(
36
+ direction: "in" | "out",
37
+ currentZoomPercent: number,
38
+ ): number {
39
+ const current = clampPreviewZoomPercent(currentZoomPercent);
40
+ const multiplier = direction === "in" ? 1.25 : 0.8;
41
+ return clampPreviewZoomPercent(current * multiplier);
42
+ }
43
+
44
+ export function clampPreviewPan(input: {
45
+ panX: number;
46
+ panY: number;
47
+ zoomPercent: number;
48
+ viewportWidth: number;
49
+ viewportHeight: number;
50
+ }): Pick<PreviewZoomState, "panX" | "panY"> {
51
+ const scale = clampPreviewZoomPercent(input.zoomPercent) / 100;
52
+ if (scale <= 1) return { panX: 0, panY: 0 };
53
+
54
+ const maxPanX = ((scale - 1) * input.viewportWidth) / 2;
55
+ const maxPanY = ((scale - 1) * input.viewportHeight) / 2;
56
+ return {
57
+ panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)),
58
+ panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)),
59
+ };
60
+ }
61
+
62
+ export function resolvePreviewWheelZoom(input: {
63
+ state: PreviewZoomState;
64
+ deltaY: number;
65
+ viewportWidth: number;
66
+ viewportHeight: number;
67
+ }): PreviewZoomState {
68
+ const nextZoomPercent = getPreviewWheelZoomPercent(
69
+ input.deltaY,
70
+ clampPreviewZoomPercent(input.state.zoomPercent),
71
+ );
72
+ const pan = clampPreviewPan({
73
+ panX: input.state.panX,
74
+ panY: input.state.panY,
75
+ zoomPercent: nextZoomPercent,
76
+ viewportWidth: input.viewportWidth,
77
+ viewportHeight: input.viewportHeight,
78
+ });
79
+
80
+ return {
81
+ zoomPercent: nextZoomPercent,
82
+ ...pan,
83
+ };
84
+ }