@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
@@ -5,6 +5,7 @@ import {
5
5
  isInsertAllowedForZone,
6
6
  isLaneFree,
7
7
  resolveInsertRow,
8
+ resolveMainTrackDropStart,
8
9
  resolvePlacement,
9
10
  resolveZoneDropPlacement,
10
11
  timeRangesOverlap,
@@ -493,3 +494,25 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
493
494
  expect(result).toEqual({ track: 0, insertRow: 1 });
494
495
  });
495
496
  });
497
+
498
+ describe("resolveMainTrackDropStart (magnetic first clip on an empty main track)", () => {
499
+ it("snaps to 0 when landing on an empty main track", () => {
500
+ expect(resolveMainTrackDropStart([], 1, 0, false, 7)).toBe(0);
501
+ });
502
+
503
+ it("leaves the drop start alone once the main track already holds another clip", () => {
504
+ expect(resolveMainTrackDropStart([el("other", 0, 0, 3)], 1, 0, false, 7)).toBe(7);
505
+ });
506
+
507
+ it("leaves the drop start alone when landing off the main track", () => {
508
+ expect(resolveMainTrackDropStart([], 1, 2, false, 7)).toBe(7);
509
+ });
510
+
511
+ it("a clip already resident on the main track doing a horizontal move is a no-op", () => {
512
+ expect(resolveMainTrackDropStart([], 0, 0, false, 7)).toBe(7);
513
+ });
514
+
515
+ it("an audio clip landing on track 0 is not the main track (visual zone only)", () => {
516
+ expect(resolveMainTrackDropStart([], 1, 0, true, 7)).toBe(7);
517
+ });
518
+ });
@@ -1,5 +1,6 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
3
+ import { isMainTrackElement } from "./timelineZones";
3
4
 
