@hyperframes/studio 0.8.48 → 0.8.49

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 (40) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/{index-B3-orIjW.js → index-BGovDksE.js} +1 -1
  3. package/dist/assets/{index-C9gJKpqQ.js → index-Bgmx2h7W.js} +135 -135
  4. package/dist/assets/{index-D_j-0PD3.js → index-mIgSsKqs.js} +1 -1
  5. package/dist/index.d.ts +4 -3
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +443 -303
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/hooks/useBlockHandlers.ts +7 -2
  11. package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
  12. package/src/hooks/useTimelineAssetDropOps.ts +4 -2
  13. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  14. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  15. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  16. package/src/player/components/timelineClipDragCommit.ts +18 -49
  17. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  18. package/src/player/components/timelineClipDragPreview.ts +8 -1
  19. package/src/player/components/timelineCollision.test.ts +23 -0
  20. package/src/player/components/timelineCollision.ts +27 -0
  21. package/src/player/components/timelineDragLanding.ts +108 -0
  22. package/src/player/components/timelineDropReveal.test.ts +34 -0
  23. package/src/player/components/timelineDropReveal.ts +12 -0
  24. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  25. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  26. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  27. package/src/player/components/useTimelineGeometry.ts +7 -2
  28. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  29. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  30. package/src/player/lib/timelineElementHelpers.ts +8 -0
  31. package/src/player/store/readinessSlice.ts +4 -3
  32. package/src/utils/blockInstaller.test.ts +4 -0
  33. package/src/utils/blockInstaller.ts +9 -3
  34. package/src/utils/retryBudget.ts +20 -0
  35. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  36. package/src/utils/sdkCutover.ts +7 -4
  37. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  38. package/src/utils/sdkCutoverEligibility.ts +34 -11
  39. package/src/utils/timelineCompositionInsert.ts +2 -1
  40. package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.48",
3
+ "version": "0.8.49",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.48",
52
- "@hyperframes/parsers": "0.8.48",
53
- "@hyperframes/player": "0.8.48",
54
- "@hyperframes/studio-server": "0.8.48",
55
- "@hyperframes/sdk": "0.8.48"
51
+ "@hyperframes/parsers": "0.8.49",
52
+ "@hyperframes/studio-server": "0.8.49",
53
+ "@hyperframes/player": "0.8.49",
54
+ "@hyperframes/sdk": "0.8.49",
55
+ "@hyperframes/core": "0.8.49"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^4.1.11",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.48"
72
+ "@hyperframes/producer": "0.8.49"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
@@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
6
6
  import type { TimelineElement } from "../player";
7
7
  import { usePlayerStore } from "../player";
8
8
  import { addBlockToProject } from "../utils/blockInstaller";
9
+ import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
9
10
  import type { BlockParam } from "@hyperframes/core/registry";
10
11
  import type { EditHistoryKind } from "../utils/editHistory";
11
12
  import type { RightPanelTab } from "../utils/studioHelpers";
@@ -148,7 +149,9 @@ export function useBlockHandlers({
148
149
  previewIframe: previewIframeRef.current,
149
150
  currentTime: usePlayerStore.getState().currentTime,
150
151
  }),
151
- );
152
+ ).then((result) => {
153
+ if (result) selectAndRevealTimelineElement(result.hostKey);
154
+ });
152
155
  },
153
156
  [projectId, blockCtx, previewIframeRef, runBlockInstall],
154
157
  );
@@ -184,7 +187,9 @@ export function useBlockHandlers({
184
187
  previewIframe: previewIframeRef.current,
185
188
  currentTime: usePlayerStore.getState().currentTime,
186
189
  }),
187
- );
190
+ ).then((result) => {
191
+ if (result) selectAndRevealTimelineElement(result.hostKey);
192
+ });
188
193
  },
189
194
  [projectId, blockCtx, previewIframeRef, runBlockInstall],
190
195
  );
@@ -4,12 +4,14 @@ import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
+ import { usePlayerStore } from "../player/store/playerStore";
7
8
 
8
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
10
 
