@hyperframes/studio 0.8.48 → 0.8.50

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 (124) hide show
  1. package/dist/assets/hyperframes-player-CxxDxHTW.js +464 -0
  2. package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +16 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6209 -5924
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useBlockHandlers.ts +7 -2
  35. package/src/hooks/useClipboard.test.ts +234 -0
  36. package/src/hooks/useClipboard.ts +264 -70
  37. package/src/hooks/useGroupCommits.test.ts +0 -1
  38. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  39. package/src/hooks/useRazorSplit.test.ts +0 -2
  40. package/src/hooks/useRazorSplit.test.tsx +0 -1
  41. package/src/hooks/useStudioContextValue.ts +5 -19
  42. package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
  43. package/src/hooks/useTimelineAssetDropOps.ts +4 -2
  44. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  45. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  46. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  47. package/src/player/components/ClipContextMenu.tsx +87 -7
  48. package/src/player/components/Timeline.tsx +14 -14
  49. package/src/player/components/TimelineCanvas.tsx +30 -0
  50. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  51. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  52. package/src/player/components/TimelineLanes.test.tsx +22 -1
  53. package/src/player/components/TimelineOverlays.tsx +12 -0
  54. package/src/player/components/TimelineTypes.ts +4 -0
  55. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  56. package/src/player/components/timelineClipChildren.test.ts +2 -2
  57. package/src/player/components/timelineClipChildren.tsx +2 -1
  58. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  59. package/src/player/components/timelineClipDragCommit.ts +20 -51
  60. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  61. package/src/player/components/timelineClipDragPreview.ts +8 -1
  62. package/src/player/components/timelineCollision.test.ts +23 -0
  63. package/src/player/components/timelineCollision.ts +27 -0
  64. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  65. package/src/player/components/timelineDragDrop.ts +30 -23
  66. package/src/player/components/timelineDragLanding.ts +108 -0
  67. package/src/player/components/timelineDropReveal.test.ts +34 -0
  68. package/src/player/components/timelineDropReveal.ts +12 -0
  69. package/src/player/components/timelineEditCapabilities.ts +1 -2
  70. package/src/player/components/timelineEditing.test.ts +0 -16
  71. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  72. package/src/player/components/timelineGroupEditing.ts +0 -1
  73. package/src/player/components/timelineTrackDisplay.ts +1 -1
  74. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  75. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  76. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  77. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  78. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  79. package/src/player/components/useTimelineGeometry.ts +7 -2
  80. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  81. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  82. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  83. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  84. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  85. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  86. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  87. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  88. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  89. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  90. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  91. package/src/player/lib/describeClips.test.ts +95 -0
  92. package/src/player/lib/describeClips.ts +103 -0
  93. package/src/player/lib/timelineDOM.test.ts +0 -80
  94. package/src/player/lib/timelineDOM.ts +1 -61
  95. package/src/player/lib/timelineElementHelpers.ts +10 -53
  96. package/src/player/store/playerStore.ts +5 -0
  97. package/src/player/store/readinessSlice.ts +4 -3
  98. package/src/player/store/timelineElement.ts +1 -3
  99. package/src/shims/canvasBrowserStub.js +50 -0
  100. package/src/utils/blockInstaller.test.ts +4 -0
  101. package/src/utils/blockInstaller.ts +9 -3
  102. package/src/utils/clipboardPayload.test.ts +42 -2
  103. package/src/utils/clipboardPayload.ts +57 -32
  104. package/src/utils/previewSafeMargins.test.ts +34 -0
  105. package/src/utils/previewSafeMargins.ts +10 -0
  106. package/src/utils/razorSplitTransaction.test.ts +0 -1
  107. package/src/utils/retryBudget.ts +20 -0
  108. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  109. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  110. package/src/utils/sdkCutover.ts +7 -4
  111. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  112. package/src/utils/sdkCutoverEligibility.ts +34 -11
  113. package/src/utils/studioUiPreferences.test.ts +43 -0
  114. package/src/utils/studioUiPreferences.ts +27 -6
  115. package/src/utils/timelineCompositionInsert.ts +2 -1
  116. package/src/utils/timelineElementSplit.test.ts +1 -2
  117. package/src/utils/timelineElementSplit.ts +0 -1
  118. package/src/webmcp/tools/lookTools.test.ts +40 -0
  119. package/src/webmcp/tools/lookTools.ts +32 -1
  120. package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
  121. package/dist/assets/index-B9LfWNyM.css +0 -1
  122. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  123. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  124. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -175,7 +175,70 @@ describe("useTimelineSelectionPreviewSync", () => {
175
175
  harness.cleanup();
176
176
  });
