@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,8 +1,21 @@
1
- import { useRef, useState, useCallback } from "react";
2
- import { buildClipRangeSelection, type TimelineRangeSelection } from "./timelineEditing";
1
+ import { useRef, useState, useCallback, useEffect } from "react";
2
+ import {
3
+ buildClipRangeSelection,
4
+ applyTimelineAutoScrollStep,
5
+ resolveTimelineAutoScrollLoopAction,
6
+ type TimelineRangeSelection,
7
+ } from "./timelineEditing";
3
8
  import type { TimelineElement } from "../store/playerStore";
4
- import { liveTime } from "../store/playerStore";
9
+ import { liveTime, usePlayerStore } from "../store/playerStore";
5
10
  import { GUTTER } from "./timelineLayout";
11
+ import {
12
+ computeMarqueeSelection,
13
+ getMarqueeRect,
14
+ isMarqueeDrag,
15
+ isTimelineRulerPress,
16
+ type MarqueeClipInput,
17
+ } from "./timelineMarquee";
18
+ import type { Rect } from "../../utils/marqueeGeometry";
6
19
 
7
20
  interface UseTimelineRangeSelectionInput {
8
21
  scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -15,11 +28,69 @@ interface UseTimelineRangeSelectionInput {
15
28
  dragScrollRaf: React.RefObject<number>;
16
29
  isDragging: React.RefObject<boolean>;
17
30
  setShowPopover: (v: boolean) => void;
31
+ elementsRef: React.RefObject<TimelineElement[]>;
32
+ trackOrderRef: React.RefObject<number[]>;
33
+ onSelectElement?: (element: TimelineElement | null) => void;
34
+ }
35
+
36
+ interface MarqueeDragState {
37
+ originX: number;
38
+ originY: number;
39
+ /** Pre-drag selection, restored on Escape-cancel. */
40
+ baseIds: Set<string>;
41
+ basePrimary: string | null;
42
+ /** Union new hits with baseIds (shift/cmd/ctrl at pointerdown). */
43
+ additive: boolean;
44
+ /** True once the pointer travelled past the click threshold. */
45
+ active: boolean;
46
+ }
47
+
48
+ function snapshotSelection(): { ids: Set<string>; primary: string | null } {
49
+ const s = usePlayerStore.getState();
50
+ const ids = new Set(s.selectedElementIds);
51
+ if (s.selectedElementId) ids.add(s.selectedElementId);
52
+ return { ids, primary: s.selectedElementId };
53
+ }
54
+
55
+ function toMarqueeClips(elements: TimelineElement[]): MarqueeClipInput[] {
56
+ return elements.map((el) => ({
57
+ id: el.key ?? el.id,
58
+ start: el.start,
59
+ duration: el.duration,
60
+ track: el.track,
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * Compute the live selection for a marquee rect and commit it to the store.
66
+ * Shift held mid-drag (or cmd/ctrl at pointerdown) unions the new hits with the
67
+ * pre-drag selection (marquee.baseIds / basePrimary).
68
+ */
69
+ function commitMarqueeSelection(
70
+ rect: Rect,
71
+ additive: boolean,
72
+ marquee: MarqueeDragState,
73
+ elements: TimelineElement[],
74
+ trackOrder: number[],
75
+ pps: number,
76
+ ): void {
77
+ const { ids, primaryId } = computeMarqueeSelection({
78
+ clips: toMarqueeClips(elements),
79
+ trackOrder,
80
+ pps,
81
+ marquee: rect,
82
+ baseSelection: additive ? marquee.baseIds : undefined,
83
+ });
84
+ const store = usePlayerStore.getState();
85
+ // Primary FIRST: setSelectedElementId collapses the multi-select set, so the set
86
+ // must be written after it or the marquee selection would be wiped every frame.
87
+ store.setSelectedElementId(primaryId ?? (additive ? marquee.basePrimary : null));
88
+ store.setSelectedElementIds(ids);
18
89
  }
19
90
 
20
91
  export function useTimelineRangeSelection({
21
92
  scrollRef,
22
- ppsRef: _ppsRef,
93
+ ppsRef,
23
94
  effectiveDuration: _effectiveDuration,
24
95
  pps,
25
96
  onSeek: _onSeek,
@@ -28,9 +99,15 @@ export function useTimelineRangeSelection({
28
99
  dragScrollRaf,
29
100
  isDragging,
30
101
  setShowPopover,
102
+ elementsRef,
103
+ trackOrderRef,
104
+ onSelectElement,
31
105
  }: UseTimelineRangeSelectionInput) {
32
106
  const isRangeSelecting = useRef(false);
33
107
  const rangeAnchorTime = useRef(0);
108
+ // Reactive mirror of the scrub gesture (isDragging is a ref, so it can't drive
109
+ // rendering). Drives the playhead head's filled-vs-hollow state.
110
+ const [isScrubbing, setIsScrubbing] = useState(false);
34
111
  const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
35
112
  const shiftClickClipRef = useRef<{
36
113
  element: TimelineElement;
@@ -41,55 +118,174 @@ export function useTimelineRangeSelection({
41
118
  const seekRafRef = useRef(0);
42
119
  const pendingClientXRef = useRef(0);
43
120
 
121
+ // Marquee (rubber-band) multi-select on the empty timeline body.
122
+ const marqueeRef = useRef<MarqueeDragState | null>(null);
123
+ const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
124
+ // Edge auto-scroll during a marquee drag: last pointer position (client-space)
125
+ // + shift flag, re-applied each RAF frame while the view scrolls under a
126
+ // stationary pointer, so the marquee can extend past the visible area.
127
+ const marqueePointerRef = useRef<{ clientX: number; clientY: number; shiftKey: boolean } | null>(
128
+ null,
129
+ );
130
+ const marqueeScrollRaf = useRef(0);
131
+
132
+ /** Pointer position → canvas/content coordinates (same space as clip rects). */
133
+ const toContentPoint = useCallback(
134
+ (clientX: number, clientY: number): { x: number; y: number } | null => {
135
+ const el = scrollRef.current;
136
+ if (!el) return null;
137
+ const rect = el.getBoundingClientRect();
138
+ return {
139
+ x: clientX - rect.left + el.scrollLeft,
140
+ y: clientY - rect.top + el.scrollTop,
141
+ };
142
+ },
143
+ [scrollRef],
144
+ );
145
+
146
+ // Recompute the marquee rect + live selection for a client-space pointer.
147
+ // Content-space (folds in scrollLeft/scrollTop), so re-running it after the
148
+ // view scrolls naturally extends the rect toward the newly revealed area.
149
+ // Shared by the pointermove handler and the edge auto-scroll stepper.
150
+ const applyMarqueeAtClient = useCallback(
151
+ (clientX: number, clientY: number, shiftKey: boolean) => {
152
+ const marquee = marqueeRef.current;
153
+ if (!marquee) return;
154
+ const point = toContentPoint(clientX, clientY);
155
+ if (!point) return;
156
+ if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) {
157
+ return;
158
+ }
159
+ marquee.active = true;
160
+ const rect = getMarqueeRect(marquee.originX, marquee.originY, point.x, point.y);
161
+ setMarqueeRect(rect);
162
+ // Live selection: every clip the box currently covers. Shift held
163
+ // mid-drag (or cmd/ctrl at pointerdown) adds to the prior selection.
164
+ const additive = marquee.additive || shiftKey;
165
+ commitMarqueeSelection(
166
+ rect,
167
+ additive,
168
+ marquee,
169
+ elementsRef.current ?? [],
170
+ trackOrderRef.current ?? [],
171
+ ppsRef.current,
172
+ );
173
+ },
174
+ [toContentPoint, elementsRef, trackOrderRef, ppsRef],
175
+ );
176
+
177
+ const stopMarqueeAutoScroll = useCallback(() => {
178
+ marqueePointerRef.current = null;
179
+ if (marqueeScrollRaf.current) {
180
+ cancelAnimationFrame(marqueeScrollRaf.current);
181
+ marqueeScrollRaf.current = 0;
182
+ }
183
+ }, []);
184
+
185
+ // Edge auto-scroll while marquee-dragging: mirrors stepClipDragAutoScroll —
186
+ // scroll the container toward the edge zone the pointer is in, then re-run the
187
+ // marquee at the (unchanged) client pointer so the rect + selection extend
188
+ // under the scroll delta. Self-perpetuating RAF until the pointer leaves the
189
+ // edge zones or the gesture ends.
190
+ const stepMarqueeAutoScroll = useCallback(() => {
191
+ marqueeScrollRaf.current = 0;
192
+ const marquee = marqueeRef.current;
193
+ const pointer = marqueePointerRef.current;
194
+ const scroll = scrollRef.current;
195
+ if (!marquee || !pointer || !scroll) return;
196
+ if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
197
+
198
+ // Re-run at the SAME client point: toContentPoint folds in the new scroll, so
199
+ // the marquee's moving corner tracks the revealed content.
200
+ applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey);
201
+ marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
202
+ }, [scrollRef, applyMarqueeAtClient]);
203
+
204
+ const syncMarqueeAutoScroll = useCallback(
205
+ (clientX: number, clientY: number, shiftKey: boolean) => {
206
+ marqueePointerRef.current = { clientX, clientY, shiftKey };
207
+ const action = resolveTimelineAutoScrollLoopAction(
208
+ scrollRef.current,
209
+ clientX,
210
+ clientY,
211
+ marqueeScrollRaf.current !== 0,
212
+ );
213
+ if (action === "stop") {
214
+ cancelAnimationFrame(marqueeScrollRaf.current);
215
+ marqueeScrollRaf.current = 0;
216
+ } else if (action === "start") {
217
+ marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
218
+ }
219
+ },
220
+ [scrollRef, stepMarqueeAutoScroll],
221
+ );
222
+
223
+ // Shift-press → start a time-range selection anchored at the pressed x.
224
+ const beginRangeSelection = useCallback(
225
+ (e: React.PointerEvent) => {
226
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
227
+ isRangeSelecting.current = true;
228
+ setShowPopover(false);
229
+ const rect = scrollRef.current?.getBoundingClientRect();
230
+ if (rect) {
231
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
232
+ const time = Math.max(0, x / pps);
233
+ rangeAnchorTime.current = time;
234
+ setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
235
+ }
236
+ },
237
+ [scrollRef, pps, setShowPopover],
238
+ );
239
+
44
240
  const handlePointerDown = useCallback(
45
241
  (e: React.PointerEvent) => {
46
242
  if (e.button !== 0) return;
47
243
  if (e.shiftKey) {
48
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
49
- isRangeSelecting.current = true;
50
- setShowPopover(false);
51
- const rect = scrollRef.current?.getBoundingClientRect();
52
- if (rect) {
53
- const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
54
- const time = Math.max(0, x / pps);
55
- rangeAnchorTime.current = time;
56
- setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
57
- }
244
+ beginRangeSelection(e);
58
245
  return;
59
246
  }
60
247
  shiftClickClipRef.current = null;
61
248
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
62
249
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
63
- isDragging.current = true;
64
250
  setRangeSelection(null);
65
251
  setShowPopover(false);
66
- seekFromX(e.clientX);
252
+ const point = toContentPoint(e.clientX, e.clientY);
253
+ // Ruler press → scrub the playhead (the standard scrub surface). The
254
+ // ruler is sticky, so this decision uses VIEWPORT-space y — content-space
255
+ // y (which folds in scrollTop) breaks once the body is scrolled down and
256
+ // the stuck ruler visually overlays scrolled-away track rows.
257
+ const scrollRect = scrollRef.current?.getBoundingClientRect();
258
+ if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
259
+ isDragging.current = true;
260
+ setIsScrubbing(true);
261
+ seekFromX(e.clientX);
262
+ return;
263
+ }
264
+ // Empty body press → pending marquee. A plain click (no drag past the
265
+ // threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
266
+ const base = snapshotSelection();
267
+ marqueeRef.current = {
268
+ originX: point.x,
269
+ originY: point.y,
270
+ baseIds: base.ids,
271
+ basePrimary: base.primary,
272
+ additive: e.metaKey || e.ctrlKey,
273
+ active: false,
274
+ };
67
275
  },
68
- [seekFromX, pps, scrollRef, isDragging, setShowPopover],
276
+ [beginRangeSelection, seekFromX, scrollRef, isDragging, setShowPopover, toContentPoint],
69
277
  );
70
278
 
71
- const handlePointerMove = useCallback(
72
- (e: React.PointerEvent) => {
73
- if (isRangeSelecting.current) {
74
- const rect = scrollRef.current?.getBoundingClientRect();
75
- if (rect) {
76
- const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
77
- setRangeSelection((prev) =>
78
- prev
79
- ? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
80
- : null,
81
- );
82
- }
83
- return;
84
- }
85
- if (!isDragging.current) return;
86
- pendingClientXRef.current = e.clientX;
279
+ // Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
280
+ const updateScrubDrag = useCallback(
281
+ (clientX: number) => {
282
+ pendingClientXRef.current = clientX;
87
283
  // Update the playhead visual immediately via liveTime for smooth feedback,
88
284
  // then RAF-throttle the full seek (adapter + React state sync).
89
285
  const el = scrollRef.current;
90
286
  if (el) {
91
287
  const rect = el.getBoundingClientRect();
92
- const x = e.clientX - rect.left + el.scrollLeft - GUTTER;
288
+ const x = clientX - rect.left + el.scrollLeft - GUTTER;
93
289
  if (x >= 0) {
94
290
  const dur = el.scrollWidth / pps;
95
291
  liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
@@ -105,40 +301,135 @@ export function useTimelineRangeSelection({
105
301
  });
106
302
  }
107
303
  },
108
- [seekFromX, autoScrollDuringDrag, pps, scrollRef, isDragging],
304
+ [scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging],
305
+ );
306
+
307
+ const handlePointerMove = useCallback(
308
+ (e: React.PointerEvent) => {
309
+ if (isRangeSelecting.current) {
310
+ const rect = scrollRef.current?.getBoundingClientRect();
311
+ if (rect) {
312
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
313
+ setRangeSelection((prev) =>
314
+ prev
315
+ ? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
316
+ : null,
317
+ );
318
+ }
319
+ return;
320
+ }
321
+ const marquee = marqueeRef.current;
322
+ if (marquee) {
323
+ applyMarqueeAtClient(e.clientX, e.clientY, e.shiftKey);
324
+ // Edge auto-scroll: once the drag is live, scroll when the pointer nears
325
+ // a viewport edge so the marquee can extend past the visible area.
326
+ if (marquee.active) syncMarqueeAutoScroll(e.clientX, e.clientY, e.shiftKey);
327
+ return;
328
+ }
329
+ if (!isDragging.current) return;
330
+ updateScrubDrag(e.clientX);
331
+ },
332
+ [pps, scrollRef, isDragging, applyMarqueeAtClient, syncMarqueeAutoScroll, updateScrubDrag],
333
+ );
334
+
335
+ // Release of a shift time-range gesture: keep a real range (or a shift-click
336
+ // clip range), otherwise clear it.
337
+ const finishRangeSelection = useCallback(() => {
338
+ isRangeSelecting.current = false;
339
+ const pendingShiftClick = shiftClickClipRef.current;
340
+ shiftClickClipRef.current = null;
341
+ setRangeSelection((prev) => {
342
+ if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
343
+ setShowPopover(true);
344
+ return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
345
+ }
346
+ if (prev && Math.abs(prev.end - prev.start) > 0.2) {
347
+ setShowPopover(true);
348
+ return prev;
349
+ }
350
+ return null;
351
+ });
352
+ }, [setShowPopover]);
353
+
354
+ // Release of a marquee gesture: plain click deselects; a real drag keeps the
355
+ // live selection and notifies the primary element.
356
+ const finishMarquee = useCallback(
357
+ (marquee: MarqueeDragState) => {
358
+ marqueeRef.current = null;
359
+ stopMarqueeAutoScroll();
360
+ setMarqueeRect(null);
361
+ const store = usePlayerStore.getState();
362
+ if (!marquee.active) {
363
+ // Plain click on empty body (click-away): deselect everything.
364
+ store.setSelectedElementId(null);
365
+ store.clearSelectedElementIds();
366
+ onSelectElement?.(null);
367
+ return;
368
+ }
369
+ const primaryKey = store.selectedElementId;
370
+ const primary =
371
+ (elementsRef.current ?? []).find((el) => (el.key ?? el.id) === primaryKey) ?? null;
372
+ onSelectElement?.(primary);
373
+ },
374
+ [stopMarqueeAutoScroll, elementsRef, onSelectElement],
109
375
  );
110
376
 
111
377
  const handlePointerUp = useCallback(() => {
112
378
  if (isRangeSelecting.current) {
113
- isRangeSelecting.current = false;
114
- const pendingShiftClick = shiftClickClipRef.current;
115
- shiftClickClipRef.current = null;
116
- setRangeSelection((prev) => {
117
- if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
118
- setShowPopover(true);
119
- return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
120
- }
121
- if (prev && Math.abs(prev.end - prev.start) > 0.2) {
122
- setShowPopover(true);
123
- return prev;
124
- }
125
- return null;
126
- });
379
+ finishRangeSelection();
380
+ return;
381
+ }
382
+ const marquee = marqueeRef.current;
383
+ if (marquee) {
384
+ finishMarquee(marquee);
127
385
  return;
128
386
  }
387
+ if (!isDragging.current) return;
129
388
  if (seekRafRef.current) {
130
389
  cancelAnimationFrame(seekRafRef.current);
131
390
  seekRafRef.current = 0;
132
391
  }
133
392
  seekFromX(pendingClientXRef.current);
134
393
  isDragging.current = false;
394
+ setIsScrubbing(false);
135
395
  cancelAnimationFrame(dragScrollRaf.current);
136
- }, [isDragging, dragScrollRaf, setShowPopover, seekFromX]);
396
+ }, [isDragging, dragScrollRaf, seekFromX, finishRangeSelection, finishMarquee]);
397
+
398
+ // Escape: cancel an in-flight marquee (restores the pre-drag selection);
399
+ // otherwise clear any lingering multi-selection.
400
+ useEffect(() => {
401
+ const onKeyDown = (e: KeyboardEvent) => {
402
+ if (e.key !== "Escape") return;
403
+ const store = usePlayerStore.getState();
404
+ const marquee = marqueeRef.current;
405
+ if (marquee) {
406
+ marqueeRef.current = null;
407
+ stopMarqueeAutoScroll();
408
+ setMarqueeRect(null);
409
+ if (marquee.active) {
410
+ // Primary FIRST (see commitMarqueeSelection): it collapses the set, so
411
+ // restore the pre-drag primary before repopulating the base ids.
412
+ store.setSelectedElementId(marquee.basePrimary);
413
+ store.setSelectedElementIds(marquee.baseIds);
414
+ }
415
+ return;
416
+ }
417
+ // Escape with no marquee clears the whole selection — primary AND set.
418
+ // setSelectedElementId(null) also collapses the multi-select set.
419
+ if (store.selectedElementId || store.selectedElementIds.size > 0) {
420
+ store.setSelectedElementId(null);
421
+ }
422
+ };
423
+ window.addEventListener("keydown", onKeyDown);
424
+ return () => window.removeEventListener("keydown", onKeyDown);
425
+ }, [stopMarqueeAutoScroll]);
137
426
 
138
427
  return {
139
428
  rangeSelection,
140
429
  setRangeSelection,
141
430
  shiftClickClipRef,
431
+ marqueeRect,
432
+ isScrubbing,
142
433
  handlePointerDown,
143
434
  handlePointerMove,
144
435
  handlePointerUp,
@@ -0,0 +1,67 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../store/playerStore";
6
+ import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ actions: null as null | {
12
+ previewIframeRef: { current: HTMLIFrameElement | null };
13
+ handleDomZIndexReorderCommit: ReturnType<typeof vi.fn>;
14
+ },
15
+ }));
16
+
17
+ vi.mock("../../contexts/DomEditContext", () => ({
18
+ useDomEditActionsContextOptional: () => mocks.actions,
19
+ }));
20
+ vi.mock("../../contexts/StudioContext", () => ({
21
+ useStudioShellContextOptional: () => ({ activeCompPath: "nested.html" }),
22
+ }));
23
+
24
+ import { useTimelineStackingSync } from "./useTimelineStackingSync";
25
+
26
+ describe("useTimelineStackingSync", () => {
27
+ it("forwards resolved entries and the lane gesture coalesce key", async () => {
28
+ const iframe = document.createElement("iframe");
29
+ document.body.appendChild(iframe);
30
+ const node = iframe.contentDocument!.createElement("div");
31
+ node.setAttribute("data-hf-id", "hf-a");
32
+ iframe.contentDocument!.body.appendChild(node);
33
+ const commit = vi.fn().mockResolvedValue(undefined);
34
+ mocks.actions = { previewIframeRef: { current: iframe }, handleDomZIndexReorderCommit: commit };
35
+ const element: TimelineElement = {
36
+ id: "a",
37
+ key: "a",
38
+ hfId: "hf-a",
39
+ tag: "div",
40
+ start: 0,
41
+ duration: 2,
42
+ track: 0,
43
+ sourceFile: "nested.html",
44
+ };
45
+ let apply: ((patches: Array<{ key: string; zIndex: number }>, key?: string) => unknown) | null =
46
+ null;
47
+ function Harness() {
48
+ apply = useTimelineStackingSync({
49
+ expandedElementsRef: { current: [element] },
50
+ }).applyStackingPatches;
51
+ return null;
52
+ }
53
+ const root = mountReactHarness(<Harness />);
54
+
55
+ await act(async () => {
56
+ await apply!([{ key: "a", zIndex: 8 }], "clip-lane-move:7");
57
+ });
58
+
59
+ expect(commit).toHaveBeenCalledWith(
60
+ [expect.objectContaining({ element: node, zIndex: 8, sourceFile: "nested.html" })],
61
+ "clip-lane-move:7",
62
+ );
63
+ act(() => root.unmount());
64
+ mocks.actions = null;
65
+ iframe.remove();
66
+ });
67
+ });
@@ -0,0 +1,83 @@
1
+ import { useCallback, type RefObject } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
5
+ import { findElementForSelection } from "../../components/editor/domEditingElement";
6
+ import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder";
7
+ import type { StackingPatch } from "./timelineStackingSync";
8
+
9
+ interface UseTimelineStackingSyncInput {
10
+ expandedElementsRef: RefObject<TimelineElement[]>;
11
+ }
12
+
13
+ // Lane ↔ stacking unification (research/STAGE3-NEEDED-WIRING.md). Provision the
14
+ // two deps commitDraggedClipMove accepts so a lane-change drag also patches the
15
+ // edited clip's z-index. Both read the SAME preview iframe + z-order persist path
16
+ // the canvas right-click menu / LayersPanel use, so a timeline lane move and a
17
+ // menu z-edit produce one shared inline-style commit shape. Optional contexts:
18
+ // outside the NLE (standalone <Timeline>) these are null ⇒ deps undefined ⇒ the
19
+ // commit's z-sync is a no-op (backward compatible).
20
+ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) {
21
+ const domEditActions = useDomEditActionsContextOptional();
22
+ const shell = useStudioShellContextOptional();
23
+ const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null;
24
+ const handleDomZIndexReorderCommit = domEditActions?.handleDomZIndexReorderCommit;
25
+ const zSyncActiveCompPath = shell?.activeCompPath ?? null;
26
+
27
+ // Resolve a TimelineElement to its live iframe HTMLElement via the same
28
+ // hfId ?? id ?? selector[selectorIndex] resolver the timeline's DOM patches use.
29
+ const resolveIframeElement = useCallback(
30
+ (el: TimelineElement): HTMLElement | null => {
31
+ const doc = zSyncPreviewIframeRef?.current?.contentDocument ?? null;
32
+ if (!doc) return null;
33
+ return findElementForSelection(
34
+ doc,
35
+ {
36
+ hfId: el.hfId,
37
+ id: el.domId ?? el.id,
38
+ selector: el.selector,
39
+ selectorIndex: el.selectorIndex,
40
+ sourceFile: el.sourceFile,
41
+ },
42
+ zSyncActiveCompPath,
43
+ );
44
+ },
45
+ [zSyncPreviewIframeRef, zSyncActiveCompPath],
46
+ );
47
+
48
+ const readClipZIndex = useCallback(
49
+ (el: TimelineElement): number => {
50
+ const node = resolveIframeElement(el);
51
+ return node ? readEffectiveZIndex(node) : 0;
52
+ },
53
+ [resolveIframeElement],
54
+ );
55
+
56
+ const applyStackingPatches = useCallback(
57
+ (patches: StackingPatch[], coalesceKey?: string) => {
58
+ if (!handleDomZIndexReorderCommit) return;
59
+ const entries = patches.flatMap((p) => {
60
+ const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key);
61
+ const node = el && resolveIframeElement(el);
62
+ if (!el || !node) return [];
63
+ return [
64
+ {
65
+ element: node,
66
+ zIndex: p.zIndex,
67
+ id: el.domId ?? el.id,
68
+ selector: el.selector,
69
+ selectorIndex: el.selectorIndex,
70
+ sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
71
+ },
72
+ ];
73
+ });
74
+ if (entries.length) return handleDomZIndexReorderCommit(entries, coalesceKey);
75
+ },
76
+ [handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
77
+ );
78
+
79
+ // Engage the z-sync only when the persist path is present (inside the NLE).
80
+ const zSyncEnabled = Boolean(handleDomZIndexReorderCommit && zSyncPreviewIframeRef);
81
+
82
+ return { readClipZIndex, applyStackingPatches, zSyncEnabled };
83
+ }
@@ -192,12 +192,9 @@ function domSiblingClips(
192
192
  start: host.start,
193
193
  duration: host.duration,
194
194
  track: host.track,
195
- zIndex: 0,
196
- stackingContextId: host.stackingContextId ?? host.domId ?? host.id ?? null,
197
195
  kind: "element",
198
196
  tagName: null,
199
197
  compositionId: null,
200
- compositionAncestors: host.compositionAncestors ?? [],
201
198
  parentCompositionId: host.id ?? null,
202
199
  compositionSrc: host.compositionSrc ?? null,
203
200
  assetUrl: null,
@@ -9,6 +9,7 @@
9
9
  import { useRef, useCallback } from "react";
10
10
  import { useCaptionStore } from "../../captions/store";
11
11
  import { shouldIgnorePlaybackShortcutEvent, SHUTTLE_SPEEDS } from "../lib/playbackShortcuts";
12
+ import { canvasNudgeKeysClaimed } from "../../utils/canvasNudgeGate";
12
13
  import { usePlayerStore } from "../store/playerStore";
13
14
  import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
14
15
  import type { PlaybackAdapter } from "../lib/playbackTypes";
@@ -97,6 +98,10 @@ export function usePlaybackKeyboard({
97
98
  togglePlay();
98
99
  return;
99
100
  }
101
+ // A nudgeable canvas selection owns the arrow keys (DomEditOverlay moves
102
+ // the element); frame-stepping would double-handle the same keystroke.
103
+ const arrowStep = e.code === "ArrowLeft" || e.code === "ArrowRight";
104
+ if (arrowStep && canvasNudgeKeysClaimed()) return;
100
105
  if (e.code === "ArrowLeft") {
101
106
  e.preventDefault();
102
107
  stepFrames(e.shiftKey ? -10 : -1);