@hyperframes/studio 0.8.76 → 0.8.77

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.
Files changed (38) hide show
  1. package/dist/assets/{hyperframes-player-BkDxgSKG.js → hyperframes-player-BSDTJ9EF.js} +1 -1
  2. package/dist/assets/{index-DeHir1up.js → index-2GgHjQq-.js} +148 -148
  3. package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
  4. package/dist/assets/{index-COX0scwJ.js → index-CsFCRdMo.js} +1 -1
  5. package/dist/assets/{index-FmKUmYhC.js → index-DcxWQ7Za.js} +1 -1
  6. package/dist/{chunk-IYDE66BY.js → chunk-5VANA2TG.js} +7 -1
  7. package/dist/{chunk-IYDE66BY.js.map → chunk-5VANA2TG.js.map} +1 -1
  8. package/dist/{domEditingLayers-2XBFXXSW.js → domEditingLayers-IGPLTI5E.js} +2 -2
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4125 -4009
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -1
  13. package/package.json +7 -7
  14. package/src/components/editor/domEditingElement.ts +13 -0
  15. package/src/components/editor/gsapLivePreview.test.ts +63 -0
  16. package/src/components/editor/gsapLivePreview.ts +2 -14
  17. package/src/components/editor/propertyPanelHelpers.ts +4 -6
  18. package/src/components/nle/NLEPreview.test.ts +110 -7
  19. package/src/components/nle/NLEPreview.tsx +51 -46
  20. package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
  21. package/src/components/nle/previewZoom.test.ts +42 -0
  22. package/src/components/nle/previewZoom.ts +33 -0
  23. package/src/hooks/useDomEditWiring.ts +1 -3
  24. package/src/hooks/useGestureRecording.test.tsx +1 -1
  25. package/src/hooks/useGestureRecording.ts +6 -10
  26. package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
  27. package/src/hooks/useGsapTweenCache.ts +5 -13
  28. package/src/player/lib/automationStoreSync.test.ts +24 -0
  29. package/src/player/lib/automationStoreSync.ts +3 -9
  30. package/src/player/lib/playbackScrub.ts +1 -1
  31. package/src/player/lib/timelineDOM.test.ts +28 -0
  32. package/src/player/lib/timelineDOM.ts +4 -2
  33. package/src/player/lib/timelineElementHelpers.ts +33 -0
  34. package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
  35. package/src/player/lib/timelineIframeHelpers.ts +13 -12
  36. package/src/utils/studioUiPreferences.test.ts +9 -3
  37. package/src/utils/studioUiPreferences.ts +0 -20
  38. /package/dist/{domEditingLayers-2XBFXXSW.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.76",
3
+ "version": "0.8.77",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,11 +52,11 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/parsers": "0.8.76",
56
- "@hyperframes/core": "0.8.76",
57
- "@hyperframes/sdk": "0.8.76",
58
- "@hyperframes/studio-server": "0.8.76",
59
- "@hyperframes/player": "0.8.76"
55
+ "@hyperframes/core": "0.8.77",
56
+ "@hyperframes/parsers": "0.8.77",
57
+ "@hyperframes/studio-server": "0.8.77",
58
+ "@hyperframes/player": "0.8.77",
59
+ "@hyperframes/sdk": "0.8.77"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@tailwindcss/vite": "^4.3.3",
@@ -72,7 +72,7 @@
72
72
  "vite": "^6.4.2",
73
73
  "vitest": "^4.1.11",
74
74
  "zustand": "^5.0.0",
75
- "@hyperframes/producer": "0.8.76"
75
+ "@hyperframes/producer": "0.8.77"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "react": "19",
@@ -314,6 +314,19 @@ export function findElementForSelection(
314
314
  return findAll(selection.selector)[selection.selectorIndex ?? 0] ?? null;
315
315
  }
316
316
 
317
+ // The selected node while mounted, else its copy in the selection's own file (ids repeat across files), else any copy.
318
+ export function findPreviewNode(
319
+ doc: Document | null | undefined,
320
+ el: DomEditSelection,
321
+ ): Element | null {
322
+ if (!doc) return null;
323
+ if (el.element?.isConnected && el.element.ownerDocument === doc) return el.element;
324
+ return (
325
+ findElementForSelection(doc, el) ??
326
+ findElementForSelection(doc, { ...el, sourceFile: undefined })
327
+ );
328
+ }
329
+
317
330
  // fallow-ignore-next-line complexity
318
331
  export function findElementForTimelineElement(
319
332
  doc: Document,
@@ -0,0 +1,63 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it, vi } from "vitest";
3
+ import { createGsapLivePreview } from "./gsapLivePreview";
4
+ import type { DomEditSelection } from "./domEditingTypes";
5
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
6
+ import { readGsapRuntimeValuesForPanel } from "./propertyPanelHelpers";
7
+
8
+ it("previews on the selected element, not an earlier same-id copy in a sub-composition", () => {
9
+ document.body.innerHTML =
10
+ '<div data-composition-id="strip"><div id="card" data-hf-id="hf-inner"></div></div>' +
11
+ '<div id="card" data-hf-id="hf-root"></div>';
12
+ const set = vi.fn();
13
+ const iframe = { contentWindow: { gsap: { set } }, contentDocument: document };
14
+ const preview = createGsapLivePreview({ current: iframe as unknown as HTMLIFrameElement });
15
+ preview({ id: "card", hfId: "hf-root" } as DomEditSelection, { x: 10 });
16
+ expect(set.mock.calls[0]?.[0]).toBe(document.querySelector('[data-hf-id="hf-root"]'));
17
+ });
18
+
19
+ it("previews on the copy in the selection's own file when a sub-composition repeats its hf-id", () => {
20
+ document.body.innerHTML =
21
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
22
+ '<div id="card" data-hf-id="hf-card"></div></div><div id="card" data-hf-id="hf-card" class="root"></div>';
23
+ const set = vi.fn();
24
+ const iframe = { contentWindow: { gsap: { set } }, contentDocument: document };
25
+ const preview = createGsapLivePreview({ current: iframe as unknown as HTMLIFrameElement });
26
+ preview({ id: "card", hfId: "hf-card", sourceFile: "index.html" } as DomEditSelection, { x: 10 });
27
+ expect(set.mock.calls[0]?.[0]).toBe(document.querySelector(".root"));
28
+ });
29
+
30
+ it("previews on the selected node while it is mounted, even in a second mount of the same sub-composition", () => {
31
+ document.body.innerHTML =
32
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card" data-hf-id="hf-card"></div></div>' +
33
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card" data-hf-id="hf-card"></div></div>';
34
+ const second = document.querySelectorAll<HTMLElement>("#card")[1];
35
+ const selection = {
36
+ id: "card",
37
+ hfId: "hf-card",
38
+ sourceFile: "compositions/strip.html",
39
+ element: second,
40
+ };
41
+ const set = vi.fn();
42
+ const iframe = { contentWindow: { gsap: { set } }, contentDocument: document };
43
+ createGsapLivePreview({ current: iframe as unknown as HTMLIFrameElement })(
44
+ selection as DomEditSelection,
45
+ { x: 10 },
46
+ );
47
+ expect(set.mock.calls[0]?.[0]).toBe(second);
48
+ });
49
+
50
+ it("the panel reads GSAP values off the node the live preview moves", () => {
51
+ document.body.innerHTML =
52
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card"></div></div>' +
53
+ '<div id="card" class="root"></div>';
54
+ const root = document.querySelector<HTMLElement>(".root");
55
+ const getProperty = vi.fn(() => 5);
56
+ const iframe = { contentWindow: { gsap: { getProperty } }, contentDocument: document };
57
+ const selection = { id: "card", sourceFile: "index.html", element: root } as DomEditSelection;
58
+ const animations = [{ properties: { x: 5 } }] as unknown as GsapAnimation[];
59
+ readGsapRuntimeValuesForPanel("anim", animations, selection, {
60
+ current: iframe as unknown as HTMLIFrameElement,
61
+ });
62
+ expect(getProperty.mock.calls[0]?.[0]).toBe(root);
63
+ });
@@ -1,4 +1,5 @@
1
1
  import type { DomEditSelection } from "./domEditingTypes";
2
+ import { findPreviewNode } from "./domEditingElement";
2
3
 
3
4
  /**
4
5
  * Build the "live preview" callback the 3D-transform sub-view fires while a
@@ -8,19 +9,6 @@ import type { DomEditSelection } from "./domEditingTypes";
8
9
  * Extracted so the identical closure exists once — shared by the legacy
9
10
  * PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
10
11
  */
11
- // Resolve by id when unique, otherwise by selector + selectorIndex — a bare
12
- // querySelector(selector) always hits the FIRST match, so dragging on the
13
- // second of two same-selector siblings would animate the wrong element.
14
- function resolvePreviewNode(
15
- doc: Document | null | undefined,
16
- el: DomEditSelection,
17
- ): Element | null {
18
- if (!doc) return null;
19
- if (el.id) return doc.querySelector(`#${el.id}`);
20
- if (!el.selector) return null;
21
- return doc.querySelectorAll(el.selector)[el.selectorIndex ?? 0] ?? null;
22
- }
23
-
24
12
  export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
25
13
  return (el: DomEditSelection, props: Record<string, number>) => {
26
14
  const iframe = iframeRef.current;
@@ -28,7 +16,7 @@ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameE
28
16
  | { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
29
17
  | null
30
18
  | undefined;
31
- const node = resolvePreviewNode(iframe?.contentDocument, el);
19
+ const node = findPreviewNode(iframe?.contentDocument, el);
32
20
  if (win?.gsap && node) win.gsap.set(node, props);
33
21
  };
34
22
  }
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "./domEditing";
4
4
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
5
5
  import type { TimelineElement } from "../../player";
6
6
  import { roundToCenti } from "../../utils/rounding";
7
+ import { findPreviewNode } from "./domEditingElement";
7
8
 
8
9
  export type {
9
10
  BackgroundRemovalProgress,
@@ -470,8 +471,6 @@ export function readGsapRuntimeValuesForPanel(
470
471
  if (!gsapAnimId || gsapAnimations.length === 0) return null;
471
472
  const iframe = previewIframeRef?.current;
472
473
  if (!iframe?.contentWindow) return null;
473
- const selector = element.id ? `#${element.id}` : element.selector;
474
- if (!selector) return null;
475
474
  try {
476
475
  const gsap = (
477
476
  iframe.contentWindow as unknown as {
@@ -479,7 +478,7 @@ export function readGsapRuntimeValuesForPanel(
479
478
  }
480
479
  ).gsap;
481
480
  if (!gsap?.getProperty) return null;
482
- const el = iframe.contentDocument?.querySelector(selector);
481
+ const el = findPreviewNode(iframe.contentDocument, element);
483
482
  if (!el) return null;
484
483
  const propKeys = collectPanelPropKeys(gsapAnimations);
485
484
  const result: Record<string, number> = {};
@@ -508,10 +507,9 @@ export function readGsapBorderRadiusForPanel(
508
507
  if (!hasBRProp) return null;
509
508
  }
510
509
  const iframe = previewIframeRef?.current;
511
- const selector = element.id ? `#${element.id}` : element.selector;
512
- if (!iframe?.contentDocument || !selector) return null;
510
+ if (!iframe?.contentDocument) return null;
513
511
  try {
514
- const el = iframe.contentDocument.querySelector(selector);
512
+ const el = findPreviewNode(iframe.contentDocument, element);
515
513
  if (!el || !iframe.contentWindow) return null;
516
514
  const cs = iframe.contentWindow.getComputedStyle(el);
517
515
  const parse = (v: string) => Number.parseFloat(v) || 0;
@@ -40,11 +40,6 @@ vi.mock("../../player", async () => {
40
40
  };
41
41
  });
42
42
 
43
- vi.mock("../../utils/studioUiPreferences", () => ({
44
- readStudioUiPreferences: () => ({}),
45
- writeStudioUiPreferences: () => {},
46
- }));
47
-
48
43
  let resizeCallbacks: Array<() => void> = [];
49
44
 
50
45
  class MockResizeObserver {
@@ -93,11 +88,11 @@ function renderPreview(
93
88
  document.body.append(host);
94
89
  const root = createRoot(host);
95
90
  const iframeRef = createRef<HTMLIFrameElement>();
96
- const render = (directUrl?: string) =>
91
+ const render = (directUrl?: string, projectId = "timeline-edit-playground") =>
97
92
  act(() => {
98
93
  root.render(
99
94
  React.createElement(NLEPreview, {
100
- projectId: "timeline-edit-playground",
95
+ projectId,
101
96
  directUrl,
102
97
  iframeRef,
103
98
  onIframeLoad: () => {},
@@ -129,6 +124,9 @@ function renderPreview(
129
124
  render,
130
125
  viewport,
131
126
  stage,
127
+ openProject(projectId: string) {
128
+ render(undefined, projectId);
129
+ },
132
130
  cleanup() {
133
131
  act(() => {
134
132
  root.unmount();
@@ -262,6 +260,111 @@ describe("NLEPreview", () => {
262
260
  view.cleanup();
263
261
  });
264
262
 
263
+ describe("zoom", () => {
264
+ beforeEach(() => {
265
+ vi.useFakeTimers();
266
+ localStorage.clear();
267
+ });
268
+ afterEach(() => {
269
+ vi.useRealTimers();
270
+ localStorage.clear();
271
+ });
272
+
273
+ /** A pinch (ctrl + wheel) over the preview, then the settle that follows it. */
274
+ function pinchIn(view: ReturnType<typeof renderPreview>, steps: number) {
275
+ act(() => {
276
+ for (let step = 0; step < steps; step += 1) {
277
+ const pinch = new WheelEvent("wheel", {
278
+ bubbles: true,
279
+ cancelable: true,
280
+ clientX: 400,
281
+ clientY: 300,
282
+ deltaY: -10,
283
+ });
284
+ // happy-dom drops ctrlKey from the WheelEvent init; a trackpad pinch sets it.
285
+ Object.defineProperty(pinch, "ctrlKey", { value: true });
286
+ view.stage.dispatchEvent(pinch);
287
+ }
288
+ });
289
+ act(() => vi.advanceTimersByTime(300));
290
+ }
291
+ const chip = (view: ReturnType<typeof renderPreview>) =>
292
+ view.host.querySelector('[data-testid="preview-zoom-chip"]');
293
+ const navigator = (view: ReturnType<typeof renderPreview>) =>
294
+ view.host.querySelector('[data-testid="preview-zoom-navigator"]');
295
+
296
+ it("labels a pan away from Fit without a zoom as panned", () => {
297
+ const view = renderPreview();
298
+ act(() => {
299
+ view.stage.dispatchEvent(
300
+ new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX: -30, deltaY: 0 }),
301
+ );
302
+ });
303
+ act(() => vi.advanceTimersByTime(300));
304
+ expect(chip(view)?.textContent).toBe("Panned·Fit");
305
+ view.cleanup();
306
+ });
307
+
308
+ it("keeps a click on Fit from reaching the pane behind it", () => {
309
+ const view = renderPreview();
310
+ // Above React's root, as the preview pane's handler is: React stops the event before either.
311
+ const pane = vi.fn();
312
+ document.body.addEventListener("pointerdown", pane);
313
+ pinchIn(view, 10);
314
+ act(() => {
315
+ view.host
316
+ .querySelector('[data-testid="preview-zoom-fit"]')!
317
+ .dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
318
+ });
319
+ document.body.removeEventListener("pointerdown", pane);
320
+ expect(pane).not.toHaveBeenCalled();
321
+ view.cleanup();
322
+ });
323
+
324
+ it("opens at Fit even when an older Studio saved a zoom", () => {
325
+ localStorage.setItem(
326
+ "hf-studio-ui-preferences",
327
+ JSON.stringify({ previewZoom: { zoomPercent: 245, panX: 0, panY: 0 } }),
328
+ );
329
+ const view = renderPreview();
330
+ expect(view.stage.style.transform).toContain("scale(1)");
331
+ expect(chip(view)).toBeNull();
332
+ view.cleanup();
333
+ });
334
+
335
+ it("says how far it is zoomed, shows where in the frame, and Fit puts it back", () => {
336
+ const view = renderPreview();
337
+ expect([chip(view), navigator(view)]).toEqual([null, null]);
338
+
339
+ pinchIn(view, 10);
340
+ expect(chip(view)?.textContent).toMatch(/^Zoomed 2\d\d%·Fit$/);
341
+ const region = view.host.querySelector<HTMLElement>(
342
+ '[data-testid="preview-zoom-navigator-region"]',
343
+ );
344
+ expect(Number.parseFloat(region!.style.width)).toBeLessThan(100);
345
+
346
+ act(() => {
347
+ view.host.querySelector<HTMLButtonElement>('[data-testid="preview-zoom-fit"]')!.click();
348
+ });
349
+ act(() => vi.advanceTimersByTime(300));
350
+ expect(view.stage.style.transform).toContain("scale(1)");
351
+ expect([chip(view), navigator(view)]).toEqual([null, null]);
352
+ view.cleanup();
353
+ });
354
+
355
+ it("keeps a zoom only while the project is open: nothing is saved, and another project opens at Fit", () => {
356
+ const view = renderPreview();
357
+ pinchIn(view, 10);
358
+ expect(chip(view)).not.toBeNull();
359
+ expect(localStorage.getItem("hf-studio-ui-preferences") ?? "").not.toContain("previewZoom");
360
+
361
+ view.openProject("another-project");
362
+ expect(view.stage.style.transform).toContain("scale(1)");
363
+ expect(chip(view)).toBeNull();
364
+ view.cleanup();
365
+ });
366
+ });
367
+
265
368
  it("insets the picture by default and fills a same-shape box when fillBox is on", () => {
266
369
  const box = { width: 640, height: 360 };
267
370
  const inset = renderPreview(undefined, { box });
@@ -2,7 +2,15 @@ import {
2
2
  readPreviewCompositionSize,
3
3
  type PreviewCompositionSize,
4
4
  } from "../../utils/previewCompositionSize";
5
- import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
5
+ import {
6
+ memo,
7
+ useCallback,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ type RefObject,
13
+ } from "react";
6
14
  import { Player } from "../../player";
7
15
  import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
8
16
  import {
@@ -10,6 +18,7 @@ import {
10
18
  canStartPreviewPan,
11
19
  clampPreviewPan,
12
20
  clampPreviewZoomPercent,
21
+ isPreviewAtFit,
13
22
  ownsPreviewPanTarget,
14
23
  resolvePreviewWheelPan,
15
24
  resolvePreviewWheelZoom,
@@ -17,7 +26,7 @@ import {
17
26
  type PreviewZoomState,
18
27
  } from "./previewZoom";
19
28
  import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
20
- import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
29
+ import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay";
21
30
  import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
22
31
  import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
23
32
  interface NLEPreviewProps {
@@ -65,25 +74,6 @@ const SHADOW_IFRAME_STYLE: React.CSSProperties = {
65
74
  pointerEvents: "none",
66
75
  };
67
76
 
68
- function isPreviewAtFit(state: PreviewZoomState): boolean {
69
- return (
70
- Math.abs(state.zoomPercent - 100) < 0.5 &&
71
- Math.abs(state.panX) < 0.1 &&
72
- Math.abs(state.panY) < 0.1
73
- );
74
- }
75
-
76
- function loadInitialZoom(): PreviewZoomState {
77
- const stored = readStudioUiPreferences().previewZoom;
78
- return stored
79
- ? {
80
- zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
81
- panX: stored.panX,
82
- panY: stored.panY,
83
- }
84
- : DEFAULT_PREVIEW_ZOOM;
85
- }
86
-
87
77
  export function resolvePreviewStageSize(
88
78
  viewportWidth: number,
89
79
  viewportHeight: number,
@@ -160,8 +150,8 @@ export const NLEPreview = memo(function NLEPreview({
160
150
  const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
161
151
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
162
152
 
163
- const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
164
- const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
153
+ const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
154
+ const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
165
155
  const hudRef = useRef<HTMLDivElement>(null);
166
156
  const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
167
157
  const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -232,14 +222,33 @@ export const NLEPreview = memo(function NLEPreview({
232
222
  const stageSizeRef = useRef(stageSize);
233
223
  stageSizeRef.current = stageSize;
234
224
 
235
- const writeTransform = useCallback((state: PreviewZoomState) => {
236
- const stage = stageRef.current;
237
- if (!stage) return;
238
- const s = toDomPrecision(state.zoomPercent / 100);
239
- const px = toDomPrecision(state.panX);
240
- const py = toDomPrecision(state.panY);
241
- stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
242
- }, []);
225
+ const { draw: drawNavigator, setRegion: setNavigatorRegion } = usePreviewNavigator(
226
+ viewportRef,
227
+ stageSize,
228
+ zoomRef,
229
+ );
230
+ const writeTransform = useCallback(
231
+ (state: PreviewZoomState) => {
232
+ const stage = stageRef.current;
233
+ if (!stage) return;
234
+ const s = toDomPrecision(state.zoomPercent / 100);
235
+ const px = toDomPrecision(state.panX);
236
+ const py = toDomPrecision(state.panY);
237
+ stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
238
+ drawNavigator(state);
239
+ },
240
+ [drawNavigator],
241
+ );
242
+
243
+ const zoomProjectRef = useRef(projectId);
244
+ // Before paint, so the next project never shows a frame at the previous one's zoom.
245
+ useLayoutEffect(() => {
246
+ if (zoomProjectRef.current === projectId) return;
247
+ zoomProjectRef.current = projectId;
248
+ zoomRef.current = DEFAULT_PREVIEW_ZOOM;
249
+ writeTransform(DEFAULT_PREVIEW_ZOOM);
250
+ setSettledZoom(DEFAULT_PREVIEW_ZOOM);
251
+ }, [projectId, writeTransform]);
243
252
 
244
253
  const applyTransform = useCallback(
245
254
  (next: PreviewZoomState, showHud: boolean) => {
@@ -269,7 +278,6 @@ export const NLEPreview = memo(function NLEPreview({
269
278
  settleTimerRef.current = setTimeout(() => {
270
279
  zoomingRef.current = false;
271
280
  const final = zoomRef.current;
272
- writeStudioUiPreferences({ previewZoom: final });
273
281
  setSettledZoom((prev) =>
274
282
  prev.zoomPercent === final.zoomPercent &&
275
283
  prev.panX === final.panX &&
@@ -304,9 +312,8 @@ export const NLEPreview = memo(function NLEPreview({
304
312
 
305
313
  const applyInitialZoom = useCallback(() => {
306
314
  const z = zoomRef.current;
307
- if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
308
- // A pan persisted on a large window can restore the composition mostly
309
- // off-screen in a smaller one; clamp against the current viewport first.
315
+ if (!isPreviewAtFit(z)) {
316
+ // A composition reload can bring a different frame size than the pan was made on; clamp first.
310
317
  const viewport = viewportRef.current;
311
318
  const rect = viewport?.getBoundingClientRect();
312
319
  const sz = stageSizeRef.current;
@@ -569,17 +576,15 @@ export const NLEPreview = memo(function NLEPreview({
569
576
  style={{ opacity: 0, transition: "opacity 200ms ease-in" }}
570
577
  aria-live="polite"
571
578
  />
572
- {!isPreviewAtFit(settledZoom) && (
573
- <button
574
- type="button"
575
- className="absolute bottom-3 right-3 z-50 rounded-md px-2.5 py-1 text-xs font-medium text-white/80 bg-black/50 backdrop-blur-xs hover:bg-black/70 hover:text-white transition-colors"
576
- onClick={() => applyZoom(DEFAULT_PREVIEW_ZOOM)}
577
- aria-label="Reset zoom to fit"
578
- data-testid="preview-reset-zoom"
579
- >
580
- {Math.round(settledZoom.zoomPercent)}% — Reset
581
- </button>
582
- )}
579
+ <PreviewZoomOverlay
580
+ zoom={settledZoom}
581
+ stageSize={stageSize}
582
+ onFit={() => {
583
+ applyZoom(DEFAULT_PREVIEW_ZOOM);
584
+ viewportRef.current?.focus();
585
+ }}
586
+ navigatorRegionRef={setNavigatorRegion}
587
+ />
583
588
  </div>
584
589
  </div>
585
590
  );
@@ -0,0 +1,111 @@
1
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
2
+ import {
3
+ isFitZoom,
4
+ isPreviewAtFit,
5
+ resolvePreviewVisibleRegion,
6
+ toDomPrecision,
7
+ type PreviewZoomState,
8
+ } from "./previewZoom";
9
+
10
+ const NAVIGATOR_PX = 112;
11
+
12
+ function zoomChipLabel(zoomPercent: number): string {
13
+ return isFitZoom(zoomPercent) ? "Panned" : `Zoomed ${Math.round(zoomPercent)}%`;
14
+ }
15
+
16
+ function navigatorFrameSize(stage: { width: number; height: number }) {
17
+ const ratio = stage.width > 0 && stage.height > 0 ? stage.width / stage.height : 16 / 9;
18
+ return ratio >= 1
19
+ ? { width: NAVIGATOR_PX, height: toDomPrecision(NAVIGATOR_PX / ratio) }
20
+ : { width: toDomPrecision(NAVIGATOR_PX * ratio), height: NAVIGATOR_PX };
21
+ }
22
+
23
+ /** `draw` moves the navigator's highlight without a render, so it can follow every transform write. */
24
+ export function usePreviewNavigator(
25
+ viewportRef: RefObject<HTMLDivElement | null>,
26
+ stageSize: { width: number; height: number },
27
+ zoomRef: RefObject<PreviewZoomState>,
28
+ ) {
29
+ const regionRef = useRef<HTMLDivElement | null>(null);
30
+ const stageSizeRef = useRef(stageSize);
31
+ stageSizeRef.current = stageSize;
32
+ const draw = useCallback(
33
+ (state: PreviewZoomState) => {
34
+ const region = regionRef.current;
35
+ const rect = viewportRef.current?.getBoundingClientRect();
36
+ if (!region || !rect) return;
37
+ const visible = resolvePreviewVisibleRegion({
38
+ state,
39
+ viewportWidth: rect.width,
40
+ viewportHeight: rect.height,
41
+ contentWidth: stageSizeRef.current.width,
42
+ contentHeight: stageSizeRef.current.height,
43
+ });
44
+ region.style.left = `${visible.left * 100}%`;
45
+ region.style.top = `${visible.top * 100}%`;
46
+ region.style.width = `${visible.width * 100}%`;
47
+ region.style.height = `${visible.height * 100}%`;
48
+ },
49
+ [viewportRef],
50
+ );
51
+ const setRegion = useCallback(
52
+ (node: HTMLDivElement | null) => {
53
+ regionRef.current = node;
54
+ draw(zoomRef.current);
55
+ },
56
+ [draw, zoomRef],
57
+ );
58
+ useEffect(() => draw(zoomRef.current), [stageSize, draw, zoomRef]);
59
+ return { draw, setRegion };
60
+ }
61
+
62
+ export function PreviewZoomOverlay({
63
+ zoom,
64
+ stageSize,
65
+ onFit,
66
+ navigatorRegionRef,
67
+ }: {
68
+ zoom: PreviewZoomState;
69
+ stageSize: { width: number; height: number };
70
+ onFit: () => void;
71
+ navigatorRegionRef: (node: HTMLDivElement | null) => void;
72
+ }) {
73
+ if (isPreviewAtFit(zoom)) return null;
74
+ return (
75
+ <>
76
+ <div
77
+ className="absolute top-3 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1.5 rounded-md py-1 pl-2.5 pr-1 text-xs text-white/80 bg-black/60 backdrop-blur-xs"
78
+ data-testid="preview-zoom-chip"
79
+ // The pane clears the timeline selection on a pointerdown outside the frame.
80
+ onPointerDown={(event) => event.stopPropagation()}
81
+ >
82
+ <span className="tabular-nums">{zoomChipLabel(zoom.zoomPercent)}</span>
83
+ <span aria-hidden="true" className="text-white/30">
84
+ ·
85
+ </span>
86
+ <button
87
+ type="button"
88
+ className="rounded px-1.5 py-0.5 font-medium text-studio-accent hover:bg-white/10 transition-colors"
89
+ onClick={onFit}
90
+ aria-label="Fit the whole frame in view"
91
+ data-testid="preview-zoom-fit"
92
+ >
93
+ Fit
94
+ </button>
95
+ </div>
96
+ <div
97
+ aria-hidden="true"
98
+ className="pointer-events-none absolute bottom-3 right-3 z-50 rounded-md p-1.5 bg-black/60 backdrop-blur-xs"
99
+ data-testid="preview-zoom-navigator"
100
+ >
101
+ <div className="relative overflow-hidden bg-white/10" style={navigatorFrameSize(stageSize)}>
102
+ <div
103
+ ref={navigatorRegionRef}
104
+ className="absolute rounded-[1px] border border-studio-accent bg-studio-accent/15"
105
+ data-testid="preview-zoom-navigator-region"
106
+ />
107
+ </div>
108
+ </div>
109
+ </>
110
+ );
111
+ }
@@ -13,6 +13,7 @@ import {
13
13
  ownsPreviewPanTarget,
14
14
  PREVIEW_PAN_OVERSCROLL_PX,
15
15
  PREVIEW_PAN_SURFACE_SELECTOR,
16
+ resolvePreviewVisibleRegion,
16
17
  resolvePreviewWheelPan,
17
18
  resolvePreviewWheelZoom,
18
19
  toDomPrecision,
@@ -321,3 +322,44 @@ describe("resolvePreviewWheelPan", () => {
321
322
  expect(next.panY).toBe(-PREVIEW_PAN_OVERSCROLL_PX);
322
323
  });
323
324
  });
325
+
326
+ describe("resolvePreviewVisibleRegion", () => {
327
+ const frame = { viewportWidth: 1000, viewportHeight: 600, contentWidth: 800, contentHeight: 450 };
328
+
329
+ it("shows the whole frame at Fit", () => {
330
+ expect(resolvePreviewVisibleRegion({ state: DEFAULT_PREVIEW_ZOOM, ...frame })).toEqual({
331
+ left: 0,
332
+ top: 0,
333
+ width: 1,
334
+ height: 1,
335
+ });
336
+ });
337
+
338
+ it("shows the centre part at 200%, and the part the pan moved to", () => {
339
+ const zoomed = { zoomPercent: 200, panX: 0, panY: 0 };
340
+ const centre = resolvePreviewVisibleRegion({ state: zoomed, ...frame });
341
+ expect(centre.width).toBeCloseTo(1000 / 1600);
342
+ expect(centre.left).toBeCloseTo((1 - 1000 / 1600) / 2);
343
+ expect(centre.height).toBeCloseTo(600 / 900);
344
+
345
+ // Panning right by 300px brings the frame's left side into view.
346
+ const panned = resolvePreviewVisibleRegion({ state: { ...zoomed, panX: 300 }, ...frame });
347
+ expect(panned.left).toBeCloseTo(centre.left - 300 / 1600);
348
+ expect(panned.width).toBeCloseTo(centre.width);
349
+ });
350
+
351
+ it("is the whole frame below 100%, and stops at the frame's edge in an overscroll", () => {
352
+ const small = resolvePreviewVisibleRegion({
353
+ state: { zoomPercent: 50, panX: 0, panY: 0 },
354
+ ...frame,
355
+ });
356
+ expect(small).toEqual({ left: 0, top: 0, width: 1, height: 1 });
357
+
358
+ const past = resolvePreviewVisibleRegion({
359
+ state: { zoomPercent: 200, panX: 400, panY: 0 },
360
+ ...frame,
361
+ });
362
+ expect(past.left).toBe(0);
363
+ expect(past.width).toBeCloseTo((1000 / 2 - 400 + 800) / 1600);
364
+ });
365
+ });