10
11
  afterEach(() => {
11
12
  document.body.innerHTML = "";
12
13
  vi.unstubAllGlobals();
14
+ usePlayerStore.getState().reset();
13
15
  });
14
16
 
15
17
  type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
@@ -69,4 +71,17 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
69
71
  const [, written] = writeProjectFile.mock.calls[0] as [string, string];
70
72
  expect(written).toContain('data-duration="10"');
71
73
  });
74
+
75
+ it("selects and reveals the newly dropped clip", async () => {
76
+ const source =
77
+ '<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
78
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
79
+ const getDrop = renderDropHook(source, writeProjectFile);
80
+
81
+ await act(async () => {
82
+ await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
83
+ });
84
+
85
+ expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
86
+ });
72
87
  });
@@ -21,7 +21,8 @@ import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
21
21
  import { readFileContent } from "./timelineTimingSync";
22
22
  import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
23
23
  import { extendRootDurationInSource } from "../utils/rootDuration";
24
- import { usePlayerStore } from "../player";
24
+ import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
25
+ import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
25
26
 
26
27
  interface UseTimelineAssetDropOpsOptions {
27
28
  projectIdRef: MutableRefObject<string | null>;
@@ -121,6 +122,7 @@ export function useTimelineAssetDropOps({
121
122
  recordEdit,
122
123
  });
123
124
 
125
+ selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
124
126
  forceReloadSdkSession?.();
125
127
  reloadPreview();
126
128
  } catch (error) {
@@ -194,7 +196,7 @@ export function useTimelineAssetDropOps({
194
196
  writeFile: writeProjectFile,
195
197
  recordEdit,
196
198
  observeVersion: observeProjectFileVersion,
197
- selectHost: (key) => usePlayerStore.getState().setSelectedElementId(key),
199
+ selectHost: selectAndRevealTimelineElement,
198
200
  resync: forceReloadSdkSession,
199
201
  refresh: reloadPreview,
200
202
  });
@@ -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();
@@ -1223,6 +1223,120 @@ describe("commitDraggedClipMove", () => {
1223
1223
  expect(onStackingPatches).not.toHaveBeenCalled();
1224
1224
  });
1225
1225
  });
