@hyperframes/studio 0.7.57 → 0.7.58
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--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { openComposition } from "@hyperframes/sdk";
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
8
|
+
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|
8
9
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
9
10
|
import { useTimelineEditing } from "./useTimelineEditing";
|
|
10
11
|
|
|
@@ -165,7 +166,11 @@ type TimelineRecordEdit = NonNullable<
|
|
|
165
166
|
function renderTimelineEditingHookWithLifecycle(input: {
|
|
166
167
|
timelineElements: TimelineElement[];
|
|
167
168
|
iframe: HTMLIFrameElement;
|
|
168
|
-
commitDomEditPatchBatches: ReturnType<
|
|
169
|
+
commitDomEditPatchBatches: ReturnType<
|
|
170
|
+
typeof vi.fn<
|
|
171
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
172
|
+
>
|
|
173
|
+
>;
|
|
169
174
|
}): {
|
|
170
175
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
171
176
|
unmount: () => void;
|
|
@@ -209,19 +214,6 @@ function renderTimelineEditingHookWithLifecycle(input: {
|
|
|
209
214
|
return { move, unmount };
|
|
210
215
|
}
|
|
211
216
|
|
|
212
|
-
function jsonResponse(body: unknown): Response {
|
|
213
|
-
return new Response(JSON.stringify(body), {
|
|
214
|
-
status: 200,
|
|
215
|
-
headers: { "content-type": "application/json" },
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function requestUrl(input: Parameters<typeof fetch>[0]): string {
|
|
220
|
-
if (typeof input === "string") return input;
|
|
221
|
-
if (input instanceof URL) return input.toString();
|
|
222
|
-
return input.url;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
217
|
async function flushAsyncWork(): Promise<void> {
|
|
226
218
|
for (let i = 0; i < 8; i += 1) {
|
|
227
219
|
await Promise.resolve();
|
|
@@ -233,24 +225,99 @@ async function flushAsyncWork(): Promise<void> {
|
|
|
233
225
|
* string, or a path → content map) and answers the GSAP-mutation endpoint
|
|
234
226
|
* with `gsapBody`. Returns the mock for call inspection.
|
|
235
227
|
*/
|
|
236
|
-
function stubProjectFetch(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
228
|
+
function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
|
|
229
|
+
// Keep this test server's capability, file-read, and mutation routes together;
|
|
230
|
+
// splitting the fixture would obscure the request sequence asserted by callers.
|
|
231
|
+
// fallow-ignore-next-line complexity
|
|
240
232
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
241
233
|
const url = requestUrl(input);
|
|
234
|
+
if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) {
|
|
235
|
+
return jsonResponse({ atomicOwnershipPairs: true });
|
|
236
|
+
}
|
|
242
237
|
if (url.includes("/api/projects/p1/files/")) {
|
|
243
238
|
if (typeof files === "string") return jsonResponse({ content: files });
|
|
244
239
|
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
|
245
240
|
return jsonResponse({ content: files[path] });
|
|
246
241
|
}
|
|
247
|
-
if (url.includes("/api/projects/p1/gsap-mutations/"))
|
|
242
|
+
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
243
|
+
const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html");
|
|
244
|
+
const content = typeof files === "string" ? files : (files[path] ?? "");
|
|
245
|
+
return jsonResponse(
|
|
246
|
+
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
248
249
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
249
250
|
});
|
|
250
251
|
vi.stubGlobal("fetch", fetchMock);
|
|
251
252
|
return fetchMock;
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/**
|
|
256
|
+
* One clip on track 0 + the editing hook wired to project "p1" — shared setup
|
|
257
|
+
* for the single-clip move-path tests (horizontal, vertical-only, track-only,
|
|
258
|
+
* diagonal) so their arrange blocks aren't clones of each other.
|
|
259
|
+
*/
|
|
260
|
+
function setupSingleClipHarness(options?: {
|
|
261
|
+
source?: string;
|
|
262
|
+
clipStyle?: string;
|
|
263
|
+
onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
|
|
264
|
+
}) {
|
|
265
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
|
|
266
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
267
|
+
const commit =
|
|
268
|
+
options?.onZIndexCommit ??
|
|
269
|
+
vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
|
270
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
271
|
+
const reloadPreview = vi.fn();
|
|
272
|
+
const fetchMock = stubProjectFetch(
|
|
273
|
+
options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
|
|
274
|
+
);
|
|
275
|
+
const hook = renderTimelineEditingHook({
|
|
276
|
+
timelineElements: [clip],
|
|
277
|
+
iframe,
|
|
278
|
+
onZIndexCommit: commit,
|
|
279
|
+
projectId: "p1",
|
|
280
|
+
writeProjectFile,
|
|
281
|
+
recordEdit: vi.fn(async () => {}),
|
|
282
|
+
reloadPreview,
|
|
283
|
+
});
|
|
284
|
+
return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
|
|
288
|
+
function expectLanePersisted(
|
|
289
|
+
iframe: HTMLIFrameElement,
|
|
290
|
+
writeProjectFile: { mock: { calls: unknown[][] } },
|
|
291
|
+
track: string,
|
|
292
|
+
): void {
|
|
293
|
+
const doc = iframe.contentDocument;
|
|
294
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
295
|
+
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe(track);
|
|
296
|
+
expect(writeProjectFile.mock.calls[0]![1]).toContain(`data-track-index="${track}"`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Two 1s clips — a@0s on track 0, b@1s on track 1 — plus the matching iframe.
|
|
301
|
+
* Shared by the group-move tests; `bSourceFile` puts b in its own file for the
|
|
302
|
+
* cross-file partition test.
|
|
303
|
+
*/
|
|
304
|
+
function makeTwoClipPair(bSourceFile?: string) {
|
|
305
|
+
const iframe = createPreviewIframe([
|
|
306
|
+
{ id: "a", track: 0 },
|
|
307
|
+
{ id: "b", track: 1 },
|
|
308
|
+
]);
|
|
309
|
+
const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
310
|
+
const b = timelineElement({
|
|
311
|
+
id: "b",
|
|
312
|
+
track: 1,
|
|
313
|
+
zIndex: 0,
|
|
314
|
+
start: 1,
|
|
315
|
+
duration: 1,
|
|
316
|
+
sourceFile: bSourceFile,
|
|
317
|
+
});
|
|
318
|
+
return { iframe, a, b };
|
|
319
|
+
}
|
|
320
|
+
|
|
254
321
|
const ROOT_DURATION_FALLBACK_SOURCE = [
|
|
255
322
|
`<div data-composition-id="main" data-duration="4">`,
|
|
256
323
|
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
|
@@ -270,7 +337,12 @@ async function setupRootDurationFallback() {
|
|
|
270
337
|
const iframeWindow = iframe.contentWindow;
|
|
271
338
|
if (!iframeWindow) throw new Error("Expected iframe window");
|
|
272
339
|
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
|
273
|
-
stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, {
|
|
340
|
+
stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, {
|
|
341
|
+
mutated: false,
|
|
342
|
+
scriptText: null,
|
|
343
|
+
before: ROOT_DURATION_FALLBACK_SOURCE,
|
|
344
|
+
after: ROOT_DURATION_FALLBACK_SOURCE,
|
|
345
|
+
});
|
|
274
346
|
usePlayerStore.getState().setDuration(4);
|
|
275
347
|
const hook = renderTimelineEditingHook({
|
|
276
348
|
timelineElements: [clip],
|
|
@@ -457,7 +529,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
457
529
|
]);
|
|
458
530
|
const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
|
|
459
531
|
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
|
|
460
|
-
const commitDomEditPatchBatches = vi.fn<
|
|
532
|
+
const commitDomEditPatchBatches = vi.fn<
|
|
533
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
534
|
+
>(async () => ({ durable: true, allMatched: true, changed: true }));
|
|
461
535
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
462
536
|
timelineElements: [front, back],
|
|
463
537
|
iframe,
|
|
@@ -502,7 +576,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
502
576
|
]);
|
|
503
577
|
const saveError = new Error("save failed");
|
|
504
578
|
const commitDomEditPatchBatches = vi
|
|
505
|
-
.fn<
|
|
579
|
+
.fn<
|
|
580
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
581
|
+
>()
|
|
506
582
|
.mockRejectedValueOnce(saveError);
|
|
507
583
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
508
584
|
timelineElements: [front, back],
|
|
@@ -566,8 +642,12 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
566
642
|
releaseBatch = resolve;
|
|
567
643
|
});
|
|
568
644
|
const commitDomEditPatchBatches = vi
|
|
569
|
-
.fn<
|
|
570
|
-
|
|
645
|
+
.fn<
|
|
646
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
647
|
+
>()
|
|
648
|
+
.mockReturnValueOnce(
|
|
649
|
+
batchSave.then(() => ({ durable: true, allMatched: true, changed: true })),
|
|
650
|
+
);
|
|
571
651
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
572
652
|
timelineElements: [front, back],
|
|
573
653
|
iframe,
|
|
@@ -608,98 +688,59 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
608
688
|
});
|
|
609
689
|
|
|
610
690
|
it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
|
|
611
|
-
const
|
|
612
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
613
|
-
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
|
614
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
615
|
-
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
616
|
-
const reloadPreview = vi.fn();
|
|
617
|
-
const fetchMock = stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
|
618
|
-
const { move, unmount } = renderTimelineEditingHook({
|
|
619
|
-
timelineElements: [clip],
|
|
620
|
-
iframe,
|
|
621
|
-
onZIndexCommit: commit,
|
|
622
|
-
projectId: "p1",
|
|
623
|
-
writeProjectFile,
|
|
624
|
-
recordEdit,
|
|
625
|
-
reloadPreview,
|
|
626
|
-
});
|
|
691
|
+
const h = setupSingleClipHarness();
|
|
627
692
|
|
|
628
693
|
await act(async () => {
|
|
629
|
-
await move(clip, { start: 1.25, track: clip.track });
|
|
694
|
+
await h.move(h.clip, { start: 1.25, track: h.clip.track });
|
|
630
695
|
});
|
|
631
696
|
|
|
632
|
-
const doc = iframe.contentDocument;
|
|
697
|
+
const doc = h.iframe.contentDocument;
|
|
633
698
|
if (!doc) throw new Error("Expected iframe document");
|
|
634
699
|
expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
|
|
635
700
|
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
|
|
636
|
-
expect(commit).not.toHaveBeenCalled();
|
|
637
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
|
|
638
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
|
|
639
|
-
expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
|
|
701
|
+
expect(h.commit).not.toHaveBeenCalled();
|
|
702
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
|
|
703
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
|
|
704
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
|
|
640
705
|
expect(
|
|
641
|
-
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
706
|
+
h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
642
707
|
).toBe(true);
|
|
643
708
|
|
|
644
|
-
unmount();
|
|
709
|
+
h.unmount();
|
|
645
710
|
});
|
|
646
711
|
|
|
647
712
|
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
|
648
713
|
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
|
649
714
|
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
|
650
715
|
// wrote NOTHING — the lane snapped back on the next reload.
|
|
651
|
-
const
|
|
652
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
653
|
-
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
|
654
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
655
|
-
stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
|
|
656
|
-
const { move, unmount } = renderTimelineEditingHook({
|
|
657
|
-
timelineElements: [clip],
|
|
658
|
-
iframe,
|
|
659
|
-
onZIndexCommit: commit,
|
|
660
|
-
projectId: "p1",
|
|
661
|
-
writeProjectFile,
|
|
662
|
-
recordEdit: vi.fn(async () => {}),
|
|
663
|
-
});
|
|
716
|
+
const h = setupSingleClipHarness();
|
|
664
717
|
|
|
665
718
|
await act(async () => {
|
|
666
719
|
// Vertical-only: same start, new track (already authored-space on this path).
|
|
667
|
-
await move(clip, { start: clip.start, track: 2 });
|
|
720
|
+
await h.move(h.clip, { start: h.clip.start, track: 2 });
|
|
668
721
|
});
|
|
669
722
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
expect(
|
|
674
|
-
// ...and the file write carries the new data-track-index with start intact.
|
|
675
|
-
expect(writeProjectFile).toHaveBeenCalled();
|
|
676
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="2"');
|
|
677
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
|
|
723
|
+
// Live DOM patched (no pre-reload lane snap-back) and the file write
|
|
724
|
+
// carries the new data-track-index with start intact.
|
|
725
|
+
expectLanePersisted(h.iframe, h.writeProjectFile, "2");
|
|
726
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
|
|
678
727
|
|
|
679
|
-
unmount();
|
|
728
|
+
h.unmount();
|
|
680
729
|
});
|
|
681
730
|
|
|
682
731
|
it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
|
|
683
|
-
const iframe = createPreviewIframe([
|
|
684
|
-
{ id: "clip", track: 0, style: "position: relative; z-index: 0" },
|
|
685
|
-
]);
|
|
686
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
687
732
|
// Gate the z-index commit so we can observe whether the timing write waits.
|
|
688
733
|
let releaseCommit!: () => void;
|
|
689
734
|
const commitGate = new Promise<void>((resolve) => {
|
|
690
735
|
releaseCommit = resolve;
|
|
691
736
|
});
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
iframe,
|
|
698
|
-
onZIndexCommit: commit,
|
|
699
|
-
projectId: "p1",
|
|
700
|
-
writeProjectFile,
|
|
701
|
-
recordEdit: vi.fn(async () => {}),
|
|
737
|
+
const h = setupSingleClipHarness({
|
|
738
|
+
clipStyle: "position: relative; z-index: 0",
|
|
739
|
+
onZIndexCommit: vi
|
|
740
|
+
.fn<(entries: ZIndexEntry[]) => Promise<void>>()
|
|
741
|
+
.mockReturnValue(commitGate),
|
|
702
742
|
});
|
|
743
|
+
const { clip, commit, writeProjectFile, move, unmount } = h;
|
|
703
744
|
|
|
704
745
|
// Diagonal drag: both a time move (start change) and a restack (z-index change).
|
|
705
746
|
let movePromise!: Promise<unknown>;
|
|
@@ -784,19 +825,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
784
825
|
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
|
785
826
|
"scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
|
|
786
827
|
};
|
|
787
|
-
const iframe =
|
|
788
|
-
{ id: "a", track: 0 },
|
|
789
|
-
{ id: "b", track: 1 },
|
|
790
|
-
]);
|
|
791
|
-
const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
792
|
-
const b = timelineElement({
|
|
793
|
-
id: "b",
|
|
794
|
-
track: 1,
|
|
795
|
-
zIndex: 0,
|
|
796
|
-
start: 1,
|
|
797
|
-
duration: 1,
|
|
798
|
-
sourceFile: "scene.html",
|
|
799
|
-
});
|
|
828
|
+
const { iframe, a, b } = makeTwoClipPair("scene.html");
|
|
800
829
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
801
830
|
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
802
831
|
stubProjectFetch(files);
|
|
@@ -867,6 +896,148 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
867
896
|
unmount();
|
|
868
897
|
});
|
|
869
898
|
|
|
899
|
+
it("skips the GSAP fallback and reload for a TRACK-ONLY group move (z-mirror lane move)", async () => {
|
|
900
|
+
// The mirrored z-order lane move persists {start: unchanged, track: new}.
|
|
901
|
+
// Nothing timing-related changed — data-track-index is never read by the
|
|
902
|
+
// renderer — so the persist must NOT be followed by the GSAP round-trip or
|
|
903
|
+
// the full preview reload (the reload is what blinked the canvas).
|
|
904
|
+
const h = setupSingleClipHarness({
|
|
905
|
+
source: '<div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>',
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
await act(async () => {
|
|
909
|
+
await h.groupMove([{ element: h.clip, start: h.clip.start, track: 2 }]);
|
|
910
|
+
await flushAsyncWork();
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// The lane write still persisted (live DOM + file)...
|
|
914
|
+
expectLanePersisted(h.iframe, h.writeProjectFile, "2");
|
|
915
|
+
expect(h.writeProjectFile).toHaveBeenCalledTimes(1);
|
|
916
|
+
// ...but no GSAP mutation ran and the preview was NOT reloaded.
|
|
917
|
+
expect(
|
|
918
|
+
h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
919
|
+
).toBe(false);
|
|
920
|
+
expect(h.reloadPreview).not.toHaveBeenCalled();
|
|
921
|
+
|
|
922
|
+
h.unmount();
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("rebinds the preview in place (no blink) for a time-move of a no-domId clip", async () => {
|
|
926
|
+
// The gap-close blink path: "Close gap" issues pure TIME moves through
|
|
927
|
+
// handleTimelineGroupMove. A selector-addressed clip (no domId — e.g. a
|
|
928
|
+
// .sub caption) has no id-addressed GSAP tweens, so the mutation step has
|
|
929
|
+
// nothing to rewrite and used to fall through to a full reloadPreview().
|
|
930
|
+
// The sync must instead rebind the runtime timing in place — seek + rebind
|
|
931
|
+
// re-derive the clip windows from the already-patched DOM — WITHOUT
|
|
932
|
+
// re-executing any script (a comp's init-style scripts, e.g. a three.js
|
|
933
|
+
// setup, must never run twice) and without the full-reload blink.
|
|
934
|
+
const iframe = document.createElement("iframe");
|
|
935
|
+
document.body.append(iframe);
|
|
936
|
+
const doc = iframe.contentDocument;
|
|
937
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
938
|
+
doc.body.innerHTML =
|
|
939
|
+
'<div class="cap" data-start="2" data-duration="1" data-track-index="0"></div>';
|
|
940
|
+
const liveScript = doc.createElement("script");
|
|
941
|
+
liveScript.textContent =
|
|
942
|
+
'window.__timelines = window.__timelines || {}; window.__timelines["root"] = { kill: function () {} };';
|
|
943
|
+
doc.body.appendChild(liveScript);
|
|
944
|
+
const win = iframe.contentWindow as unknown as Record<string, unknown>;
|
|
945
|
+
win.gsap = { timeline: vi.fn(), set: vi.fn() };
|
|
946
|
+
win.__timelines = { root: { kill: vi.fn() } };
|
|
947
|
+
win.__hfForceTimelineRebind = vi.fn();
|
|
948
|
+
win.__player = { getTime: () => 0, seek: vi.fn() };
|
|
949
|
+
|
|
950
|
+
const cap: TimelineElement = {
|
|
951
|
+
...timelineElement({ id: "cap", track: 0, zIndex: 0, start: 2, duration: 1 }),
|
|
952
|
+
domId: undefined,
|
|
953
|
+
selector: ".cap",
|
|
954
|
+
selectorIndex: 0,
|
|
955
|
+
};
|
|
956
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
957
|
+
const reloadPreview = vi.fn();
|
|
958
|
+
const fetchMock = stubProjectFetch(
|
|
959
|
+
'<div class="cap" data-hf-id="hf-cap" data-start="2" data-duration="1" data-track-index="0"></div>',
|
|
960
|
+
);
|
|
961
|
+
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
962
|
+
timelineElements: [cap],
|
|
963
|
+
iframe,
|
|
964
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
965
|
+
projectId: "p1",
|
|
966
|
+
writeProjectFile,
|
|
967
|
+
recordEdit: vi.fn(async () => {}),
|
|
968
|
+
reloadPreview,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
await act(async () => {
|
|
972
|
+
// Mirror timelineGapCommit: a pure time move keeping the current lane.
|
|
973
|
+
await groupMove([{ element: cap, start: 1, track: cap.track }]);
|
|
974
|
+
await flushAsyncWork();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// The move persisted (live DOM + file)...
|
|
978
|
+
expect(doc.querySelector(".cap")?.getAttribute("data-start")).toBe("1");
|
|
979
|
+
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1"');
|
|
980
|
+
// ...no GSAP mutation ran (nothing id-addressed to rewrite)...
|
|
981
|
+
expect(
|
|
982
|
+
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
983
|
+
).toBe(false);
|
|
984
|
+
// ...and the preview was rebound in place, NOT full-reloaded (blink),
|
|
985
|
+
// with the live script element left untouched (no re-execution).
|
|
986
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
987
|
+
expect(win.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
|
|
988
|
+
expect(doc.body.contains(liveScript)).toBe(true);
|
|
989
|
+
expect(doc.querySelectorAll("script")).toHaveLength(1);
|
|
990
|
+
|
|
991
|
+
unmount();
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("keeps the GSAP fallback + reload for a MIXED batch (any start change)", async () => {
|
|
995
|
+
// One clip changes lane only, the other shifts in time — the batch is not
|
|
996
|
+
// track-only, so the existing behavior (GSAP shift + full reload when no
|
|
997
|
+
// rewritten scriptText comes back — this stub iframe has no runtime rebind
|
|
998
|
+
// hook, so the in-place rebind can't apply) must be preserved.
|
|
999
|
+
const source = [
|
|
1000
|
+
'<div id="a" data-start="0" data-duration="1" data-track-index="0"></div>',
|
|
1001
|
+
'<div id="b" data-start="1" data-duration="1" data-track-index="1"></div>',
|
|
1002
|
+
].join("\n");
|
|
1003
|
+
const { iframe, a, b } = makeTwoClipPair();
|
|
1004
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1005
|
+
const reloadPreview = vi.fn();
|
|
1006
|
+
const fetchMock = stubProjectFetch(source, {
|
|
1007
|
+
mutated: false,
|
|
1008
|
+
scriptText: null,
|
|
1009
|
+
before: source,
|
|
1010
|
+
after: source,
|
|
1011
|
+
});
|
|
1012
|
+
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
1013
|
+
timelineElements: [a, b],
|
|
1014
|
+
iframe,
|
|
1015
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1016
|
+
projectId: "p1",
|
|
1017
|
+
writeProjectFile,
|
|
1018
|
+
recordEdit: vi.fn(async () => {}),
|
|
1019
|
+
reloadPreview,
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
await act(async () => {
|
|
1023
|
+
await groupMove([
|
|
1024
|
+
{ element: a, start: a.start, track: 2 },
|
|
1025
|
+
{ element: b, start: 1.5, track: b.track },
|
|
1026
|
+
]);
|
|
1027
|
+
await flushAsyncWork();
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
1031
|
+
// The time-shifted clip still goes through the GSAP shift endpoint...
|
|
1032
|
+
expect(
|
|
1033
|
+
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
1034
|
+
).toBe(true);
|
|
1035
|
+
// ...and with no rewritten scriptText the sync escalates to one full reload.
|
|
1036
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
1037
|
+
|
|
1038
|
+
unmount();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
870
1041
|
it("matches the single-clip move output when a group move contains one clip", async () => {
|
|
871
1042
|
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
|
|
872
1043
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
@@ -949,79 +1120,56 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
|
|
|
949
1120
|
return { iframe, clip, hook, writeError };
|
|
950
1121
|
}
|
|
951
1122
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1123
|
+
/**
|
|
1124
|
+
* Run a persist that must reject with the harness's writeError, then assert
|
|
1125
|
+
* the store duration AND the live root rolled back to the pre-edit 4s —
|
|
1126
|
+
* the shared act/assert core of the four failed-persist tests below.
|
|
1127
|
+
*/
|
|
1128
|
+
async function expectPersistRollback(
|
|
1129
|
+
ctx: ReturnType<typeof setupFailedPersist>,
|
|
1130
|
+
run: () => Promise<unknown>,
|
|
1131
|
+
): Promise<void> {
|
|
955
1132
|
let rejection: unknown;
|
|
956
1133
|
await act(async () => {
|
|
957
|
-
|
|
958
|
-
await hook.move(clip, { start: 3, track: clip.track }).catch((error) => {
|
|
1134
|
+
await run().catch((error) => {
|
|
959
1135
|
rejection = error;
|
|
960
1136
|
});
|
|
961
1137
|
await flushAsyncWork();
|
|
962
1138
|
});
|
|
963
|
-
|
|
964
|
-
expect(rejection).toBe(writeError);
|
|
1139
|
+
expect(rejection).toBe(ctx.writeError);
|
|
965
1140
|
expect(usePlayerStore.getState().duration).toBe(4);
|
|
966
|
-
expect(rootDurationAttr(iframe)).toBe("4");
|
|
1141
|
+
expect(rootDurationAttr(ctx.iframe)).toBe("4");
|
|
1142
|
+
}
|
|
967
1143
|
|
|
968
|
-
|
|
1144
|
+
it("rolls back the store duration and live root when a move persist fails", async () => {
|
|
1145
|
+
const ctx = setupFailedPersist();
|
|
1146
|
+
// Move past the end: the optimistic sync grows the readout to 5s.
|
|
1147
|
+
await expectPersistRollback(ctx, () =>
|
|
1148
|
+
ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }),
|
|
1149
|
+
);
|
|
1150
|
+
ctx.hook.unmount();
|
|
969
1151
|
});
|
|
970
1152
|
|
|
971
1153
|
it("rolls back the store duration and live root when a resize persist fails", async () => {
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
.resize(clip, { start: 0, duration: 6, playbackStart: undefined })
|
|
978
|
-
.catch((error) => {
|
|
979
|
-
rejection = error;
|
|
980
|
-
});
|
|
981
|
-
await flushAsyncWork();
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
expect(rejection).toBe(writeError);
|
|
985
|
-
expect(usePlayerStore.getState().duration).toBe(4);
|
|
986
|
-
expect(rootDurationAttr(iframe)).toBe("4");
|
|
987
|
-
|
|
988
|
-
hook.unmount();
|
|
1154
|
+
const ctx = setupFailedPersist();
|
|
1155
|
+
await expectPersistRollback(ctx, () =>
|
|
1156
|
+
ctx.hook.resize(ctx.clip, { start: 0, duration: 6, playbackStart: undefined }),
|
|
1157
|
+
);
|
|
1158
|
+
ctx.hook.unmount();
|
|
989
1159
|
});
|
|
990
1160
|
|
|
991
1161
|
it("rolls back the store duration and live root when a group move persist fails", async () => {
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
await act(async () => {
|
|
996
|
-
await hook.groupMove([{ element: clip, start: 3.5 }]).catch((error) => {
|
|
997
|
-
rejection = error;
|
|
998
|
-
});
|
|
999
|
-
await flushAsyncWork();
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
expect(rejection).toBe(writeError);
|
|
1003
|
-
expect(usePlayerStore.getState().duration).toBe(4);
|
|
1004
|
-
expect(rootDurationAttr(iframe)).toBe("4");
|
|
1005
|
-
|
|
1006
|
-
hook.unmount();
|
|
1162
|
+
const ctx = setupFailedPersist();
|
|
1163
|
+
await expectPersistRollback(ctx, () => ctx.hook.groupMove([{ element: ctx.clip, start: 3.5 }]));
|
|
1164
|
+
ctx.hook.unmount();
|
|
1007
1165
|
});
|
|
1008
1166
|
|
|
1009
1167
|
it("rolls back the store duration and live root when a group resize persist fails", async () => {
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
rejection = error;
|
|
1016
|
-
});
|
|
1017
|
-
await flushAsyncWork();
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
expect(rejection).toBe(writeError);
|
|
1021
|
-
expect(usePlayerStore.getState().duration).toBe(4);
|
|
1022
|
-
expect(rootDurationAttr(iframe)).toBe("4");
|
|
1023
|
-
|
|
1024
|
-
hook.unmount();
|
|
1168
|
+
const ctx = setupFailedPersist();
|
|
1169
|
+
await expectPersistRollback(ctx, () =>
|
|
1170
|
+
ctx.hook.groupResize([{ element: ctx.clip, start: 0, duration: 7 }]),
|
|
1171
|
+
);
|
|
1172
|
+
ctx.hook.unmount();
|
|
1025
1173
|
});
|
|
1026
1174
|
|
|
1027
1175
|
it("rolls back the store duration and live root when a delete persist fails", async () => {
|