@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
@@ -0,0 +1,257 @@
1
+ import type { RefObject } from "react";
2
+ import { type DomEditSelection } from "./domEditing";
3
+ import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
4
+ import type { BlockedMoveState, ResizeHandle } from "./domEditOverlayGestures";
5
+ import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
+ import { DomEditCropHandles } from "./DomEditCropHandles";
7
+ import { DomEditRotateHandle } from "./DomEditRotateHandle";
8
+ import { resolveRotatedResizeCursor } from "./domEditResizeLocal";
9
+
10
+ // Corner resize handles, Canva-style: one per corner, diagonal cursors.
11
+ // Corners scale about the element center; the translate keeps the center
12
+ // planted, so they need the manual-offset capability in addition to manual-size.
13
+ const RESIZE_HANDLE_DEFS: Array<{
14
+ handle: ResizeHandle;
15
+ cursor: string;
16
+ x: "left" | "right";
17
+ y: "top" | "bottom";
18
+ }> = [
19
+ { handle: "nw", cursor: "nwse-resize", x: "left", y: "top" },
20
+ { handle: "ne", cursor: "nesw-resize", x: "right", y: "top" },
21
+ { handle: "sw", cursor: "nesw-resize", x: "left", y: "bottom" },
22
+ { handle: "se", cursor: "nwse-resize", x: "right", y: "bottom" },
23
+ ];
24
+
25
+ // Visible dot is 9px; the pointer target is a 16px invisible square centered
26
+ // on the corner so click targets don't shrink with the smaller dot.
27
+ const RESIZE_HANDLE_HIT_PX = 16;
28
+
29
+ type CropInset = { top: number; right: number; bottom: number; left: number };
30
+ const NO_CROP_INSET: CropInset = { top: 0, right: 0, bottom: 0, left: 0 };
31
+
32
+ function resizeHandleStyle(
33
+ def: (typeof RESIZE_HANDLE_DEFS)[number],
34
+ overlayRect: { left: number; top: number; width: number; height: number },
35
+ cropInset?: CropInset,
36
+ ): React.CSSProperties {
37
+ const half = RESIZE_HANDLE_HIT_PX / 2;
38
+ const inset = cropInset ?? NO_CROP_INSET;
39
+ const style: React.CSSProperties = { cursor: def.cursor, touchAction: "none" };
40
+ // Position relative to the overlay container (not the selection box).
41
+ // This ensures the dots render as siblings of the box border div — strictly
42
+ // above it — rather than as children where the parent border can visually
43
+ // overlap the dot circle at the corner.
44
+ style.left =
45
+ def.x === "left"
46
+ ? overlayRect.left + inset.left - half
47
+ : overlayRect.left + overlayRect.width - inset.right - half;
48
+ style.top =
49
+ def.y === "top"
50
+ ? overlayRect.top + inset.top - half
51
+ : overlayRect.top + overlayRect.height - inset.bottom - half;
52
+ return style;
53
+ }
54
+
55
+ type GestureHandlers = ReturnType<typeof createDomEditOverlayGestureHandlers>;
56
+
57
+ interface DomEditGroupChromeProps {
58
+ groupOverlayItems: GroupOverlayItem[];
59
+ groupBounds: OverlayRect;
60
+ allowCanvasMovement: boolean;
61
+ groupCanMove: boolean;
62
+ gestures: GestureHandlers;
63
+ onBoxMouseDown: (e: React.MouseEvent) => void;
64
+ onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
65
+ }
66
+
67
+ // Multi-selection chrome: per-member outlines plus a single draggable bounding
68
+ // box spanning the union of the members.
69
+ export function DomEditGroupChrome({
70
+ groupOverlayItems,
71
+ groupBounds,
72
+ allowCanvasMovement,
73
+ groupCanMove,
74
+ gestures,
75
+ onBoxMouseDown,
76
+ onBoxClick,
77
+ }: DomEditGroupChromeProps) {
78
+ return (
79
+ <>
80
+ {groupOverlayItems.map((item) => (
81
+ <div
82
+ key={item.key}
83
+ aria-hidden="true"
84
+ className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
85
+ style={{
86
+ left: item.rect.left,
87
+ top: item.rect.top,
88
+ width: item.rect.width,
89
+ height: item.rect.height,
90
+ }}
91
+ />
92
+ ))}
93
+ <div
94
+ data-dom-edit-selection-box="true"
95
+ className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
96
+ style={{
97
+ left: groupBounds.left,
98
+ top: groupBounds.top,
99
+ width: groupBounds.width,
100
+ height: groupBounds.height,
101
+ cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
102
+ }}
103
+ onPointerDown={(e) => {
104
+ if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
105
+ gestures.startGroupDrag(e);
106
+ }}
107
+ onMouseDown={onBoxMouseDown}
108
+ onClick={onBoxClick}
109
+ />
110
+ </>
111
+ );
112
+ }
113
+
114
+ interface DomEditSelectionChromeProps {
115
+ selection: DomEditSelection;
116
+ overlayRect: OverlayRect;
117
+ allowCanvasMovement: boolean;
118
+ cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
119
+ boxRef: RefObject<HTMLDivElement | null>;
120
+ boxChromeClass: string;
121
+ boxClipPath: string | undefined;
122
+ selectionKey: string;
123
+ groupSelectionCount: number;
124
+ blockedMoveRef: RefObject<BlockedMoveState | null>;
125
+ gestures: GestureHandlers;
126
+ onStyleCommit?: (property: string, value: string) => Promise<void> | void;
127
+ onBoxMouseDown: (e: React.MouseEvent) => void;
128
+ onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
129
+ }
130
+
131
+ // Oriented selection chrome: a rotation wrapper spanning the overlay, rotated by
132
+ // the element's live angle about the selection box CENTER. Its children (border
133
+ // box, corner dots, and rotate handle) keep their existing
134
+ // overlay-absolute positions — rotating the whole plane about the box center
135
+ // lands them on the element's real transformed corners for free. At angle 0 the
136
+ // transform is a no-op, so the chrome is pixel-identical.
137
+ export function DomEditSelectionChrome({
138
+ selection,
139
+ overlayRect,
140
+ allowCanvasMovement,
141
+ cropOutlineInsetPx,
142
+ boxRef,
143
+ boxChromeClass,
144
+ boxClipPath,
145
+ selectionKey,
146
+ groupSelectionCount,
147
+ blockedMoveRef,
148
+ gestures,
149
+ onStyleCommit,
150
+ onBoxMouseDown,
151
+ onBoxClick,
152
+ }: DomEditSelectionChromeProps) {
153
+ return (
154
+ <>
155
+ <div
156
+ className="pointer-events-none absolute inset-0"
157
+ style={{
158
+ transformOrigin: `${overlayRect.left + overlayRect.width / 2}px ${overlayRect.top + overlayRect.height / 2}px`,
159
+ transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
160
+ }}
161
+ >
162
+ {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
163
+ <DomEditRotateHandle
164
+ overlayRect={overlayRect}
165
+ cropOutlineInsetPx={cropOutlineInsetPx}
166
+ onStartRotate={(e) => {
167
+ e.stopPropagation();
168
+ gestures.startGesture("rotate", e);
169
+ }}
170
+ />
171
+ )}
172
+ <div
173
+ key={selectionKey}
174
+ ref={boxRef}
175
+ data-dom-edit-selection-box="true"
176
+ className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
177
+ style={{
178
+ left: overlayRect.left,
179
+ top: overlayRect.top,
180
+ width: overlayRect.width,
181
+ height: overlayRect.height,
182
+ clipPath: boxClipPath,
183
+ cursor:
184
+ allowCanvasMovement && selection.capabilities.canApplyManualOffset
185
+ ? "move"
186
+ : "default",
187
+ }}
188
+ onPointerDown={(e) => {
189
+ if (!allowCanvasMovement || e.shiftKey) return;
190
+ if (selection.capabilities.canApplyManualOffset) {
191
+ gestures.startGesture("drag", e);
192
+ return;
193
+ }
194
+ e.preventDefault();
195
+ e.stopPropagation();
196
+ e.currentTarget.setPointerCapture(e.pointerId);
197
+ blockedMoveRef.current = {
198
+ pointerId: e.pointerId,
199
+ startX: e.clientX,
200
+ startY: e.clientY,
201
+ notified: false,
202
+ };
203
+ }}
204
+ onMouseDown={onBoxMouseDown}
205
+ onClick={onBoxClick}
206
+ >
207
+ {cropOutlineInsetPx && (
208
+ <div
209
+ className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
210
+ style={{
211
+ left: cropOutlineInsetPx.left,
212
+ top: cropOutlineInsetPx.top,
213
+ right: cropOutlineInsetPx.right,
214
+ bottom: cropOutlineInsetPx.bottom,
215
+ }}
216
+ />
217
+ )}
218
+ </div>
219
+ {/* Resize-handle dots rendered as siblings of the selection box, not
220
+ children, so they paint strictly above the box border. Each handle
221
+ is positioned relative to the overlay container using the
222
+ overlayRect origin, matching the old child-relative offsets. */}
223
+ {allowCanvasMovement &&
224
+ selection.capabilities.canApplyManualSize &&
225
+ RESIZE_HANDLE_DEFS.map((def) =>
226
+ def.handle !== "se" && !selection.capabilities.canApplyManualOffset ? null : (
227
+ <div
228
+ key={def.handle}
229
+ className="pointer-events-auto absolute flex h-4 w-4 items-center justify-center"
230
+ style={{
231
+ ...resizeHandleStyle(def, overlayRect, cropOutlineInsetPx ?? undefined),
232
+ // Cursor rotates with the object: bucket the corner's base
233
+ // diagonal + element rotation into the 8 CSS resize cursors.
234
+ cursor: resolveRotatedResizeCursor(def.handle, overlayRect.angle ?? 0),
235
+ }}
236
+ onPointerDown={(e) => {
237
+ e.stopPropagation();
238
+ gestures.startGesture("resize", e, { resizeHandle: def.handle });
239
+ }}
240
+ >
241
+ <div className="pointer-events-none h-[12px] w-[12px] rounded-full border-[1.5px] border-studio-accent bg-white shadow-[0_0_3px_rgba(0,0,0,0.45)]" />
242
+ </div>
243
+ ),
244
+ )}
245
+ </div>
246
+ {/* Crop owns its element-local oriented frame. Keep it outside the chrome's
247
+ rotated plane or a rotated selection applies the angle twice. */}
248
+ {selection.capabilities.canCrop && groupSelectionCount <= 1 && (
249
+ <DomEditCropHandles
250
+ selection={selection}
251
+ overlayRect={overlayRect}
252
+ onStyleCommit={onStyleCommit}
253
+ />
254
+ )}
255
+ </>
256
+ );
257
+ }
@@ -1,10 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { describe, expect, it } from "vitest";
3
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
4
4
  import { Window } from "happy-dom";