1226
+
1227
+ describe("magnetic main track on a track-insert", () => {
1228
+ it("a top-gutter insert that renumbers to literal track 0 snaps its start to 0", () => {
1229
+ // Sole visual clip sits on track 1 — the real main track (0) is genuinely
1230
+ // empty. Dragging it into the top insert-gutter creates a new lane that
1231
+ // normalizeToZones renumbers to literal 0, so the main-track rule must apply
1232
+ // even though the preview never saw a literal-0 landing track.
1233
+ const elements = [el("v1", 1, 0, 5)];
1234
+ const { onMoveElements } = runClipMove(
1235
+ drag(elements[0], { previewStart: 8, previewTrack: 1, insertRow: 0 }),
1236
+ { elements, trackOrder: [1] },
1237
+ );
1238
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1239
+ expect(map.v1).toEqual({ start: 0, track: 0 });
1240
+ });
1241
+
1242
+ it("the z-sync candidate reflects the snapped start, not the raw previewStart", async () => {
1243
+ // v1 snaps from previewStart 8 to 0 (see the test above). A foreign-file
1244
+ // sibling at [2, 5) only overlaps the SNAPPED window [0, 5), never the
1245
+ // raw [8, 13) — so a z-sync firing at all proves the candidate the
1246
+ // stacking check reads from carries the snapped start.
1247
+ const v1 = el("v1", 2, 0, 5);
1248
+ const sibling = el("s", 1, 2, 3); // [2, 5) — same source file, same paint scope
1249
+ const elements = [v1, sibling];
1250
+ const onStackingPatches = vi.fn();
1251
+ // v1 lands on lane 0, which paints above lane 1 (lower track paints higher),
1252
+ // yet carries the lower z: a violation only while the two overlap in time.
1253
+ commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 2, insertRow: 0 }), {
1254
+ elements,
1255
+ trackOrder: [1, 2],
1256
+ updateElement: vi.fn(),
1257
+ onMoveElement: vi.fn(),
1258
+ onMoveElements: vi.fn(),
1259
+ readZIndex: (element) => (element.key === "v1" ? 1 : 10),
1260
+ onStackingPatches,
1261
+ });
1262
+ await flushMicrotasks();
1263
+ expect(onStackingPatches).toHaveBeenCalledTimes(1);
1264
+ });
1265
+
1266
+ it("a plain lane change onto the empty main track snaps its committed start to 0", () => {
1267
+ const elements = [el("v1", 1, 0, 5)];
1268
+ const spies = runClipMove(drag(elements[0], { previewStart: 8, previewTrack: 0 }), {
1269
+ elements,
1270
+ trackOrder: [0, 1],
1271
+ });
1272
+ expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 0, track: 0 });
1273
+ });
1274
+
1275
+ it("the z-sync candidate of a plain lane change carries the snapped start", async () => {
1276
+ // The sibling [2, 5) overlaps v1 only at the snapped [0, 5), never at the raw [8, 13).
1277
+ const v1 = el("v1", 2, 0, 5);
1278
+ const sibling = el("s", 1, 2, 3);
1279
+ const onStackingPatches = vi.fn();
1280
+ commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 0 }), {
1281
+ elements: [v1, sibling],
1282
+ trackOrder: [0, 1, 2],
1283
+ updateElement: vi.fn(),
1284
+ onMoveElement: vi.fn(),
1285
+ onMoveElements: vi.fn(),
1286
+ readZIndex: (element) => (element.key === "v1" ? 1 : 10),
1287
+ onStackingPatches,
1288
+ });
1289
+ await flushMicrotasks();
1290
+ expect(onStackingPatches).toHaveBeenCalledTimes(1);
1291
+ });
1292
+
1293
+ it("a top-gutter insert that pushes the old track-0 clip down snaps to the new track 0", () => {
1294
+ const oldMain = el("old", 0, 0, 3);
1295
+ const dragged = el("v1", 2, 0, 5);
1296
+ const { onMoveElements } = runClipMove(
1297
+ drag(dragged, { previewStart: 8, previewTrack: 2, insertRow: 0 }),
1298
+ { elements: [oldMain, dragged], trackOrder: [0, 2] },
1299
+ );
1300
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1301
+ expect(map.old.track).toBe(1);
1302
+ expect(map.v1).toEqual({ start: 0, track: 0 });
1303
+ });
1304
+
1305
+ it("an expanded child dragged with its host commits the HOST at 0", () => {
1306
+ for (const [hostStart, childStart] of [
1307
+ [30, 32],
1308
+ [20, 22],
1309
+ ]) {
1310
+ const host = el("host", 1, hostStart, 10);
1311
+ const child: TimelineElement = {
1312
+ ...el("child", 2, childStart, 4),
1313
+ expandedHostKey: "host",
1314
+ expandedParentStart: hostStart,
1315
+ };
1316
+ const { onMoveElements } = runClipMove(
1317
+ drag(child, { previewStart: childStart, previewTrack: 0 }),
1318
+ {
1319
+ elements: [host, child],
1320
+ trackOrder: [0, 1, 2],
1321
+ selectedKeys: new Set(["host", "child"]),
1322
+ },
1323
+ );
1324
+ expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({ start: 0, track: 0 });
1325
+ }
1326
+ });
1327
+
1328
+ it("a multi-selection top-gutter insert does NOT snap (siblings key off the unsnapped start)", () => {
1329
+ const dragged = el("v1", 1, 0, 5);
1330
+ const sibling = el("v2", 1, 10, 5);
1331
+ const elements = [dragged, sibling];
1332
+ const { onMoveElements } = runClipMove(
1333
+ drag(dragged, { previewStart: 8, previewTrack: 1, insertRow: 0 }),
1334
+ { elements, trackOrder: [1], selectedKeys: new Set(["v1", "v2"]) },
1335
+ );
1336
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1337
+ expect(map.v1.start).toBe(8); // unsnapped — multi-selection guard
1338
+ });
1339
+ });
1226
1340
  });
1227
1341
 
1228
1342
  describe("commitZMirrorLaneMove", () => {