@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
@@ -5,8 +5,8 @@ import {
5
5
  buildCompositionSnapTarget,
6
6
  buildGridSnapEdges,
7
7
  resolveSnapAdjustment,
8
- resolveResizeSnapAdjustment,
9
8
  resolveEquidistanceGuides,
9
+ resolveGuideLineRect,
10
10
  SNAP_THRESHOLD_PX,
11
11
  type SnapTarget,
12
12
  } from "./snapEngine";
@@ -385,88 +385,22 @@ describe("resolveSnapAdjustment", () => {
385
385
  });
386
386
 
387
387
  // ---------------------------------------------------------------------------
388
- // resolveResizeSnapAdjustment
388
+ // resolveGuideLineRect
389
389
  // ---------------------------------------------------------------------------
390
390
 
391
- describe("resolveResizeSnapAdjustment", () => {
392
- test("only right edge snaps on X", () => {
393
- // Moving rect at (100, 100) size 50x50, right=150.
394
- // Target left at 200. Propose dx=47 => proposed right=197. Dist to 200=3.
395
- const t = target("a", 200, 100, 100, 100);
396
- const result = resolveResizeSnapAdjustment({
397
- movingRect: rect(100, 100, 50, 50),
398
- proposedDx: 47,
399
- proposedDy: 0,
400
- targets: [t],
401
- threshold: SNAP_THRESHOLD_PX,
402
- disabled: false,
403
- });
404
- expect(result.dx).toBe(50); // right edge snaps to 200
405
- });
391
+ describe("resolveGuideLineRect", () => {
392
+ const composition = rect(120, 80, 640, 360); // letterboxed inside the overlay
406
393
 
407
- test("only bottom edge snaps on Y", () => {
408
- // Moving rect at (100, 100) size 50x50, bottom=150.
409
- // Target top at 200. Propose dy=47 => proposed bottom=197. Dist to 200=3.
410
- const t = target("a", 100, 200, 100, 100);
411
- const result = resolveResizeSnapAdjustment({
412
- movingRect: rect(100, 100, 50, 50),
413
- proposedDx: 0,
414
- proposedDy: 47,
415
- targets: [t],
416
- threshold: SNAP_THRESHOLD_PX,
417
- disabled: false,
418
- });
419
- expect(result.dy).toBe(50); // bottom edge snaps to 200
420
- });
421
-
422
- test("left edge does NOT snap during resize", () => {
423
- // Target right at 150. Moving rect left=100. If drag were active,
424
- // left would snap. But during resize, only right edge snaps.
425
- // Moving rect at (100, 100) size 200x200, right=300.
426
- // Target right=150. Proposed dx=-153 => proposed right=147. Dist to 150=3.
427
- // This SHOULD snap right to 150 (dx = -150). But left stays at 100.
428
- const t = target("a", 50, 100, 100, 100); // right=150
429
- const result = resolveResizeSnapAdjustment({
430
- movingRect: rect(100, 100, 200, 200),
431
- proposedDx: -153,
432
- proposedDy: 0,
433
- targets: [t],
434
- threshold: SNAP_THRESHOLD_PX,
435
- disabled: false,
436
- });
437
- // Right edge: 300 + (-153) = 147 => snaps to 150, adjustment = +3, dx = -150
438
- expect(result.dx).toBe(-150);
439
- });
440
-
441
- test("disabled=true returns passthrough for resize", () => {
442
- const t = target("a", 200, 200, 100, 100);
443
- const result = resolveResizeSnapAdjustment({
444
- movingRect: rect(100, 100, 50, 50),
445
- proposedDx: 47,
446
- proposedDy: 47,
447
- targets: [t],
448
- threshold: SNAP_THRESHOLD_PX,
449
- disabled: true,
450
- });
451
- expect(result.dx).toBe(47);
452
- expect(result.dy).toBe(47);
453
- expect(result.guides).toHaveLength(0);
394
+ test("vertical guide (axis x) spans the composition's height at the snap x", () => {
395
+ expect(resolveGuideLineRect({ axis: "x", position: 440, from: 0, to: 0 }, composition)).toEqual(
396
+ { left: 440, top: 80, width: 1, height: 360 },
397
+ );
454
398
  });
455
399
 
456
- test("resize produces guide lines", () => {
457
- const t = target("a", 200, 100, 100, 100);
458
- const result = resolveResizeSnapAdjustment({
459
- movingRect: rect(100, 100, 50, 50),
460
- proposedDx: 47,
461
- proposedDy: 0,
462
- targets: [t],
463
- threshold: SNAP_THRESHOLD_PX,
464
- disabled: false,
465
- });
466
- expect(result.guides.length).toBeGreaterThanOrEqual(1);
467
- const xGuide = result.guides.find((g) => g.axis === "x");
468
- expect(xGuide).toBeDefined();
469
- expect(xGuide!.position).toBe(200);
400
+ test("horizontal guide (axis y) spans the composition's width at the snap y", () => {
401
+ expect(resolveGuideLineRect({ axis: "y", position: 260, from: 0, to: 0 }, composition)).toEqual(
402
+ { left: 120, top: 260, width: 640, height: 1 },
403
+ );
470
404
  });
471
405
  });
472
406
 
@@ -410,62 +410,22 @@ export function resolveSnapAdjustment(input: {
410
410
  }
411
411
 
412
412
  // ---------------------------------------------------------------------------
413
- // resolveResizeSnapAdjustmentresize variant (only right/bottom snap)
413
+ // resolveGuideLineRectscreen rect for rendering a snap guide line
414
414
  // ---------------------------------------------------------------------------
415
415
 
416
- // fallow-ignore-next-line complexity
417
- export function resolveResizeSnapAdjustment(input: {
418
- movingRect: Rect;
419
- proposedDx: number;
420
- proposedDy: number;
421
- targets: SnapTarget[];
422
- gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
423
- threshold: number;
424
- disabled: boolean;
425
- }): SnapResult {
426
- if (input.disabled || input.threshold <= 0) {
427
- return DISABLED_RESULT(input.proposedDx, input.proposedDy);
416
+ /**
417
+ * Full-length guide line spanning the composition: a vertical line (axis "x")
418
+ * runs the composition's height at the snapped x position; a horizontal line
419
+ * (axis "y") runs the composition's width. `composition` is the composition
420
+ * rect in overlay space — guide positions are already overlay-space, so the
421
+ * line must be offset by the composition's left/top (the canvas is usually
422
+ * letterboxed inside the overlay).
423
+ */
424
+ export function resolveGuideLineRect(guide: SnapGuide, composition: Rect): Rect {
425
+ if (guide.axis === "x") {
426
+ return { left: guide.position, top: composition.top, width: 1, height: composition.height };
428
427
  }
429
-
430
- const mr = input.movingRect;
431
- const proposedRight = rectRight(mr) + input.proposedDx;
432
- const proposedBottom = rectBottom(mr) + input.proposedDy;
433
-
434
- const xCandidates = collectCandidates(
435
- [proposedRight],
436
- input.targets,
437
- (t) => [t.left, t.centerX, t.right],
438
- input.gridEdges?.x,
439
- input.threshold,
440
- );
441
- const yCandidates = collectCandidates(
442
- [proposedBottom],
443
- input.targets,
444
- (t) => [t.top, t.centerY, t.bottom],
445
- input.gridEdges?.y,
446
- input.threshold,
447
- );
448
-
449
- const bestX = pickBest(xCandidates);
450
- const bestY = pickBest(yCandidates);
451
- const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
452
- const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
453
-
454
- const adjustedRect: Rect = {
455
- left: mr.left,
456
- top: mr.top,
457
- width: mr.width + adjustedDx,
458
- height: mr.height + adjustedDy,
459
- };
460
-
461
- const targetMap = new Map(input.targets.map((t) => [t.id, t]));
462
-
463
- return {
464
- dx: adjustedDx,
465
- dy: adjustedDy,
466
- guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
467
- spacingGuides: [], // computed separately via resolveEquidistanceGuides
468
- };
428
+ return { left: composition.left, top: guide.position, width: composition.width, height: 1 };
469
429
  }
470
430
 
471
431
  // ---------------------------------------------------------------------------
@@ -0,0 +1,228 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6
+ import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
7
+ import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
8
+ import { __resetForTests } from "../../utils/canvasNudgeGate";
9
+ import type { DomEditSelection } from "./domEditing";
10
+ import type { OverlayRect } from "./domEditOverlayGeometry";
11
+
12
+ installReactActEnvironment();
13
+
14
+ function makeRef<T>(current: T): { current: T } {
15
+ return { current };
16
+ }
17
+
18
+ const REST_RECT: OverlayRect = {
19
+ left: 0,
20
+ top: 0,
21
+ width: 100,
22
+ height: 50,
23
+ editScaleX: 1,
24
+ editScaleY: 1,
25
+ };
26
+
27
+ // Stable across renders on purpose: the test targets the `selection` identity
28
+ // key specifically, so `groupSelections` must not itself be a source of churn.
29
+ const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
30
+
31
+ function Harness({
32
+ selection,
33
+ onPathOffsetCommit,
34
+ }: {
35
+ selection: DomEditSelection | null;
36
+ onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
37
+ }) {
38
+ useDomEditNudge({
39
+ selection,
40
+ groupSelections: EMPTY_GROUP_SELECTIONS,
41
+ allowCanvasMovement: true,
42
+ selectionRef: makeRef(selection),
43
+ overlayRectRef: makeRef(REST_RECT),
44
+ groupOverlayItemsRef: makeRef([]),
45
+ gestureRef: makeRef(null),
46
+ groupGestureRef: makeRef(null),
47
+ blockedMoveRef: makeRef(null),
48
+ onManualDragStartRef: makeRef(() => {}),
49
+ onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
50
+ onGroupPathOffsetCommitRef: makeRef(async () => {}),
51
+ });
52
+ return null;
53
+ }
54
+
55
+ function dispatchArrowRight(): void {
56
+ window.dispatchEvent(
57
+ new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
58
+ );
59
+ }
60
+
61
+ describe("useDomEditNudge — selection cleanup keyed on stable identity", () => {
62
+ beforeEach(() => {
63
+ vi.useFakeTimers();
64
+ __resetForTests();
65
+ });
66
+
67
+ afterEach(() => {
68
+ vi.useRealTimers();
69
+ });
70
+
71
+ it("keeps a nudge burst alive when the parent hands down a new selection object for the same element", () => {
72
+ const host = document.createElement("div");
73
+ document.body.append(host);
74
+ const root = createRoot(host);
75
+
76
+ const element = document.createElement("div");
77
+ element.id = "dot-a";
78
+ document.body.append(element);
79
+
80
+ const commit = vi.fn();
81
+ const firstSelection = makeSelection("Dot", element);
82
+
83
+ act(() => {
84
+ root.render(
85
+ React.createElement(Harness, { selection: firstSelection, onPathOffsetCommit: commit }),
86
+ );
87
+ });
88
+
89
+ act(() => {
90
+ dispatchArrowRight();
91
+ });
92
+ expect(commit).not.toHaveBeenCalled();
93
+
94
+ // Re-render with a BRAND NEW selection object describing the SAME element
95
+ // (same id) — exactly what an un-memoized parent does on every render.
96
+ // Before the fix, the cleanup effect was keyed on this object's identity
97
+ // and would flush the burst right here, one arrow-press early.
98
+ const secondSelection = makeSelection("Dot", element);
99
+ act(() => {
100
+ root.render(
101
+ React.createElement(Harness, { selection: secondSelection, onPathOffsetCommit: commit }),
102
+ );
103
+ });
104
+ expect(commit).not.toHaveBeenCalled();
105
+
106
+ act(() => {
107
+ dispatchArrowRight();
108
+ });
109
+ expect(commit).not.toHaveBeenCalled();
110
+
111
+ act(() => {
112
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
113
+ });
114
+
115
+ // One combined commit for both presses, not two separate (premature) ones.
116
+ expect(commit).toHaveBeenCalledTimes(1);
117
+ const [, next] = commit.mock.calls[0] as [DomEditSelection, { x: number; y: number }];
118
+ expect(next.x).toBeCloseTo(2 * CANVAS_NUDGE_STEP_PX);
119
+ expect(next.y).toBeCloseTo(0);
120
+
121
+ act(() => root.unmount());
122
+ host.remove();
123
+ element.remove();
124
+ });
125
+
126
+ it("still flushes the burst when the selection actually changes to a different element", () => {
127
+ const host = document.createElement("div");
128
+ document.body.append(host);
129
+ const root = createRoot(host);
130
+
131
+ const elementA = document.createElement("div");
132
+ elementA.id = "dot-a";
133
+ document.body.append(elementA);
134
+ const elementB = document.createElement("div");
135
+ elementB.id = "dot-b";
136
+ document.body.append(elementB);
137
+
138
+ const commit = vi.fn();
139
+ const selectionA = makeSelection("Dot A", elementA);
140
+ const selectionB = makeSelection("Dot B", elementB);
141
+
142
+ act(() => {
143
+ root.render(
144
+ React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
145
+ );
146
+ });
147
+
148
+ act(() => {
149
+ dispatchArrowRight();
150
+ });
151
+ expect(commit).not.toHaveBeenCalled();
152
+
153
+ // A genuine selection change (different id) must still flush immediately —
154
+ // only same-identity re-renders should be ignored.
155
+ act(() => {
156
+ root.render(
157
+ React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
158
+ );
159
+ });
160
+ expect(commit).toHaveBeenCalledTimes(1);
161
+ const [committedSelection, next] = commit.mock.calls[0] as [
162
+ DomEditSelection,
163
+ { x: number; y: number },
164
+ ];
165
+ expect(committedSelection.element).toBe(elementA);
166
+ expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
167
+
168
+ act(() => root.unmount());
169
+ host.remove();
170
+ elementA.remove();
171
+ elementB.remove();
172
+ });
173
+
174
+ it("flushes the burst when switching between two id-less siblings that share a selector", () => {
175
+ const host = document.createElement("div");
176
+ document.body.append(host);
177
+ const root = createRoot(host);
178
+
179
+ // Two id-less siblings with the SAME selector — distinguished only by
180
+ // selectorIndex. Under the old `id ?? selector ?? label` key they shared
181
+ // one identity, so selecting B mid-burst didn't flush A and the next arrow
182
+ // kept moving A. The key now folds in selectorIndex, so they're distinct.
183
+ const elementA = document.createElement("div");
184
+ document.body.append(elementA);
185
+ const elementB = document.createElement("div");
186
+ document.body.append(elementB);
187
+
188
+ const commit = vi.fn();
189
+ const base = (label: string, element: HTMLElement) => ({
190
+ ...makeSelection(label, element),
191
+ id: undefined,
192
+ selector: "div.row",
193
+ });
194
+ const selectionA: DomEditSelection = { ...base("Row", elementA), selectorIndex: 0 };
195
+ const selectionB: DomEditSelection = { ...base("Row", elementB), selectorIndex: 1 };
196
+
197
+ act(() => {
198
+ root.render(
199
+ React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
200
+ );
201
+ });
202
+
203
+ act(() => {
204
+ dispatchArrowRight();
205
+ });
206
+ expect(commit).not.toHaveBeenCalled();
207
+
208
+ act(() => {
209
+ root.render(
210
+ React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
211
+ );
212
+ });
213
+
214
+ // The sibling switch must flush A's pending burst exactly once, for A.
215
+ expect(commit).toHaveBeenCalledTimes(1);
216
+ const [committedSelection, next] = commit.mock.calls[0] as [
217
+ DomEditSelection,
218
+ { x: number; y: number },
219
+ ];
220
+ expect(committedSelection.element).toBe(elementA);
221
+ expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
222
+
223
+ act(() => root.unmount());
224
+ host.remove();
225
+ elementA.remove();
226
+ elementB.remove();
227
+ });
228
+ });
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Canvas arrow-key nudge for DomEditOverlay: arrows move the selected
3
+ * element(s) 1 composition px, Shift = 10. Each keypress previews through the
4
+ * same GSAP/CSS channel as a drag draft, and the burst commits ONCE through
5
+ * the same onPathOffsetCommit / onGroupPathOffsetCommit path a drag drop uses
6
+ * — so a nudge burst is exactly a tiny drag: one source patch, one undo entry.
7
+ */
8
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
9
+ import { useMountEffect } from "../../hooks/useMountEffect";
10
+ import { isEditableTarget } from "../../utils/timelineDiscovery";
11
+ import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
+ import type { DomEditSelection } from "./domEditing";
13
+ import {
14
+ type GroupOverlayItem,
15
+ type OverlayRect,
16
+ filterNestedDomEditGroupItems,
17
+ } from "./domEditOverlayGeometry";
18
+ import type {
19
+ BlockedMoveState,
20
+ DomEditGroupPathOffsetCommit,
21
+ GestureState,
22
+ GroupGestureState,
23
+ } from "./domEditOverlayGestures";
24
+ import {
25
+ applyManualOffsetNudgeCommit,
26
+ applyManualOffsetNudgeDraft,
27
+ createManualOffsetDragMember,
28
+ endManualOffsetDragMembers,
29
+ restoreManualOffsetDragMembers,
30
+ type ManualOffsetDragMember,
31
+ } from "./manualOffsetDrag";
32
+ import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
33
+ import {
34
+ CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
35
+ canCanvasNudgeTargets,
36
+ resolveCanvasNudgeDelta,
37
+ } from "./domEditNudge";
38
+
39
+ interface NudgeSession {
40
+ members: ManualOffsetDragMember[];
41
+ isGroup: boolean;
42
+ /** Accumulated delta of the burst, in composition px. */
43
+ accum: { x: number; y: number };
44
+ timer: ReturnType<typeof setTimeout> | null;
45
+ }
46
+
47
+ export interface UseDomEditNudgeParams {
48
+ selection: DomEditSelection | null;
49
+ groupSelections: DomEditSelection[];
50
+ allowCanvasMovement: boolean;
51
+ selectionRef: RefObject<DomEditSelection | null>;
52
+ overlayRectRef: RefObject<OverlayRect | null>;
53
+ groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
54
+ gestureRef: RefObject<GestureState | null>;
55
+ groupGestureRef: RefObject<GroupGestureState | null>;
56
+ blockedMoveRef: RefObject<BlockedMoveState | null>;
57
+ onManualDragStartRef: RefObject<(() => void) | undefined>;
58
+ onPathOffsetCommitRef: RefObject<
59
+ (
60
+ s: DomEditSelection,
61
+ n: { x: number; y: number },
62
+ m?: { altKey?: boolean },
63
+ ) => Promise<void> | void
64
+ >;
65
+ onGroupPathOffsetCommitRef: RefObject<
66
+ (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
67
+ >;
68
+ }
69
+
70
+ type NudgeTarget = {
71
+ key: string;
72
+ selection: DomEditSelection;
73
+ element: HTMLElement;
74
+ rect: OverlayRect;
75
+ };
76
+
77
+ /**
78
+ * A selection's stable identity, not its object reference. A parent that
79
+ * doesn't memoize the selection it passes down hands us a new object every
80
+ * render for the SAME element — keying an effect on the object itself would
81
+ * then re-fire on every render instead of on an actual selection change.
82
+ *
83
+ * All discriminators are folded in (id / hfId / selector / selectorIndex /
84
+ * label), not just the first present one: two id-less siblings can share a
85
+ * selector, so `id ?? selector ?? label` collapsed them to the same key —
86
+ * selecting sibling B mid-burst then didn't flush A's pending nudge, so the
87
+ * next arrow kept moving A. selectorIndex is the discriminator that separates
88
+ * them. The parts are only ever compared for equality (never parsed back),
89
+ * so they're joined on a NUL delimiter — a byte no discriminator can contain,
90
+ * so distinct selections stay distinct even when a value (e.g. a label) holds
91
+ * a space. It's written as the escape "\u0000", not a literal 0x00 byte, so
92
+ * this source file stays text (a raw NUL makes git treat it as binary).
93
+ */
94
+ function selectionIdentityKey(selection: DomEditSelection | null): string {
95
+ if (!selection) return "";
96
+ return [
97
+ selection.id ?? "",
98
+ selection.hfId ?? "",
99
+ selection.selector ?? "",
100
+ selection.selectorIndex ?? "",
101
+ selection.label,
102
+ ].join("\u0000");
103
+ }
104
+
105
+ function groupSelectionsIdentityKey(selections: DomEditSelection[]): string {
106
+ return selections.map(selectionIdentityKey).join(" ");
107
+ }
108
+
109
+ /** Drag members for a multi-selection nudge (same snapshot a group drag uses). */
110
+ function resolveGroupNudgeTargets(groupItems: GroupOverlayItem[]): NudgeTarget[] | null {
111
+ if (!canCanvasNudgeTargets(groupItems.map((item) => item.selection))) return null;
112
+ return filterNestedDomEditGroupItems(groupItems);
113
+ }
114
+
115
+ /** Drag member for a single-selection nudge, or null when it can't be moved. */
116
+ function resolveSingleNudgeTarget(
117
+ sel: DomEditSelection | null,
118
+ rect: OverlayRect | null,
119
+ ): NudgeTarget[] | null {
120
+ if (!sel || !rect || !sel.capabilities.canApplyManualOffset || !sel.element.isConnected) {
121
+ return null;
122
+ }
123
+ return [{ key: sel.id ?? sel.selector ?? sel.label, selection: sel, element: sel.element, rect }];
124
+ }
125
+
126
+ /**
127
+ * True when a keydown must not start/extend a nudge: canvas movement disabled,
128
+ * a pointer gesture already owns the element, or the user is typing in a field.
129
+ */
130
+ function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
131
+ if (!p.allowCanvasMovement || event.defaultPrevented) return true;
132
+ if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
133
+ return isEditableTarget(event.target);
134
+ }
135
+
136
+ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {
137
+ const sessionRef = useRef<NudgeSession | null>(null);
138
+ const paramsRef = useRef(params);
139
+ paramsRef.current = params;
140
+
141
+ // Commit the pending burst: one source write per burst = one undo entry.
142
+ // Mirrors the drag's onPointerUp — same commit callbacks, same failure
143
+ // restore, same member teardown.
144
+ const commitSession = () => {
145
+ const session = sessionRef.current;
146
+ if (!session) return;
147
+ sessionRef.current = null;
148
+ if (session.timer) clearTimeout(session.timer);
149
+ const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
150
+ selection: member.selection,
151
+ next: applyManualOffsetNudgeCommit(member, session.accum),
152
+ }));
153
+ const p = paramsRef.current;
154
+ const commit = session.isGroup
155
+ ? p.onGroupPathOffsetCommitRef.current(updates)
156
+ : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next);
157
+ void Promise.resolve(commit)
158
+ .catch(() => {
159
+ for (const member of session.members) {
160
+ if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
161
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
162
+ }
163
+ }
164
+ })
165
+ .finally(() => endManualOffsetDragMembers(session.members));
166
+ };
167
+ const commitSessionRef = useRef(commitSession);
168
+ commitSessionRef.current = commitSession;
169
+
170
+ // Build drag members for the current target set — the same member snapshot a
171
+ // pointer drag starts from (startGesture / startGroupDrag), so the nudge
172
+ // commit converts offsets → GSAP x/y with identical math.
173
+ const beginSession = (): NudgeSession | null => {
174
+ const p = paramsRef.current;
175
+ const groupItems = p.groupOverlayItemsRef.current;
176
+ const isGroup = groupItems.length > 1;
177
+ const targets = isGroup
178
+ ? resolveGroupNudgeTargets(groupItems)
179
+ : resolveSingleNudgeTarget(p.selectionRef.current, p.overlayRectRef.current);
180
+ if (!targets) return null;
181
+ const members: ManualOffsetDragMember[] = [];
182
+ for (const target of targets) {
183
+ const result = createManualOffsetDragMember(target);
184
+ if (!result.ok) {
185
+ restoreManualOffsetDragMembers(members);
186
+ return null;
187
+ }
188
+ members.push(result.member);
189
+ }
190
+ if (members.length === 0) return null;
191
+ // Same side effect a drag start has (pauses preview playback).
192
+ p.onManualDragStartRef.current?.();
193
+ return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
194
+ };
195
+
196
+ const handleKeyDown = (event: KeyboardEvent) => {
197
+ const p = paramsRef.current;
198
+ if (shouldIgnoreNudgeKey(p, event)) return;
199
+ const delta = resolveCanvasNudgeDelta(event);
200
+ if (!delta) return;
201
+ const session = sessionRef.current ?? beginSession();
202
+ if (!session) return;
203
+ sessionRef.current = session;
204
+ event.preventDefault();
205
+ session.accum = { x: session.accum.x + delta.dx, y: session.accum.y + delta.dy };
206
+ for (const member of session.members) applyManualOffsetNudgeDraft(member, session.accum);
207
+ if (session.timer) clearTimeout(session.timer);
208
+ session.timer = setTimeout(() => commitSessionRef.current(), CANVAS_NUDGE_COMMIT_DEBOUNCE_MS);
209
+ };
210
+ const handleKeyDownRef = useRef(handleKeyDown);
211
+ handleKeyDownRef.current = handleKeyDown;
212
+
213
+ useMountEffect(() => {
214
+ const listener = (event: KeyboardEvent) => handleKeyDownRef.current(event);
215
+ // Capture, like the other app-level key handlers, so a focused panel
216
+ // can't swallow the nudge before it reaches us.
217
+ window.addEventListener("keydown", listener, true);
218
+ return () => {
219
+ window.removeEventListener("keydown", listener, true);
220
+ commitSessionRef.current();
221
+ };
222
+ });
223
+
224
+ // Selection change ends the burst: commit to the OLD target before the
225
+ // arrows start moving the new one. Keyed on the selection's stable identity
226
+ // (id/selector/label), NOT the object reference — a parent that re-creates
227
+ // the selection object on every render (without memoizing it) must not
228
+ // flush a burst that's still in progress for the same element.
229
+ const selectionKey = selectionIdentityKey(params.selection);
230
+ const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
231
+ // eslint-disable-next-line no-restricted-syntax
232
+ useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
233
+
234
+ // Claim the arrow keys from the playback frame-step while the selection is
235
+ // nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
236
+ // preventDefault alone can't arbitrate).
237
+ // eslint-disable-next-line no-restricted-syntax
238
+ useEffect(() => {
239
+ const targets =
240
+ params.groupSelections.length > 1
241
+ ? params.groupSelections
242
+ : params.selection
243
+ ? [params.selection]
244
+ : [];
245
+ if (!params.allowCanvasMovement || !canCanvasNudgeTargets(targets)) return;
246
+ return acquireCanvasNudgeKeys();
247
+ }, [params.selection, params.groupSelections, params.allowCanvasMovement]);
248
+
249
+ // A pointer gesture supersedes a pending burst — DomEditOverlay flushes on
250
+ // pointerdown-capture so the drag's member snapshot starts from the nudged,
251
+ // committed position.
252
+ const flushNudge = useCallback(() => commitSessionRef.current(), []);
253
+ return { flushNudge };
254
+ }