@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Consumes playerStore.clipRevealRequest: when another surface (the sidebar
3
+ * asset card / audio row) asks for a clip to be revealed, smooth-scroll the
4
+ * timeline's scroll container so that clip is visible — horizontally to its
5
+ * time and vertically to its lane.
6
+ *
7
+ * The request is consumed (cleared) whether or not the clip node is found, so
8
+ * a stale request can never replay a scroll later. Respects zoom mode: in
9
+ * "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
10
+ * only the vertical axis is scrolled there.
11
+ */
12
+ import { useEffect } from "react";
13
+ import { usePlayerStore } from "../store/playerStore";
14
+ import { GUTTER, RULER_H } from "./timelineLayout";
15
+ import { computeRevealScroll } from "./timelineRevealScroll";
16
+
17
+ export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
18
+ const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
19
+
20
+ useEffect(() => {
21
+ if (!revealRequest) return;
22
+ // Consume the request first — reveal is one-shot, even when the clip node
23
+ // isn't currently rendered (e.g. drilled into a different composition).
24
+ usePlayerStore.getState().clearClipRevealRequest();
25
+
26
+ const container = scrollRef.current;
27
+ if (!container) return;
28
+ const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
29
+ if (!(clip instanceof HTMLElement)) return;
30
+
31
+ const containerRect = container.getBoundingClientRect();
32
+ const clipRect = clip.getBoundingClientRect();
33
+ const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
34
+ const clipTop = clipRect.top - containerRect.top + container.scrollTop;
35
+
36
+ const target = computeRevealScroll({
37
+ scrollLeft: container.scrollLeft,
38
+ scrollTop: container.scrollTop,
39
+ viewportWidth: container.clientWidth,
40
+ viewportHeight: container.clientHeight,
41
+ clipLeft,
42
+ clipRight: clipLeft + clipRect.width,
43
+ clipTop,
44
+ clipBottom: clipTop + clipRect.height,
45
+ stickyLeft: GUTTER,
46
+ stickyTop: RULER_H,
47
+ allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
48
+ });
49
+ if (target.left === null && target.top === null) return;
50
+ container.scrollTo({
51
+ left: target.left ?? container.scrollLeft,
52
+ top: target.top ?? container.scrollTop,
53
+ behavior: "smooth",
54
+ });
55
+ }, [revealRequest, scrollRef]);
56
+ }
@@ -0,0 +1,73 @@
1
+ import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { shouldShowTimelineShortcutHint } from "./timelineLayout";
4
+
5
+ /**
6
+ * The timeline scroll container's viewport plumbing — extracted verbatim from
7
+ * Timeline.tsx (600-line studio cap): the ResizeObserver-backed viewport width,
8
+ * the rAF-throttled shortcut-hint visibility sync, and the callback ref that
9
+ * wires both to the scroll element. `resyncShortcutHintOn` re-checks the hint
10
+ * whenever any of its values change (timeline readiness / element count /
11
+ * canvas height), matching the original effect.
12
+ */
13
+ export function useTimelineScrollViewport(
14
+ scrollRef: RefObject<HTMLDivElement | null>,
15
+ resyncShortcutHintOn: ReadonlyArray<unknown>,
16
+ ): {
17
+ viewportWidth: number;
18
+ showShortcutHint: boolean;
19
+ setScrollRef: (el: HTMLDivElement | null) => void;
20
+ } {
21
+ const [viewportWidth, setViewportWidth] = useState(0);
22
+ const [showShortcutHint, setShowShortcutHint] = useState(true);
23
+ const roRef = useRef<ResizeObserver | null>(null);
24
+ const shortcutHintRafRef = useRef(0);
25
+
26
+ const syncShortcutHintVisibility = useCallback(() => {
27
+ const scroll = scrollRef.current;
28
+ setShowShortcutHint(
29
+ scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
30
+ );
31
+ }, [scrollRef]);
32
+
33
+ const scheduleShortcutHintVisibilitySync = useCallback(() => {
34
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
35
+ shortcutHintRafRef.current = requestAnimationFrame(() => {
36
+ shortcutHintRafRef.current = 0;
37
+ syncShortcutHintVisibility();
38
+ });
39
+ }, [syncShortcutHintVisibility]);
40
+
41
+ const setScrollRef = useCallback(
42
+ (el: HTMLDivElement | null) => {
43
+ if (roRef.current) {
44
+ roRef.current.disconnect();
45
+ roRef.current = null;
46
+ }
47
+ scrollRef.current = el;
48
+ if (!el) return;
49
+
50
+ const syncScrollViewport = () => {
51
+ setViewportWidth(el.clientWidth);
52
+ scheduleShortcutHintVisibilitySync();
53
+ };
54
+
55
+ syncScrollViewport();
56
+ roRef.current = new ResizeObserver(syncScrollViewport);
57
+ roRef.current.observe(el);
58
+ },
59
+ [scrollRef, scheduleShortcutHintVisibilitySync],
60
+ );
61
+
62
+ useMountEffect(() => () => {
63
+ roRef.current?.disconnect();
64
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
65
+ });
66
+
67
+ useEffect(() => {
68
+ syncShortcutHintVisibility();
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [syncShortcutHintVisibility, ...resyncShortcutHintOn]);
71
+
72
+ return { viewportWidth, showShortcutHint, setScrollRef };
73
+ }
@@ -40,7 +40,6 @@ describe("useTimelineStackingSync", () => {
40
40
  start: 0,
41
41
  duration: 2,
42
42
  track: 0,
43
- sourceFile: "nested.html",
44
43
  };
45
44
  let apply: ((patches: Array<{ key: string; zIndex: number }>, key?: string) => unknown) | null =
46
45
  null;
@@ -57,7 +56,17 @@ describe("useTimelineStackingSync", () => {
57
56
  });
58
57
 
59
58
  expect(commit).toHaveBeenCalledWith(
60
- [expect.objectContaining({ element: node, zIndex: 8, sourceFile: "nested.html" })],
59
+ [
60
+ expect.objectContaining({
61
+ element: node,
62
+ zIndex: 8,
63
+ sourceFile: "nested.html",
64
+ // The patch's store key rides along so the commit updates the store
65
+ // zIndex synchronously on the lane-sync path (and rolls it back on
66
+ // failure) instead of waiting for a preview reload.
67
+ key: "a",
68
+ }),
69
+ ],
61
70
  "clip-lane-move:7",
62
71
  );
63
72
  act(() => root.unmount());
@@ -37,7 +37,7 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
37
37
  id: el.domId ?? el.id,
38
38
  selector: el.selector,
39
39
  selectorIndex: el.selectorIndex,
40
- sourceFile: el.sourceFile,
40
+ sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
41
41
  },
42
42
  zSyncActiveCompPath,
43
43
  );