4
5
  /**
5
6
  * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
@@ -140,6 +141,32 @@ export function resolveZoneDropPlacement(input: {
140
141
  return { track: placement.track, insertRow: null };
141
142
  }
142
143
 
144
+ function landsOnMainTrack(landingTrack: number, isAudio: boolean): boolean {
145
+ return isMainTrackElement({
146
+ id: "",
147
+ tag: isAudio ? "audio" : "video",
148
+ start: 0,
149
+ duration: 0,
150
+ track: landingTrack,
151
+ });
152
+ }
153
+
154
+ /** An existing clip newly landing on an empty main track commits at start=0.
155
+ * No-op once the main track holds another clip, off the main track, or for
156
+ * a clip already resident there. `others` excludes the landing clip itself. */
157
+ export function resolveMainTrackDropStart(
158
+ others: readonly TimelineElement[],
159
+ originTrack: number,
160
+ landingTrack: number,
161
+ isAudio: boolean,
162
+ desiredStart: number,
163
+ ): number {
164
+ if (originTrack === landingTrack || !landsOnMainTrack(landingTrack, isAudio)) {
165
+ return desiredStart;
166
+ }
167
+ return others.some(isMainTrackElement) ? desiredStart : 0;
168
+ }
169
+
143
170
  /**
144
171
  * Decide whether a vertical drag is inserting a new track at a lane boundary.
145
172
  * `rowFloat` is the pointer's position in track-height units from the top of the
@@ -220,4 +220,35 @@ describe("useTimelineAssetDrop", () => {
220
220
  expect(view.api.isDragOver).toBe(false);
221
221
  act(() => view.root.unmount());
222
222
  });
223
+
224
+ it("previews the exact placement the drop commits, and clears it after", () => {
225
+ const onAssetDrop = vi.fn();
226
+ const view = renderHarness(onAssetDrop);
227
+ const payload = JSON.stringify({ path: "assets/a.png" });
228
+
229
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
230
+ const preview = view.api.dropPreview;
231
+ expect(preview).toEqual({ start: 10, track: 0 });
232
+
233
+ act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
234
+ expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
235
+ expect(view.api.dropPreview).toBeNull();
236
+ act(() => view.root.unmount());
237
+ });
238
+
239
+ it("moves the preview with the pointer and drops it when the drag leaves", () => {
240
+ const view = renderHarness(vi.fn());
241
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
242
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
243
+ expect(view.api.dropPreview?.start).toBe(20);
244
+
245
+ act(() =>
246
+ view.api.handleAssetDragLeave({
247
+ relatedTarget: null,
248
+ currentTarget: document.body,
249
+ } as unknown as React.DragEvent),
250
+ );
251
+ expect(view.api.dropPreview).toBeNull();
252
+ act(() => view.root.unmount());
253
+ });
223
254
  });
@@ -98,6 +98,7 @@ export function useTimelineAssetDrop({
98
98
  sessionEpoch,
99
99
  }: UseTimelineAssetDropOptions) {
100
100
  const [isDragOver, setIsDragOver] = useState(false);
101
+ const [dropPreview, setDropPreview] = useState<TimelinePlacement | null>(null);
101
102
  const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
102
103
  null,
103
104
  );
@@ -142,6 +143,28 @@ export function useTimelineAssetDrop({
142
143
  [scrollRef, sessionEpoch, stepAutoScroll],
143
144
  );
144
145
 
146
+ const resolveDropPlacement = useCallback(
147
+ (clientX: number, clientY: number): TimelinePlacement => {
148
+ const scroll = scrollRef.current;
149
+ const rect = scroll?.getBoundingClientRect();
150
+ return resolveTimelineAssetDrop(
151
+ {
152
+ rectLeft: rect?.left ?? 0,
153
+ rectTop: rect?.top ?? 0,
154
+ scrollLeft: scroll?.scrollLeft ?? 0,
155
+ scrollTop: scroll?.scrollTop ?? 0,
156
+ contentOrigin,
157
+ pixelsPerSecond: ppsRef.current,
158
+ rowHeights: rowGeometryRef.current.rowHeights,
159
+ trackOrder: trackOrderRef.current,
160
+ },
161
+ clientX,
162
+ clientY,
163
+ );
164
+ },
165
+ [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
166
+ );
167
+
145
168
  const handleAssetDragOver = useCallback(
146
169
  (e: React.DragEvent) => {
147
170
  const types = Array.from(e.dataTransfer.types);
@@ -154,15 +177,20 @@ export function useTimelineAssetDrop({
154
177
  e.dataTransfer.dropEffect = "copy";
155
178
  activeDropEpochRef.current = sessionEpoch;
156
179
  setIsDragOver(true);
180
+ const next = resolveDropPlacement(e.clientX, e.clientY);
181
+ setDropPreview((prev) =>
182
+ prev?.start === next.start && prev.track === next.track ? prev : next,
183
+ );
157
184
  syncAutoScroll(e.clientX, e.clientY);
158
185
  },
159
- [sessionEpoch, syncAutoScroll],
186
+ [resolveDropPlacement, sessionEpoch, syncAutoScroll],
160
187
  );
161
188
 
162
189
  const clearDropPreview = useCallback(() => {
163
190
  activeDropEpochRef.current = null;
164
191
  stopAutoScroll();
165
192
  setIsDragOver(false);
193
+ setDropPreview(null);
166
194
  }, [stopAutoScroll]);
167
195
 
168
196
  const handleAssetDragLeave = useCallback(
@@ -174,28 +202,6 @@ export function useTimelineAssetDrop({
174
202
  [clearDropPreview],
175
203
  );
176
204
 
177
- const resolveDropPlacement = useCallback(
178
- (clientX: number, clientY: number): TimelinePlacement => {
179
- const scroll = scrollRef.current;
180
- const rect = scroll?.getBoundingClientRect();
181
- return resolveTimelineAssetDrop(
182
- {
183
- rectLeft: rect?.left ?? 0,
184
- rectTop: rect?.top ?? 0,
185
- scrollLeft: scroll?.scrollLeft ?? 0,
186
- scrollTop: scroll?.scrollTop ?? 0,
187
- contentOrigin,
188
- pixelsPerSecond: ppsRef.current,
189
- rowHeights: rowGeometryRef.current.rowHeights,
190
- trackOrder: trackOrderRef.current,
191
- },
192
- clientX,
193
- clientY,
194
- );
195
- },
196
- [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
197
- );
198
-
199
205
  const handleAssetDrop = useCallback(
200
206
  (e: React.DragEvent) => {
201
207
  e.preventDefault();
@@ -240,6 +246,7 @@ export function useTimelineAssetDrop({
240
246
 
241
247
  return {
242
248
  isDragOver,
249
+ dropPreview,
243
250
  handleAssetDragOver,
244
251
  handleAssetDragLeave,
245
252
  handleAssetDrop,
@@ -0,0 +1,108 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import type { DragCommitDeps } from "./timelineClipDragCommit";
3
+ import type { DraggedClipState } from "./timelineClipDragTypes";
4
+ import { classifyZone, normalizeToZones } from "./timelineZones";
5
+ import { resolveMainTrackDropStart } from "./timelineCollision";
6
+ import { isAudioTimelineElement } from "../../utils/timelineInspector";
7
+ import { resolveExpandedHostAlias } from "./timelineAuthoredMoveTarget";
8
+ import { sameSourceFile } from "./timelineAuthoredTrack";
9
+ import { round3 } from "./timelineGaps";
10
+
11
+ const keyOf = (e: TimelineElement) => e.key ?? e.id;
12
+
13
+ /** A fractional track for a new lane at boundary `insertRow` (0 = above the top);
14
+ * normalizeToZones compacts it to an integer lane and shifts the clips below. */
15
+ function insertTrackValue(trackOrder: number[], insertRow: number): number {
16
+ if (trackOrder.length === 0) return 0;
17
+ if (insertRow <= 0) return trackOrder[0] - 0.5;
18
+ if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5;
19
+ return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
20
+ }
21
+
22
+ /** The source-file lane layout after inserting `element` at boundary `insertRow`:
23
+ * the whole-set renumber both the commit and the drag preview read. */
24
+ export function layoutAfterTrackInsert(
25
+ element: TimelineElement,
26
+ previewStart: number,
27
+ insertRow: number,
28
+ multi: {
29
+ keys: ReadonlySet<string>;
30
+ movedStart: (e: TimelineElement) => number;
31
+ } | null,
32
+ deps: Pick<DragCommitDeps, "elements" | "trackOrder">,
33
+ ): {
34
+ normalized: TimelineElement[];
35
+ targetTrack: number;
36
+ writable: (src: TimelineElement) => boolean;
37
+ } | null {
38
+ const { elements, trackOrder } = deps;
39
+ const editKey = keyOf(element);
40
+ // Expanded-child rows are synthetic host lanes, not source-file topology.
41
+ if (element.expandedParentStart != null) return null;
42
+ const targetTrack = insertTrackValue(trackOrder, insertRow);
43
+ // Foreign display rows and the opposite zone must not affect this topology.
44
+ const writableZone = classifyZone(element);
45
+ const writable = (src: TimelineElement): boolean =>
46
+ sameSourceFile(src, element) &&
47
+ classifyZone(src) === writableZone &&
48
+ src.expandedParentStart == null;
49
+ const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
50
+ (a, b) => a - b,
51
+ );
52
+ const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
53
+ const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
54
+ const normalized = normalizeToZones(
55
+ elements.filter(writable).map((e) => {
56
+ if (keyOf(e) === editKey) {
57
+ return { ...e, start: previewStart, track: topologyTargetTrack };
58
+ }
59
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
60
+ return e;
61
+ }),
62
+ );
63
+ return { normalized, targetTrack, writable };
64
+ }
65
+
66
+ /** The start a solo clip lands at, for both the ghost and the commit: 0 on an empty
67
+ * main track, judged on the final lane (after the renumber and the host alias).
68
+ * Returned in the dragged clip's own frame. */
69
+ export function resolveDragLandingStart(
70
+ drag: DraggedClipState,
71
+ deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "selectedKeys">,
72
+ ): number {
73
+ const alias = resolveExpandedHostAlias(drag, deps);
74
+ const target = alias?.drag ?? drag;
75
+ const keys = alias?.selectedKeys ?? deps.selectedKeys;
76
+ const element = target.element;
77
+ const key = keyOf(element);
78
+ if (keys && keys.size > 1 && keys.has(key)) return drag.previewStart;
79
+
80
+ let landingTrack = target.previewTrack;
81
+ let renumbered = new Map<string, number>();
82
+ if (target.insertRow != null) {
83
+ const layout = layoutAfterTrackInsert(
84
+ element,
85
+ target.previewStart,
86
+ target.insertRow,
87
+ null,
88
+ deps,
89
+ );
90
+ if (layout) {
91
+ renumbered = new Map(layout.normalized.map((n) => [keyOf(n), n.track]));
92
+ landingTrack = renumbered.get(key) ?? layout.targetTrack;
93
+ }
94
+ }
95
+ const others = deps.elements
96
+ .filter((e) => keyOf(e) !== key)
97
+ .map((e) => (renumbered.has(keyOf(e)) ? { ...e, track: renumbered.get(keyOf(e))! } : e));
98
+ const snapped = resolveMainTrackDropStart(
99
+ others,
100
+ element.track,
101
+ landingTrack,
102
+ isAudioTimelineElement(element),
103
+ target.previewStart,
104
+ );
105
+ return snapped === target.previewStart
106
+ ? drag.previewStart
107
+ : round3(drag.previewStart + snapped - target.previewStart);
108
+ }
@@ -0,0 +1,34 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { usePlayerStore } from "../store/playerStore";
5
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
6
+ import { selectAndRevealTimelineElement } from "./timelineDropReveal";
7
+
8
+ beforeEach(() => {
9
+ usePlayerStore.getState().reset();
10
+ });
11
+
12
+ describe("selectAndRevealTimelineElement", () => {
13
+ it("selects the element and requests a scroll-to-focus on the same key", () => {
14
+ selectAndRevealTimelineElement("index.html#clip-1");
15
+
16
+ expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip-1");
17
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(
18
+ timelineClipFocusId("index.html#clip-1"),
19
+ );
20
+ });
21
+
22
+ it("moves the focus request to the new element when called again", () => {
23
+ selectAndRevealTimelineElement("index.html#clip-1");
24
+ const firstNonce = usePlayerStore.getState().timelineFocus?.nonce;
25
+
26
+ selectAndRevealTimelineElement("index.html#clip-2");
27
+
28
+ expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip-2");
29
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(
30
+ timelineClipFocusId("index.html#clip-2"),
31
+ );
32
+ expect(usePlayerStore.getState().timelineFocus?.nonce).not.toBe(firstNonce);
33
+ });
34
+ });
@@ -0,0 +1,12 @@
1
+ import { usePlayerStore } from "../store/playerStore";
2
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
3
+
4
+ /**
5
+ * Select a newly-created clip and scroll the timeline to it, the same pair of
6
+ * calls AssetCard/AudioRow use to reveal an existing clip on activation.
7
+ */
8
+ export function selectAndRevealTimelineElement(key: string): void {
9
+ const store = usePlayerStore.getState();
10
+ store.setSelectedElementId(key);
11
+ store.requestTimelineFocus(timelineClipFocusId(key));
12
+ }
@@ -42,10 +42,9 @@ export function getTimelineEditCapabilities(input: {
42
42
  playbackStart?: number;
43
43
  playbackStartAttr?: "media-start" | "playback-start";
44
44
  sourceDuration?: number;
45
- timingSource?: "authored" | "implicit";
46
45
  timelineLocked?: boolean;
47
46
  }): TimelineEditCapabilities {
48
- if (input.timingSource === "implicit" || input.timelineLocked) {
47
+ if (input.timelineLocked) {
49
48
  return { canMove: false, canTrimStart: false, canTrimEnd: false };
50
49
  }
51
50
 
@@ -412,21 +412,6 @@ describe("getTimelineEditCapabilities", () => {
412
412
  });
413
413
  });
