@hyperframes/studio 0.7.56 → 0.7.57
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 +459 -0
- package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
- package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4345 -3812
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditOverlay.tsx +31 -62
- package/src/components/editor/LayersPanel.tsx +10 -1
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +36 -16
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/timelineEditingHelpers.test.ts +91 -0
- package/src/hooks/timelineEditingHelpers.ts +31 -244
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +5 -3
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +92 -1
- package/src/hooks/useDomEditCommits.ts +63 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
- package/src/hooks/useElementLifecycleOps.ts +20 -7
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +378 -212
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineGroupEditing.ts +153 -132
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +85 -8
- package/src/player/components/Timeline.tsx +9 -9
- package/src/player/components/TimelineCanvas.tsx +11 -2
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/timelineClipDragCommit.test.ts +91 -8
- package/src/player/components/timelineClipDragCommit.ts +106 -9
- package/src/player/components/timelineLayout.ts +99 -26
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
- package/src/player/components/useTimelineStackingSync.ts +13 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.ts +9 -0
- package/src/player/lib/timelineElementHelpers.ts +19 -0
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +37 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
|
@@ -265,7 +265,7 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
265
265
|
document.body.replaceChildren();
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
-
it("persists an N-element reorder with one batch POST, one undo entry, and
|
|
268
|
+
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
|
|
269
269
|
const original =
|
|
270
270
|
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
|
|
271
271
|
const after =
|
|
@@ -338,12 +338,103 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
338
338
|
coalesceKey: "z-reorder:test",
|
|
339
339
|
files: { "index.html": { before: original, after } },
|
|
340
340
|
});
|
|
341
|
+
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
|
|
342
|
+
// The live DOM + store already hold the final state and the server matched
|
|
343
|
+
// every style-only patch, so the reload is provably redundant.
|
|
344
|
+
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
|
345
|
+
} finally {
|
|
346
|
+
rendered.cleanup();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("falls back to reloading when the server response omits matched[]", async () => {
|
|
351
|
+
// Without a matched[] confirmation the persist can't be proven in sync with
|
|
352
|
+
// the live DOM — the skip-reload path must not engage.
|
|
353
|
+
const original = '<div id="a" style="z-index: 1"></div>';
|
|
354
|
+
const after = '<div id="a" style="z-index: 2"></div>';
|
|
355
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
356
|
+
const url = requestUrl(input);
|
|
357
|
+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
|
358
|
+
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
359
|
+
return jsonResponse({ ok: true, changed: true, content: after });
|
|
360
|
+
}
|
|
361
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
362
|
+
});
|
|
363
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
364
|
+
const { iframe, element } = createPreviewElement();
|
|
365
|
+
element.id = "a";
|
|
366
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
await act(async () => {
|
|
370
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
371
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
372
|
+
]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
341
376
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
342
377
|
} finally {
|
|
343
378
|
rendered.cleanup();
|
|
344
379
|
}
|
|
345
380
|
});
|
|
346
381
|
|
|
382
|
+
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
|
|
383
|
+
// The server reports per-patch matched[]: #b was not found in the source.
|
|
384
|
+
// The matched subset persisted, so the commit must complete (no rollback of
|
|
385
|
+
// applied state) while surfacing the partial failure. An unmatched target
|
|
386
|
+
// also means the live DOM shows z-order the disk lacks, so the skip-reload
|
|
387
|
+
// path must NOT engage — the reload reconverges the preview with disk.
|
|
388
|
+
const original = '<div id="a" style="z-index: 1"></div>';
|
|
389
|
+
const after = '<div id="a" style="z-index: 2"></div>';
|
|
390
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
391
|
+
const url = requestUrl(input);
|
|
392
|
+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
|
393
|
+
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
394
|
+
return jsonResponse({ ok: true, changed: true, matched: [true, false], content: after });
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
397
|
+
});
|
|
398
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
399
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
400
|
+
const { iframe, element } = createPreviewElement(
|
|
401
|
+
'<div data-hf-id="hf-card"></div><div id="b"></div>',
|
|
402
|
+
);
|
|
403
|
+
element.id = "a";
|
|
404
|
+
const second = iframe.contentDocument!.getElementById("b")!;
|
|
405
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
await act(async () => {
|
|
409
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
410
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
411
|
+
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
|
|
412
|
+
]);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// No throw: the applied live state stays, the matched subset is recorded.
|
|
416
|
+
expect(element.style.zIndex).toBe("2");
|
|
417
|
+
expect(second.style.zIndex).toBe("1");
|
|
418
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
419
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
420
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
421
|
+
expect.stringContaining("could not match 1 patch target(s) in index.html"),
|
|
422
|
+
"b",
|
|
423
|
+
);
|
|
424
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
425
|
+
"save_failure",
|
|
426
|
+
expect.objectContaining({
|
|
427
|
+
mutation_type: "z-reorder-unmatched",
|
|
428
|
+
file_path: "index.html",
|
|
429
|
+
error_message: expect.stringContaining("b"),
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
} finally {
|
|
433
|
+
warnSpy.mockRestore();
|
|
434
|
+
rendered.cleanup();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
347
438
|
it("rolls back live state after a failed batch POST without a disk write-back", async () => {
|
|
348
439
|
const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
|
|
349
440
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
@@ -61,6 +61,34 @@ interface RecordEditInput {
|
|
|
61
61
|
files: Record<string, { before: string; after: string }>;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
|
65
|
+
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
|
66
|
+
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Surface server-reported unmatched patches. The matched subset already
|
|
71
|
+
* persisted, so this must NOT throw (a throw would roll back applied state) —
|
|
72
|
+
* warn and emit save-failure telemetry with a distinct reason instead.
|
|
73
|
+
*/
|
|
74
|
+
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
|
75
|
+
const unmatchedIds = batch.patches
|
|
76
|
+
.filter((_, index) => matched[index] === false)
|
|
77
|
+
.map(describeBatchPatchTarget);
|
|
78
|
+
if (unmatchedIds.length === 0) return;
|
|
79
|
+
console.warn(
|
|
80
|
+
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
|
81
|
+
`${batch.sourceFile} (their z-order will revert on reload):`,
|
|
82
|
+
unmatchedIds.join(", "),
|
|
83
|
+
);
|
|
84
|
+
trackStudioSaveFailure({
|
|
85
|
+
source: "dom_edit",
|
|
86
|
+
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
|
87
|
+
filePath: batch.sourceFile,
|
|
88
|
+
mutationType: "z-reorder-unmatched",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
64
92
|
async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
|
|
65
93
|
const before = await readProjectFileContent(projectId, batch.sourceFile);
|
|
66
94
|
const response = await fetch(
|
|
@@ -77,16 +105,39 @@ async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
|
|
|
77
105
|
}
|
|
78
106
|
const result = (await response.json()) as {
|
|
79
107
|
changed?: boolean;
|
|
108
|
+
matched?: boolean[];
|
|
80
109
|
content?: string;
|
|
81
110
|
};
|
|
111
|
+
if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
|
|
82
112
|
return {
|
|
83
113
|
sourceFile: batch.sourceFile,
|
|
84
114
|
changed: result.changed === true,
|
|
115
|
+
// Skip-reload safety: the persist is only provably in sync with the live
|
|
116
|
+
// DOM when the server confirmed EVERY patch target matched. A missing /
|
|
117
|
+
// short matched[] is treated as unknown (false) so the caller falls back
|
|
118
|
+
// to reloading rather than silently diverging from disk.
|
|
119
|
+
allMatched:
|
|
120
|
+
Array.isArray(result.matched) &&
|
|
121
|
+
result.matched.length === batch.patches.length &&
|
|
122
|
+
result.matched.every(Boolean),
|
|
85
123
|
before,
|
|
86
124
|
after: typeof result.content === "string" ? result.content : before,
|
|
87
125
|
};
|
|
88
126
|
}
|
|
89
127
|
|
|
128
|
+
/**
|
|
129
|
+
* A batch is reload-skippable only when it is style-only: every operation is an
|
|
130
|
+
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
|
131
|
+
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
|
132
|
+
* doesn't already show. Any other op type (attribute / text-content / …) can
|
|
133
|
+
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
|
134
|
+
*/
|
|
135
|
+
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
|
136
|
+
return batches.every((batch) =>
|
|
137
|
+
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
90
141
|
export interface UseDomEditCommitsParams {
|
|
91
142
|
activeCompPath: string | null;
|
|
92
143
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
@@ -360,7 +411,18 @@ export function useDomEditCommits({
|
|
|
360
411
|
files,
|
|
361
412
|
});
|
|
362
413
|
forceReloadSdkSession?.();
|
|
363
|
-
|
|
414
|
+
// A z-only reorder already applied its inline styles to the live iframe
|
|
415
|
+
// DOM (and the store) synchronously, so remounting the iframe here only
|
|
416
|
+
// produces a visible blink. Skip the reload when the caller asked for it
|
|
417
|
+
// AND the persist is provably in sync: style-only ops, every target
|
|
418
|
+
// matched. Any unmatched patch means the live DOM now shows state disk
|
|
419
|
+
// doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
|
|
420
|
+
// reload is independently suppressed by domEditSaveTimestampRef above.)
|
|
421
|
+
const skipSafe =
|
|
422
|
+
options.skipReload === true &&
|
|
423
|
+
batchesAreInlineStyleOnly(batches) &&
|
|
424
|
+
results.every((result) => result.allMatched);
|
|
425
|
+
if (!skipSafe) reloadPreview();
|
|
364
426
|
}).catch((error) => {
|
|
365
427
|
const alreadyToasted =
|
|
366
428
|
(error instanceof StudioSaveHttpError ||
|
|
@@ -18,6 +18,7 @@ afterEach(() => {
|
|
|
18
18
|
interface BatchOptions {
|
|
19
19
|
label: string;
|
|
20
20
|
coalesceKey: string;
|
|
21
|
+
skipReload?: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface CapturedBatchCall {
|
|
@@ -36,6 +37,7 @@ type ReorderCommit = (
|
|
|
36
37
|
key?: string;
|
|
37
38
|
}>,
|
|
38
39
|
coalesceKeyOverride?: string,
|
|
40
|
+
actionKind?: string,
|
|
39
41
|
) => Promise<void>;
|
|
40
42
|
|
|
41
43
|
function renderReorderHook(
|
|
@@ -109,6 +111,25 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
|
|
|
109
111
|
act(() => root.unmount());
|
|
110
112
|
});
|
|
111
113
|
|
|
114
|
+
it("requests skipReload on every z-reorder persist (live DOM already final)", async () => {
|
|
115
|
+
// The commit applies the z-index (and any injected position) to the live
|
|
116
|
+
// iframe DOM and the store synchronously, so the persisted style-only patch
|
|
117
|
+
// adds nothing the preview doesn't already show — the batch commit is asked
|
|
118
|
+
// to skip the iframe remount. commitDomEditPatchBatches still falls back to
|
|
119
|
+
// reloading when the server can't confirm every patch target matched.
|
|
120
|
+
const el = document.createElement("div");
|
|
121
|
+
el.id = "clip-z";
|
|
122
|
+
|
|
123
|
+
const { captured, root } = await runReorderCommit(el, [
|
|
124
|
+
{ element: el, zIndex: 4, id: "clip-z", sourceFile: "index.html" },
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
expect(captured).toHaveLength(1);
|
|
128
|
+
expect(captured[0]?.options.skipReload).toBe(true);
|
|
129
|
+
|
|
130
|
+
act(() => root.unmount());
|
|
131
|
+
});
|
|
132
|
+
|
|
112
133
|
it("preserves a real id when the element has one", async () => {
|
|
113
134
|
const el = document.createElement("video");
|
|
114
135
|
el.id = "v-hero";
|
|
@@ -176,6 +197,150 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
|
|
|
176
197
|
act(() => root.unmount());
|
|
177
198
|
});
|
|
178
199
|
|
|
200
|
+
it("keeps distinct actions in distinct default coalesce keys", async () => {
|
|
201
|
+
const el = document.createElement("div");
|
|
202
|
+
el.id = "clip-a";
|
|
203
|
+
document.body.appendChild(el);
|
|
204
|
+
const captured: CapturedBatchCall[] = [];
|
|
205
|
+
let commit: ReorderCommit | undefined;
|
|
206
|
+
const root = renderReorderHook(captured, (fn) => (commit = fn));
|
|
207
|
+
|
|
208
|
+
await act(async () => {
|
|
209
|
+
await commit!(
|
|
210
|
+
[{ element: el, zIndex: 1, id: "clip-a", sourceFile: "index.html" }],
|
|
211
|
+
undefined,
|
|
212
|
+
"bring-forward",
|
|
213
|
+
);
|
|
214
|
+
await commit!(
|
|
215
|
+
[{ element: el, zIndex: 0, id: "clip-a", sourceFile: "index.html" }],
|
|
216
|
+
undefined,
|
|
217
|
+
"send-backward",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Same element set, different actions — the keys must differ so the two
|
|
222
|
+
// edits never coalesce into one undo step within the coalesce window.
|
|
223
|
+
expect(captured).toHaveLength(2);
|
|
224
|
+
expect(captured[0]?.options.coalesceKey).toBe("z-reorder:bring-forward:clip-a");
|
|
225
|
+
expect(captured[1]?.options.coalesceKey).toBe("z-reorder:send-backward:clip-a");
|
|
226
|
+
act(() => root.unmount());
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("updates the store zIndex synchronously for entries that carry a store key", async () => {
|
|
230
|
+
const el = document.createElement("div");
|
|
231
|
+
el.id = "clip-a";
|
|
232
|
+
document.body.appendChild(el);
|
|
233
|
+
usePlayerStore.getState().setElements([
|
|
234
|
+
{
|
|
235
|
+
id: "clip-a",
|
|
236
|
+
key: "index.html#clip-a",
|
|
237
|
+
tag: "div",
|
|
238
|
+
start: 0,
|
|
239
|
+
duration: 1,
|
|
240
|
+
track: 0,
|
|
241
|
+
zIndex: 0,
|
|
242
|
+
hasExplicitZIndex: false,
|
|
243
|
+
},
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
let commit: ReorderCommit | undefined;
|
|
247
|
+
let resolveBatch: (() => void) | undefined;
|
|
248
|
+
function Harness() {
|
|
249
|
+
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
|
250
|
+
activeCompPath: "index.html",
|
|
251
|
+
showToast: vi.fn(),
|
|
252
|
+
writeProjectFile: vi.fn(async () => {}),
|
|
253
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
254
|
+
editHistory: { recordEdit: vi.fn(async () => {}) },
|
|
255
|
+
projectIdRef: { current: null },
|
|
256
|
+
reloadPreview: vi.fn(),
|
|
257
|
+
clearDomSelection: vi.fn(),
|
|
258
|
+
// Persist stays pending so the assertion below can only be satisfied
|
|
259
|
+
// by the SYNCHRONOUS store update (the lane-sync path's requirement).
|
|
260
|
+
commitDomEditPatchBatches: () => new Promise((resolve) => (resolveBatch = resolve)),
|
|
261
|
+
});
|
|
262
|
+
commit = handleDomZIndexReorderCommit;
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const root = mountReactHarness(<Harness />);
|
|
266
|
+
|
|
267
|
+
let pending: Promise<void> | undefined;
|
|
268
|
+
act(() => {
|
|
269
|
+
pending = commit!([
|
|
270
|
+
{
|
|
271
|
+
element: el,
|
|
272
|
+
zIndex: 5,
|
|
273
|
+
id: "clip-a",
|
|
274
|
+
sourceFile: "index.html",
|
|
275
|
+
key: "index.html#clip-a",
|
|
276
|
+
},
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(usePlayerStore.getState().elements[0]).toMatchObject({
|
|
281
|
+
zIndex: 5,
|
|
282
|
+
hasExplicitZIndex: true,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
resolveBatch?.();
|
|
286
|
+
await act(async () => pending);
|
|
287
|
+
act(() => root.unmount());
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// The canvas context-menu path: the menu no longer pre-applies styles, so the
|
|
291
|
+
// hook sees the PRISTINE element — prior styles are captured before any
|
|
292
|
+
// mutation and a failed persist restores them exactly (previously the menu's
|
|
293
|
+
// optimistic write made the "rollback" restore the already-mutated values,
|
|
294
|
+
// and the never-persisted position patch silently reverted on reload).
|
|
295
|
+
it("rolls back a static, inline-style-free element to pristine styles on failure", async () => {
|
|
296
|
+
const el = document.createElement("div");
|
|
297
|
+
el.id = "clip-a";
|
|
298
|
+
el.style.position = "static"; // happy-dom computes "" for unset position
|
|
299
|
+
document.body.appendChild(el);
|
|
300
|
+
const failure = new Error("persist failed");
|
|
301
|
+
|
|
302
|
+
let commit: ReorderCommit | undefined;
|
|
303
|
+
function Harness() {
|
|
304
|
+
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
|
305
|
+
activeCompPath: "index.html",
|
|
306
|
+
showToast: vi.fn(),
|
|
307
|
+
writeProjectFile: vi.fn(async () => {}),
|
|
308
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
309
|
+
editHistory: { recordEdit: vi.fn(async () => {}) },
|
|
310
|
+
projectIdRef: { current: null },
|
|
311
|
+
reloadPreview: vi.fn(),
|
|
312
|
+
clearDomSelection: vi.fn(),
|
|
313
|
+
commitDomEditPatchBatches: vi.fn(async () => {
|
|
314
|
+
// The live styles were applied by the hook before persist ran.
|
|
315
|
+
expect(el.style.zIndex).toBe("2");
|
|
316
|
+
expect(el.style.position).toBe("relative");
|
|
317
|
+
throw failure;
|
|
318
|
+
}),
|
|
319
|
+
});
|
|
320
|
+
commit = handleDomZIndexReorderCommit;
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const root = mountReactHarness(<Harness />);
|
|
324
|
+
|
|
325
|
+
let rejection: unknown;
|
|
326
|
+
await act(async () => {
|
|
327
|
+
try {
|
|
328
|
+
await commit!(
|
|
329
|
+
[{ element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html" }],
|
|
330
|
+
undefined,
|
|
331
|
+
"bring-forward",
|
|
332
|
+
);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
rejection = error;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(rejection).toBe(failure);
|
|
339
|
+
expect(el.style.zIndex).toBe("");
|
|
340
|
+
expect(el.style.position).toBe("static");
|
|
341
|
+
act(() => root.unmount());
|
|
342
|
+
});
|
|
343
|
+
|
|
179
344
|
it("rolls back only live and store state after an atomic reorder failure", async () => {
|
|
180
345
|
const writeProjectFile = vi.fn(async () => {});
|
|
181
346
|
const recordEdit = vi.fn(async () => {});
|
|
@@ -146,6 +146,7 @@ export function useElementLifecycleOps({
|
|
|
146
146
|
key?: string;
|
|
147
147
|
}>,
|
|
148
148
|
gestureCoalesceKey?: string,
|
|
149
|
+
actionKind?: string,
|
|
149
150
|
) => {
|
|
150
151
|
if (entries.length === 0) return Promise.resolve();
|
|
151
152
|
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
|
|
@@ -153,9 +154,13 @@ export function useElementLifecycleOps({
|
|
|
153
154
|
onReorderShadow?.(
|
|
154
155
|
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
|
|
155
156
|
);
|
|
157
|
+
// The default key carries the action kind so two DIFFERENT actions on the
|
|
158
|
+
// same element set (e.g. "bring-forward" then "send-backward" within the
|
|
159
|
+
// coalesce window) never merge into one undo step. Callers that share a
|
|
160
|
+
// gesture (lane moves) pass an explicit gestureCoalesceKey instead.
|
|
156
161
|
const coalesceKey =
|
|
157
162
|
gestureCoalesceKey ??
|
|
158
|
-
`z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
|
163
|
+
`z-reorder:${actionKind ?? "reorder"}:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
|
159
164
|
const patchesBySourceFile = new Map<string, DomEditPatchBatch["patches"]>();
|
|
160
165
|
const rollbacks: Array<() => void> = [];
|
|
161
166
|
for (const entry of entries) {
|
|
@@ -212,12 +217,20 @@ export function useElementLifecycleOps({
|
|
|
212
217
|
}));
|
|
213
218
|
// Resolves once every source-file batch is persisted so a same-file timing write
|
|
214
219
|
// can be ordered after it (see applyTimelineStackingReorder callers).
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
)
|
|
220
|
+
//
|
|
221
|
+
// skipReload: the live iframe DOM and the player store already hold the
|
|
222
|
+
// final z state (applied synchronously above), and the persisted patch is
|
|
223
|
+
// inline-style-only — a full iframe remount would only blink the preview.
|
|
224
|
+
// commitDomEditPatchBatches still falls back to reloading whenever the
|
|
225
|
+
// server reports an unmatched patch target (live DOM ≠ disk).
|
|
226
|
+
return commitDomEditPatchBatches(batches, {
|
|
227
|
+
label: "Reorder layers",
|
|
228
|
+
coalesceKey,
|
|
229
|
+
skipReload: true,
|
|
230
|
+
}).catch((error) => {
|
|
231
|
+
for (const rollback of rollbacks) rollback();
|
|
232
|
+
throw error;
|
|
233
|
+
});
|
|
221
234
|
},
|
|
222
235
|
[commitDomEditPatchBatches, onReorderShadow],
|
|
223
236
|
);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import { resolveSourceFile, applyPatch } from "../utils/sourcePatcher";
|
|
4
|
+
import {
|
|
5
|
+
acceptStudioRuntimeMessage,
|
|
6
|
+
postRuntimeControlMessage,
|
|
7
|
+
} from "../player/lib/runtimeProtocol";
|
|
4
8
|
|
|
5
9
|
export interface PickedElement {
|
|
6
10
|
id: string | null;
|
|
@@ -65,10 +69,7 @@ export function useElementPicker(
|
|
|
65
69
|
|
|
66
70
|
const enablePick = useCallback(() => {
|
|
67
71
|
try {
|
|
68
|
-
getActiveIframe()?.contentWindow
|
|
69
|
-
{ source: "hf-parent", type: "control", action: "enable-pick-mode" },
|
|
70
|
-
"*",
|
|
71
|
-
);
|
|
72
|
+
postRuntimeControlMessage(getActiveIframe()?.contentWindow, "enable-pick-mode");
|
|
72
73
|
setIsPickMode(true);
|
|
73
74
|
} catch {
|
|
74
75
|
/* cross-origin */
|
|
@@ -77,10 +78,7 @@ export function useElementPicker(
|
|
|
77
78
|
|
|
78
79
|
const disablePick = useCallback(() => {
|
|
79
80
|
try {
|
|
80
|
-
getActiveIframe()?.contentWindow
|
|
81
|
-
{ source: "hf-parent", type: "control", action: "disable-pick-mode" },
|
|
82
|
-
"*",
|
|
83
|
-
);
|
|
81
|
+
postRuntimeControlMessage(getActiveIframe()?.contentWindow, "disable-pick-mode");
|
|
84
82
|
} catch {
|
|
85
83
|
/* cross-origin */
|
|
86
84
|
}
|
|
@@ -96,6 +94,7 @@ export function useElementPicker(
|
|
|
96
94
|
const handleMessage = (e: MessageEvent) => {
|
|
97
95
|
const data = e.data;
|
|
98
96
|
if (data?.source !== "hf-preview") return;
|
|
97
|
+
if (!acceptStudioRuntimeMessage(data)) return;
|
|
99
98
|
// Accept events from either the primary iframe or the active override
|
|
100
99
|
const activeIframe = getActiveIframe();
|
|
101
100
|
if (!activeIframe) return;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Asset-drop handlers for the timeline: drop an existing project asset at a
|
|
2
|
+
// placement, or upload dragged-in OS files and place them sequentially.
|
|
3
|
+
// Extracted verbatim from useTimelineEditing.ts to keep it under the studio
|
|
4
|
+
// 600-line cap.
|
|
5
|
+
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
|
6
|
+
import type { TimelineElement } from "../player";
|
|
7
|
+
import {
|
|
8
|
+
buildTimelineAssetId,
|
|
9
|
+
buildTimelineAssetInsertHtml,
|
|
10
|
+
buildTimelineFileDropPlacements,
|
|
11
|
+
fitTimelineAssetGeometry,
|
|
12
|
+
getTimelineAssetKind,
|
|
13
|
+
insertTimelineAssetIntoSource,
|
|
14
|
+
resolveTimelineAssetCompositionSize,
|
|
15
|
+
resolveTimelineAssetSrc,
|
|
16
|
+
} from "../utils/timelineAssetDrop";
|
|
17
|
+
import { generateId } from "../utils/generateId";
|
|
18
|
+
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
19
|
+
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
|
|
20
|
+
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
21
|
+
import { readFileContent } from "./timelineTimingSync";
|
|
22
|
+
|
|
23
|
+
interface UseTimelineAssetDropOpsOptions {
|
|
24
|
+
projectIdRef: MutableRefObject<string | null>;
|
|
25
|
+
activeCompPath: string | null;
|
|
26
|
+
timelineElements: TimelineElement[];
|
|
27
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
28
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
29
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
30
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
31
|
+
reloadPreview: () => void;
|
|
32
|
+
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
33
|
+
isRecordingRef?: RefObject<boolean>;
|
|
34
|
+
forceReloadSdkSession?: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useTimelineAssetDropOps({
|
|
38
|
+
projectIdRef,
|
|
39
|
+
activeCompPath,
|
|
40
|
+
timelineElements,
|
|
41
|
+
showToast,
|
|
42
|
+
writeProjectFile,
|
|
43
|
+
recordEdit,
|
|
44
|
+
domEditSaveTimestampRef,
|
|
45
|
+
reloadPreview,
|
|
46
|
+
uploadProjectFiles,
|
|
47
|
+
isRecordingRef,
|
|
48
|
+
forceReloadSdkSession,
|
|
49
|
+
}: UseTimelineAssetDropOpsOptions) {
|
|
50
|
+
// fallow-ignore-next-line complexity
|
|
51
|
+
const handleTimelineAssetDrop = useCallback(
|
|
52
|
+
// fallow-ignore-next-line complexity
|
|
53
|
+
async (
|
|
54
|
+
assetPath: string,
|
|
55
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
56
|
+
durationOverride?: number,
|
|
57
|
+
) => {
|
|
58
|
+
if (isRecordingRef?.current) {
|
|
59
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const pid = projectIdRef.current;
|
|
63
|
+
if (!pid) throw new Error("No active project");
|
|
64
|
+
|
|
65
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
66
|
+
if (!kind) {
|
|
67
|
+
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const targetPath = activeCompPath || "index.html";
|
|
72
|
+
try {
|
|
73
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
74
|
+
|
|
75
|
+
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
76
|
+
const duration =
|
|
77
|
+
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
|
78
|
+
? durationOverride
|
|
79
|
+
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
80
|
+
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
81
|
+
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
82
|
+
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
83
|
+
|
|
84
|
+
const resolvedTargetPath = targetPath || "index.html";
|
|
85
|
+
const relevantElements = timelineElements.filter(
|
|
86
|
+
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
87
|
+
);
|
|
88
|
+
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
|
89
|
+
|
|
90
|
+
const patchedContent = insertTimelineAssetIntoSource(
|
|
91
|
+
originalContent,
|
|
92
|
+
buildTimelineAssetInsertHtml({
|
|
93
|
+
id: newId,
|
|
94
|
+
hfId: `hf-${generateId()}`,
|
|
95
|
+
assetPath: resolvedAssetSrc,
|
|
96
|
+
kind,
|
|
97
|
+
start: normalizedStart,
|
|
98
|
+
duration: normalizedDuration,
|
|
99
|
+
track: placement.track,
|
|
100
|
+
zIndex: newElementZIndex,
|
|
101
|
+
geometry: fitTimelineAssetGeometry(
|
|
102
|
+
null,
|
|
103
|
+
resolveTimelineAssetCompositionSize(originalContent),
|
|
104
|
+
),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
109
|
+
await saveProjectFilesWithHistory({
|
|
110
|
+
projectId: pid,
|
|
111
|
+
label: "Add timeline asset",
|
|
112
|
+
kind: "timeline",
|
|
113
|
+
files: { [targetPath]: patchedContent },
|
|
114
|
+
readFile: async () => originalContent,
|
|
115
|
+
writeFile: writeProjectFile,
|
|
116
|
+
recordEdit,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
forceReloadSdkSession?.();
|
|
120
|
+
reloadPreview();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const message =
|
|
123
|
+
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
|
124
|
+
showToast(message);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
[
|
|
128
|
+
projectIdRef,
|
|
129
|
+
activeCompPath,
|
|
130
|
+
recordEdit,
|
|
131
|
+
showToast,
|
|
132
|
+
timelineElements,
|
|
133
|
+
writeProjectFile,
|
|
134
|
+
domEditSaveTimestampRef,
|
|
135
|
+
reloadPreview,
|
|
136
|
+
isRecordingRef,
|
|
137
|
+
forceReloadSdkSession,
|
|
138
|
+
],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// fallow-ignore-next-line complexity
|
|
142
|
+
const handleTimelineFileDrop = useCallback(
|
|
143
|
+
// fallow-ignore-next-line complexity
|
|
144
|
+
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
145
|
+
if (isRecordingRef?.current) {
|
|
146
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const pid = projectIdRef.current;
|
|
150
|
+
if (!pid) return;
|
|
151
|
+
const uploaded = await uploadProjectFiles(files);
|
|
152
|
+
if (uploaded.length === 0) return;
|
|
153
|
+
const durations: number[] = [];
|
|
154
|
+
for (const assetPath of uploaded) {
|
|
155
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
156
|
+
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
|
157
|
+
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
|
158
|
+
}
|
|
159
|
+
const placements = buildTimelineFileDropPlacements(
|
|
160
|
+
placement ?? { start: 0, track: 0 },
|
|
161
|
+
durations,
|
|
162
|
+
);
|
|
163
|
+
for (const [index, assetPath] of uploaded.entries()) {
|
|
164
|
+
await handleTimelineAssetDrop(
|
|
165
|
+
assetPath,
|
|
166
|
+
placements[index] ?? placements[0],
|
|
167
|
+
durations[index],
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return { handleTimelineAssetDrop, handleTimelineFileDrop };
|
|
175
|
+
}
|