@hyperframes/studio 0.7.60 → 0.7.62

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 (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -19,19 +19,22 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
19
19
  import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
20
20
  import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
21
21
  import { readFileContent } from "./timelineTimingSync";
22
+ import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
23
+ import { usePlayerStore } from "../player";
22
24
 
23
25
  interface UseTimelineAssetDropOpsOptions {
24
26
  projectIdRef: MutableRefObject<string | null>;
25
27
  activeCompPath: string | null;
26
28
  timelineElements: TimelineElement[];
27
29
  showToast: (message: string, tone?: "error" | "info") => void;
28
- writeProjectFile: (path: string, content: string) => Promise<void>;
30
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
29
31
  recordEdit: (input: RecordEditInput) => Promise<void>;
30
32
  domEditSaveTimestampRef: MutableRefObject<number>;
31
33
  reloadPreview: () => void;
32
34
  uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
33
35
  isRecordingRef?: RefObject<boolean>;
34
36
  forceReloadSdkSession?: () => void;
37
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
35
38
  }
36
39
 
37
40
  export function useTimelineAssetDropOps({
@@ -46,6 +49,7 @@ export function useTimelineAssetDropOps({
46
49
  uploadProjectFiles,
47
50
  isRecordingRef,
48
51
  forceReloadSdkSession,
52
+ observeProjectFileVersion,
49
53
  }: UseTimelineAssetDropOpsOptions) {
50
54
  // fallow-ignore-next-line complexity
51
55
  const handleTimelineAssetDrop = useCallback(
@@ -171,5 +175,51 @@ export function useTimelineAssetDropOps({
171
175
  [handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
172
176
  );
173
177
 
174
- return { handleTimelineAssetDrop, handleTimelineFileDrop };
178
+ const handleTimelineCompositionDrop = useCallback(
179
+ async (sourcePath: string, placement: Pick<TimelineElement, "start" | "track">) => {
180
+ if (isRecordingRef?.current) {
181
+ showToast("Cannot edit timeline while recording", "error");
182
+ return;
183
+ }
184
+ const pid = projectIdRef.current;
185
+ if (!pid) throw new Error("No active project");
186
+ const targetPath = activeCompPath || "index.html";
187
+ try {
188
+ await commitTimelineCompositionInsertion({
189
+ projectId: pid,
190
+ targetPath,
191
+ sourcePath,
192
+ start: placement.start,
193
+ track: placement.track,
194
+ writeFile: writeProjectFile,
195
+ recordEdit,
196
+ observeVersion: observeProjectFileVersion,
197
+ selectHost: (key) => usePlayerStore.getState().setSelectedElementId(key),
198
+ resync: forceReloadSdkSession,
199
+ refresh: reloadPreview,
200
+ });
201
+ domEditSaveTimestampRef.current = Date.now();
202
+ showToast("Composition added to the timeline.", "info");
203
+ } catch (error) {
204
+ showToast(
205
+ error instanceof Error ? error.message : "Failed to add composition to timeline",
206
+ "error",
207
+ );
208
+ }
209
+ },
210
+ [
211
+ activeCompPath,
212
+ domEditSaveTimestampRef,
213
+ forceReloadSdkSession,
214
+ isRecordingRef,
215
+ observeProjectFileVersion,
216
+ projectIdRef,
217
+ recordEdit,
218
+ reloadPreview,
219
+ showToast,
220
+ writeProjectFile,
221
+ ],
222
+ );
223
+
224
+ return { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop };
175
225
  }
@@ -12,6 +12,7 @@ import {
12
12
  applyTimelineStackingReorder,
13
13
  buildPatchTarget,
14
14
  patchIframeDomTiming,
15
+ playbackStartAttributeForElement,
15
16
  persistTimelineEdit,
16
17
  formatTimelineAttributeNumber,
17
18
  extendRootDurationIfNeeded,
@@ -263,10 +264,7 @@ export function useTimelineEditing({
263
264
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
264
265
  ];
265
266
  if (updates.playbackStart != null) {
266
- const liveAttr =
267
- element.playbackStartAttr === "playback-start"
268
- ? "data-playback-start"
269
- : "data-media-start";
267
+ const liveAttr = playbackStartAttributeForElement(element);
270
268
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
271
269
  }
272
270
  patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
@@ -475,19 +473,21 @@ export function useTimelineEditing({
475
473
  ],
476
474
  );
477
475
 
478
- const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
479
- projectIdRef,
480
- activeCompPath,
481
- timelineElements,
482
- showToast,
483
- writeProjectFile,
484
- recordEdit,
485
- domEditSaveTimestampRef,
486
- reloadPreview,
487
- uploadProjectFiles,
488
- isRecordingRef,
489
- forceReloadSdkSession,
490
- });
476
+ const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
477
+ useTimelineAssetDropOps({
478
+ projectIdRef,
479
+ activeCompPath,
480
+ timelineElements,
481
+ showToast,
482
+ writeProjectFile,
483
+ recordEdit,
484
+ domEditSaveTimestampRef,
485
+ reloadPreview,
486
+ uploadProjectFiles,
487
+ isRecordingRef,
488
+ forceReloadSdkSession,
489
+ observeProjectFileVersion,
490
+ });
491
491
 
492
492
  const handleBlockedTimelineEdit = useCallback(
493
493
  (_element: TimelineElement) => {
@@ -509,6 +509,7 @@ export function useTimelineEditing({
509
509
  domEditSaveTimestampRef,
510
510
  reloadPreview,
511
511
  isRecordingRef,
512
+ forceReloadSdkSession,
512
513
  });
513
514
 
514
515
  return {
@@ -522,6 +523,7 @@ export function useTimelineEditing({
522
523
  handleRazorSplitAll,
523
524
  handleTimelineAssetDrop,
524
525
  handleTimelineFileDrop,
526
+ handleTimelineCompositionDrop,
525
527
  handleBlockedTimelineEdit,
526
528
  ...groupEditing,
527
529
  };
@@ -12,6 +12,7 @@ import {
12
12
  extendRootDurationIfNeeded,
13
13
  formatTimelineAttributeNumber,
14
14
  patchIframeDomTiming,
15
+ playbackStartAttributeForElement,
15
16
  persistTimelineBatchEdit,
16
17
  type PersistTimelineBatchChange,
17
18
  type RecordEditInput,
@@ -242,9 +243,10 @@ export function useTimelineGroupEditing({
242
243
  // duration change), so nothing timing-related changed — the batch only
243
244
  // rewrites data-track-index, which the renderer never reads (documented
244
245
  // in core runtime/timeline.ts; track is a studio lane concept). The live
245
- // DOM patch above + the gesture owner's optimistic store update fully
246
- // cover the UI, so after the persist there is nothing to GSAP-shift and
247
- // nothing for the preview to recompute: skip the fallback below entirely.
246
+ // DOM patch above + the gesture owner's optimistic store update cover the
247
+ // in-flight UI; after the complete lane + z transaction, that owner
248
+ // refreshes the preview so its runtime manifest converges to disk. There
249
+ // is still nothing to GSAP-shift here, so skip this fallback entirely.
248
250
  // Running it anyway is what made the mirrored z-order lane move blink —
249
251
  // a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback
250
252
  // used to full-reload the iframe when there was no script to soft-swap
@@ -348,10 +350,7 @@ export function useTimelineGroupEditing({
348
350
  ["data-duration", formatTimelineAttributeNumber(change.duration)],
349
351
  ];
350
352
  if (change.playbackStart != null) {
351
- const liveAttr =
352
- change.element.playbackStartAttr === "playback-start"
353
- ? "data-playback-start"
354
- : "data-media-start";
353
+ const liveAttr = playbackStartAttributeForElement(change.element);
355
354
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
356
355
  }
357
356
  patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
@@ -28,6 +28,7 @@ import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallb
28
28
  import type { TimelineProps } from "./TimelineTypes";
29
29
  import { useTrackGapMenu } from "./useTrackGapMenu";
30
30
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
31
+ import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
31
32
 
32
33
  // Re-export pure utilities so existing imports from "./Timeline" still resolve.
33
34
  export {
@@ -52,6 +53,7 @@ export const Timeline = memo(function Timeline({
52
53
  onFileDrop,
53
54
  onAssetDrop,
54
55
  onBlockDrop,
56
+ onCompositionDrop,
55
57
  onDeleteElement: _onDeleteElement,
56
58
  onMoveElement: onMoveElementOverride,
57
59
  onMoveElements: onMoveElementsOverride,
@@ -84,6 +86,11 @@ export const Timeline = memo(function Timeline({
84
86
  onSplitElement: onSplitElementOverride,
85
87
  });
86
88
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
89
+ const playbackContext = useStudioPlaybackContextOptional();
90
+ const setRefreshKey = playbackContext?.setRefreshKey;
91
+ const refreshAfterLaneMove = useCallback(() => {
92
+ setRefreshKey?.((key) => key + 1);
93
+ }, [setRefreshKey]);
87
94
  useMusicBeatAnalysis();
88
95
  const rawElements = usePlayerStore((s) => s.elements);
89
96
  const expandedElements = useExpandedTimelineElements();
@@ -169,6 +176,7 @@ export const Timeline = memo(function Timeline({
169
176
  pinnedOnFileDrop,
170
177
  pinnedOnAssetDrop,
171
178
  pinnedOnBlockDrop,
179
+ pinnedOnCompositionDrop,
172
180
  } = useTimelineEditPinning({
173
181
  ppsRef,
174
182
  fitPpsRef,
@@ -179,6 +187,7 @@ export const Timeline = memo(function Timeline({
179
187
  onFileDrop,
180
188
  onAssetDrop,
181
189
  onBlockDrop,
190
+ onCompositionDrop,
182
191
  });
183
192
 
184
193
  const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
@@ -223,6 +232,7 @@ export const Timeline = memo(function Timeline({
223
232
  setRangeSelectionRef,
224
233
  readZIndex: zSyncEnabled ? readClipZIndex : undefined,
225
234
  onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
235
+ refreshAfterLaneMove,
226
236
  });
227
237
 
228
238
  const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
@@ -234,6 +244,7 @@ export const Timeline = memo(function Timeline({
234
244
  onFileDrop: pinnedOnFileDrop,
235
245
  onAssetDrop: pinnedOnAssetDrop,
236
246
  onBlockDrop: pinnedOnBlockDrop,
247
+ onCompositionDrop: pinnedOnCompositionDrop,
237
248
  });
238
249
 
239
250
  const displayTrackOrder = useMemo(() => {
@@ -399,7 +410,7 @@ export const Timeline = memo(function Timeline({
399
410
  <div
400
411
  ref={setContainerRef}
401
412
  aria-label="Timeline"
402
- className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
413
+ className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
403
414
  onMouseMove={(e) => {
404
415
  if (activeTool === "razor" && scrollRef.current) {
405
416
  const rect = scrollRef.current.getBoundingClientRect();
@@ -0,0 +1,77 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
+ import { getTimelineEditCapabilities } from "./timelineEditing";
4
+ import type { DraggedClipState } from "./timelineClipDragTypes";
5
+
6
+ /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
7
+ export function canMoveTimelineElement(element: TimelineElement): boolean {
8
+ return getTimelineEditCapabilities({
9
+ tag: element.tag,
10
+ kind: element.kind,
11
+ duration: element.duration,
12
+ domId: element.domId,
13
+ selector: element.selector,
14
+ compositionSrc: element.compositionSrc,
15
+ playbackStart: element.playbackStart,
16
+ playbackStartAttr: element.playbackStartAttr,
17
+ sourceDuration: element.sourceDuration,
18
+ timingSource: element.timingSource,
19
+ timelineLocked: element.timelineLocked,
20
+ }).canMove;
21
+ }
22
+
23
+ interface ExpandedHostAliasDeps {
24
+ elements: TimelineElement[];
25
+ selectedKeys?: ReadonlySet<string> | null;
26
+ }
27
+
28
+ /**
29
+ * Expanded children keep their own source-file identity for direct edits, but a
30
+ * selection can briefly contain both a composition host and one of its visible
31
+ * expanded children. That pair is one authored move target, not two. Resolve it
32
+ * to the host before time/lane/collision commit so ordinary clip placement stays
33
+ * the single owner of the gesture semantics.
34
+ */
35
+ export function resolveExpandedHostAlias(
36
+ drag: DraggedClipState,
37
+ deps: ExpandedHostAliasDeps,
38
+ ): { drag: DraggedClipState; selectedKeys: ReadonlySet<string> } | null {
39
+ const selectedKeys = deps.selectedKeys;
40
+ if (!selectedKeys) return null;
41
+
42
+ const collapsedKeys = new Set(selectedKeys);
43
+ const candidates = deps.elements.includes(drag.element)
44
+ ? deps.elements
45
+ : [...deps.elements, drag.element];
46
+ for (const element of candidates) {
47
+ const hostKey = element.expandedHostKey;
48
+ const childKey = getTimelineElementIdentity(element);
49
+ if (hostKey && collapsedKeys.has(hostKey) && collapsedKeys.has(childKey)) {
50
+ collapsedKeys.delete(childKey);
51
+ }
52
+ }
53
+
54
+ const hostKey = drag.element.expandedHostKey;
55
+ const childKey = getTimelineElementIdentity(drag.element);
56
+ if (!hostKey || collapsedKeys.has(childKey)) {
57
+ if (collapsedKeys.size === selectedKeys.size) return null;
58
+ return { drag, selectedKeys: collapsedKeys };
59
+ }
60
+
61
+ const host = deps.elements.find((element) => getTimelineElementIdentity(element) === hostKey);
62
+ if (!host || !canMoveTimelineElement(host)) return null;
63
+
64
+ const delta = drag.previewStart - drag.element.start;
65
+ const mapTrack = (track: number | undefined): number | undefined =>
66
+ track === drag.element.track ? host.track : track;
67
+ return {
68
+ drag: {
69
+ ...drag,
70
+ element: host,
71
+ previewStart: Math.max(0, Math.round((host.start + delta) * 1000) / 1000),
72
+ previewTrack: mapTrack(drag.previewTrack) ?? host.track,
73
+ desiredTrack: mapTrack(drag.desiredTrack),
74
+ },
75
+ selectedKeys: collapsedKeys,
76
+ };
77
+ }
@@ -0,0 +1,29 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+
3
+ const keyOf = (element: TimelineElement) => element.key ?? element.id;
4
+
5
+ /** Authored track numbers only compare within one source file. */
6
+ export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
7
+ (a.sourceFile ?? null) === (b.sourceFile ?? null);
8
+
9
+ /** Translate a display lane into the source-file track to persist. */
10
+ export function authoredTrackForLane(
11
+ lane: number,
12
+ elements: TimelineElement[],
13
+ dragged: TimelineElement,
14
+ ): number {
15
+ const dragKey = keyOf(dragged);
16
+ const peers = elements.filter((element) => {
17
+ return keyOf(element) !== dragKey && sameSourceFile(element, dragged);
18
+ });
19
+ const occupant = peers.find((element) => element.track === lane);
20
+ if (occupant) return occupant.authoredTrack ?? occupant.track;
21
+
22
+ let nearest: TimelineElement | null = null;
23
+ for (const peer of peers) {
24
+ if (!nearest || Math.abs(peer.track - lane) < Math.abs(nearest.track - lane)) nearest = peer;
25
+ }
26
+ if (!nearest) return lane;
27
+ // Synthetic expanded-child display rows can be fractional; authored tracks cannot.
28
+ return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
29
+ }
@@ -22,6 +22,10 @@ export interface TimelineDropCallbacks {
22
22
  blockName: string,
23
23
  placement: { start: number; track: number },
24
24
  ) => Promise<void> | void;
25
+ onCompositionDrop?: (
26
+ sourcePath: string,
27
+ placement: { start: number; track: number },
28
+ ) => Promise<void> | void;
25
29
  }
26
30
 
27
31
  export interface TimelineEditCallbacks {
@@ -4,6 +4,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
4
4
  import {
5
5
  commitDraggedClipMove,
6
6
  commitZMirrorLaneMove,
7
+ persistMoveEdits,
7
8
  type DragCommitDeps,
8
9
  type TimelineMoveEdit,
9
10
  } from "./timelineClipDragCommit";
@@ -314,6 +315,118 @@ describe("commitDraggedClipMove", () => {
314
315
  expect(map.c).toBeUndefined(); // unselected clips untouched
315
316
  });
316
317
 
318
+ it("collapses a selected expanded child onto its authored composition host", () => {
319
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
320
+ const child = {
321
+ ...el("scene.html#title", 0.25, 12, 2),
322
+ sourceFile: "scene.html",
323
+ expandedParentStart: 10,
324
+ expandedHostKey: "host",
325
+ };
326
+ const { updateElement, onMoveElement, onMoveElements } = runClipMove(
327
+ drag(child, { previewStart: 15, previewTrack: child.track }),
328
+ {
329
+ elements: [host],
330
+ trackOrder: [0, child.track],
331
+ selectedKeys: new Set(["host", "scene.html#title"]),
332
+ },
333
+ );
334
+
335
+ expect(onMoveElements).not.toHaveBeenCalled();
336
+ expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
337
+ expect(updateElement).toHaveBeenCalledWith("host", { start: 13, track: 0 });
338
+ expect(updateElement).not.toHaveBeenCalledWith("scene.html#title", expect.anything());
339
+ });
340
+
341
+ it("drops a selected expanded child alias when the authored host initiates the drag", () => {
342
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
343
+ const child = {
344
+ ...el("scene.html#title", 0.25, 12, 2),
345
+ sourceFile: "scene.html",
346
+ expandedParentStart: 10,
347
+ expandedHostKey: "host",
348
+ };
349
+ const { onMoveElement, onMoveElements } = runClipMove(
350
+ drag(host, { previewStart: 13, previewTrack: host.track }),
351
+ {
352
+ elements: [host, child],
353
+ trackOrder: [0, child.track],
354
+ selectedKeys: new Set(["host", "scene.html#title"]),
355
+ },
356
+ );
357
+
358
+ expect(onMoveElements).not.toHaveBeenCalled();
359
+ expect(onMoveElement).toHaveBeenCalledOnce();
360
+ expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
361
+ });
362
+
363
+ it("keeps an expanded child as the edit target when its host is not selected", () => {
364
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
365
+ const child = {
366
+ ...el("scene.html#title", 0.25, 12, 2),
367
+ sourceFile: "scene.html",
368
+ expandedParentStart: 10,
369
+ expandedHostKey: "host",
370
+ };
371
+ const { onMoveElement, onMoveElements } = runClipMove(
372
+ drag(child, { previewStart: 15, previewTrack: child.track }),
373
+ {
374
+ elements: [host],
375
+ trackOrder: [0, child.track],
376
+ selectedKeys: new Set(["scene.html#title"]),
377
+ },
378
+ );
379
+
380
+ expect(onMoveElements).not.toHaveBeenCalled();
381
+ expect(onMoveElement).toHaveBeenCalledWith(child, { start: 15, track: child.track });
382
+ });
383
+
384
+ it("moves a host alias and an ordinary selected clip once each in one batch", () => {
385
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
386
+ const ordinary = el("ordinary", 1, 20, 3);
387
+ const child = {
388
+ ...el("scene.html#title", 0.25, 12, 2),
389
+ sourceFile: "scene.html",
390
+ expandedParentStart: 10,
391
+ expandedHostKey: "host",
392
+ };
393
+ const { onMoveElement, onMoveElements } = runClipMove(
394
+ drag(child, { previewStart: 14, previewTrack: child.track }),
395
+ {
396
+ elements: [host, ordinary],
397
+ trackOrder: [0, child.track, 1],
398
+ selectedKeys: new Set(["host", "scene.html#title", "ordinary"]),
399
+ },
400
+ );
401
+
402
+ const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
403
+ expect(map).toEqual({
404
+ host: { start: 12, track: 0 },
405
+ ordinary: { start: 22, track: 1 },
406
+ });
407
+ });
408
+
409
+ it("applies an expanded-child vertical drag to the selected host lane", () => {
410
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
411
+ const child = {
412
+ ...el("scene.html#title", 0.25, 12, 2),
413
+ sourceFile: "scene.html",
414
+ expandedParentStart: 10,
415
+ expandedHostKey: "host",
416
+ };
417
+ const { onMoveElement, onMoveElements } = runClipMove(
418
+ drag(child, { previewStart: 12, previewTrack: 1, desiredTrack: 1 }),
419
+ {
420
+ elements: [host],
421
+ trackOrder: [0, child.track, 1],
422
+ selectedKeys: new Set(["host", "scene.html#title"]),
423
+ },
424
+ );
425
+
426
+ const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
427
+ expect(map).toEqual({ host: { start: 10, track: 1 } });
428
+ });
429
+
317
430
  it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
318
431
  const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
319
432
  // Drag 'a' −5s: b would land at −3 → clamps to 0.
@@ -959,20 +1072,40 @@ describe("commitDraggedClipMove", () => {
959
1072
  expectZLiftedToSix(onStackingPatches);
960
1073
  });
961
1074
 
1075
+ it("refreshes the preview only after the complete lane and z transaction", async () => {
1076
+ const order: string[] = [];
1077
+ commitInsertAbove(overlapping(), {
1078
+ onMoveElements: vi.fn(async () => {
1079
+ order.push("lane");
1080
+ }),
1081
+ onStackingPatches: vi.fn(async () => {
1082
+ order.push("z");
1083
+ }),
1084
+ refreshAfterLaneMove: () => order.push("refresh"),
1085
+ });
1086
+
1087
+ await flushMicrotasks();
1088
+
1089
+ expect(order).toEqual(["lane", "z", "refresh"]);
1090
+ });
1091
+
962
1092
  it("rolls back the move and skips the z-sync when the persist fails", async () => {
963
1093
  const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
964
1094
  const elements = overlapping();
965
1095
  const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
966
1096
  const onStackingPatches = vi.fn();
1097
+ const refreshAfterLaneMove = vi.fn();
967
1098
  const updateElement = vi.fn();
968
1099
  commitInsertAbove(elements, {
969
1100
  updateElement,
970
1101
  onMoveElements,
971
1102
  onStackingPatches,
1103
+ refreshAfterLaneMove,
972
1104
  });
973
1105
  await flushMicrotasks();
974
1106
  // Failed move → z patch never issued (no orphaned z change left behind)...
975
1107
  expect(onStackingPatches).not.toHaveBeenCalled();
1108
+ expect(refreshAfterLaneMove).not.toHaveBeenCalled();
976
1109
  // ...and the optimistic start/track edit for the dragged clip is rolled back.
977
1110
  expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
978
1111
  errSpy.mockRestore();
@@ -1300,3 +1433,46 @@ describe("commitZMirrorLaneMove", () => {
1300
1433
  expect(onMoveElements).not.toHaveBeenCalled();
1301
1434
  });
1302
1435
  });
1436
+
1437
+ describe("persistMoveEdits convergence", () => {
1438
+ it("reasserts a saved lane after a stale runtime sync", async () => {
1439
+ const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
1440
+ let releaseSave: (() => void) | undefined;
1441
+ const pendingSave = new Promise<void>((resolve) => {
1442
+ releaseSave = resolve;
1443
+ });
1444
+ let liveTrack = clip.track;
1445
+ let liveAuthoredTrack = clip.authoredTrack;
1446
+ const updateElement = vi.fn((_key: string, updates: Partial<TimelineElement>) => {
1447
+ if (updates.track != null) liveTrack = updates.track;
1448
+ if (updates.authoredTrack != null) liveAuthoredTrack = updates.authoredTrack;
1449
+ });
1450
+
1451
+ const persisted = persistMoveEdits(
1452
+ [
1453
+ {
1454
+ element: clip,
1455
+ updates: { start: clip.start, track: 0 },
1456
+ persistTrack: 0,
1457
+ },
1458
+ ],
1459
+ {
1460
+ elements: [clip],
1461
+ trackOrder: [0, 1, 2],
1462
+ updateElement,
1463
+ onMoveElements: () => pendingSave,
1464
+ },
1465
+ );
1466
+ expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
1467
+
1468
+ // Reproduce the real failure: the preview emits its cached pre-drag lane
1469
+ // while the file write is still pending.
1470
+ liveTrack = 2;
1471
+ liveAuthoredTrack = 2;
1472
+ releaseSave?.();
1473
+
1474
+ await expect(persisted).resolves.toBe(true);
1475
+ expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
1476
+ expect(updateElement).toHaveBeenCalledTimes(2);
1477
+ });
1478
+ });