@@ -45,17 +45,23 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
45
45
  [zSyncPreviewIframeRef, zSyncActiveCompPath],
46
46
  );
47
47
 
48
+ // NaN (NOT 0) when the element can't be resolved in the preview iframe — a
49
+ // nested / unmounted sub-comp node, or one outside the active file. Fabricating
50
+ // z=0 would enter computeStackingPatches as a real overlapping neighbour at the
51
+ // z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
52
+ // clip instead. NaN (rather than null) keeps the return assignable to the
53
+ // `(el) => number` reader contract the drag hook / commit deps declare.
48
54
  const readClipZIndex = useCallback(
49
55
  (el: TimelineElement): number => {
50
56
  const node = resolveIframeElement(el);
51
- return node ? readEffectiveZIndex(node) : 0;
57
+ return node ? readEffectiveZIndex(node) : Number.NaN;
52
58
  },
53
59
  [resolveIframeElement],
54
60
  );
55
61
 
56
62
  const applyStackingPatches = useCallback(
57
63
  (patches: StackingPatch[], coalesceKey?: string) => {
58
- if (!handleDomZIndexReorderCommit) return;
64
+ if (!handleDomZIndexReorderCommit) return Promise.resolve();
59
65
  const entries = patches.flatMap((p) => {
60
66
  const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key);
61
67
  const node = el && resolveIframeElement(el);
@@ -68,10 +74,17 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
68
74
  selector: el.selector,
69
75
  selectorIndex: el.selectorIndex,
70
76
  sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
77
+ // The store key: lets the commit update the store's zIndex
78
+ // synchronously (and roll it back on failure).
79
+ key: p.key,
71
80
  },
72
81
  ];
73
82
  });