177
177
 
178
- it("warns once while retrying a timeline selection after preview refreshes", async () => {
178
+ it("does not clear the selection when the selected clip is not in timelineElements yet", async () => {
179
+ // A freshly-dropped clip is selected before the reload that adds it to
180
+ // timelineElements has completed. Bailing quietly here (like the
181
+ // DOM-node-not-ready case above) lets the later effect run, once the
182
+ // element exists, apply the selection instead of wiping it.
183
+ const { timelineElements } = makeSyncFixture();
184
+ const applyDomSelection = vi.fn();
185
+ const applyMarqueeSelection = vi.fn();
186
+ const onSelectionNotFound = vi.fn();
187
+ const buildDomSelectionForTimelineElement = vi.fn(async () => null);
188
+ const harness = renderHarness();
189
+
190
+ await harness.rerender({
191
+ selectedElementId: "clip-3",
192
+ selectedElementIds: new Set(["clip-3"]),
193
+ timelineElements,
194
+ domEditSelection: null,
195
+ domEditGroupSelections: [],
196
+ buildDomSelectionForTimelineElement,
197
+ applyDomSelection,
198
+ applyMarqueeSelection,
199
+ onSelectionNotFound,
200
+ });
201
+
202
+ // Still within the retry grace window: quiet, not yet a warning.
203
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
204
+ expect(applyDomSelection).not.toHaveBeenCalled();
205
+ harness.cleanup();
206
+ });
207
+
208
+ it("does not shrink a pasted or duplicated group's selection when only some members have appeared yet", async () => {
209
+ // Paste/duplicate of a group selects every new id up front; one member
210
+ // (clip-1) is already in timelineElements, the other (a fresh paste/dup
211
+ // target, "clip-3") is not yet. Applying only the resolved member would
212
+ // silently drop the rest of the group from the store.
213
+ const { firstSelection, timelineElements, selectionById } = makeSyncFixture();
214
+ const applyDomSelection = vi.fn();
215
+ const applyMarqueeSelection = vi.fn();
216
+ const onSelectionNotFound = vi.fn();
217
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
218
+ return selectionById.get(element.id) ?? firstSelection;
219
+ });
220
+ const harness = renderHarness();
221
+
222
+ await harness.rerender({
223
+ selectedElementId: "clip-1",
224
+ selectedElementIds: new Set(["clip-1", "clip-3"]),
225
+ timelineElements,
226
+ domEditSelection: null,
227
+ domEditGroupSelections: [],
228
+ buildDomSelectionForTimelineElement,
229
+ applyDomSelection,
230
+ applyMarqueeSelection,
231
+ onSelectionNotFound,
232
+ });
233
+
234
+ // Still within the retry grace window: quiet, not yet a warning.
235
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
236
+ expect(applyDomSelection).not.toHaveBeenCalled();
237
+ expect(applyMarqueeSelection).not.toHaveBeenCalled();
238
+ harness.cleanup();
239
+ });
240
+
241
+ it("stays quiet through retries and resolves once the preview refreshes", async () => {
179
242
  const { secondSelection, timelineElements } = makeSyncFixture();
180
243
  const applyDomSelection = vi.fn();
181
244
  const applyMarqueeSelection = vi.fn();
@@ -199,7 +262,7 @@ describe("useTimelineSelectionPreviewSync", () => {
199
262
  onSelectionNotFound,
200
263
  });
201
264
 
202
- expect(onSelectionNotFound).toHaveBeenCalledOnce();
265
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
203
266
  expect(applyDomSelection).not.toHaveBeenCalled();
204
267
 
205
268
  await harness.rerender({
@@ -214,7 +277,7 @@ describe("useTimelineSelectionPreviewSync", () => {
214
277
  onSelectionNotFound,
215
278
  });
216
279
 
217
- expect(onSelectionNotFound).toHaveBeenCalledOnce();
280
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
218
281
 
219
282
  previewReady = true;
220
283
  await harness.rerender({
@@ -230,6 +293,155 @@ describe("useTimelineSelectionPreviewSync", () => {
230
293
  });
231
294
 
232
295
  expect(applyDomSelection).toHaveBeenCalledWith(secondSelection);
296
+ expect(onSelectionNotFound).not.toHaveBeenCalled();
297
+ harness.cleanup();
298
+ });
299
+
300
+ it("degrades to the resolvable subset once a group member never resolves", async () => {
301
+ // clip-2 is a permanent hold-out (deleted out of band, a stale id, or
302
+ // anything else that will never build a selection). Retrying forever
303
+ // would leave the whole group's selection stuck; after a few attempts,
304
+ // apply the members that did resolve instead.
305
+ const { firstSelection, timelineElements } = makeSyncFixture();
306
+ const applyDomSelection = vi.fn();
307
+ const applyMarqueeSelection = vi.fn();
308
+ const onSelectionNotFound = vi.fn();
309
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) =>
310
+ element.id === "clip-1" ? firstSelection : null,
311
+ );
312
+ const selectedElementIds = new Set(["clip-1", "clip-2"]);
313
+ const harness = renderHarness();
314
+
315
+ for (let attempt = 0; attempt < 4; attempt++) {
316
+ await harness.rerender({
317
+ selectedElementId: "clip-1",
318
+ selectedElementIds,
319
+ timelineElements: [...timelineElements],
320
+ domEditSelection: null,
321
+ domEditGroupSelections: [],
322
+ buildDomSelectionForTimelineElement,
323
+ applyDomSelection,
324
+ applyMarqueeSelection,
325
+ onSelectionNotFound,
326
+ });
327
+ }
328
+
329
+ expect(onSelectionNotFound).toHaveBeenCalledOnce();
330
+ expect(applyDomSelection).toHaveBeenCalledWith(firstSelection);
331
+ expect(applyMarqueeSelection).not.toHaveBeenCalled();
332
+ harness.cleanup();
333
+ });
334
+
335
+ it("gives a fresh retry budget after deselecting a permanently-unresolvable selection", async () => {
336
+ // Same permanent hold-out as the test above, but the user deselects and
337
+ // reselects the exact same group afterward. Without resetting the retry
338
+ // budget on deselect, reselecting the same key resumes an already-
339
+ // exhausted count and degrades on the very first attempt with no warning.
340
+ const { firstSelection, timelineElements } = makeSyncFixture();
341
+ const applyDomSelection = vi.fn();
342
+ const applyMarqueeSelection = vi.fn();
343
+ const onSelectionNotFound = vi.fn();
344
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) =>
345
+ element.id === "clip-1" ? firstSelection : null,
346
+ );
347
+ const selectedElementIds = new Set(["clip-1", "clip-2"]);
348
+ const harness = renderHarness();
349
+
350
+ for (let attempt = 0; attempt < 4; attempt++) {
351
+ await harness.rerender({
352
+ selectedElementId: "clip-1",
353
+ selectedElementIds,
354
+ timelineElements: [...timelineElements],
355
+ domEditSelection: null,
356
+ domEditGroupSelections: [],
357
+ buildDomSelectionForTimelineElement,
358
+ applyDomSelection,
359
+ applyMarqueeSelection,
360
+ onSelectionNotFound,
361
+ });
362
+ }
363
+ expect(onSelectionNotFound).toHaveBeenCalledOnce();
364
+
365
+ await harness.rerender({
366
+ selectedElementId: null,
367
+ selectedElementIds: new Set(),
368
+ timelineElements: [...timelineElements],
369
+ domEditSelection: null,
370
+ domEditGroupSelections: [],
371
+ buildDomSelectionForTimelineElement,
372
+ applyDomSelection,
373
+ applyMarqueeSelection,
374
+ onSelectionNotFound,
375
+ });
376
+
377
+ for (let attempt = 0; attempt < 4; attempt++) {
378
+ await harness.rerender({
379
+ selectedElementId: "clip-1",
380
+ selectedElementIds,
381
+ timelineElements: [...timelineElements],
382
+ domEditSelection: null,
383
+ domEditGroupSelections: [],
384
+ buildDomSelectionForTimelineElement,
385
+ applyDomSelection,
386
+ applyMarqueeSelection,
387
+ onSelectionNotFound,
388
+ });
389
+ }
390
+
391
+ expect(onSelectionNotFound).toHaveBeenCalledTimes(2);
392
+ harness.cleanup();
393
+ });
394
+
395
+ it("resolves every member of a large selection concurrently, not one network round trip at a time", async () => {
396
+ const elementCount = 30;
397
+ const timelineElements: TimelineElement[] = Array.from({ length: elementCount }, (_, i) => ({
398
+ id: `clip-${i}`,
399
+ domId: `clip-${i}`,
400
+ tag: "div",
401
+ start: i,
402
+ duration: 1,
403
+ track: i % 4,
404
+ }));
405
+ const selectionById = new Map(
406
+ timelineElements.map((el) => {
407
+ const domEl = document.createElement("div");
408
+ domEl.id = el.id;
409
+ return [el.id, makeSelection(el.id, domEl)];
410
+ }),
411
+ );
412
+ let inFlight = 0;
413
+ let maxInFlight = 0;
414
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
415
+ inFlight += 1;
416
+ maxInFlight = Math.max(maxInFlight, inFlight);
417
+ // Stands in for the network round trip: yields once so every member's
418
+ // call has a chance to start before any of them finish.
419
+ await Promise.resolve();
420
+ inFlight -= 1;
421
+ return selectionById.get(element.id) ?? null;
422
+ });
423
+ const applyDomSelection = vi.fn();
424
+ const applyMarqueeSelection = vi.fn();
425
+ const harness = renderHarness();
426
+
427
+ await harness.rerender({
428
+ selectedElementId: timelineElements[0].id,
429
+ selectedElementIds: new Set(timelineElements.map((el) => el.id)),
430
+ timelineElements,
431
+ domEditSelection: null,
432
+ domEditGroupSelections: [],
433
+ buildDomSelectionForTimelineElement,
434
+ applyDomSelection,
435
+ applyMarqueeSelection,
436
+ onSelectionNotFound: vi.fn(),
437
+ });
438
+
439
+ expect(buildDomSelectionForTimelineElement).toHaveBeenCalledTimes(elementCount);
440
+ // A sequential loop never has more than one call in flight at once; this
441
+ // asserts every member's probe started before any of them resolved.
442
+ expect(maxInFlight).toBe(elementCount);
443
+ expect(applyMarqueeSelection).toHaveBeenCalledOnce();
444
+ expect(applyMarqueeSelection.mock.calls[0]?.[0]).toHaveLength(elementCount);
233
445
  harness.cleanup();
