@hyperframes/studio 0.8.76 → 0.8.78
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/dist/assets/hyperframes-player-BdnQGwG5.js +464 -0
- package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
- package/dist/assets/{index-DeHir1up.js → index-CX_4MYbk.js} +148 -148
- package/dist/assets/{index-FmKUmYhC.js → index-C_n__Uh3.js} +1 -1
- package/dist/assets/{index-COX0scwJ.js → index-DdpuHaGN.js} +1 -1
- package/dist/{chunk-IYDE66BY.js → chunk-5VANA2TG.js} +7 -1
- package/dist/{chunk-IYDE66BY.js.map → chunk-5VANA2TG.js.map} +1 -1
- package/dist/{domEditingLayers-2XBFXXSW.js → domEditingLayers-IGPLTI5E.js} +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4132 -4010
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -7
- package/src/components/editor/domEditingElement.ts +13 -0
- package/src/components/editor/gsapLivePreview.test.ts +63 -0
- package/src/components/editor/gsapLivePreview.ts +2 -14
- package/src/components/editor/propertyPanelHelpers.ts +4 -6
- package/src/components/nle/NLEPreview.test.ts +110 -7
- package/src/components/nle/NLEPreview.tsx +51 -46
- package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
- package/src/components/nle/previewZoom.test.ts +42 -0
- package/src/components/nle/previewZoom.ts +33 -0
- package/src/hooks/useDomEditWiring.ts +1 -3
- package/src/hooks/useGestureRecording.test.tsx +1 -1
- package/src/hooks/useGestureRecording.ts +6 -10
- package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
- package/src/hooks/useGsapTweenCache.ts +5 -13
- package/src/index.ts +7 -0
- package/src/player/lib/automationStoreSync.test.ts +24 -0
- package/src/player/lib/automationStoreSync.ts +3 -9
- package/src/player/lib/playbackScrub.ts +1 -1
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineElementHelpers.ts +33 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
- package/src/player/lib/timelineIframeHelpers.ts +13 -12
- package/src/telemetry/client.test.ts +26 -0
- package/src/telemetry/policy.test.ts +10 -0
- package/src/telemetry/policy.ts +7 -0
- package/src/utils/studioUiPreferences.test.ts +9 -3
- package/src/utils/studioUiPreferences.ts +0 -20
- package/dist/assets/hyperframes-player-BkDxgSKG.js +0 -464
- /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.
|
|
3
|
+
"version": "0.8.78",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,11 +52,12 @@
|
|
|
52
52
|
"dockview-react": "8.3.1",
|
|
53
53
|
"gsap": "^3.13.0",
|
|
54
54
|
"mediabunny": "^1.45.3",
|
|
55
|
-
"@hyperframes/parsers": "0.8.
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
59
|
-
"@hyperframes/
|
|
55
|
+
"@hyperframes/parsers": "0.8.78",
|
|
56
|
+
"@hyperframes/sdk": "0.8.78",
|
|
57
|
+
"@hyperframes/player": "0.8.78",
|
|
58
|
+
"@hyperframes/shader-transitions": "0.8.78",
|
|
59
|
+
"@hyperframes/studio-server": "0.8.78",
|
|
60
|
+
"@hyperframes/core": "0.8.78"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -72,7 +73,7 @@
|
|
|
72
73
|
"vite": "^6.4.2",
|
|
73
74
|
"vitest": "^4.1.11",
|
|
74
75
|
"zustand": "^5.0.0",
|
|
75
|
-
"@hyperframes/producer": "0.8.
|
|
76
|
+
"@hyperframes/producer": "0.8.78"
|
|
76
77
|
},
|
|
77
78
|
"peerDependencies": {
|
|
78
79
|
"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 =
|
|
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
|
|
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
|
-
|
|
512
|
-
if (!iframe?.contentDocument || !selector) return null;
|
|
510
|
+
if (!iframe?.contentDocument) return null;
|
|
513
511
|
try {
|
|
514
|
-
const el = iframe.contentDocument
|
|
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
|
|
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 {
|
|
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 {
|
|
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>(
|
|
164
|
-
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 (
|
|
308
|
-
// A
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
+
});
|