@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  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 +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,393 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { classifyZone } from "./timelineZones";
3
+ import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
4
+ import { authoredTrackForLane, sameSourceFile } from "./timelineClipDragCommit";
5
+ import { samePaintScope } from "./timelineStackingSync";
6
+
7
+ /**
8
+ * Mirror a canvas z-order action (Bring to Front / Bring Forward / Send Backward /
9
+ * Send to Back) into a timeline LANE move — the pure resolver, no UI wiring.
10
+ *
11
+ * ── The model ────────────────────────────────────────────────────────────────
12
+ * Track order is the DEFAULT paint order; authored z is the ADVANCED override.
13
+ * Render truth stays z — the renderer never reads track index — and the studio
14
+ * maintains z ↔ track consistency at EDIT time: a deliberate vertical lane move
15
+ * syncs z (timelineStackingSync), and a z-order menu action calls THIS resolver
16
+ * to compute the accompanying lane move. When the user authors z that diverges
17
+ * from track order, the mirror never fights the authored override — it only
18
+ * keeps the default in step.
19
+ *
20
+ * ── Locked rules (agreed design — do not re-litigate here) ───────────────────
21
+ * - The mirror computes a lane move to ACCOMPANY a z action on a timeline clip;
22
+ * it never replaces the z patch.
23
+ * - ONE-ELEMENT STEP (bring-forward / send-backward): the z action stepped past
24
+ * exactly ONE element — the reference neighbor — so the lane move must too.
25
+ * Target = the free lane (whole-span, file-agnostic occupancy, same zone)
26
+ * closest to the reference, searched STRICTLY BETWEEN the reference's lane
27
+ * and the next temporally-overlapping same-file visual element's lane in the
28
+ * direction (exclusive bound). No free lane in that open interval (the common
29
+ * back-to-back case) → CREATE one at the boundary immediately beyond the
30
+ * reference neighbor (commitTrackInsert semantics) — never scan past the
31
+ * second element to a farther free lane, which would overshoot the paint
32
+ * order. With no second overlapping element
33
+ * beyond the reference, the bound is the zone edge: closest free lane beyond
34
+ * the neighbor, else insert immediately beyond it.
35
+ * - bring-to-front / send-to-back move past the WHOLE overlap set: closest free
36
+ * lane beyond the extreme overlap in the direction, else insert adjacent to
37
+ * the extreme.
38
+ * - Direction: bring-forward/front = toward LOWER display lanes (up = above);
39
+ * send-backward/back = toward HIGHER lanes, but only within the visual zone —
40
+ * the audio zone is untouched and never crossed (a bottom-of-zone insert lands
41
+ * AT the visual/audio boundary, i.e. still a visual lane).
42
+ * - Reference scope: same source file AND same stacking context (see
43
+ * samePaintScope — a file can contain several stacking contexts, and leaf z
44
+ * is only comparable within one). Lane FREENESS stays file-agnostic (any
45
+ * clip in the zone occupies its lane for everyone).
46
+ * - Non-clip decorations (no timeline presence) are out of scope — callers keep
47
+ * z-only behavior for them. Audio elements never mirror (z on audio is
48
+ * meaningless); the resolver returns null.
49
+ *
50
+ * ── OPEN product question ────────────────────────────────────────────────────
51
+ * send-to-back / bring-to-front scope: below/above EVERYTHING visual, or only
52
+ * the clips that temporally overlap the moved clip? The default implemented
53
+ * here is TEMPORAL-OVERLAP scope (the extreme is computed over same-file clips
54
+ * that overlap the moved clip in time), pending M/Bin sign-off. A clip with no
55
+ * temporal overlaps in the direction is "already at the extreme" → null.
56
+ *
57
+ * Deterministic: a pure function of its inputs — no Date, no randomness, no DOM.
58
+ */
59
+
60
+ export type ZMirrorAction = "bring-to-front" | "bring-forward" | "send-backward" | "send-to-back";
61
+
62
+ export interface ZMirrorInput {
63
+ action: ZMirrorAction;
64
+ /** The clip acted on — store/display space (post-normalizeToZones lanes),
65
+ * carrying `authoredTrack` when the display lane diverges from the file. */
66
+ element: TimelineElement;
67
+ /** The expanded display element set (same set the drag commit reasons on). */
68
+ elements: TimelineElement[];
69
+ /** Timeline key of the neighbor the z action stepped over (forward/backward),
70
+ * when known — see resolveCrossedNeighbor in canvasContextMenuZOrder. */
71
+ crossedKey?: string | null;
72
+ }
73
+
74
+ export type ZMirrorLaneMove =
75
+ | {
76
+ /** Land on an existing display lane. */
77
+ kind: "move";
78
+ /** Display lane to move to (store space). */
79
+ displayTrack: number;
80
+ /** Authored-space value to write (authoredTrackForLane translation). */
81
+ persistTrack: number;
82
+ }
83
+ | {
84
+ /** Create a new lane: boundary row compatible with commitTrackInsert's
85
+ * insertRow (index into the ascending display trackOrder; 0 = above the
86
+ * top lane, length = below the bottom). */
87
+ kind: "insert";
88
+ insertRow: number;
89
+ }
90
+ | null;
91
+
92
+ const keyOf = (el: TimelineElement): string => el.key ?? el.id;
93
+
94
+ /**
95
+ * Lane candidates for an EXPANDED sub-comp child: only its own siblings' lanes
96
+ * (same file). An expanded child's display row is synthetic host-space — landing
97
+ * it on an arbitrary host lane has no same-file occupant to translate the
98
+ * authored track from, and a track INSERT would renumber host space from a
99
+ * child origin. Ordinary top-level clips return null (no restriction).
100
+ */
101
+ function expandedChildAllowedLanes(
102
+ element: TimelineElement,
103
+ elements: TimelineElement[],
104
+ ): ReadonlySet<number> | null {
105
+ if (element.expandedParentStart == null) return null;
106
+ const selfKey = keyOf(element);
107
+ return new Set(
108
+ elements
109
+ .filter((el) => keyOf(el) !== selfKey && sameSourceFile(el, element))
110
+ .map((el) => el.track),
111
+ );
112
+ }
113
+
114
+ /** Ascending unique display lanes of `elements` — identical to how Timeline.tsx
115
+ * builds `trackOrder`, so `insertRow` indexes the same boundary space. Exported
116
+ * so the mirror wiring can hand commitZMirrorLaneMove the matching trackOrder. */
117
+ export function displayTrackOrder(elements: TimelineElement[]): number[] {
118
+ return [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
119
+ }
120
+
121
+ /**
122
+ * Resolve the timeline lane move that mirrors a z-order action on `element`.
123
+ * Returns null when no timeline mirror applies: audio / zero-length clips, no
124
+ * reference neighbor in the action's direction (the menu action was likely
125
+ * disabled or a no-op), or the clip is already laned where the action puts it.
126
+ */
127
+ export function resolveZMirrorLaneMove(input: ZMirrorInput): ZMirrorLaneMove {
128
+ const { action, element, elements } = input;
129
+ if (classifyZone(element) === "audio") return null;
130
+ if (!(element.duration > 0)) return null;
131
+
132
+ const selfKey = keyOf(element);
133
+ const start = element.start;
134
+ const end = element.start + element.duration;
135
+ const up = action === "bring-forward" || action === "bring-to-front";
136
+
137
+ // Same paint scope (source file + stacking context), visual, temporally overlapping.
138
+ const overlapSet = elements.filter(
139
+ (el) =>
140
+ keyOf(el) !== selfKey &&
141
+ classifyZone(el) === "visual" &&
142
+ samePaintScope(el, element) &&
143
+ timeRangesOverlap(start, end, el.start, el.start + el.duration),
144
+ );
145
+
146
+ const referenceLane = resolveReferenceLane(input, overlapSet, up);
147
+ if (referenceLane == null) return null;
148
+
149
+ const order = displayTrackOrder(elements);
150
+ const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
151
+ const refIdx = visualLanes.indexOf(referenceLane);
152
+ if (refIdx === -1) return null; // reference is not a visual lane — no mirror
153
+
154
+ const boundLane = stepBoundLane(action, overlapSet, referenceLane, up);
155
+ const allowedLanes = expandedChildAllowedLanes(element, elements);
156
+ const lane = closestFreeLane({
157
+ elements,
158
+ visualLanes,
159
+ refIdx,
160
+ up,
161
+ boundLane,
162
+ start,
163
+ end,
164
+ selfKey,
165
+ allowedLanes,
166
+ });
167
+ if (lane != null) {
168
+ // The closest free lane is the clip's OWN lane (possible only when z and
169
+ // track had diverged): the clip already sits where the action puts it.
170
+ if (lane === element.track) return null;
171
+ return {
172
+ kind: "move",
173
+ displayTrack: lane,
174
+ persistTrack: authoredTrackForLane(lane, elements, element),
175
+ };
176
+ }
177
+
178
+ // Expanded children never INSERT: a new lane is a host-lane-space renumber,
179
+ // meaningless in the child's own file (buildTrackInsertEdits refuses too).
180
+ if (allowedLanes) return null;
181
+ // No free lane before the bound (or the zone edge) → create one adjacent to
182
+ // the reference: the boundary between its lane and the next in direction.
183
+ return { kind: "insert", insertRow: order.indexOf(referenceLane) + (up ? 0 : 1) };
184
+ }
185
+
186
+ export interface ZRepositionInput {
187
+ /** The clip the Layers-panel drag moved — store/display space. */
188
+ element: TimelineElement;
189
+ /** The expanded display element set (same set the drag commit reasons on). */
190
+ elements: TimelineElement[];
191
+ /**
192
+ * Timeline keys of the reordered sibling set in DESIRED render order,
193
+ * bottom→top, the moved element's own key included at its new slot. Siblings
194
+ * with no timeline presence carry null — they are skipped when resolving the
195
+ * nearest clip neighbors.
196
+ */
197
+ desiredOrderKeys: ReadonlyArray<string | null>;
198
+ }
199
+
200
+ /**
201
+ * Mirror an ARBITRARY z repositioning (a Layers-panel drag, which can jump
202
+ * several siblings in one drop) into a timeline lane move — the "equal jump"
203
+ * generalization of {@link resolveZMirrorLaneMove}'s one-step rule: the clip
204
+ * lands between its NEW paint neighbors' lanes.
205
+ *
206
+ * The reference lanes are the nearest siblings in the desired render order
207
+ * that are visual, same-file timeline clips: `above` = the first such sibling
208
+ * that now paints above the moved clip, `below` = the first that paints below.
209
+ * Target lane = the free lane (whole-span, same zone) strictly between the
210
+ * above-neighbor's lane and the below-neighbor's lane, closest to the above
211
+ * neighbor; when only one neighbor exists the zone edge bounds the search on
212
+ * the open side. No free lane in the interval → INSERT a new lane immediately
213
+ * beyond the above neighbor (below it), or immediately above the below
214
+ * neighbor when dropped on top — commitTrackInsert semantics, exactly like the
215
+ * menu mirror's insert fallback.
216
+ *
217
+ * Null when no mirror applies: audio / zero-length clips, no clip neighbor in
218
+ * the set (a z-only decoration shuffle), or the clip already sits where the
219
+ * drop puts it.
220
+ */
221
+ // fallow-ignore-next-line complexity
222
+ export function resolveRepositionLaneMove(input: ZRepositionInput): ZMirrorLaneMove {
223
+ const { element, elements, desiredOrderKeys } = input;
224
+ if (classifyZone(element) === "audio") return null;
225
+ if (!(element.duration > 0)) return null;
226
+
227
+ const selfKey = keyOf(element);
228
+ const selfIdx = desiredOrderKeys.indexOf(selfKey);
229
+ if (selfIdx === -1) return null;
230
+
231
+ const clipLaneForKey = (key: string | null): number | null => {
232
+ if (key == null) return null;
233
+ const el = elements.find((candidate) => keyOf(candidate) === key);
234
+ return el &&
235
+ classifyZone(el) === "visual" &&
236
+ el.duration > 0 &&
237
+ samePaintScope(el, element) &&
238
+ keyOf(el) !== selfKey
239
+ ? el.track
240
+ : null;
241
+ };
242
+
243
+ // Nearest clip neighbor painting ABOVE (later in bottom→top order) / BELOW.
244
+ let aboveLane: number | null = null;
245
+ for (let i = selfIdx + 1; i < desiredOrderKeys.length && aboveLane == null; i++) {
246
+ aboveLane = clipLaneForKey(desiredOrderKeys[i]);
247
+ }
248
+ let belowLane: number | null = null;
249
+ for (let i = selfIdx - 1; i >= 0 && belowLane == null; i--) {
250
+ belowLane = clipLaneForKey(desiredOrderKeys[i]);
251
+ }
252
+ if (aboveLane == null && belowLane == null) return null;
253
+
254
+ const order = displayTrackOrder(elements);
255
+ const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
256
+ const allowedLanes = expandedChildAllowedLanes(element, elements);
257
+ const args = {
258
+ elements,
259
+ visualLanes,
260
+ start: element.start,
261
+ end: element.start + element.duration,
262
+ selfKey,
263
+ allowedLanes,
264
+ };
265
+
266
+ let lane: number | null;
267
+ let insertRow: number;
268
+ if (aboveLane != null) {
269
+ // Paints above = LOWER display lane: search DOWNWARD from the above
270
+ // neighbor (closest lane under it first), bounded by the below neighbor
271
+ // (exclusive) when one exists, else by the zone edge.
272
+ const refIdx = visualLanes.indexOf(aboveLane);
273
+ if (refIdx === -1) return null;
274
+ lane = closestFreeLane({ ...args, refIdx, up: false, boundLane: belowLane });
275
+ insertRow = order.indexOf(aboveLane) + 1;
276
+ } else {
277
+ // Dropped above everything that remains: search UPWARD from the below
278
+ // neighbor toward the zone top.
279
+ const refIdx = visualLanes.indexOf(belowLane!);
280
+ if (refIdx === -1) return null;
281
+ lane = closestFreeLane({ ...args, refIdx, up: true, boundLane: null });
282
+ insertRow = order.indexOf(belowLane!);
283
+ }
284
+
285
+ if (lane != null) {
286
+ if (lane === element.track) return null;
287
+ return {
288
+ kind: "move",
289
+ displayTrack: lane,
290
+ persistTrack: authoredTrackForLane(lane, elements, element),
291
+ };
292
+ }
293
+ // Expanded children never INSERT (host-lane-space renumber from a child
294
+ // origin) — see expandedChildAllowedLanes.
295
+ if (allowedLanes) return null;
296
+ return { kind: "insert", insertRow };
297
+ }
298
+
299
+ /**
300
+ * ONE-ELEMENT-STEP bound (bring-forward / send-backward only): the lane of the
301
+ * NEXT temporally-overlapping same-file visual element strictly beyond the
302
+ * reference in the direction — the free-lane search may not reach it
303
+ * (exclusive). Front/back have no bound (they step past the whole overlap
304
+ * set), and neither does a step with no second overlapping element beyond the
305
+ * reference (the zone edge bounds instead).
306
+ */
307
+ function stepBoundLane(
308
+ action: ZMirrorAction,
309
+ overlapSet: TimelineElement[],
310
+ referenceLane: number,
311
+ up: boolean,
312
+ ): number | null {
313
+ if (action !== "bring-forward" && action !== "send-backward") return null;
314
+ const beyond = overlapSet
315
+ .map((el) => el.track)
316
+ .filter((lane) => (up ? lane < referenceLane : lane > referenceLane));
317
+ if (beyond.length === 0) return null;
318
+ return (up ? Math.max : Math.min)(...beyond);
319
+ }
320
+
321
+ /**
322
+ * Closest free lane strictly beyond the reference, lane-by-lane in direction,
323
+ * whole-span freeness, same zone (visual lanes only — never into audio),
324
+ * stopping at the exclusive bound when one applies. Null → no free lane in
325
+ * the open interval.
326
+ */
327
+ function closestFreeLane(args: {
328
+ elements: TimelineElement[];
329
+ visualLanes: number[];
330
+ refIdx: number;
331
+ up: boolean;
332
+ boundLane: number | null;
333
+ start: number;
334
+ end: number;
335
+ selfKey: string;
336
+ /** When set, only these lanes are candidates (expanded-child scoping). */
337
+ allowedLanes?: ReadonlySet<number> | null;
338
+ }): number | null {
339
+ const { elements, visualLanes, refIdx, up, boundLane, start, end, selfKey, allowedLanes } = args;
340
+ const step = up ? -1 : 1;
341
+ for (let i = refIdx + step; i >= 0 && i < visualLanes.length; i += step) {
342
+ const lane = visualLanes[i];
343
+ if (pastSearchBound(lane, boundLane, up)) break;
344
+ if (allowedLanes && !allowedLanes.has(lane)) continue;
345
+ if (isLaneFree(elements, lane, start, end, selfKey)) return lane;
346
+ }
347
+ return null;
348
+ }
349
+
350
+ /** The exclusive one-element-step bound: stop at (never on/past) `boundLane`. */
351
+ function pastSearchBound(lane: number, boundLane: number | null, up: boolean): boolean {
352
+ if (boundLane == null) return false;
353
+ return up ? lane <= boundLane : lane >= boundLane;
354
+ }
355
+
356
+ /**
357
+ * The lane the search starts from (the "reference neighbor"):
358
+ * - forward/backward: the crossed neighbor when provided and valid (a visual
359
+ * clip in the set); otherwise the closest temporally-overlapping same-file
360
+ * clip in the direction. None → null (the menu was probably disabled).
361
+ * - front/back: the extreme of the temporal-overlap set — topmost (lowest lane)
362
+ * for front, bottommost (highest lane) for back — restricted to overlaps
363
+ * strictly beyond the clip's own lane. None → already at the extreme → null.
364
+ */
365
+ function resolveReferenceLane(
366
+ input: ZMirrorInput,
367
+ overlapSet: TimelineElement[],
368
+ up: boolean,
369
+ ): number | null {
370
+ const stepAction = input.action === "bring-forward" || input.action === "send-backward";
371
+ if (stepAction) {
372
+ const crossedLane = crossedNeighborLane(input);
373
+ // Unknown / absent / non-visual crossed key → the temporal neighbor below.
374
+ if (crossedLane != null) return crossedLane;
375
+ }
376
+
377
+ // Overlapping same-file lanes strictly beyond the moved clip's lane, in direction.
378
+ const lanes = overlapSet
379
+ .map((el) => el.track)
380
+ .filter((lane) => (up ? lane < input.element.track : lane > input.element.track));
381
+ if (lanes.length === 0) return null;
382
+
383
+ // Step actions want the CLOSEST lane in direction (max when up, min when
384
+ // down); front/back want the EXTREME of the set (min when up, max when down).
385
+ return (stepAction === up ? Math.max : Math.min)(...lanes);
386
+ }
387
+
388
+ /** The crossed neighbor's display lane, when the key names a visual clip in the set. */
389
+ function crossedNeighborLane({ elements, crossedKey }: ZMirrorInput): number | null {
390
+ if (crossedKey == null) return null;
391
+ const crossed = elements.find((el) => keyOf(el) === crossedKey);
392
+ return crossed && classifyZone(crossed) === "visual" ? crossed.track : null;
393
+ }
@@ -45,11 +45,11 @@ describe("getTimelinePixelsPerSecond", () => {
45
45
 
46
46
  describe("getNextTimelineZoomPercent", () => {
47
47
  it("zooms out from fit relative to 100 percent", () => {
48
- expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(80);
48
+ expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(50);
49
49
  });
50
50
 
51
51
  it("zooms in from fit relative to 100 percent", () => {
52
- expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(125);
52
+ expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(200);
53
53
  });
54
54
 
55
55
  it("clamps the lower bound", () => {
@@ -2,9 +2,13 @@ import type { ZoomMode } from "../store/playerStore";
2
2
 
3
3
  export const MIN_TIMELINE_ZOOM_PERCENT = 10;
4
4
  export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
5
- const ZOOM_OUT_FACTOR = 0.8;
6
- const ZOOM_IN_FACTOR = 1.25;
7
- const PINCH_ZOOM_SENSITIVITY = 0.0035;
5
+ // CapCut-strength steps: one button press / pinch gesture moves the zoom
6
+ // meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
7
+ // "zooming several times to get anywhere", then 1.5× + 0.007 still too soft).
8
+ // Kept reciprocal (2 × 0.5 = 1) so in+out round-trips.
9
+ const ZOOM_OUT_FACTOR = 0.5;
10
+ const ZOOM_IN_FACTOR = 2;
11
+ const PINCH_ZOOM_SENSITIVITY = 0.014;
8
12
 
9
13
  export function clampTimelineZoomPercent(percent: number): number {
10
14
  if (!Number.isFinite(percent)) return 100;
@@ -76,7 +76,7 @@ interface UseTimelineClipDragInput {
76
76
  * commitDraggedClipMove. Both optional → absent = no-op (backward compatible).
77
77
  */
78
78
  readZIndex?: (element: TimelineElement) => number;
79
- onStackingPatches?: (patches: StackingPatch[]) => void;
79
+ onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
80
80
  }
81
81
 
82
82
  export function useTimelineClipDrag({
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { buildTimelineGapStrips } from "./useTimelineGapHighlights";
4
+
5
+ function el(id: string, track: number, start: number, duration: number): TimelineElement {
6
+ return { id, key: id, tag: "video", start, duration, track, domId: id };
7
+ }
8
+
9
+ const laneA = [el("a1", 0, 1, 2), el("a2", 0, 5, 2)]; // gaps: [0,1), [3,5)
10
+ const laneB = [el("b1", 1, 0, 2), el("b2", 1, 2, 3)]; // contiguous
11
+ const tracks: [number, TimelineElement[]][] = [
12
+ [0, laneA],
13
+ [1, laneB],
14
+ ];
15
+ const expandedElements = [...laneA, ...laneB];
16
+
17
+ const base = {
18
+ gapHighlight: null,
19
+ tracks,
20
+ selectedElementId: null,
21
+ selectedElementIds: new Set<string>(),
22
+ expandedElements,
23
+ dragActive: false,
24
+ displayDuration: 60,
25
+ };
26
+
27
+ describe("buildTimelineGapStrips", () => {
28
+ it("emits a loud hover strip set from the gap-menu highlight", () => {
29
+ const strips = buildTimelineGapStrips({
30
+ ...base,
31
+ gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
32
+ });
33
+ expect(strips).toEqual([{ track: 0, intervals: [{ start: 3, end: 5 }], kind: "hover" }]);
34
+ });
35
+
36
+ it("click-selection lights the WHOLE lane minus clips, trailing space included", () => {
37
+ const strips = buildTimelineGapStrips({ ...base, selectedElementId: "a1" });
38
+ expect(strips).toEqual([
39
+ {
40
+ track: 0,
41
+ intervals: [
42
+ { start: 0, end: 1 },
43
+ { start: 3, end: 5 },
44
+ { start: 7, end: 60 },
45
+ ],
46
+ kind: "selected",
47
+ },
48
+ ]);
49
+ });
50
+
51
+ it("a contiguous lane still lights its trailing open space", () => {
52
+ expect(buildTimelineGapStrips({ ...base, selectedElementId: "b1" })).toEqual([
53
+ { track: 1, intervals: [{ start: 5, end: 60 }], kind: "selected" },
54
+ ]);
55
+ });
56
+
57
+ it("a lane filling the whole rendered extent emits nothing", () => {
58
+ const laneFull = [el("f1", 5, 0, 60)];
59
+ const strips = buildTimelineGapStrips({
60
+ ...base,
61
+ tracks: [...tracks, [5, laneFull]],
62
+ expandedElements: [...expandedElements, ...laneFull],
63
+ selectedElementId: "f1",
64
+ });
65
+ expect(strips).toEqual([]);
66
+ });
67
+
68
+ it("a one-member selectedElementIds mirror of the click still counts as single", () => {
69
+ // The store mirrors a plain click into selectedElementIds = {clicked}.
70
+ const strips = buildTimelineGapStrips({
71
+ ...base,
72
+ selectedElementId: "a1",
73
+ selectedElementIds: new Set(["a1"]),
74
+ });
75
+ expect(strips).toHaveLength(1);
76
+ expect(strips[0].kind).toBe("selected");
77
+ });
78
+
79
+ it("marquee multi-selection never emits the selected hint", () => {
80
+ const strips = buildTimelineGapStrips({
81
+ ...base,
82
+ selectedElementId: "a1",
83
+ selectedElementIds: new Set(["a1", "b1"]),
84
+ });
85
+ expect(strips).toEqual([]);
86
+ });
87
+
88
+ it("hover wins on its own lane — no doubled strips for the same track", () => {
89
+ const strips = buildTimelineGapStrips({
90
+ ...base,
91
+ gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
92
+ selectedElementId: "a1",
93
+ });
94
+ expect(strips).toHaveLength(1);
95
+ expect(strips[0].kind).toBe("hover");
96
+ });
97
+
98
+ it("hover and selection on DIFFERENT lanes coexist", () => {
99
+ const laneC = [el("c1", 2, 4, 2)];
100
+ const strips = buildTimelineGapStrips({
101
+ ...base,
102
+ tracks: [...tracks, [2, laneC]],
103
+ expandedElements: [...expandedElements, ...laneC],
104
+ gapHighlight: { track: 2, intervals: [{ start: 0, end: 4 }] },
105
+ selectedElementId: "a1",
106
+ });
107
+ expect(strips.map((s) => s.kind)).toEqual(["hover", "selected"]);
108
+ });
109
+
110
+ it("a live drag suppresses every strip", () => {
111
+ const strips = buildTimelineGapStrips({
112
+ ...base,
113
+ dragActive: true,
114
+ gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
115
+ selectedElementId: "a1",
116
+ });
117
+ expect(strips).toEqual([]);
118
+ });
119
+ });
@@ -0,0 +1,110 @@
1
+ import { useMemo } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { laneGapFloor, resolveLaneEmptyIntervals, type TrackGapInterval } from "./timelineGaps";
4
+ import type { TrackGapHighlight } from "./useTrackGapMenu";
5
+
6
+ /**
7
+ * One lane's gap strips for the TimelineCanvas overlay.
8
+ *
9
+ * kind "hover" — the gap(s) a hovered "Close gap" / "Close all gaps" menu
10
+ * row would collapse: the loud affordance.
11
+ * kind "selected" — every empty interval on a click-selected clip's lane: the
12
+ * quiet always-on hint (single click-selection only — a
13
+ * marquee multi-select spans lanes and would paint noise).
14
+ */
15
+ export interface TimelineLaneGapStrips {
16
+ track: number;
17
+ intervals: TrackGapInterval[];
18
+ kind: "hover" | "selected";
19
+ }
20
+
21
+ /**
22
+ * Derive the gap strips TimelineCanvas paints. The menu-hover highlight wins
23
+ * on its lane (painting both would just double the same strips); the
24
+ * selected-clip hint renders on the selection's lane otherwise. Suppressed
25
+ * entirely during a live drag — the drop placeholder / insert line own that
26
+ * moment, and the lane set is in flux.
27
+ */
28
+ interface GapHighlightInput {
29
+ gapHighlight: TrackGapHighlight | null;
30
+ tracks: [number, TimelineElement[]][];
31
+ selectedElementId: string | null;
32
+ selectedElementIds: ReadonlySet<string>;
33
+ expandedElements: TimelineElement[];
34
+ dragActive: boolean;
35
+ /** Rendered timeline extent (seconds) — the selected-lane highlight spans the
36
+ * WHOLE lane minus its clips, trailing open space included. */
37
+ displayDuration: number;
38
+ }
39
+
40
+ /**
41
+ * Single selection only: the store mirrors a plain click into a one-member
42
+ * selectedElementIds set (setSelectedElementId collapses the multi-select),
43
+ * so "single" means empty OR exactly the selected clip itself. A marquee
44
+ * multi-select spans lanes and stays hint-free.
45
+ */
46
+ function isSingleSelection(selectedElementId: string, ids: ReadonlySet<string>): boolean {
47
+ return ids.size === 0 || (ids.size === 1 && ids.has(selectedElementId));
48
+ }
49
+
50
+ /** The subtle strips for a single click-selected clip's lane, or null. */
51
+ function selectedLaneStrips(input: GapHighlightInput): TimelineLaneGapStrips | null {
52
+ const { selectedElementId, selectedElementIds, expandedElements, tracks, gapHighlight } = input;
53
+ if (!selectedElementId || !isSingleSelection(selectedElementId, selectedElementIds)) return null;
54
+ const selected = expandedElements.find((el) => (el.key ?? el.id) === selectedElementId);
55
+ if (!selected || selected.track === gapHighlight?.track) return null;
56
+ const laneElements = tracks.find(([t]) => t === selected.track)?.[1] ?? [];
57
+ const intervals = resolveLaneEmptyIntervals(
58
+ laneElements,
59
+ input.displayDuration,
60
+ undefined,
61
+ laneGapFloor(laneElements),
62
+ );
63
+ return intervals.length > 0 ? { track: selected.track, intervals, kind: "selected" } : null;
64
+ }
65
+
66
+ /** Pure strip derivation — exported for direct unit testing. */
67
+ export function buildTimelineGapStrips(input: GapHighlightInput): TimelineLaneGapStrips[] {
68
+ if (input.dragActive) return [];
69
+ const strips: TimelineLaneGapStrips[] = [];
70
+ if (input.gapHighlight && input.gapHighlight.intervals.length > 0) {
71
+ strips.push({ ...input.gapHighlight, kind: "hover" });
72
+ }
73
+ // Single click-selection → subtle gap hint on that clip's lane.
74
+ const selected = selectedLaneStrips(input);
75
+ if (selected) strips.push(selected);
76
+ return strips;
77
+ }
78
+
79
+ export function useTimelineGapHighlights(input: GapHighlightInput): TimelineLaneGapStrips[] {
80
+ const {
81
+ gapHighlight,
82
+ tracks,
83
+ selectedElementId,
84
+ selectedElementIds,
85
+ expandedElements,
86
+ dragActive,
87
+ displayDuration,
88
+ } = input;
89
+ return useMemo(
90
+ () =>
91
+ buildTimelineGapStrips({
92
+ gapHighlight,
93
+ tracks,
94
+ selectedElementId,
95
+ selectedElementIds,
96
+ expandedElements,
97
+ dragActive,
98
+ displayDuration,
99
+ }),
100
+ [
101
+ gapHighlight,
102
+ tracks,
103
+ selectedElementId,
104
+ selectedElementIds,
105
+ expandedElements,
106
+ dragActive,
107
+ displayDuration,
108
+ ],
109
+ );
110
+ }