@hyperframes/studio 0.8.85 → 0.8.87
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-CtNVImMi.js +473 -0
- package/dist/assets/{index-Bus1BKtk.js → index-B_MF86RE.js} +1 -1
- package/dist/assets/{index-CZxhjQpw.js → index-DJ-Ol7YS.js} +1 -1
- package/dist/assets/{index-ClLdmgua.js → index-DZKcDAtf.js} +203 -203
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
- package/dist/chunk-7PLTC6SV.js.map +1 -0
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +207 -161
- package/dist/index.html +2 -2
- package/dist/index.js +2084 -1705
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +53 -2
- package/src/components/TimelineToolbar.tsx +119 -101
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +179 -0
- package/src/components/nle/useCompositionStack.ts +26 -20
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +41 -0
- package/src/player/components/Timeline.test.ts +32 -19
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineLayout.ts +16 -23
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineGeometry.test.tsx +105 -0
- package/src/player/components/useTimelineGeometry.ts +18 -10
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelinePlayhead.ts +1 -1
- package/src/player/components/useTimelineProviderState.tsx +6 -12
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/src/player/store/liveTime.ts +6 -1
- package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- package/dist/chunk-7VDQIGOU.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -7,6 +7,7 @@ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
|
|
7
7
|
import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
|
|
8
8
|
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
9
9
|
import { useColorGradingController } from "./useColorGradingController";
|
|
10
|
+
import { sceneSwapFor } from "../../player/sceneSwap";
|
|
10
11
|
import type { DomEditSelection } from "./domEditing";
|
|
11
12
|
|
|
12
13
|
function brightPopGrading() {
|
|
@@ -390,6 +391,29 @@ describe("useColorGradingController", () => {
|
|
|
390
391
|
vi.useRealTimers();
|
|
391
392
|
});
|
|
392
393
|
|
|
394
|
+
it("re-sends the grade to the preview after edited scenes are swapped in", async () => {
|
|
395
|
+
vi.useFakeTimers();
|
|
396
|
+
const { contentWindow, iframe } = createPreviewFrame();
|
|
397
|
+
(contentWindow as unknown as { __hfSwapScenes: () => Promise<void> }).__hfSwapScenes =
|
|
398
|
+
async () => {};
|
|
399
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html></html>"));
|
|
400
|
+
const postMessage = vi.spyOn(contentWindow, "postMessage");
|
|
401
|
+
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
|
|
402
|
+
act(() => getState().commitColorGrading(brightPopGrading()));
|
|
403
|
+
await act(async () => {
|
|
404
|
+
vi.advanceTimersByTime(100);
|
|
405
|
+
await vi.runAllTimersAsync();
|
|
406
|
+
});
|
|
407
|
+
postMessage.mockClear();
|
|
408
|
+
|
|
409
|
+
await act(() => sceneSwapFor(iframe)!("/preview", () => true));
|
|
410
|
+
const replayed = colorGradingMessages(postMessage.mock.calls);
|
|
411
|
+
expect(replayed.at(-1)?.grading).toMatchObject({ preset: "bright-pop" });
|
|
412
|
+
|
|
413
|
+
act(() => root.unmount());
|
|
414
|
+
vi.useRealTimers();
|
|
415
|
+
});
|
|
416
|
+
|
|
393
417
|
it("animates only the selected video card without starting the project player", async () => {
|
|
394
418
|
vi.useFakeTimers();
|
|
395
419
|
const { contentWindow, iframe } = createPreviewFrame();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mediaMetadataUrl } from "../../utils/studioHelpers";
|
|
2
2
|
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
3
|
+
import { onPreviewContentReplaced } from "../../player/sceneSwap";
|
|
3
4
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
4
5
|
import {
|
|
5
6
|
HF_COLOR_GRADING_ATTR,
|
|
@@ -445,11 +446,11 @@ export function useColorGradingController({
|
|
|
445
446
|
if (!acceptStudioRuntimeMessage(data)) return;
|
|
446
447
|
refreshAndReplay();
|
|
447
448
|
};
|
|
448
|
-
iframe.addEventListener("load", refreshAndReplay);
|
|
449
449
|
window.addEventListener("message", onMessage);
|
|
450
450
|
const timer = window.setTimeout(refreshAndReplay, 80);
|
|
451
|
+
const stopReplay = onPreviewContentReplaced(iframe, refreshAndReplay);
|
|
451
452
|
return () => {
|
|
452
|
-
|
|
453
|
+
stopReplay();
|
|
453
454
|
window.removeEventListener("message", onMessage);
|
|
454
455
|
window.clearTimeout(timer);
|
|
455
456
|
};
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import { resolveCenterResizeSize } from "./domEditResizeLocal";
|
|
46
46
|
import { resolveResizeDraftRect } from "./resizeDraft";
|
|
47
47
|
import {
|
|
48
|
+
notifyBlockedPress,
|
|
48
49
|
startGesture as _startGesture,
|
|
49
50
|
startGroupDrag as _startGroupDrag,
|
|
50
51
|
} from "./domEditOverlayStartGesture";
|
|
@@ -103,6 +104,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
103
104
|
},
|
|
104
105
|
) => _startGesture(kind, e, opts, options);
|
|
105
106
|
|
|
107
|
+
// A press on a box that cannot move says why at once.
|
|
108
|
+
const startBlockedMove = (e: React.PointerEvent<HTMLElement>, selection: DomEditSelection) => {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
112
|
+
opts.blockedMoveRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY };
|
|
113
|
+
notifyBlockedPress(e, opts, selection);
|
|
114
|
+
};
|
|
115
|
+
|
|
106
116
|
const moveGroupDrag = createGroupDragMover(opts, setDraftGroupOverlayItems);
|
|
107
117
|
|
|
108
118
|
// fallow-ignore-next-line complexity
|
|
@@ -116,13 +126,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
116
126
|
opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
|
|
117
127
|
}
|
|
118
128
|
|
|
119
|
-
if (blockedMove
|
|
129
|
+
if (blockedMove) {
|
|
120
130
|
const dx = e.clientX - blockedMove.startX;
|
|
121
131
|
const dy = e.clientY - blockedMove.startY;
|
|
122
|
-
if (
|
|
123
|
-
blockedMove.notified = true;
|
|
132
|
+
if (Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
|
|
124
133
|
opts.suppressNextBoxClickRef.current = true;
|
|
125
|
-
opts.onBlockedMoveRef.current(sel);
|
|
126
134
|
}
|
|
127
135
|
return;
|
|
128
136
|
}
|
|
@@ -545,5 +553,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
545
553
|
opts.rafPausedRef.current = false;
|
|
546
554
|
};
|
|
547
555
|
|
|
548
|
-
return {
|
|
556
|
+
return {
|
|
557
|
+
startGesture,
|
|
558
|
+
startGroupDrag,
|
|
559
|
+
startBlockedMove,
|
|
560
|
+
onPointerMove,
|
|
561
|
+
onPointerUp,
|
|
562
|
+
clearPointerState,
|
|
563
|
+
};
|
|
549
564
|
}
|
|
@@ -3,10 +3,64 @@ import React, { act } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
|
|
6
|
+
import { liveTime, usePlayerStore } from "../../player/store/playerStore";
|
|
6
7
|
import { useCompositionStack } from "./useCompositionStack";
|
|
7
8
|
|
|
8
9
|
installReactActEnvironment();
|
|
9
10
|
|
|
11
|
+
describe("useCompositionStack — drilling into a composition file", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
for (const compositionSrc of [
|
|
17
|
+
"sections/compositions/x.html",
|
|
18
|
+
"http://localhost:5190/api/projects/p/preview/sections/compositions/x.html",
|
|
19
|
+
"/api/projects/p/preview/comp/sections/compositions/x.html",
|
|
20
|
+
]) {
|
|
21
|
+
it(`opens the full project path for ${compositionSrc}`, async () => {
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
document.body.append(host);
|
|
24
|
+
const root = createRoot(host);
|
|
25
|
+
let stack!: ReturnType<typeof useCompositionStack>;
|
|
26
|
+
function Harness() {
|
|
27
|
+
stack = useCompositionStack({ projectId: "p" });
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
await act(async () => {
|
|
31
|
+
root.render(<Harness />);
|
|
32
|
+
});
|
|
33
|
+
await act(async () => {
|
|
34
|
+
stack.handleDrillDown({ id: "x", compositionSrc });
|
|
35
|
+
});
|
|
36
|
+
expect(stack.compositionStack[1]).toMatchObject({
|
|
37
|
+
id: "sections/compositions/x.html",
|
|
38
|
+
previewUrl: "/api/projects/p/preview/comp/sections/compositions/x.html",
|
|
39
|
+
});
|
|
40
|
+
act(() => root.unmount());
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it("keeps a top-level compositions/ path and drops a leading ./", async () => {
|
|
45
|
+
const host = document.createElement("div");
|
|
46
|
+
document.body.append(host);
|
|
47
|
+
const root = createRoot(host);
|
|
48
|
+
let stack!: ReturnType<typeof useCompositionStack>;
|
|
49
|
+
function Harness() {
|
|
50
|
+
stack = useCompositionStack({ projectId: "p" });
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
await act(async () => {
|
|
54
|
+
root.render(<Harness />);
|
|
55
|
+
});
|
|
56
|
+
await act(async () => {
|
|
57
|
+
stack.handleDrillDown({ id: "x", compositionSrc: "./compositions/x.html" });
|
|
58
|
+
});
|
|
59
|
+
expect(stack.compositionStack[1]?.id).toBe("compositions/x.html");
|
|
60
|
+
act(() => root.unmount());
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
10
64
|
describe("useCompositionStack — project scoping", () => {
|
|
11
65
|
afterEach(() => {
|
|
12
66
|
document.body.innerHTML = "";
|
|
@@ -111,3 +165,128 @@ describe("useCompositionStack — activating a composition by path", () => {
|
|
|
111
165
|
act(() => root.unmount());
|
|
112
166
|
});
|
|
113
167
|
});
|
|
168
|
+
|
|
169
|
+
describe("useCompositionStack — back to the master", () => {
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
document.body.innerHTML = "";
|
|
172
|
+
usePlayerStore.getState().setCurrentTime(0);
|
|
173
|
+
usePlayerStore.getState().setIsPlaying(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const INTRO = { id: "intro", compositionSrc: "compositions/intro.html" };
|
|
177
|
+
const LOGO = { id: "logo", compositionSrc: "compositions/logo.html" };
|
|
178
|
+
const seek = (time: number) => act(() => usePlayerStore.getState().setCurrentTime(time));
|
|
179
|
+
const time = () => usePlayerStore.getState().currentTime;
|
|
180
|
+
|
|
181
|
+
async function mount(props: { projectId?: string; activeCompositionPath?: string | null } = {}) {
|
|
182
|
+
const host = document.createElement("div");
|
|
183
|
+
document.body.append(host);
|
|
184
|
+
const root = createRoot(host);
|
|
185
|
+
const seen = {} as { stack: ReturnType<typeof useCompositionStack> };
|
|
186
|
+
function Harness(p: { projectId: string; activeCompositionPath: string | null }) {
|
|
187
|
+
seen.stack = useCompositionStack(p);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const render = async (next: typeof props) => {
|
|
191
|
+
props = { ...props, ...next };
|
|
192
|
+
await act(async () => {
|
|
193
|
+
root.render(
|
|
194
|
+
<Harness
|
|
195
|
+
projectId={props.projectId ?? "p"}
|
|
196
|
+
activeCompositionPath={props.activeCompositionPath ?? null}
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
await render({});
|
|
202
|
+
const run = async (step: (stack: ReturnType<typeof useCompositionStack>) => void) => {
|
|
203
|
+
await act(async () => step(seen.stack));
|
|
204
|
+
};
|
|
205
|
+
return { seen, render, run, unmount: () => act(() => root.unmount()) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("restores the master playhead from the breadcrumb after a two-level drill", async () => {
|
|
209
|
+
const { seen, run, unmount } = await mount();
|
|
210
|
+
seek(12);
|
|
211
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
212
|
+
seek(3);
|
|
213
|
+
await run((s) => s.handleDrillDown(LOGO));
|
|
214
|
+
expect(seen.stack.compositionStack.map((level) => level.label)).toEqual([
|
|
215
|
+
"Master",
|
|
216
|
+
"intro",
|
|
217
|
+
"logo",
|
|
218
|
+
]);
|
|
219
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
220
|
+
expect(time()).toBe(12);
|
|
221
|
+
unmount();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("restores the master playhead when Escape or a timeline double-click goes back", async () => {
|
|
225
|
+
const { run, unmount } = await mount();
|
|
226
|
+
seek(12);
|
|
227
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
228
|
+
seek(3);
|
|
229
|
+
await run((s) => s.handleDrillDown(LOGO));
|
|
230
|
+
seek(1);
|
|
231
|
+
await run((s) => s.updateCompositionStack((prev) => prev.slice(0, -1)));
|
|
232
|
+
expect(time()).toBe(1);
|
|
233
|
+
await run((s) => s.updateCompositionStack((prev) => prev.slice(0, -1)));
|
|
234
|
+
expect(time()).toBe(12);
|
|
235
|
+
unmount();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("restores the live master playhead when the drill happens during playback", async () => {
|
|
239
|
+
const { run, unmount } = await mount();
|
|
240
|
+
seek(4);
|
|
241
|
+
act(() => usePlayerStore.getState().setIsPlaying(true));
|
|
242
|
+
liveTime.notify(9);
|
|
243
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
244
|
+
act(() => usePlayerStore.getState().setIsPlaying(false));
|
|
245
|
+
seek(2);
|
|
246
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
247
|
+
expect(time()).toBe(9);
|
|
248
|
+
unmount();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("saves the master playhead at the first of two drills in one batch", async () => {
|
|
252
|
+
const { run, unmount } = await mount();
|
|
253
|
+
seek(12);
|
|
254
|
+
await run((s) => {
|
|
255
|
+
s.handleDrillDown(INTRO);
|
|
256
|
+
usePlayerStore.getState().setCurrentTime(7);
|
|
257
|
+
s.handleDrillDown(LOGO);
|
|
258
|
+
});
|
|
259
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
260
|
+
expect(time()).toBe(12);
|
|
261
|
+
unmount();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("restores a master playhead of exactly 0", async () => {
|
|
265
|
+
const { run, unmount } = await mount();
|
|
266
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
267
|
+
seek(3);
|
|
268
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
269
|
+
expect(time()).toBe(0);
|
|
270
|
+
unmount();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("restores the master playhead when the Comps panel goes there and back", async () => {
|
|
274
|
+
const { render, unmount } = await mount();
|
|
275
|
+
seek(30);
|
|
276
|
+
await render({ activeCompositionPath: "compositions/intro.html" });
|
|
277
|
+
seek(4);
|
|
278
|
+
await render({ activeCompositionPath: "index.html" });
|
|
279
|
+
expect(time()).toBe(30);
|
|
280
|
+
unmount();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("does not carry one project's master playhead into another", async () => {
|
|
284
|
+
const { run, render, unmount } = await mount({ projectId: "a" });
|
|
285
|
+
seek(12);
|
|
286
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
287
|
+
seek(3);
|
|
288
|
+
await render({ projectId: "b" });
|
|
289
|
+
expect(time()).toBe(3);
|
|
290
|
+
unmount();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
// Composition drill-down stack management for NLEContext/EditorShell
|
|
3
3
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
4
|
-
import { usePlayerStore } from "../../player/store/playerStore";
|
|
4
|
+
import { liveTime, usePlayerStore } from "../../player/store/playerStore";
|
|
5
5
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
6
6
|
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
|
|
7
|
+
import { normalizeTimelineCompositionSource } from "../editor/domEditingDom";
|
|
7
8
|
|
|
8
9
|
interface UseCompositionStackOptions {
|
|
9
10
|
projectId: string;
|
|
@@ -37,16 +38,30 @@ export function useCompositionStack({
|
|
|
37
38
|
const onCompositionChangeRef = useRef(onCompositionChange);
|
|
38
39
|
onCompositionChangeRef.current = onCompositionChange;
|
|
39
40
|
|
|
41
|
+
const masterSeekRef = useRef(0);
|
|
42
|
+
const masterSeekProjectRef = useRef<string | null>(null);
|
|
43
|
+
const projectIdRef = useRef(projectId);
|
|
44
|
+
projectIdRef.current = projectId;
|
|
45
|
+
const stackRef = useRef(compositionStack);
|
|
46
|
+
|
|
40
47
|
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
const prev = stackRef.current;
|
|
49
|
+
const next = typeof action === "function" ? action(prev) : action;
|
|
50
|
+
stackRef.current = next;
|
|
51
|
+
const player = usePlayerStore.getState();
|
|
52
|
+
if (prev.length === 1 && next.length > 1) {
|
|
53
|
+
masterSeekRef.current = player.isPlaying ? liveTime.latest() : player.currentTime;
|
|
54
|
+
masterSeekProjectRef.current = projectIdRef.current;
|
|
55
|
+
} else if (next.length === 1 && prev.length > 1) {
|
|
56
|
+
if (masterSeekProjectRef.current === projectIdRef.current) {
|
|
57
|
+
player.setCurrentTime(masterSeekRef.current);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
setCompositionStack(next);
|
|
61
|
+
const id = next[next.length - 1]?.id;
|
|
62
|
+
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
|
47
63
|
}, []);
|
|
48
64
|
|
|
49
|
-
const masterSeekRef = useRef(0);
|
|
50
65
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
51
66
|
|
|
52
67
|
const compIdToSrcRef = useRef(compIdToSrc);
|
|
@@ -54,9 +69,6 @@ export function useCompositionStack({
|
|
|
54
69
|
|
|
55
70
|
const handleNavigateComposition = useCallback(
|
|
56
71
|
(index: number) => {
|
|
57
|
-
if (index === 0 && masterSeekRef.current > 0) {
|
|
58
|
-
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
|
|
59
|
-
}
|
|
60
72
|
usePlayerStore.getState().setElements([]);
|
|
61
73
|
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
|
62
74
|
},
|
|
@@ -67,16 +79,10 @@ export function useCompositionStack({
|
|
|
67
79
|
const handleDrillDown = useCallback(
|
|
68
80
|
(element: { id: string; compositionSrc?: string }) => {
|
|
69
81
|
if (!element.compositionSrc) return;
|
|
70
|
-
masterSeekRef.current = usePlayerStore.getState().currentTime;
|
|
71
82
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!resolvedPath) {
|
|
76
|
-
const src = element.compositionSrc;
|
|
77
|
-
const compMatch = src.match(/compositions\/.*\.html/);
|
|
78
|
-
resolvedPath = compMatch ? compMatch[0] : src;
|
|
79
|
-
}
|
|
83
|
+
const src = compIdToSrcRef.current.get(element.id) ?? element.compositionSrc;
|
|
84
|
+
const normalized = normalizeTimelineCompositionSource(src) ?? src;
|
|
85
|
+
const resolvedPath = normalized.replace(/^(\.\/|\/)+/, "");
|
|
80
86
|
|
|
81
87
|
usePlayerStore.getState().setElements([]);
|
|
82
88
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
2
|
+
import {
|
|
3
|
+
isImageBackgroundValue,
|
|
4
|
+
isManualGeometryStyleProperty,
|
|
5
|
+
normalizeDomEditStyleValue,
|
|
6
|
+
} from "../utils/studioHelpers";
|
|
7
|
+
import {
|
|
8
|
+
injectPreviewGoogleFont,
|
|
9
|
+
injectPreviewImportedFont,
|
|
10
|
+
ensureImportedFontFace,
|
|
11
|
+
} from "../utils/studioFontHelpers";
|
|
12
|
+
import {
|
|
13
|
+
buildDomEditStylePatchOperation,
|
|
14
|
+
findElementForSelection,
|
|
15
|
+
getDomEditTargetKey,
|
|
16
|
+
type DomEditSelection,
|
|
17
|
+
} from "../components/editor/domEditing";
|
|
18
|
+
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
19
|
+
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
20
|
+
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
|
|
21
|
+
import {
|
|
22
|
+
bumpDomEditCommitMapVersion,
|
|
23
|
+
domEditCommitDeclined,
|
|
24
|
+
runReportedDomEditCommit,
|
|
25
|
+
type DomEditCommitOutcome,
|
|
26
|
+
} from "./domEditCommitRunner";
|
|
27
|
+
|
|
28
|
+
const IMAGE_BACKGROUND_FIT: Array<[string, string]> = [
|
|
29
|
+
["background-position", "center"],
|
|
30
|
+
["background-repeat", "no-repeat"],
|
|
31
|
+
["background-size", "contain"],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export interface DomStyleCommitContext {
|
|
35
|
+
activeCompPath: string | null;
|
|
36
|
+
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
37
|
+
persistDomEditOperations: PersistDomEditOperations;
|
|
38
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
39
|
+
/** Latest-commit versions per target and property set; keep one map per caller. */
|
|
40
|
+
versions: Map<string, symbol>;
|
|
41
|
+
resolveImportedFontAsset?: (fontFamilyValue: string) => ImportedFontAsset | null;
|
|
42
|
+
resync?: (selection: DomEditSelection) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Applies inline styles live and saves them as one source patch and one undo step. */
|
|
46
|
+
// fallow-ignore-next-line complexity
|
|
47
|
+
export async function commitDomStyles(
|
|
48
|
+
context: DomStyleCommitContext,
|
|
49
|
+
selection: DomEditSelection,
|
|
50
|
+
styles: Record<string, string>,
|
|
51
|
+
): Promise<DomEditCommitOutcome> {
|
|
52
|
+
const entries = Object.entries(styles);
|
|
53
|
+
if (entries.length === 0) return domEditCommitDeclined("no-selection");
|
|
54
|
+
if (entries.some(([property]) => isManualGeometryStyleProperty(property)))
|
|
55
|
+
return domEditCommitDeclined("geometry-property");
|
|
56
|
+
if (!selection.capabilities.canEditStyles) return domEditCommitDeclined("styles-not-editable");
|
|
57
|
+
|
|
58
|
+
const { activeCompPath, previewIframeRef, persistDomEditOperations, showToast } = context;
|
|
59
|
+
// One version per element and property, so a failed save never reverts a later edit's property.
|
|
60
|
+
const targetKey = getDomEditTargetKey(selection);
|
|
61
|
+
const owned = entries.map(([property]) => ({
|
|
62
|
+
property,
|
|
63
|
+
isLatest: bumpDomEditCommitMapVersion(context.versions, `${targetKey}:${property}`),
|
|
64
|
+
}));
|
|
65
|
+
const ownsAny = () => owned.some(({ isLatest }) => isLatest());
|
|
66
|
+
const fontFamily = styles["font-family"];
|
|
67
|
+
const importedFont =
|
|
68
|
+
fontFamily !== undefined ? (context.resolveImportedFontAsset?.(fontFamily) ?? null) : null;
|
|
69
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
70
|
+
const operations: PatchOperation[] = [];
|
|
71
|
+
const liveStyles: Array<[string, string]> = [];
|
|
72
|
+
for (const [property, value] of entries) {
|
|
73
|
+
const normalized = normalizeDomEditStyleValue(property, value);
|
|
74
|
+
operations.push(buildDomEditStylePatchOperation(property, normalized));
|
|
75
|
+
liveStyles.push([property, normalized]);
|
|
76
|
+
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
77
|
+
for (const [fit, fitValue] of IMAGE_BACKGROUND_FIT) {
|
|
78
|
+
if (fit in styles) continue;
|
|
79
|
+
operations.push(buildDomEditStylePatchOperation(fit, fitValue));
|
|
80
|
+
liveStyles.push([fit, fitValue]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let editedElement: HTMLElement | null = null;
|
|
85
|
+
const previousInline = new Map<string, string>();
|
|
86
|
+
|
|
87
|
+
return runReportedDomEditCommit({
|
|
88
|
+
capture: () => {
|
|
89
|
+
const el = doc ? findElementForSelection(doc, selection, activeCompPath) : null;
|
|
90
|
+
if (!el) return;
|
|
91
|
+
editedElement = el;
|
|
92
|
+
for (const [property] of entries)
|
|
93
|
+
previousInline.set(property, el.style.getPropertyValue(property));
|
|
94
|
+
},
|
|
95
|
+
apply: () => {
|
|
96
|
+
if (!editedElement) return;
|
|
97
|
+
for (const [property, value] of liveStyles) editedElement.style.setProperty(property, value);
|
|
98
|
+
if (fontFamily !== undefined && doc) {
|
|
99
|
+
injectPreviewGoogleFont(doc, fontFamily);
|
|
100
|
+
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
persist: () =>
|
|
104
|
+
persistDomEditOperations(selection, operations, {
|
|
105
|
+
label: "Edit layer style",
|
|
106
|
+
// Inline styles are already live, so a reload would only blank the preview.
|
|
107
|
+
skipRefresh: true,
|
|
108
|
+
prepareContent: importedFont
|
|
109
|
+
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
110
|
+
: undefined,
|
|
111
|
+
}),
|
|
112
|
+
shouldRevert: ownsAny,
|
|
113
|
+
revert: () => {
|
|
114
|
+
const el = editedElement;
|
|
115
|
+
if (!el) return;
|
|
116
|
+
// ponytail: background-image fit styles are not reverted here.
|
|
117
|
+
for (const { property, isLatest } of owned) {
|
|
118
|
+
const previous = previousInline.get(property);
|
|
119
|
+
if (previous === undefined || !isLatest()) continue;
|
|
120
|
+
if (previous === "") el.style.removeProperty(property);
|
|
121
|
+
else el.style.setProperty(property, previous);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
onError: (error) => reportDomEditPersistFailure(selection, operations, error, showToast),
|
|
125
|
+
shouldResync: ownsAny,
|
|
126
|
+
resync: () => context.resync?.(selection),
|
|
127
|
+
onFinally: () => owned.forEach(({ isLatest }) => isLatest.release()),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -45,7 +45,7 @@ export type GsapEditOutcome =
|
|
|
45
45
|
}
|
|
46
46
|
| { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail };
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
|
|
49
49
|
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
|
|
50
50
|
"unroll-required":
|
|
51
51
|
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
|
|
@@ -57,7 +57,7 @@ export class GsapEditBlockedError extends Error {
|
|
|
57
57
|
readonly reason: GsapEditBlockReason,
|
|
58
58
|
readonly detail?: GsapEditBlockDetail,
|
|
59
59
|
) {
|
|
60
|
-
super(
|
|
60
|
+
super(GSAP_EDIT_BLOCK_COPY[reason]);
|
|
61
61
|
this.name = "GsapEditBlockedError";
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
|
|
3
|
+
interface GsapElementTarget {
|
|
4
|
+
id?: string | null;
|
|
5
|
+
selector?: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tweens whose target addresses the element: by id, by its selection selector, as one part of a
|
|
10
|
+
* group selector, or, given the live element, by any selector part the element matches.
|
|
11
|
+
*/
|
|
12
|
+
export function getAnimationsForElement(
|
|
13
|
+
animations: GsapAnimation[],
|
|
14
|
+
target: GsapElementTarget,
|
|
15
|
+
element?: Element | null,
|
|
16
|
+
): GsapAnimation[] {
|
|
17
|
+
const matchers = new Set<string>();
|
|
18
|
+
if (target.id) matchers.add(`#${target.id}`);
|
|
19
|
+
if (target.selector) matchers.add(target.selector);
|
|
20
|
+
if (matchers.size === 0 && !element) return [];
|
|
21
|
+
return animations.filter((a) =>
|
|
22
|
+
a.targetSelector.split(",").some((part) => {
|
|
23
|
+
const trimmed = part.trim();
|
|
24
|
+
if (!trimmed) return false;
|
|
25
|
+
if (matchers.has(trimmed)) return true;
|
|
26
|
+
const lastSimple = trimmed.split(/\s+/).pop();
|
|
27
|
+
if (lastSimple && matchers.has(lastSimple)) return true;
|
|
28
|
+
if (element) {
|
|
29
|
+
try {
|
|
30
|
+
if (element.matches(trimmed)) return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* frame can't jump. Split from gsapRuntimeBridge, which owns the shared
|
|
6
6
|
* group-tween resolution used by the drag/resize/rotate intercepts.
|
|
7
7
|
*/
|
|
8
|
-
import type { GsapAnimation
|
|
8
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
9
9
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
10
|
import { clearStudioBoxSize } from "../components/editor/manualEdits";
|
|
11
11
|
import {
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
} from "../components/editor/manualEditsTypes";
|
|
15
15
|
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
|
|
16
16
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
17
|
-
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
18
17
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
|
19
18
|
import {
|
|
20
19
|
commitStaticGsapPosition,
|
|
@@ -35,11 +34,8 @@ import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsa
|
|
|
35
34
|
import { roundTo3 } from "../utils/rounding";
|
|
36
35
|
import { resolveGroupTween } from "./gsapRuntimeBridge";
|
|
37
36
|
import { logResize } from "../utils/resizeDebug";
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
directEditOutcomeForProperties,
|
|
41
|
-
type GsapEditOutcome,
|
|
42
|
-
} from "./gsapEditOutcome";
|
|
37
|
+
import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
38
|
+
import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
|
|
43
39
|
|
|
44
40
|
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
45
41
|
|
|
@@ -103,17 +99,12 @@ export async function tryGsapResizeIntercept(
|
|
|
103
99
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
104
100
|
): Promise<GsapEditOutcome> {
|
|
105
101
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const resizeProperties =
|
|
113
|
-
resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
|
|
114
|
-
const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
|
|
115
|
-
if (editability.status === "blocked") return editability;
|
|
116
|
-
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
|
|
102
|
+
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
|
|
103
|
+
if (outcome.status === "blocked") return outcome;
|
|
104
|
+
const { resizeGroup, resizeProperties, workingAnimations } = resizeRoute(
|
|
105
|
+
animations,
|
|
106
|
+
fetchedAnimations,
|
|
107
|
+
);
|
|
117
108
|
// The initial ownership fetch already supplied the complete parse. Only retain
|
|
118
109
|
// the fetch callback when a legacy mixed tween may be split and must then be
|
|
119
110
|
// re-read; otherwise resolveGroupTween would perform the same network read twice.
|
|
@@ -132,20 +123,8 @@ export async function tryGsapResizeIntercept(
|
|
|
132
123
|
resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
|
|
133
124
|
? resolved.anim
|
|
134
125
|
: null;
|
|
135
|
-
const liveSelector = selectorFromSelection(selection);
|
|
136
|
-
const hasLiveResizeTween = liveSelector
|
|
137
|
-
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
|
|
138
|
-
: false;
|
|
139
|
-
if (!anim && hasLiveResizeTween) {
|
|
140
|
-
// Third twin of the position/rotation cases: a live tween with no source match.
|
|
141
|
-
return {
|
|
142
|
-
status: "blocked",
|
|
143
|
-
reason: "source-uneditable",
|
|
144
|
-
detail: "live-resize-no-source-tween",
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
126
|
logResize("intercept-enter", {
|
|
148
|
-
hasScaleGroup,
|
|
127
|
+
hasScaleGroup: resizeGroup === "scale",
|
|
149
128
|
resizeGroup,
|
|
150
129
|
animMethod: anim?.method ?? null,
|
|
151
130
|
animId: anim?.id ?? null,
|