414
414
 
415
- it("keeps implicit layout layers selectable but not timeline-editable", () => {
416
- expect(
417
- getTimelineEditCapabilities({
418
- duration: 8,
419
- selector: ".scene-shell",
420
- tag: "div",
421
- timingSource: "implicit",
422
- }),
423
- ).toEqual({
424
- canMove: false,
425
- canTrimStart: false,
426
- canTrimEnd: false,
427
- });
428
- });
429
-
430
415
  it("allows move and both trims for patchable media clips with offset support", () => {
431
416
  expect(
432
417
  getTimelineEditCapabilities({
@@ -496,7 +481,6 @@ describe("getTimelineEditCapabilities", () => {
496
481
  tag: "div",
497
482
  duration: 4,
498
483
  selector: "#hero-card",
499
- timingSource: "authored",
500
484
  }),
501
485
  ).toEqual({
502
486
  canMove: true,
@@ -34,11 +34,9 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
34
34
  expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
35
35
  });
36
36
 
37
- it("degrades when a member is implicitly timed or has no patch target", () => {
37
+ it("degrades when a member has no patch target", () => {
38
38
  const a = el("a");
39
- const implicit = el("b", { timingSource: "implicit" });
40
39
  const noTarget = el("c", { domId: undefined, selector: undefined });
41
- expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
42
40
  expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
43
41
  });
44
42
 
@@ -198,7 +198,6 @@ function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): b
198
198
  playbackStart: element.playbackStart,
199
199
  playbackStartAttr: element.playbackStartAttr,
200
200
  sourceDuration: element.sourceDuration,
201
- timingSource: element.timingSource,
202
201
  timelineLocked: element.timelineLocked,
203
202
  });
204
203
  return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
5
5
  * sub-composition child gets a synthesized fractional key (`host.track + n /
6
- * (siblings + 2)`, see `useExpandedTimelineElements`), so putting it in a string
6
+ * (siblings + 2)`, see `useTimelineRowElements`), so putting it in a string
7
7
  * announces "Hide track 0.16666666666666666". Every user-visible track number,
8
8
  * whether it is rendered by a component or baked into an undo-history label,
9
9
  * routes through here; the raw key stays in callbacks and lookups only.
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import type { ClipManifestClip } from "../lib/playbackTypes";
4
4
  import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
5
- import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
6
5
  import { normalizeToZones } from "./timelineZones";
7
6
  import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
8
7
  import type { DraggedClipState } from "./useTimelineClipDrag";
@@ -113,56 +112,4 @@ describe("track persist pipeline (manifest → factory → lanes → drag commit
113
112
  const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
114
113
  expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
115
114
  });
116
-
117
- it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
118
- // Host timeline: the sub-comp host plus a root clip, discovered through the
119
- // factory and lane-normalized like the store does.
120
- const hostManifest = [
121
- manifestClip({
122
- id: "scene",
123
- kind: "composition",
124
- tagName: "div",
125
- track: 0,
126
- start: 0,
127
- duration: 10,
128
- compositionId: "scene",
129
- compositionSrc: "scene.html",
130
- }),
131
- manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
132
- ];
133
- // scene.html has SPARSE authored tracks 3 and 7.
134
- const childClips = [
135
- manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
136
- manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
137
- ];
138
- const storeElements = normalizeToZones(fromManifest(hostManifest));
139
- const parentMap = new Map([
140
- ["c3", "scene"],
141
- ["c7", "scene"],
142
- ]);
143
- const expanded = buildExpandedElements(
144
- storeElements,
145
- [...hostManifest, ...childClips],
146
- parentMap,
147
- "scene",
148
- "scene",
149
- );
150
-
151
- // The children replaced the host row: synthetic display lanes, but the
152
- // authored track (in scene.html's coordinate space) survived the expansion.
153
- const c3 = expanded.find((e) => e.domId === "c3")!;
154
- const c7 = expanded.find((e) => e.domId === "c7")!;
155
- expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
156
- expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
157
- expect(c3.stackingContextId).toBe("root");
158
- expect(c3.track).not.toBe(3); // display row is synthetic
159
-
160
- // Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
161
- // the written track must be c7's authored 7 — not the display-lane integer.
162
- const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
163
- const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
164
- expect(edits).toHaveLength(1);
165
- expect(edits[0].updates.track).toBe(7);
166
- expect(edits[0].updates.track).not.toBe(c7.track);
167
- });
168
115
  });
@@ -0,0 +1,32 @@
1
+ import { useCallback } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import type { ClipContextMenuState } from "./TimelineOverlays";
4
+
5
+ /** A clicked member of an active multi-selection keeps the whole group
6
+ * selected, so the context menu it opens acts on all of it. */
7
+ export function useClipContextMenu(
8
+ onSelectElement: ((element: TimelineElement | null) => void) | undefined,
9
+ dismissGapMenu: () => void,
10
+ setClipContextMenu: (state: ClipContextMenuState | null) => void,
11
+ ) {
12
+ const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
13
+ const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
14
+ return useCallback(
15
+ (e: React.MouseEvent, el: TimelineElement) => {
16
+ e.preventDefault();
17
+ const id = el.key ?? el.id;
18
+ if (!(selectedElementIds.size > 1 && selectedElementIds.has(id))) {
19
+ setSelectedElementId(id);
20
+ onSelectElement?.(el);
21
+ }
22
+ dismissGapMenu();
23
+ setClipContextMenu({
24
+ x: e.clientX,
25
+ y: e.clientY,
26
+ element: el,
27
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
28
+ });
29
+ },
30
+ [selectedElementIds, setSelectedElementId, onSelectElement, dismissGapMenu, setClipContextMenu],
31
+ );
32
+ }
@@ -35,12 +35,16 @@ function Harness({
35
35
  mountedId,
36
36
  logicalRows = rows,
37
37
  projectId = "project-a",
38
+ lastScrollLeftRef: sharedLastScrollLeftRef,
38
39
  }: {
39
40
  mountedId?: string;
40
41
  logicalRows?: readonly TimelineLogicalRow[];
41
42
  projectId?: string;
43
+ lastScrollLeftRef?: React.RefObject<number>;
42
44
  }) {
43
45
  const scrollRef = useRef<HTMLDivElement>(null);
46
+ const ownLastScrollLeftRef = useRef(0);
47
+ const lastScrollLeftRef = sharedLastScrollLeftRef ?? ownLastScrollLeftRef;
44
48
  const rowGeometry = useMemo(() => {
45
49
  const rowKeys = [...new Set(logicalRows.map((row) => row.physicalTrackKey))];
46
50
  return createTimelineRowGeometry(
@@ -60,6 +64,7 @@ function Harness({
60
64
  projectId,
61
65
  sessionEpoch: 1,
62
66
  syncScrollViewport,
67
+ lastScrollLeftRef,
63
68
  });
64
69
  return (
65
70
  <div
@@ -171,4 +176,65 @@ describe("useTimelineFocusCoordinator", () => {
171
176
  expect(usePlayerStore.getState().timelineFocus?.id).toBe(nextRowId);
172
177
  expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(nextRowId);
173
178
  });
179
+
180
+ it("keeps a request alive across a few renders where its target hasn't landed yet", async () => {
181
+ // A request issued the same tick as its own element's creation (a fresh
182
+ // drop) can race logicalRows by a render or two; it must not be dropped
183
+ // before the element actually appears.
184
+ usePlayerStore.getState().requestTimelineFocus(clipId);
185
+ for (let attempt = 0; attempt < 2; attempt++) {
186
+ const notYetRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
187
+ await act(async () => root.render(<Harness logicalRows={notYetRows} />));
188
+ }
189
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
190
+
191
+ await act(async () => root.render(<Harness logicalRows={rows} mountedId={clipId} />));
192
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
193
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
194
+ });
195
+
196
+ it("gives up once a target never lands after its retry budget", async () => {
197
+ usePlayerStore.getState().requestTimelineFocus(clipId);
198
+ for (let attempt = 0; attempt < 6; attempt++) {
199
+ // A fresh array reference each render, like a real re-render caused by
200
+ // unrelated state, or the memoized resolution never recomputes at all.
201
+ const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
202
+ await act(async () => root.render(<Harness logicalRows={neverRows} />));
203
+ }
204
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
205
+ });
206
+
207
+ it("clears an unresolved request on a timeout even if nothing else ever re-renders", async () => {
208
+ // The render-count retry budget can't advance without a rerun; if the
209
+ // target never lands and nothing else re-renders, only a wall-time
210
+ // backstop clears it — otherwise it sits forever, eligible to resolve
211
+ // against an unrelated element that later reuses its id.
212
+ vi.useFakeTimers();
213
+ try {
214
+ usePlayerStore.getState().requestTimelineFocus(clipId);
215
+ const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
216
+ await act(async () => root.render(<Harness logicalRows={neverRows} />));
217
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
218
+
219
+ await act(async () => {
220
+ vi.advanceTimersByTime(4001);
221
+ });
222
+
223
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
224
+ } finally {
225
+ vi.useRealTimers();
226
+ }
227
+ });
228
+
229
+ it("keeps lastScrollLeftRef in sync with a reveal so a sibling restore effect doesn't undo it", async () => {
230
+ // Timeline.tsx's own "restore scroll after an edit re-derives the elements"
231
+ // effect reads this ref to decide what to scroll back to. A reveal that
232
+ // doesn't update it looks, to that effect, like scroll drift to undo.
233
+ const lastScrollLeftRef = { current: 999 };
234
+ usePlayerStore.getState().requestTimelineFocus(clipId);
235
+ await act(async () => root.render(<Harness lastScrollLeftRef={lastScrollLeftRef} />));
236
+ const scroll = host.firstElementChild as HTMLDivElement;
237
+
238
+ expect(lastScrollLeftRef.current).toBe(scroll.scrollLeft);
239
+ });
174
240
  });