5
5
  import type { DomEditLayerItem } from "./domEditingTypes";
6
- import { sortLayersByZIndex } from "./LayersPanel";
6
+ import { createRafThrottle, sortLayersByZIndex } from "./LayersPanel";
7
7
  import { isLayerDraggable } from "./useLayerDrag";
8
+ import { liveTime } from "../../player";
8
9
 
9
10
  function makeLayer(
10
11
  overrides: Partial<DomEditLayerItem> & { zIndex?: string; locked?: boolean },
@@ -133,3 +134,66 @@ describe("isLayerDraggable", () => {
133
134
  expect(isLayerDraggable(layer)).toBe(true);
134
135
  });
135
136
  });
137
+
138
+ // ── liveTime throttle contract (mirrors the useEffect in LayersPanel) ──────
139
+ // The panel subscribes to liveTime with a rAF + 100 ms trailing throttle so
140
+ // it refreshes during scrubbing without a collectLayers call every frame.
141
+ // These tests exercise the subscribe/unsubscribe contract that the effect
142
+ // relies on.
143
+
144
+ describe("liveTime subscribe / unsubscribe (LayersPanel scrub contract)", () => {
145
+ let rafCallbacks: FrameRequestCallback[];
146
+ let originalRaf: typeof requestAnimationFrame;
147
+ let originalCancelRaf: typeof cancelAnimationFrame;
148
+
149
+ beforeEach(() => {
150
+ rafCallbacks = [];
151
+ originalRaf = globalThis.requestAnimationFrame;
152
+ originalCancelRaf = globalThis.cancelAnimationFrame;
153
+ let nextId = 1;
154
+ globalThis.requestAnimationFrame = (cb) => {
155
+ const id = nextId++;
156
+ rafCallbacks.push(cb);
157
+ return id;
158
+ };
159
+ globalThis.cancelAnimationFrame = vi.fn();
160
+ });
161
+
162
+ afterEach(() => {
163
+ globalThis.requestAnimationFrame = originalRaf;
164
+ globalThis.cancelAnimationFrame = originalCancelRaf;
165
+ });
166
+
167
+ it("unsubscribing stops the callback from receiving further notifications", () => {
168
+ const cb = vi.fn();
169
+ const unsubscribe = liveTime.subscribe(cb);
170
+
171
+ liveTime.notify(1);
172
+ expect(cb).toHaveBeenCalledTimes(1);
173
+
174
+ unsubscribe();
175
+ liveTime.notify(2);
176
+ expect(cb).toHaveBeenCalledTimes(1); // no new call after unsubscribe
177
+ });
178
+
179
+ it("queuing a rAF on liveTime notify then flushing calls the refresh exactly once", () => {
180
+ const refresh = vi.fn();
181
+ const throttle = createRafThrottle(refresh, 100);
182
+ const unsubscribe = liveTime.subscribe(throttle.invoke);
183
+
184
+ // First notify enqueues one rAF
185
+ liveTime.notify(0.1);
186
+ expect(rafCallbacks).toHaveLength(1);
187
+ expect(refresh).not.toHaveBeenCalled();
188
+
189
+ // Second notify before rAF flush is ignored (rafId is set)
190
+ liveTime.notify(0.2);
191
+ expect(rafCallbacks).toHaveLength(1);
192
+
193
+ // Flush the rAF
194
+ rafCallbacks[0](performance.now());
195
+ expect(refresh).toHaveBeenCalledTimes(1);
196
+
197
+ unsubscribe();
198
+ });
199
+ });
@@ -7,7 +7,7 @@ import {
7
7
  } from "./domEditing";