74
- if (entries.length) return handleDomZIndexReorderCommit(entries, coalesceKey);
83
+ // Forward the drag-commit's shared coalesce key so the z-reorder history
84
+ // entry merges with the lane change's move entry into one undo step.
85
+ return entries.length
86
+ ? handleDomZIndexReorderCommit(entries, coalesceKey).then(() => undefined)
87
+ : Promise.resolve();
75
88
  },
76
89
  [handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
77
90
  );
@@ -0,0 +1,38 @@
1
+ import { useMemo } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
4
+
5
+ /**
6
+ * Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
7
+ * clip grouping (`tracks`, ascending), per-lane visual styles, the ascending
8
+ * `trackOrder`, and the z-override badge set. Extracted from Timeline.tsx as a
9
+ * cohesive unit (600-line studio cap); each memo keys on the expanded display
10
+ * element set exactly as before.
11
+ */
12
+ export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
13
+ tracks: [number, TimelineElement[]][];
14
+ trackStyles: Map<number, TrackVisualStyle>;
15
+ trackOrder: number[];
16
+ } {
17
+ const tracks = useMemo(() => {
18
+ const map = new Map<number, TimelineElement[]>();
19
+ for (const el of expandedElements) {
20
+ const list = map.get(el.track) ?? [];
21
+ list.push(el);
22
+ map.set(el.track, list);
23
+ }
24
+ return Array.from(map.entries()).sort(([a], [b]) => a - b);
25
+ }, [expandedElements]);
26
+
27
+ const trackStyles = useMemo(() => {
28
+ const map = new Map<number, TrackVisualStyle>();
29
+ for (const [trackNum, els] of tracks) {
30
+ map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
31
+ }
32
+ return map;
33
+ }, [tracks]);
34
+
35
+ const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
36
+
37
+ return { tracks, trackStyles, trackOrder };
38
+ }
@@ -0,0 +1,155 @@
1
+ import { useCallback, useMemo, useState, type MutableRefObject } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import type { DragCommitDeps } from "./timelineClipDragCommit";
4
+ import {
5
+ laneGapFloor,
6
+ resolveAllGapIntervals,
7
+ resolveAllTrackGaps,
8
+ resolveCloseGapShifts,
9
+ resolveTrackGapAt,
10
+ type TrackGapInterval,
11
+ } from "./timelineGaps";
12
+ import {
13
+ canShiftTrackGapClips,
14
+ commitCloseAllTrackGaps,
15
+ commitCloseTrackGap,
16
+ } from "./timelineGapCommit";
17
+
18
+ /** Right-click anchor on EMPTY lane space: pointer position + clicked lane/time. */
19
+ interface TrackGapMenuAnchor {
20
+ x: number;
21
+ y: number;
22
+ track: number;
23
+ time: number;
24
+ }
25
+
26
+ /** Gap strips to paint on one lane while a menu row is hovered. */
27
+ export interface TrackGapHighlight {
28
+ track: number;
29
+ intervals: TrackGapInterval[];
30
+ }
31
+
32
+ /**
33
+ * Track-gap context menu (right-click on empty lane space) — state, the
34
+ * derived menu model, and the two commit actions. Extracted from Timeline.tsx
35
+ * as a cohesive unit (600-line studio cap); behavior identical.
36
+ *
37
+ * Only the ANCHOR (and the hovered row) is state; the menu model (gap under
38
+ * the pointer, compaction, movability) derives from live `tracks` so an open
39
+ * menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
40
+ * paints while "Close gap" / "Close all gaps" is hovered — derives the same
41
+ * way. Commits are ONE atomic batch each via the existing move-persist
42
+ * pipeline (see timelineGapCommit.ts).
43
+ */
44
+ export function useTrackGapMenu({
45
+ tracks,
46
+ expandedElementsRef,
47
+ trackOrderRef,
48
+ onMoveElement,
49
+ onMoveElements,
50
+ }: {
51
+ tracks: [number, TimelineElement[]][];
52
+ expandedElementsRef: MutableRefObject<TimelineElement[]>;
53
+ trackOrderRef: MutableRefObject<number[]>;
54
+ onMoveElement: DragCommitDeps["onMoveElement"];
55
+ onMoveElements: DragCommitDeps["onMoveElements"];
56
+ }) {
57
+ const updateElement = usePlayerStore((s) => s.updateElement);
58
+ const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
59
+ const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
60
+
61
+ const gapMenuLaneElements = useMemo(
62
+ () => (gapContextMenu ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? []) : null),
63
+ [gapContextMenu, tracks],
64
+ );
65
+ const gapMenuModel = useMemo(() => {
66
+ if (!gapContextMenu || !gapMenuLaneElements) return null;
67
+ const floor = laneGapFloor(gapMenuLaneElements);
68
+ const gap = resolveTrackGapAt(gapMenuLaneElements, gapContextMenu.time, undefined, floor);
69
+ const allShifts = resolveAllTrackGaps(gapMenuLaneElements, undefined, floor);
70
+ return {
71
+ x: gapContextMenu.x,
72
+ y: gapContextMenu.y,
73
+ gapWidth: gap ? gap.gapEnd - gap.gapStart : null,
74
+ canCloseGap:
75
+ gap != null &&
76
+ canShiftTrackGapClips(gapMenuLaneElements, resolveCloseGapShifts(gapMenuLaneElements, gap)),
77
+ hasAnyGaps: allShifts.length > 0,
78
+ canCloseAllGaps:
79
+ allShifts.length > 0 && canShiftTrackGapClips(gapMenuLaneElements, allShifts),
80
+ };
81
+ }, [gapContextMenu, gapMenuLaneElements]);
82
+
83
+ // The strips to paint while a menu row is hovered: the one gap under the
84
+ // pointer for "Close gap", every current gap (leading included) for
85
+ // "Close all gaps". Null when nothing is hovered / nothing would close.
86
+ const gapHighlight = useMemo<TrackGapHighlight | null>(() => {
87
+ if (!gapContextMenu || !gapMenuLaneElements || !hoveredGapAction) return null;
88
+ const floor = laneGapFloor(gapMenuLaneElements);
89
+ if (hoveredGapAction === "close-gap") {
90
+ const gap = resolveTrackGapAt(gapMenuLaneElements, gapContextMenu.time, undefined, floor);
91
+ if (!gap) return null;
92
+ return {
93
+ track: gapContextMenu.track,
94
+ intervals: [{ start: gap.gapStart, end: gap.gapEnd }],
95
+ };
96
+ }
97
+ const intervals = resolveAllGapIntervals(gapMenuLaneElements, undefined, floor);
98
+ return intervals.length > 0 ? { track: gapContextMenu.track, intervals } : null;
99
+ }, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
100
+
101
+ const closeTrackGap = useCallback(() => {
102
+ if (!gapContextMenu || !gapMenuLaneElements) return;
103
+ commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
104
+ elements: expandedElementsRef.current,
105
+ trackOrder: trackOrderRef.current,
106
+ updateElement,
107
+ onMoveElement,
108
+ onMoveElements,
109
+ });
110
+ }, [
111
+ gapContextMenu,
112
+ gapMenuLaneElements,
113
+ expandedElementsRef,
114
+ trackOrderRef,
115
+ updateElement,
116
+ onMoveElement,
117
+ onMoveElements,
118
+ ]);
119
+ const closeAllTrackGaps = useCallback(() => {
120
+ if (!gapMenuLaneElements) return;
121
+ commitCloseAllTrackGaps(gapMenuLaneElements, {
122
+ elements: expandedElementsRef.current,
123
+ trackOrder: trackOrderRef.current,
124
+ updateElement,
125
+ onMoveElement,
126
+ onMoveElements,
127
+ });
128
+ }, [
129
+ gapMenuLaneElements,
130
+ expandedElementsRef,
131
+ trackOrderRef,
132
+ updateElement,
133
+ onMoveElement,
134
+ onMoveElements,
135
+ ]);
136
+
137
+ const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
138
+ setHoveredGapAction(null);
139
+ setGapContextMenu(anchor);
140
+ }, []);
141
+ const dismissGapMenu = useCallback(() => {
142
+ setHoveredGapAction(null);
143
+ setGapContextMenu(null);
144
+ }, []);
145
+
146
+ return {
147
+ gapMenuModel,
148
+ gapHighlight,
149
+ setHoveredGapAction,
150
+ openGapMenu,
151
+ dismissGapMenu,
152
+ closeTrackGap,
153
+ closeAllTrackGaps,
154
+ };
155
+ }
@@ -152,10 +152,34 @@ describe("buildExpandedElements", () => {
152
152
  ["pill-3", "group-1"],
153
153
  ]);
