@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.
Files changed (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. 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 one reload", async () => {
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
- reloadPreview();
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
- return commitDomEditPatchBatches(batches, { label: "Reorder layers", coalesceKey }).catch(
216
- (error) => {
217
- for (const rollback of rollbacks) rollback();
218
- throw error;
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?.postMessage(
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?.postMessage(
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
+ }