8
8
  import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
9
9
  import { useDomEditContext } from "../../contexts/DomEditContext";
10
- import { usePlayerStore } from "../../player";
10
+ import { usePlayerStore, liveTime } from "../../player";
11
11
  import {
12
12
  findMatchingTimelineElementId,
13
13
  resolveTimelineSelectionSeekTime,
@@ -49,6 +49,34 @@ function isCompositionHost(el: HTMLElement): boolean {
49
49
  return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
50
50
  }
51
51
 
52
+ /**
53
+ * A trailing-rAF + cooldown throttle: `invoke` runs `run` at most once per
54
+ * animation frame and no more often than `throttleMs`. `cancel` clears any
55
+ * pending frame (call on cleanup). Extracted so the throttle can be exercised
56
+ * directly in tests instead of being reconstructed there.
57
+ */
58
+ export function createRafThrottle(
59
+ run: () => void,
60
+ throttleMs = 100,
61
+ ): { invoke: () => void; cancel: () => void } {
62
+ let rafId: number | null = null;
63
+ let lastFired = 0;
64
+ return {
65
+ invoke: () => {
66
+ const now = performance.now();
67
+ if (rafId !== null || now - lastFired < throttleMs) return;
68
+ rafId = requestAnimationFrame(() => {
69
+ rafId = null;
70
+ lastFired = performance.now();
71
+ run();
72
+ });
73
+ },
74
+ cancel: () => {
75
+ if (rafId !== null) cancelAnimationFrame(rafId);
76
+ },
77
+ };
78
+ }
79
+
52
80
  interface CollapsedState {
53
81
  [key: string]: boolean;
54
82
  }
@@ -122,6 +150,20 @@ export const LayersPanel = memo(function LayersPanel() {
122
150
  }
123
151
  }, [compositionLoading, collectLayers]);