154
154
  const domClipChildren = [
155
- { id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" },
156
- { id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" },
157
- { id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" },
158
- { id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" },
155
+ {
156
+ id: "group-1",
157
+ parentId: "scene-host",
158
+ hostId: "scene-host",
159
+ label: "Group 1",
160
+ stackingContextId: "css:0",
161
+ },
162
+ {
163
+ id: "pill-1",
164
+ parentId: "group-1",
165
+ hostId: "scene-host",
166
+ label: "pill-1",
167
+ stackingContextId: "css:0.0",
168
+ },
169
+ {
170
+ id: "pill-2",
171
+ parentId: "group-1",
172
+ hostId: "scene-host",
173
+ label: "pill-2",
174
+ stackingContextId: "css:0.1",
175
+ },
176
+ {
177
+ id: "pill-3",
178
+ parentId: "group-1",
179
+ hostId: "scene-host",
180
+ label: "pill-3",
181
+ stackingContextId: "css:0.1",
182
+ },
159
183
  ];
160
184
 
161
185
  // Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
@@ -173,6 +197,7 @@ describe("buildExpandedElements", () => {
173
197
  expect(pills[0]!.start).toBe(5);
174
198
  expect(pills[0]!.duration).toBe(6);
175
199
  expect(pills[0]!.sourceFile).toBe("scene.html");
200
+ expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
176
201
  // The host row is replaced by its children.
177
202
  expect(out.some((e) => e.domId === "scene-host")).toBe(false);
178
203
  });
@@ -273,3 +298,41 @@ describe("resolveTimelineExpansionRawId", () => {
273
298
  ).toBe("caption");
274
299
  });
275
300
  });
301
+
302
+ describe("buildExpandedElements — collision-free synthetic rows (cross-file lane safety)", () => {
303
+ it("expanded children NEVER share a display track with an unrelated top-level clip", () => {
304
+ // The Deepwork regression: host on track 0 with two children used to put
305
+ // child #2 on integer track 1 — the same lane as index.html#foreign. Lane
306
+ // grouping merges purely by track number, so a gap-close on that "one"
307
+ // lane batch-persisted a foreign file's clip.
308
+ const elements = [
309
+ el({ id: "host", start: 0, duration: 20, track: 0, compositionSrc: "scene.html" }),
310
+ el({ id: "foreign", start: 20, duration: 5, track: 1 }),
311
+ ];
312
+ const manifest = [
313
+ clip({ id: "host", start: 0, duration: 20, compositionSrc: "scene.html" }),
314
+ clip({ id: "c1", start: 0, duration: 5 }),
315
+ clip({ id: "c2", start: 10, duration: 5 }),
316
+ ];
317
+ const parentMap = new Map([
318
+ ["c1", "host"],
319
+ ["c2", "host"],
320
+ ]);
321
+
322
+ const out = buildExpandedElements(elements, manifest, parentMap, "host", "host");
323
+ const foreign = out.find((e) => e.id === "foreign")!;
324
+ const children = out.filter((e) => e.domId === "c1" || e.domId === "c2");
325
+ expect(children).toHaveLength(2);
326
+ for (const child of children) {
327
+ // No lane sharing with the foreign clip…
328
+ expect(child.track).not.toBe(foreign.track);
329
+ // …and structurally impossible to collide with ANY normalized (integer)
330
+ // lane: synthetic rows are strict fractions under the host's lane.
331
+ expect(Number.isInteger(child.track)).toBe(false);
332
+ expect(child.track).toBeGreaterThan(0);
333
+ expect(child.track).toBeLessThan(1);
334
+ }
335
+ // Distinct ordered rows per child.
336
+ expect(children[0].track).not.toBe(children[1].track);
337
+ });
338
+ });
@@ -163,7 +163,24 @@ function buildChildElements(
163
163
  key,
164
164
  start: clamped.start,
165
165
  duration: clamped.duration,
166
- track: display.track + result.length,
166
+ // `track` becomes a synthetic display row under the expanded host, but the
167
+ // factory-set `authoredTrack` (the child's data-track-index in ITS OWN
168
+ // file's coordinate space) and the runtime-computed `stackingContextId`
169
+ // must survive verbatim — lane persists and z-sync read them, they are
170
+ // never reconstructed from display lanes.
171
+ //
172
+ // COLLISION-FREE synthetic rows: the old `display.track + index` scheme
173
+ // could equal a REAL clip's integer lane (host on track 0 with two
174
+ // children puts child #2 on track 1 — where an unrelated top-level clip
175
+ // may live). Lane grouping merges purely by track number, so that
176
+ // collision fused clips from DIFFERENT source files into one display
177
+ // lane, and lane-scoped actions (gap close) then batch-persisted foreign
178
+ // clips. Fractions strictly between the host's lane and the next integer
179
+ // can never equal a normalized (integer) lane, while still rendering the
180
+ // children as their own ordered rows directly under the host.
181
+ track: display.track + (result.length + 1) / (siblings.length + 2),
182
+ authoredTrack: base.authoredTrack,
183
+ stackingContextId: base.stackingContextId,
167
184
  expandedParentStart: editBasis.start,
168
185
  domId,
169
186
  selector,
@@ -198,6 +215,7 @@ function domSiblingClips(
198
215
  parentCompositionId: host.id ?? null,
199
216
  compositionSrc: host.compositionSrc ?? null,
200
217
  assetUrl: null,
218
+ stackingContextId: c.stackingContextId,
201
219
  }),
202
220
  );
