@hyperframes/studio 0.8.100 → 0.8.102
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-fWt87gyh.js → hyperframes-player-BuRiFtrF.js} +1 -1
- package/dist/assets/{index-DofdzdxJ.js → index-BM35fjFf.js} +1 -1
- package/dist/assets/{index-D34Oy5DN.js → index-BT3sauee.js} +1 -1
- package/dist/assets/{index-D6TjRAvz.js → index-C2utDZt-.js} +114 -114
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +101 -21
- package/dist/index.html +1 -1
- package/dist/index.js +1211 -990
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
- package/src/components/editor/CanvasContextMenu.tsx +7 -10
- package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
- package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
- package/src/components/editor/gsapAnimatesProperty.ts +0 -29
- package/src/components/editor/gsapLivePreview.test.ts +46 -1
- package/src/components/editor/propertyPanelHelpers.ts +3 -0
- package/src/components/editor/useDomEditZOrder.ts +185 -0
- package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
- package/src/domEditExports.test.tsx +92 -0
- package/src/hooks/appHotkeysDispatch.ts +1 -7
- package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useDomEditCommits.test.tsx +94 -90
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +7 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- package/dist/chunk-U3EE4ACX.js.map +0 -1
|
@@ -4,7 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
6
6
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
7
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
8
|
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
9
9
|
import { resolveDropInsertRow, useTimelineAssetDrop } from "./timelineDragDrop";
|
|
10
10
|
import { getTimelineRowTop, TRACK_H } from "./timelineLayout";
|
|
@@ -39,10 +39,19 @@ function assetTransfer(payload: string): DropTransfer {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// One far-off clip per lane, so a drop keeps its pointer time instead of an empty lane's 0.
|
|
43
|
+
const FAR_CLIPS: TimelineElement[] = Array.from({ length: 100 }, (_, track) => ({
|
|
44
|
+
id: `far-${track}`,
|
|
45
|
+
tag: "div",
|
|
46
|
+
start: 900,
|
|
47
|
+
duration: 1,
|
|
48
|
+
track,
|
|
49
|
+
}));
|
|
50
|
+
|
|
42
51
|
function renderHarness(
|
|
43
52
|
onAssetDrop: Mock,
|
|
44
53
|
sessionEpoch = 1,
|
|
45
|
-
options: { onBlockDrop?: Mock; strict?: boolean } = {},
|
|
54
|
+
options: { onBlockDrop?: Mock; strict?: boolean; elements?: TimelineElement[] } = {},
|
|
46
55
|
) {
|
|
47
56
|
const tracks = Array.from({ length: 100 }, (_, index) => index);
|
|
48
57
|
const geometry = createTimelineRowGeometry(
|
|
@@ -60,6 +69,7 @@ function renderHarness(
|
|
|
60
69
|
scrollRef: { current: scroll },
|
|
61
70
|
ppsRef: { current: 40 },
|
|
62
71
|
trackOrderRef: { current: tracks },
|
|
72
|
+
elementsRef: { current: options.elements ?? FAR_CLIPS },
|
|
63
73
|
rowGeometryRef: { current: geometry },
|
|
64
74
|
contentOrigin: 0,
|
|
65
75
|
sessionEpoch: epoch,
|
|
@@ -149,8 +159,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
149
159
|
});
|
|
150
160
|
|
|
151
161
|
expect(onAssetDrop).toHaveBeenCalledTimes(1);
|
|
152
|
-
//
|
|
153
|
-
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start:
|
|
162
|
+
// The appended lane is empty, so the drop starts at 0.
|
|
163
|
+
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 0, track: 100 });
|
|
154
164
|
expect(view.api.isDragOver).toBe(false);
|
|
155
165
|
act(() => view.root.unmount());
|
|
156
166
|
});
|
|
@@ -256,6 +266,52 @@ describe("useTimelineAssetDrop", () => {
|
|
|
256
266
|
});
|
|
257
267
|
});
|
|
258
268
|
|
|
269
|
+
describe("useTimelineAssetDrop start", () => {
|
|
270
|
+
function dropAt(elements: TimelineElement[], clientX: number) {
|
|
271
|
+
const onAssetDrop = vi.fn();
|
|
272
|
+
const view = renderHarness(onAssetDrop, 1, { elements });
|
|
273
|
+
const transfer = assetTransfer(JSON.stringify({ path: "assets/a.mp4" }));
|
|
274
|
+
act(() => view.api.handleAssetDragOver(dragEvent(transfer, clientX, ROW0_MID_Y)));
|
|
275
|
+
const preview = view.api.dropPreview;
|
|
276
|
+
act(() => view.api.handleAssetDrop(dragEvent(transfer, clientX, ROW0_MID_Y)));
|
|
277
|
+
act(() => view.root.unmount());
|
|
278
|
+
return { preview, committed: onAssetDrop.mock.calls[0]?.[1] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
it("starts a drop on an empty timeline at 0, in the preview and the commit", () => {
|
|
282
|
+
// pps=40, clientX=80 -> 2s at the pointer.
|
|
283
|
+
const { preview, committed } = dropAt([], 80);
|
|
284
|
+
expect(preview).toEqual({ start: 0, track: 0 });
|
|
285
|
+
expect(committed).toEqual(preview);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("starts a drop on an empty lane at 0 while other lanes hold clips", () => {
|
|
289
|
+
const { committed } = dropAt([{ id: "c", tag: "div", start: 0, duration: 5, track: 1 }], 80);
|
|
290
|
+
expect(committed).toEqual({ start: 0, track: 0 });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("snaps a drop within TIMELINE_SNAP_PX of a clip edge onto that edge", () => {
|
|
294
|
+
const lane: TimelineElement[] = [{ id: "c", tag: "div", start: 0, duration: 5, track: 0 }];
|
|
295
|
+
// The clip ends at x=200; 6 px past it is 5.15s, inside the 8 px radius.
|
|
296
|
+
const { preview, committed } = dropAt(lane, 206);
|
|
297
|
+
expect(preview).toEqual({ start: 5, track: 0 });
|
|
298
|
+
expect(committed).toEqual(preview);
|
|
299
|
+
// 12 px past it is outside the radius and keeps the pointer time.
|
|
300
|
+
expect(dropAt(lane, 212).committed).toEqual({ start: 5.3, track: 0 });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("keeps the pointer time with the magnet off, and still starts an empty lane at 0", () => {
|
|
304
|
+
usePlayerStore.getState().setTimelineSnapEnabled(false);
|
|
305
|
+
try {
|
|
306
|
+
const lane: TimelineElement[] = [{ id: "c", tag: "div", start: 0, duration: 5, track: 0 }];
|
|
307
|
+
expect(dropAt(lane, 206).committed).toEqual({ start: 5.15, track: 0 });
|
|
308
|
+
expect(dropAt([], 80).committed).toEqual({ start: 0, track: 0 });
|
|
309
|
+
} finally {
|
|
310
|
+
usePlayerStore.getState().setTimelineSnapEnabled(true);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
259
315
|
describe("resolveDropInsertRow", () => {
|
|
260
316
|
const rows = [TRACK_H, TRACK_H, TRACK_H];
|
|
261
317
|
it("stays on the row at the boundary between two rows", () => {
|
|
@@ -302,7 +358,7 @@ describe("useTimelineAssetDrop new-track drops", () => {
|
|
|
302
358
|
expect(onAssetDrop).toHaveBeenCalledWith(
|
|
303
359
|
"assets/a.png",
|
|
304
360
|
expect.objectContaining({
|
|
305
|
-
start:
|
|
361
|
+
start: 0,
|
|
306
362
|
insertRow: 0,
|
|
307
363
|
trackOrder: Array.from({ length: 100 }, (_, index) => index),
|
|
308
364
|
}),
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type TimelineRowGeometry,
|
|
11
11
|
} from "./timelineLayout";
|
|
12
12
|
import { resolveInsertRow } from "./timelineCollision";
|
|
13
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
14
|
+
import { collectTimelineSnapTargets, snapTimelineTime, TIMELINE_SNAP_PX } from "./timelineSnapping";
|
|
13
15
|
import type { TimelineDropCallbacks, TimelineDropPlacement } from "./timelineCallbacks";
|
|
14
16
|
import {
|
|
15
17
|
applyTimelineAutoScrollStep,
|
|
@@ -20,6 +22,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
|
20
22
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
21
23
|
ppsRef: RefObject<number>;
|
|
22
24
|
trackOrderRef: RefObject<number[]>;
|
|
25
|
+
elementsRef: RefObject<readonly TimelineElement[]>;
|
|
23
26
|
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
|
24
27
|
contentOrigin: number;
|
|
25
28
|
sessionEpoch: number;
|
|
@@ -65,20 +68,36 @@ export function resolveDropInsertRow(
|
|
|
65
68
|
return resolveInsertRow(rowFloat, trackCount);
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
function alignDropStart(
|
|
72
|
+
placement: TimelineDropPlacement,
|
|
73
|
+
elements: readonly TimelineElement[],
|
|
74
|
+
pixelsPerSecond: number,
|
|
75
|
+
): TimelineDropPlacement {
|
|
76
|
+
const laneHasClips =
|
|
77
|
+
placement.insertRow == null && elements.some((el) => el.track === placement.track);
|
|
78
|
+
if (!laneHasClips) return { ...placement, start: 0 };
|
|
79
|
+
if (!usePlayerStore.getState().timelineSnapEnabled) return placement;
|
|
80
|
+
const targets = collectTimelineSnapTargets({ elements, playheadTime: null, beatTimes: [] });
|
|
81
|
+
const threshold = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
|
|
82
|
+
return { ...placement, start: snapTimelineTime(placement.start, targets, threshold).time };
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
function placeDrop(
|
|
69
86
|
geometry: Parameters<typeof resolveTimelineAssetDrop>[0],
|
|
87
|
+
elements: readonly TimelineElement[],
|
|
70
88
|
clientX: number,
|
|
71
89
|
clientY: number,
|
|
72
90
|
// Blocks and compositions are written by their own installers, which only take a track.
|
|
73
91
|
canInsertTrack: boolean,
|
|
74
92
|
): TimelineDropPlacement {
|
|
75
93
|
const placement = resolveTimelineAssetDrop(geometry, clientX, clientY);
|
|
76
|
-
if (!canInsertTrack) return placement;
|
|
77
94
|
const contentY = clientY - geometry.rectTop + geometry.scrollTop;
|
|
78
|
-
const insertRow =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
const insertRow = canInsertTrack
|
|
96
|
+
? resolveDropInsertRow(contentY, geometry.rowHeights, geometry.trackOrder.length)
|
|
97
|
+
: null;
|
|
98
|
+
const aimed =
|
|
99
|
+
insertRow == null ? placement : { ...placement, insertRow, trackOrder: geometry.trackOrder };
|
|
100
|
+
return alignDropStart(aimed, elements, geometry.pixelsPerSecond);
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
function invokeDropCallback(callback: () => Promise<void> | void): void {
|
|
@@ -118,14 +137,15 @@ function applyTypedJsonDrop(
|
|
|
118
137
|
}
|
|
119
138
|
|
|
120
139
|
/**
|
|
121
|
-
* Dropping an asset/file/block/composition
|
|
122
|
-
*
|
|
140
|
+
* Dropping an asset/file/block/composition places it on the row at 0 on an empty lane, else at
|
|
141
|
+
* the pointer snapped to a near clip edge; an asset moves to that row's nearest free time.
|
|
123
142
|
* Supersedes the prior playhead decision (#2291); see useAddAssetAtPlayhead.
|
|
124
143
|
*/
|
|
125
144
|
export function useTimelineAssetDrop({
|
|
126
145
|
scrollRef,
|
|
127
146
|
ppsRef,
|
|
128
147
|
trackOrderRef,
|
|
148
|
+
elementsRef,
|
|
129
149
|
rowGeometryRef,
|
|
130
150
|
contentOrigin,
|
|
131
151
|
onFileDrop,
|
|
@@ -197,12 +217,13 @@ export function useTimelineAssetDrop({
|
|
|
197
217
|
rowHeights: rowGeometryRef.current.rowHeights,
|
|
198
218
|
trackOrder: trackOrderRef.current,
|
|
199
219
|
},
|
|
220
|
+
elementsRef.current,
|
|
200
221
|
clientX,
|
|
201
222
|
clientY,
|
|
202
223
|
canInsertTrack,
|
|
203
224
|
);
|
|
204
225
|
},
|
|
205
|
-
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
226
|
+
[scrollRef, ppsRef, trackOrderRef, elementsRef, rowGeometryRef, contentOrigin],
|
|
206
227
|
);
|
|
207
228
|
|
|
208
229
|
const handleAssetDragOver = useCallback(
|
|
@@ -20,6 +20,7 @@ interface DomEditSaveQueueOptions {
|
|
|
20
20
|
export interface DomEditSaveQueue {
|
|
21
21
|
enqueue: <T>(save: () => Promise<T>) => Promise<T>;
|
|
22
22
|
waitForIdle: () => Promise<DomEditSaveDrainResult>;
|
|
23
|
+
isIdle: () => boolean;
|
|
23
24
|
reset: () => void;
|
|
24
25
|
destroy: () => void;
|
|
25
26
|
}
|
|
@@ -39,6 +40,7 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
39
40
|
const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
|
40
41
|
|
|
41
42
|
let tail = Promise.resolve();
|
|
43
|
+
let queuedCount = 0;
|
|
42
44
|
let consecutiveFailures = 0;
|
|
43
45
|
let breakerOpen = false;
|
|
44
46
|
let drainError: unknown = null;
|
|
@@ -81,6 +83,7 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
81
83
|
return {
|
|
82
84
|
enqueue(save) {
|
|
83
85
|
if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
|
|
86
|
+
queuedCount += 1;
|
|
84
87
|
const queued = tail
|
|
85
88
|
.catch(() => undefined)
|
|
86
89
|
.then(() => {
|
|
@@ -88,8 +91,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
88
91
|
return run(save);
|
|
89
92
|
});
|
|
90
93
|
tail = queued.then(
|
|
91
|
-
() =>
|
|
92
|
-
() =>
|
|
94
|
+
() => void (queuedCount -= 1),
|
|
95
|
+
() => void (queuedCount -= 1),
|
|
93
96
|
);
|
|
94
97
|
return queued;
|
|
95
98
|
},
|
|
@@ -103,6 +106,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
103
106
|
return { status: "clean" };
|
|
104
107
|
},
|
|
105
108
|
|
|
109
|
+
isIdle: () => queuedCount === 0,
|
|
110
|
+
|
|
106
111
|
reset,
|
|
107
112
|
|
|
108
113
|
destroy() {
|
|
@@ -91,7 +91,7 @@ function isGsapScript(text: string): boolean {
|
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
export function findGsapScriptElements(doc:
|
|
94
|
+
export function findGsapScriptElements(doc: ParentNode): HTMLScriptElement[] {
|
|
95
95
|
const results: HTMLScriptElement[] = [];
|
|
96
96
|
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
|
|
97
97
|
for (const script of scripts) {
|
|
@@ -366,6 +366,52 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
366
366
|
expect(meta.content).toBe(content);
|
|
367
367
|
});
|
|
368
368
|
|
|
369
|
+
const SUB = "compositions/sub.html";
|
|
370
|
+
const sub = (style: string, rootAttrs = "") =>
|
|
371
|
+
`<template id="sub-template"><div data-hf-id="hf-root" id="sub" data-composition-id="sub"${rootAttrs}><div ${style} data-hf-id="hf-t" id="target"></div></div></template>`;
|
|
372
|
+
const host = (style: string) =>
|
|
373
|
+
`<div data-composition-file="${SUB}" data-hf-id="hf-host"><div data-hf-inner-root="true" data-hf-authored-id="sub" data-hf-id="hf-root"><div data-hf-id="hf-t" id="target" ${style}></div></div></div>`;
|
|
374
|
+
|
|
375
|
+
it("restores a sub-composition file in place, on every host that inlines it", () => {
|
|
376
|
+
const { iframe, doc } = buildLiveIframe(
|
|
377
|
+
host(`style="clip-path: inset(0px 40px 0px 0px);"`) +
|
|
378
|
+
host(`style="clip-path: inset(0px 40px 0px 0px);"`),
|
|
379
|
+
);
|
|
380
|
+
const reloadPreview = vi.fn();
|
|
381
|
+
const files = {
|
|
382
|
+
[SUB]: { previous: sub(`style="clip-path: inset(0px 40px 0px 0px)"`), restored: sub("") },
|
|
383
|
+
};
|
|
384
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
|
|
385
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
386
|
+
const targets = [...doc.querySelectorAll('[data-hf-id="hf-t"]')];
|
|
387
|
+
expect(targets.map((el) => el.getAttribute("style"))).toEqual([null, null]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("full-reloads a sub-composition restore that changes the file's own root", () => {
|
|
391
|
+
const { iframe } = buildLiveIframe(host(""));
|
|
392
|
+
const reloadPreview = vi.fn();
|
|
393
|
+
const files = { [SUB]: { previous: sub("", ' data-width="10"'), restored: sub("") } };
|
|
394
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
395
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("full-reloads a sub-composition restore whose file has a GSAP script", () => {
|
|
399
|
+
const { iframe, doc } = buildLiveIframe(host(`style="clip-path: inset(0px 40px 0px 0px);"`));
|
|
400
|
+
const reloadPreview = vi.fn();
|
|
401
|
+
const script = `<script>gsap.timeline().to("#target", { x: 10 });</script>`;
|
|
402
|
+
const scripted = (style: string) =>
|
|
403
|
+
sub(style).replace("</div></template>", `${script}</div></template>`);
|
|
404
|
+
const files = {
|
|
405
|
+
[SUB]: {
|
|
406
|
+
previous: scripted(`style="clip-path: inset(0px 40px 0px 0px)"`),
|
|
407
|
+
restored: scripted(""),
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
411
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
412
|
+
expect(doc.querySelector('[data-hf-id="hf-t"]')?.getAttribute("style")).toContain("clip-path");
|
|
413
|
+
});
|
|
414
|
+
|
|
369
415
|
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
|
370
416
|
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
371
417
|
const reloadPreview = vi.fn();
|