124
152
 
153
+ // Subscribe to liveTime so the panel refreshes during scrubbing.
154
+ // liveTime bypasses React state (no re-renders per frame), so a plain
155
+ // usePlayerStore(s => s.currentTime) subscription never fires while the
156
+ // RAF loop is running. Throttle with a trailing rAF + 100 ms cooldown to
157
+ // avoid a collectLayers call on every animation frame.
158
+ useEffect(() => {
159
+ const throttle = createRafThrottle(collectLayers, 100);
160
+ const unsubscribe = liveTime.subscribe(throttle.invoke);
161
+ return () => {
162
+ unsubscribe();
163
+ throttle.cancel();
164
+ };
165
+ }, [collectLayers]);
166
+
125
167
  const resolveSelection = useCallback(
126
168
  (layer: DomEditLayerItem) => {
127
169
  // Re-find the element from the live DOM — layer.element may be stale
@@ -0,0 +1,34 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { expect, it } from "vitest";
6
+ import { OffCanvasIndicators } from "./OffCanvasIndicators";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ it("rotates an off-canvas indicator with its element", () => {
11
+ const host = document.createElement("div");
12
+ document.body.append(host);
13
+ const root = createRoot(host);
14
+ act(() => {
15
+ root.render(
16
+ <OffCanvasIndicators
17
+ rects={[{ key: "card", left: 80, top: 20, width: 40, height: 20, angle: 30 }]}
18
+ elements={{ current: new Map() }}
19
+ compRect={{ left: 0, top: 0, width: 100, height: 100 }}
20
+ selection={null}
21
+ groupSelections={[]}
22
+ activeCompositionPathRef={{ current: null }}
23
+ onSelectionChangeRef={{ current: () => undefined }}
24
+ />,
25
+ );
26
+ });
27
+
28
+ const indicator = host.querySelector<HTMLElement>('[role="button"]');
29
+ expect(indicator?.parentElement?.style.transform).toBe("rotate(30deg)");
30
+ expect(indicator?.parentElement?.style.transformOrigin).toBe("center");
31
+
32
+ act(() => root.unmount());
33
+ host.remove();
34
+ });
@@ -7,6 +7,7 @@ export interface OffCanvasRect {
7
7
  top: number;
8
8
  width: number;
9
9
  height: number;
10
+ angle?: number;
10
11
  }
11
12
 
12
13
  interface OffCanvasIndicatorsProps {
@@ -21,6 +22,44 @@ interface OffCanvasIndicatorsProps {
21
22
  >;
22
23
  }
23
24
 
25
+ function clipOutsideCanvas(
26
+ rect: OffCanvasRect,
27
+ compRect: { left: number; top: number; width: number; height: number },
28
+ ): string | undefined {
29
+ const angle = rect.angle ?? 0;
30
+ if (!angle) {
31
+ const left = Math.max(0, compRect.left - rect.left);
32
+ const top = Math.max(0, compRect.top - rect.top);
33
+ const right = Math.min(rect.width, compRect.left + compRect.width - rect.left);
34
+ const bottom = Math.min(rect.height, compRect.top + compRect.height - rect.top);
35
+ if (left >= right || top >= bottom) return undefined;
36
+ return `polygon(evenodd, 0 0, ${rect.width}px 0, ${rect.width}px ${rect.height}px, 0 ${rect.height}px, 0 0, ${left}px ${top}px, ${right}px ${top}px, ${right}px ${bottom}px, ${left}px ${bottom}px, ${left}px ${top}px)`;
37
+ }
38
+
39
+ const radians = (-angle * Math.PI) / 180;
40
+ const cos = Math.cos(radians);
41
+ const sin = Math.sin(radians);
42
+ const centerX = rect.left + rect.width / 2;
43
+ const centerY = rect.top + rect.height / 2;
44
+ const toLocal = (x: number, y: number) => {
45
+ const dx = x - centerX;
46
+ const dy = y - centerY;
47
+ return `${rect.width / 2 + dx * cos - dy * sin}px ${rect.height / 2 + dx * sin + dy * cos}px`;
48
+ };
49
+ const left = compRect.left;
50
+ const top = compRect.top;
51
+ const right = left + compRect.width;
52
+ const bottom = top + compRect.height;
53
+ const canvas = [
54
+ toLocal(left, top),
55
+ toLocal(right, top),
56
+ toLocal(right, bottom),
57
+ toLocal(left, bottom),
58
+ toLocal(left, top),
59
+ ].join(", ");
60
+ return `polygon(evenodd, 0 0, ${rect.width}px 0, ${rect.width}px ${rect.height}px, 0 ${rect.height}px, 0 0, ${canvas})`;
61
+ }
62
+
24
63
  /**
25
64
  * Dashed teal indicators for elements whose bounds extend past the composition
26
65
  * (the "gray zone"). The in-canvas portion is clipped away so only the
@@ -49,15 +88,15 @@ export function OffCanvasIndicators({
49
88
  return !groupSelections.some((g) => g.element === el);
50
89
  })
51
90
  .map((r) => {
52
- const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
53
- const cL = Math.max(0, compRect.left - r.left);
54
- const cT = Math.max(0, compRect.top - r.top);
55
- const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
56
- const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
57
- const hasInside = cL < cR && cT < cB;
58
- const clipOutside = hasInside
59
- ? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
60
- : undefined;
91
+ const pos = {
92
+ left: r.left,
93
+ top: r.top,
94
+ width: r.width,
95
+ height: r.height,
96
+ transform: r.angle ? `rotate(${r.angle}deg)` : undefined,
97
+ transformOrigin: "center",
98
+ };
99
+ const clipOutside = clipOutsideCanvas(r, compRect);
61
100
  const selectOffCanvas = async () => {
62
101
  const el = elements.current.get(r.key);
63
102
  if (!el) return;
@@ -86,7 +125,7 @@ export function OffCanvasIndicators({
86
125
  role="button"
87
126
  tabIndex={0}
88
127
  aria-label={`Select off-canvas element ${r.key}`}
89
- className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
128
+ className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/10 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
90
129
  style={clipOutside ? { clipPath: clipOutside } : undefined}
91
130
  title={`Off-canvas: ${r.key} — click to select`}
92
131
  onClick={handleClick}
@@ -1,7 +1,6 @@
1
- // fallow-ignore-file unused-file
2
1
  import { memo, useRef, type RefObject } from "react";
3
2
  import { useMountEffect } from "../../hooks/useMountEffect";
4
- import type { SnapGuide, SpacingGuide } from "./snapEngine";
3
+ import { resolveGuideLineRect, type SnapGuide, type SpacingGuide } from "./snapEngine";
5
4
 
6
5
  export interface SnapGuidesState {
7
6
  guides: SnapGuide[];
@@ -17,22 +16,35 @@ const SPACING_BG = "rgba(255, 68, 204, 0.15)";
17
16
 
18
17
  interface SnapGuideOverlayProps {
19
18
  snapGuidesRef: RefObject<SnapGuidesState | null>;
20
- overlayWidth: number;
21
- overlayHeight: number;
19
+ /** Composition rect in overlay space — guide lines span exactly this rect. */
20
+ compositionLeft: number;
21
+ compositionTop: number;
22
+ compositionWidth: number;
23
+ compositionHeight: number;
22
24
  }
23
25
 
24
26
  export const SnapGuideOverlay = memo(function SnapGuideOverlay({
25
27
  snapGuidesRef,
26
- overlayWidth,
27
- overlayHeight,
28
+ compositionLeft,
29
+ compositionTop,
30
+ compositionWidth,
31
+ compositionHeight,
28
32
  }: SnapGuideOverlayProps) {
29
33
  const guideElsRef = useRef<(HTMLDivElement | null)[]>([]);
30
34
  const spacingElsRef = useRef<(HTMLDivElement | null)[]>([]);
31
35
  const spacingLabelElsRef = useRef<(HTMLSpanElement | null)[]>([]);
32
- const overlayWidthRef = useRef(overlayWidth);
33
- overlayWidthRef.current = overlayWidth;
34
- const overlayHeightRef = useRef(overlayHeight);
35
- overlayHeightRef.current = overlayHeight;
36
+ const compositionRectRef = useRef({
37
+ left: compositionLeft,
38
+ top: compositionTop,
39
+ width: compositionWidth,
40
+ height: compositionHeight,
41
+ });
42
+ compositionRectRef.current = {
43
+ left: compositionLeft,
44
+ top: compositionTop,
45
+ width: compositionWidth,
46
+ height: compositionHeight,
47
+ };
36
48
 
37
49
  useMountEffect(() => {
38
50
  let frame = 0;
@@ -44,8 +56,7 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
44
56
  const state = snapGuidesRef.current;
45
57
  const guides = state?.guides ?? [];
46
58
  const spacingGuides = state?.spacingGuides ?? [];
47
- const w = overlayWidthRef.current;
48
- const h = overlayHeightRef.current;
59
+ const composition = compositionRectRef.current;
49
60
 
50
61
  for (let i = 0; i < MAX_GUIDES; i++) {
51
62
  const el = guideElsRef.current[i];
@@ -58,17 +69,11 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
58
69
  }
59
70
 
60
71
  el.style.display = "";
61
- if (guide.axis === "x") {
62
- el.style.left = `${guide.position}px`;
63
- el.style.top = "0";
64
- el.style.width = "1px";
65
- el.style.height = `${h}px`;
66
- } else {
67
- el.style.left = "0";
68
- el.style.top = `${guide.position}px`;
69
- el.style.width = `${w}px`;
70
- el.style.height = "1px";
71
- }
72
+ const line = resolveGuideLineRect(guide, composition);
73
+ el.style.left = `${line.left}px`;
74
+ el.style.top = `${line.top}px`;
75
+ el.style.width = `${line.width}px`;
76
+ el.style.height = `${line.height}px`;
72
77
  }
73
78
 
74
79
  for (let i = 0; i < MAX_SPACING_GUIDES; i++) {
@@ -34,7 +34,6 @@ function AppHotkeyHarness() {
34
34
  const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
35
35
 
36
36
  useAppHotkeys({
37
- toggleTimelineVisibility: vi.fn(),
38
37
  handleTimelineElementDelete: vi.fn(),
39
38
  handleTimelineElementSplit: vi.fn(),
40
39
  handleDomEditElementDelete: vi.fn(),