203
221
  }
@@ -153,6 +153,61 @@ describe("useTimelinePlayer seek hydration", () => {
153
153
  unmountWithAct(root);
154
154
  unsubscribe();
155
155
  });
156
+
157
+ it("does not settle from an unsupported runtime protocol message", () => {
158
+ const { api, root } = renderTimelinePlayerHarness();
159
+ const iframe = document.createElement("iframe");
160
+ const iframeWindow = {
161
+ postMessage: vi.fn(),
162
+ scrollTo: vi.fn(),
163
+ addEventListener: vi.fn(),
164
+ removeEventListener: vi.fn(),
165
+ } as Record<string, unknown>;
166
+ Object.defineProperty(iframe, "contentWindow", {
167
+ value: iframeWindow,
168
+ configurable: true,
169
+ });
170
+ Object.defineProperty(iframe, "contentDocument", {
171
+ value: document.implementation.createHTMLDocument("preview"),
172
+ configurable: true,
173
+ });
174
+
175
+ act(() => {
176
+ api.iframeRef.current = iframe;
177
+ api.onIframeLoad();
178
+ });
179
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
180
+
181
+ iframeWindow.__player = {
182
+ play: vi.fn(),
183
+ pause: vi.fn(),
184
+ seek: vi.fn(),
185
+ getTime: () => 0,
186
+ getDuration: () => 30,
187
+ isPlaying: () => false,
188
+ };
189
+ act(() => {
190
+ window.dispatchEvent(
191
+ new MessageEvent("message", {
192
+ source: iframeWindow as unknown as Window,
193
+ data: { source: "hf-preview", type: "state", protocolVersion: 999 },
194
+ }),
195
+ );
196
+ });
197
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
198
+
199
+ act(() => {
200
+ window.dispatchEvent(
201
+ new MessageEvent("message", {
202
+ source: iframeWindow as unknown as Window,
203
+ data: { source: "hf-preview", type: "state" },
204
+ }),
205
+ );
206
+ });
207
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
208
+
209
+ unmountWithAct(root);
210
+ });
156
211
  });