234
446
  });
235
447
  });
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../player";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
5
5
  import { logSelect } from "../utils/selectDebug";
6
+ import { recordRetryAttempt, type RetryBudgetState } from "../utils/retryBudget";
6
7
 
7
8
  interface UseTimelineSelectionPreviewSyncParams {
8
9
  selectedElementId: string | null;
@@ -27,6 +28,12 @@ interface UseTimelineSelectionPreviewSyncParams {
27
28
  onSelectionNotFound: () => void;
28
29
  }
29
30
 
31
+ // A member still resolving (a just-dropped/pasted/duplicated clip, or a preview
32
+ // still reloading) heals within a few retries. One that never will (deleted out
33
+ // of band, a stale id) would otherwise bail forever; past this cap, apply
34
+ // whatever did resolve instead of leaving the group selection stuck.
35
+ const MAX_UNRESOLVED_SYNC_RETRIES = 3;
36
+
30
37
  function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
31
38
  const ordered = [...ids];
32
39
  if (!anchor || !ids.has(anchor)) return ordered;
@@ -63,6 +70,36 @@ function anchorIsOutsideSelection(anchor: string | null, selectedIds: string[]):
63
70
  return anchor !== null && !selectedIds.includes(anchor);
64
71
  }
65
72
 
73
+ async function resolveSelectionsForIds(
74
+ ids: string[],
75
+ timelineElements: TimelineElement[],
76
+ buildDomSelectionForTimelineElement: UseTimelineSelectionPreviewSyncParams["buildDomSelectionForTimelineElement"],
77
+ ): Promise<DomEditSelection[]> {
78
+ // Each element's resolution fires its own network probe; a selection of N
79
+ // members used to pay N sequential round trips here instead of one.
80
+ const elements = ids
81
+ .map((id) => timelineElements.find((item) => (item.key ?? item.id) === id))
82
+ .filter((element): element is TimelineElement => Boolean(element));
83
+ const resolved = await Promise.all(
84
+ elements.map((element) => buildDomSelectionForTimelineElement(element)),
85
+ );
86
+ return resolved.filter((selection): selection is DomEditSelection => Boolean(selection));
87
+ }
88
+
89
+ function applyResolvedSelections(
90
+ selections: DomEditSelection[],
91
+ applyDomSelection: UseTimelineSelectionPreviewSyncParams["applyDomSelection"],
92
+ applyMarqueeSelection: UseTimelineSelectionPreviewSyncParams["applyMarqueeSelection"],
93
+ ): void {
94
+ if (selections.length === 0) {
95
+ applyDomSelection(null, { revealPanel: false });
96
+ } else if (selections.length === 1) {
97
+ applyDomSelection(selections[0]);
98
+ } else {
99
+ applyMarqueeSelection(selections, false);
100
+ }
101
+ }
102
+
66
103
  export function useTimelineSelectionPreviewSync({
67
104
  selectedElementId,
68
105
  selectedElementIds,
@@ -84,6 +121,7 @@ export function useTimelineSelectionPreviewSync({
84
121
  const domEditGroupSelectionsRef = useRef(domEditGroupSelections);
85
122
  const lastSyncedSelectedKeyRef = useRef("");
86
123
  const missingSelectionKeyRef = useRef("");
124
+ const unresolvedAttemptsRef = useRef<RetryBudgetState<string>>({ id: "", count: 0 });
87
125
  domEditSelectionRef.current = domEditSelection;
88
126
  domEditGroupSelectionsRef.current = domEditGroupSelections;
89
127
 
@@ -109,6 +147,10 @@ export function useTimelineSelectionPreviewSync({
109
147
 
110
148
  if (selectedIds.length === 0) {
111
149
  missingSelectionKeyRef.current = "";
150
+ // A deselect is the one unambiguous "new attempt" signal: without it, reselecting the
151
+ // same permanently-unresolvable id later picks up an already-exhausted retry budget
152
+ // and skips straight to the degraded fallback instead of getting a fresh grace window.
153
+ unresolvedAttemptsRef.current = { id: "", count: 0 };
112
154
  // The timeline holds nothing, so the canvas is about to hold nothing either.
113
155
  // This is the path that silently drops a selection the user can still see.
114
156
  logSelect("timeline-empty", {
@@ -134,46 +176,35 @@ export function useTimelineSelectionPreviewSync({
134
176
  onSelectionNotFound();
135
177
  };
136
178
  const syncSelection = async () => {
137
- const selections: DomEditSelection[] = [];
138
- let resolvableCount = 0;
139
- for (const id of selectedIds) {
140
- const element = timelineElements.find((item) => (item.key ?? item.id) === id);
141
- if (!element) continue;
142
- resolvableCount += 1;
143
- const selection = await buildDomSelectionForTimelineElement(element);
144
- if (selection) selections.push(selection);
145
- }
179
+ const selections = await resolveSelectionsForIds(
180
+ selectedIds,
181
+ timelineElements,
182
+ buildDomSelectionForTimelineElement,
183
+ );
146
184
  if (cancelled) return;
147
- // The store is the source of truth: applying a partial set would write that
148
- // shrunk set back and silently drop the members whose DOM node was not ready.
149
- // Bail instead; a later effect run (on timelineElements/DOM change) applies the
150
- // full set once every resolvable member has a live node.
151
- if (selections.length < resolvableCount) {
152
- warnSelectionMissingOnce();
153
- // Bailing keeps whatever the canvas already held, and Delete acts on the
154
- // canvas first — so an anchor pointing OUTSIDE this selection is an
155
- // element the user is no longer looking at, and deleting it is the
156
- // damage. Only that goes: a member still resolving has no anchor of its
157
- // own here and is left for the later run. Quietly, because announcing
158
- // the clear would deselect the clip that was just picked.
159
- if (anchorIsOutsideSelection(currentAnchor, selectedIds)) {
160
- applyDomSelection(null, { revealPanel: false, announce: false });
185
+ if (selections.length < selectedIds.length) {
186
+ if (recordRetryAttempt(unresolvedAttemptsRef, selectedKey, MAX_UNRESOLVED_SYNC_RETRIES)) {
187
+ // Still within the retry grace window: stay quiet (warn only once
188
+ // exhausted, below). Delete acts on the canvas first, so only an
189
+ // anchor OUTSIDE this selection is cleared here, quietly.
190
+ if (anchorIsOutsideSelection(currentAnchor, selectedIds)) {
191
+ applyDomSelection(null, { revealPanel: false, announce: false });
192
+ }
193
+ return;
161
194
  }
162
- return;
195
+ // Budget exhausted: warn once (missingSelectionKeyRef dedupes further
196
+ // reruns of this same still-unresolved key) and apply whatever did resolve.
197
+ warnSelectionMissingOnce();
198
+ } else {
199
+ unresolvedAttemptsRef.current = { id: "", count: 0 };
200
+ missingSelectionKeyRef.current = "";
163
201
  }
164
- missingSelectionKeyRef.current = "";
165
202
  logSelect("timeline-sync", {
166
203
  wanted: selectedIds.length,
167
204
  had: currentIds.length,
168
205
  resolved: selections.length,
169
206
  });
170
- if (selections.length === 0) {
171
- applyDomSelection(null, { revealPanel: false });
172
- } else if (selections.length === 1) {
173
- applyDomSelection(selections[0]);
174
- } else {
175
- applyMarqueeSelection(selections, false);
176
- }
207
+ applyResolvedSelections(selections, applyDomSelection, applyMarqueeSelection);
177
208
  };
178
209
 
179
210
  void syncSelection();
@@ -1,6 +1,6 @@
1
1
  import { memo } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import type { TimelineElement } from "../store/playerStore";
3
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
5
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
6
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
@@ -13,8 +13,14 @@ interface ClipContextMenuProps {
13
13
  onClose: () => void;
14
14
  onSplit: (element: TimelineElement, splitTime: number) => void;
15
15
  onDelete: (element: TimelineElement) => void;
16
+ onCopy?: () => boolean;
17
+ onPaste?: () => Promise<void>;
18
+ onDuplicate?: () => Promise<boolean>;
19
+ canPaste?: boolean;
16
20
  }
17
21
 
22
+ // A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
23
+ // fallow-ignore-next-line complexity
18
24
  export const ClipContextMenu = memo(function ClipContextMenu({
19
25
  x,
20
26
  y,
@@ -23,15 +29,22 @@ export const ClipContextMenu = memo(function ClipContextMenu({
23
29
  onClose,
24
30
  onSplit,
25
31
  onDelete,
32
+ onCopy,
33
+ onPaste,
34
+ onDuplicate,
35
+ canPaste,
26
36
  }: ClipContextMenuProps) {
27
37
  const menuRef = useContextMenuDismiss(onClose);
28
38
  useMenuKeyboardNav(menuRef);
29
-
30
- const menuWidth = 200;
31
- const menuHeight = 80;
32
- const overflowY = y + menuHeight - window.innerHeight;
33
- const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
34
- const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
39
+ // The right-clicked clip's own id: a member of the live multi-selection
40
+ // means Copy/Duplicate act on the whole group, matching onContextMenuClip's
41
+ // selection-preserving behaviour for a right-click inside it.
42
+ const selectionSize = usePlayerStore((s) => {
43
+ const id = element.key ?? element.id;
44
+ return s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)
45
+ ? s.selectedElementIds.size
46
+ : 1;
47
+ });
35
48
 
36
49
  const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
37
50
  const canSplit =
@@ -43,6 +56,23 @@ export const ClipContextMenu = memo(function ClipContextMenu({
43
56
  ? `Split at ${currentTime.toFixed(2)}s`
44
57
  : "Split (move playhead inside clip)";
45
58
 
59
+ const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
60
+ const rowCount = (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
61
+ const dividerCount = (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
62
+ const menuWidth = 200;
63
+ const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
64
+ const overflowY = y + menuHeight - window.innerHeight;
65
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
66
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
67
+
68
+ // Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
69
+ const itemClass = (enabled: boolean) =>
70
+ `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
71
+ enabled
72
+ ? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
73
+ : " text-neutral-600 cursor-not-allowed"
74
+ }`;
75
+
46
76
  return createPortal(
47
77
  <div
48
78
  ref={menuRef}
@@ -76,6 +106,56 @@ export const ClipContextMenu = memo(function ClipContextMenu({
76
106
  </>
77
107
  )}
78
108
 
109
+ {(onCopy || onPaste || onDuplicate) && (
110
+ <>
111
+ {onCopy && (
112
+ <button
113
+ type="button"
114
+ role="menuitem"
115
+ className={itemClass(true)}
116
+ onClick={() => {
117
+ onCopy();
118
+ onClose();
119
+ }}
120
+ >
121
+ <span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
122
+ <span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
123
+ </button>
124
+ )}
125
+ {onPaste && (
126
+ <button
127
+ type="button"
128
+ role="menuitem"
129
+ className={itemClass(!!canPaste)}
130
+ disabled={!canPaste}
131
+ onClick={() => {
132
+ if (!canPaste) return;
133
+ void onPaste();
134
+ onClose();
135
+ }}
136
+ >
137
+ <span>Paste</span>
138
+ <span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
139
+ </button>
140
+ )}
141
+ {onDuplicate && (
142
+ <button
143
+ type="button"
144
+ role="menuitem"
145
+ className={itemClass(true)}
146
+ onClick={() => {
147
+ void onDuplicate();
148
+ onClose();
149
+ }}
150
+ >
151
+ <span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
152
+ <span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
153
+ </button>
154
+ )}
155
+ <div className="my-1 border-t border-neutral-700/60" />
156
+ </>
157
+ )}
158
+
79
159
  <button
80
160
  type="button"
81
161
  role="menuitem"
@@ -1,7 +1,7 @@
1
1
  import { useRef, useMemo, useCallback, useState, memo } from "react";
2
2
  import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
3
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
- import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
4
+ import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
5
5
  import { defaultTimelineTheme } from "./timelineTheme";
6
6
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
7
7
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
@@ -43,6 +43,7 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
43
43
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
44
44
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
45
45
  import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
46
+ import { useClipContextMenu } from "./useTimelineClipContextMenu";
46
47
 
47
48
  export {
48
49
  shouldAutoScrollTimeline,
@@ -79,6 +80,10 @@ export const Timeline = memo(function Timeline({
79
80
  onBlockedEditAttempt: onBlockedEditAttemptOverride,
80
81
  onSplitElement: onSplitElementOverride,
81
82
  onSelectElement,
83
+ onCopyClip,
84
+ onPasteClip,
85
+ onDuplicateClip,
86
+ canPasteClip,
82
87
  theme: themeOverrides,
83
88
  sessionEpoch = 0,
84
89
  }: TimelineProps = {}) {
@@ -106,7 +111,7 @@ export const Timeline = memo(function Timeline({
106
111
  const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
107
112
  useMusicBeatAnalysis();
108
113
  const rawElements = usePlayerStore((s) => s.elements);
109
- const expandedElements = useExpandedTimelineElements();
114
+ const expandedElements = useTimelineRowElements();
110
115
  const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
111
116
  const duration = usePlayerStore((s) => s.duration);
112
117
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
@@ -208,6 +213,7 @@ export const Timeline = memo(function Timeline({
208
213
  onMoveElement: pinnedOnMoveElement,
209
214
  onMoveElements: pinnedOnMoveElements,
210
215
  });
216
+ const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu);
211
217
 
212
218
  const {
213
219
  draggedClip,
@@ -492,6 +498,7 @@ export const Timeline = memo(function Timeline({
492
498
  rangeSelection={rangeSelection}
493
499
  marqueeRect={marqueeRect}
494
500
  laneGapStrips={laneGapStrips}
501
+ dropPreview={assetDrop.dropPreview}
495
502
  theme={theme}
496
503
  displayTrackOrder={displayLayout.displayTrackOrder}
497
504
  rowHeights={displayLayout.displayRowHeights}
@@ -548,18 +555,7 @@ export const Timeline = memo(function Timeline({
548
555
  onShiftClickKeyframe={onShiftClickKeyframe}
549
556
  onMoveKeyframe={onMoveKeyframe}
550
557
  onContextMenuKeyframe={onContextMenuKeyframe}
551
- onContextMenuClip={(e, el) => {
552
- e.preventDefault();
553
- setSelectedElementId(el.key ?? el.id);
554
- onSelectElement?.(el);
555
- dismissGapMenu();
556
- setClipContextMenu({
557
- x: e.clientX,
558
- y: e.clientY,
559
- element: el,
560
- sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
561
- });
562
- }}
558
+ onContextMenuClip={onContextMenuClip}
563
559
  onContextMenuLane={(e, track, time) => {
564
560
  if (draggedClip?.started || resizingClip) return;
565
561
  setClipContextMenu(null);
@@ -588,6 +584,10 @@ export const Timeline = memo(function Timeline({
588
584
  onSplitElement={onSplitElement}
589
585
  pinZoomBeforeEdit={pinZoomBeforeEdit}
590
586
  onDeleteElement={_onDeleteElement}
587
+ onCopyClip={onCopyClip}
588
+ onPasteClip={onPasteClip}
589
+ onDuplicateClip={onDuplicateClip}
590
+ canPasteClip={canPasteClip}
591
591
  gapContextMenu={gapMenuModel}
592
592
  onDismissGapContextMenu={dismissGapMenu}
593
593
  onCloseTrackGap={closeTrackGap}
@@ -39,12 +39,22 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
39
39
  playheadRef: React.RefObject<HTMLDivElement | null>;
40
40
  /** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
41
41
  laneGapStrips: TimelineLaneGapStrips[];
42
+ /** Landing spot of an outside drag in progress, or null. */
43
+ dropPreview: { start: number; track: number } | null;
42
44
  }
43
45
 
46
+ // A dropped clip's length is unknown until it lands; the preview shows a default.
47
+ const DROP_PREVIEW_SECONDS = 3;
48
+
44
49
  export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
45
50
  const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
46
51
  const draggedRowIndex =
47
52
  draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
53
+ const dropTrackIndex = props.dropPreview
54
+ ? displayTrackOrder.indexOf(props.dropPreview.track)
55
+ : -1;
56
+ // A track not in the order is a new track, drawn one row past the last lane.
57
+ const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
48
58
  const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
49
59
  // A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
50
60
  // occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
@@ -171,6 +181,26 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
171
181
  />
172
182
  )}
173
183
 
184
+ {/* Drop preview: where an asset or file dragged in from outside will land
185
+ (a row past the last lane means a new track). */}
186
+ {props.dropPreview && (
187
+ <div
188
+ aria-hidden="true"
189
+ data-testid="timeline-drop-preview"
190
+ className="absolute pointer-events-none"
191
+ style={{
192
+ top: getTimelineRowTop(dropRowIndex, props.rowHeights) + CLIP_Y,
193
+ left: props.contentOrigin + props.dropPreview.start * props.pps,
194
+ width: DROP_PREVIEW_SECONDS * props.pps,
195
+ height: TRACK_H - CLIP_Y * 2,
196
+ border: "1px solid rgba(60,230,172,0.55)",
197
+ background: "rgba(60,230,172,0.12)",
198
+ borderRadius: 4,
199
+ zIndex: 30,
200
+ }}
201
+ />
202
+ )}
203
+
174
204
  {/* Insertion line — a new track will be inserted at this boundary on drop.
175
205
  Shown while the pointer is near a lane boundary (insert mode). */}
176
206
  {draggedClip?.started && draggedClip.insertRow != null && (