157
212
 
158
213
  describe("useTimelinePlayer audio controls (#835)", () => {
@@ -437,12 +437,19 @@ describe("anonymous timeline identity", () => {
437
437
  });
438
438
 
439
439
  describe("mergeTimelineElementsPreservingDowngrades", () => {
440
- it("preserves missing current elements when a shorter manifest arrives", () => {
440
+ it("preserves missing sub-composition elements when a shorter manifest arrives", () => {
441
441
  expect(
442
442
  mergeTimelineElementsPreservingDowngrades(
443
443
  [
444
444
  { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
445
- { id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
445
+ {
446
+ id: "cta",
447
+ tag: "div",
448
+ start: 4,
449
+ duration: 2,
450
+ track: 1,
451
+ compositionSrc: "scenes/cta.html",
452
+ },
446
453
  ],
447
454
  [{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
448
455
  8,
@@ -450,10 +457,31 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
450
457
  ),
451
458
  ).toEqual([
452
459
  { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
453
- { id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
460
+ {
461
+ id: "cta",
462
+ tag: "div",
463
+ start: 4,
464
+ duration: 2,
465
+ track: 1,
466
+ compositionSrc: "scenes/cta.html",
467
+ },
454
468
  ]);
455
469
  });
456
470
 
471
+ it("drops missing top-level elements so undo does not leave ghost clips", () => {
472
+ expect(
473
+ mergeTimelineElementsPreservingDowngrades(
474
+ [
475
+ { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
476
+ { id: "split-clone", tag: "div", start: 4, duration: 2, track: 1 },
477
+ ],
478
+ [{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
479
+ 8,
480
+ 8,
481
+ ),
482
+ ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
483
+ });
484
+
457
485
  it("accepts longer-duration or same-size updates as authoritative", () => {
458
486
  expect(
459
487
  mergeTimelineElementsPreservingDowngrades(
@@ -477,6 +505,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
477
505
  start: 0,
478
506
  duration: 3,
479
507
  track: 0,
508
+ compositionSrc: "scenes/cards.html",
480
509
  },
481
510
  {
482
511
  id: "Card",
@@ -486,6 +515,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
486
515
  start: 3,
487
516
  duration: 3,
488
517
  track: 1,
518
+ compositionSrc: "scenes/cards.html",
489
519
  },
490
520
  ],
491
521
  [
@@ -497,6 +527,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
497
527
  start: 0,
498
528
  duration: 3,
499
529
  track: 0,
530
+ compositionSrc: "scenes/cards.html",
500
531
  },
501
532
  ],
502
533
  8,
@@ -511,6 +542,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
511
542
  start: 0,
512
543
  duration: 3,
513
544
  track: 0,
545
+ compositionSrc: "scenes/cards.html",
514
546
  },
515
547
  {
516
548
  id: "Card",
@@ -520,6 +552,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
520
552
  start: 3,
521
553
  duration: 3,
522
554
  track: 1,
555
+ compositionSrc: "scenes/cards.html",
523
556
  },
524
557
  ]);
525
558
  });