@hyperframes/studio 0.8.114 → 0.8.116

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 (266) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
  4. package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
  7. package/dist/chunk-3F47EFCT.js.map +1 -0
  8. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  9. package/dist/chunk-5EKWJZLM.js.map +1 -0
  10. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
  12. package/dist/index.d.ts +146 -40
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +4021 -3591
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/captions/hooks/useCaptionSync.ts +10 -6
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  23. package/src/components/dock/Dock.tsx +5 -7
  24. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  25. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  27. package/src/components/editor/DomEditOverlay.tsx +5 -1
  28. package/src/components/editor/FileTreeNodes.tsx +13 -10
  29. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  30. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  31. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  32. package/src/components/editor/SnapToolbar.tsx +43 -5
  33. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  34. package/src/components/editor/audioFxTelemetry.ts +2 -3
  35. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  36. package/src/components/editor/cropResize.ts +4 -3
  37. package/src/components/editor/domEditingDom.ts +4 -0
  38. package/src/components/editor/domEditingLayers.ts +1 -5
  39. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  40. package/src/components/editor/domGroupMoveBy.ts +95 -0
  41. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  42. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  43. package/src/components/editor/manualEditingAvailability.ts +8 -0
  44. package/src/components/editor/manualEdits.ts +1 -0
  45. package/src/components/editor/manualEditsParsing.ts +7 -0
  46. package/src/components/editor/manualOffsetDrag.ts +5 -6
  47. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  48. package/src/components/editor/marqueeCommit.ts +24 -8
  49. package/src/components/editor/motionPathCommit.test.ts +29 -1
  50. package/src/components/editor/motionPathCommit.ts +21 -9
  51. package/src/components/editor/motionPathSelection.test.ts +6 -31
  52. package/src/components/editor/probeSourceElement.test.ts +45 -0
  53. package/src/components/editor/probeSourceElement.ts +41 -16
  54. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  55. package/src/components/editor/propertyPanelFont.tsx +3 -2
  56. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  57. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  58. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  59. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  60. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  61. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  62. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  63. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  64. package/src/components/editor/propertyPanelTypes.ts +6 -3
  65. package/src/components/editor/useColorGradingController.ts +2 -1
  66. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  67. package/src/components/editor/useDomEditNudge.ts +2 -0
  68. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  69. package/src/components/editor/useDomEditZOrder.ts +12 -2
  70. package/src/components/editor/useFxCarveNodes.ts +2 -1
  71. package/src/components/editor/useFxLevelling.ts +2 -1
  72. package/src/components/feedback/projectProvenance.ts +2 -1
  73. package/src/components/nle/NLEContext.test.ts +1 -0
  74. package/src/components/nle/NLEContext.tsx +2 -1
  75. package/src/components/nle/NLEPreview.tsx +12 -10
  76. package/src/components/nle/PreviewPoster.tsx +2 -1
  77. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  78. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  79. package/src/components/renders/useFfmpegStatus.ts +2 -1
  80. package/src/components/renders/useRenderQueue.ts +5 -4
  81. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  82. package/src/components/sidebar/AssetsTab.tsx +2 -1
  83. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  84. package/src/components/ui/menuStyle.test.ts +61 -0
  85. package/src/components/ui/menuStyle.ts +10 -0
  86. package/src/contexts/DomEditContext.tsx +5 -1
  87. package/src/contexts/TimelineEditContext.tsx +1 -0
  88. package/src/domEditExports.test.tsx +9 -1
  89. package/src/hooks/domEditCommitRunner.ts +1 -1
  90. package/src/hooks/domSelectionTestHarness.ts +23 -0
  91. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  92. package/src/hooks/domStyleCommit.ts +8 -1
  93. package/src/hooks/draggedGsapPosition.ts +10 -0
  94. package/src/hooks/elementOffsetStager.ts +5 -4
  95. package/src/hooks/gsapDragCommit.test.ts +75 -164
  96. package/src/hooks/gsapDragCommit.ts +4 -125
  97. package/src/hooks/gsapDragPositionCommit.ts +36 -384
  98. package/src/hooks/gsapEditOutcome.ts +22 -2
  99. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  100. package/src/hooks/gsapGestureOutcome.ts +64 -0
  101. package/src/hooks/gsapGroupPreflight.ts +34 -0
  102. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  103. package/src/hooks/gsapMutationClient.ts +4 -3
  104. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  105. package/src/hooks/gsapParsedTween.test.ts +62 -0
  106. package/src/hooks/gsapParsedTween.ts +243 -0
  107. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  108. package/src/hooks/gsapPositionDetection.ts +52 -33
  109. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  110. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  111. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  112. package/src/hooks/gsapResizeIntercept.test.ts +96 -11
  113. package/src/hooks/gsapResizeIntercept.ts +79 -168
  114. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  115. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  116. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  117. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  118. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  119. package/src/hooks/gsapScriptCommitTypes.ts +13 -0
  120. package/src/hooks/gsapShared.ts +7 -0
  121. package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
  122. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  123. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  124. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  125. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  126. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  127. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  128. package/src/hooks/keyframeCacheAstLoad.ts +8 -6
  129. package/src/hooks/newTweenTarget.test.ts +41 -76
  130. package/src/hooks/plainRotation.ts +3 -2
  131. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  132. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  133. package/src/hooks/refreshTimelineRowText.ts +29 -0
  134. package/src/hooks/timelineTimingSync.test.ts +1 -0
  135. package/src/hooks/timelineTimingSync.ts +2 -1
  136. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  137. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  138. package/src/hooks/useAskAgentModal.ts +2 -1
  139. package/src/hooks/useBlockCatalog.ts +2 -1
  140. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  141. package/src/hooks/useCaptionDetection.ts +2 -1
  142. package/src/hooks/useCompositionContentLoader.ts +2 -1
  143. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  144. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  145. package/src/hooks/useDomEditSession.test.tsx +30 -1
  146. package/src/hooks/useDomEditSession.ts +10 -0
  147. package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
  148. package/src/hooks/useDomEditTextCommits.ts +13 -3
  149. package/src/hooks/useDomEditWiring.ts +4 -2
  150. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  151. package/src/hooks/useDomGeometryCommit.ts +8 -4
  152. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  153. package/src/hooks/useDomGeometryCommits.ts +3 -2
  154. package/src/hooks/useDomSelection.test.ts +26 -0
  155. package/src/hooks/useDomSelection.ts +10 -3
  156. package/src/hooks/useDomSelectionTypes.ts +6 -1
  157. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  158. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  159. package/src/hooks/useEditHistoryActions.ts +16 -5
  160. package/src/hooks/useElementLifecycleOps.ts +2 -1
  161. package/src/hooks/useEnableKeyframes.test.ts +34 -3
  162. package/src/hooks/useEnableKeyframes.ts +7 -4
  163. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
  164. package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
  165. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  166. package/src/hooks/useFileManager.ts +14 -10
  167. package/src/hooks/useFileTree.ts +3 -2
  168. package/src/hooks/useFrameCapture.ts +5 -1
  169. package/src/hooks/useFreezeFrame.ts +3 -2
  170. package/src/hooks/useGestureCommit.test.tsx +225 -55
  171. package/src/hooks/useGestureCommit.ts +86 -55
  172. package/src/hooks/useGroupCommits.ts +2 -1
  173. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  174. package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
  175. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  176. package/src/hooks/useGsapAwareEditing.ts +101 -195
  177. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  178. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  179. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  180. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  181. package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
  182. package/src/hooks/useGsapScriptCommits.ts +15 -10
  183. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  184. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  185. package/src/hooks/useGsapTweenCache.ts +18 -12
  186. package/src/hooks/useLintModal.ts +2 -1
  187. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  188. package/src/hooks/usePersistentEditHistory.ts +47 -8
  189. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  190. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  191. package/src/hooks/usePreviewInteraction.ts +2 -0
  192. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  193. package/src/hooks/usePreviewPersistence.ts +13 -11
  194. package/src/hooks/useProjectFileWriter.ts +20 -19
  195. package/src/hooks/useRemoveBackground.ts +2 -1
  196. package/src/hooks/useRenderClipContent.test.ts +18 -0
  197. package/src/hooks/useRenderClipContent.ts +4 -5
  198. package/src/hooks/useSdkSession.ts +2 -1
  199. package/src/hooks/useServerConnection.ts +4 -3
  200. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  201. package/src/hooks/useThumbnailStill.test.ts +100 -0
  202. package/src/hooks/useThumbnailStill.ts +37 -0
  203. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  204. package/src/hooks/useTimelineDeleteOps.ts +22 -27
  205. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  206. package/src/index.ts +4 -0
  207. package/src/player/components/AudioWaveform.tsx +3 -2
  208. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  209. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  210. package/src/player/components/ClipContextMenu.tsx +33 -31
  211. package/src/player/components/CompositionThumbnail.tsx +2 -1
  212. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  213. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  214. package/src/player/components/TextClipContent.test.ts +19 -0
  215. package/src/player/components/TextClipContent.tsx +27 -0
  216. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  217. package/src/player/components/clipMenuNormalize.ts +2 -1
  218. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  219. package/src/player/components/clipMenuToolItems.tsx +13 -9
  220. package/src/player/components/clipPeakMap.ts +2 -1
  221. package/src/player/components/timelineCallbacks.ts +1 -0
  222. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  223. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  224. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  225. package/src/player/components/timelineEditing.ts +3 -1
  226. package/src/player/components/timelineLinkSelection.ts +35 -1
  227. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  228. package/src/player/components/useTimelineClipDrag.ts +6 -7
  229. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  230. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  231. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  232. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  233. package/src/player/lib/thumbnailScheduler.ts +10 -2
  234. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  235. package/src/player/lib/timelineElementHelpers.ts +6 -15
  236. package/src/player/lib/timelineText.ts +40 -0
  237. package/src/player/sceneSwap.ts +2 -1
  238. package/src/player/store/timelineElement.ts +12 -0
  239. package/src/styles/theme.css +3 -0
  240. package/src/telemetry/client.ts +2 -1
  241. package/src/utils/blockInstaller.ts +2 -1
  242. package/src/utils/elementGsap.test.ts +9 -0
  243. package/src/utils/elementGsap.ts +7 -0
  244. package/src/utils/gsapSoftReload.ts +2 -3
  245. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  246. package/src/utils/gsapUndoRestore.ts +3 -6
  247. package/src/utils/keyframeUsage.ts +79 -0
  248. package/src/utils/previewFeatureUsage.test.ts +32 -0
  249. package/src/utils/previewFeatureUsage.ts +46 -0
  250. package/src/utils/previewPrefetch.ts +2 -1
  251. package/src/utils/razorSplitTransaction.ts +12 -8
  252. package/src/utils/softReloadTargets.ts +1 -3
  253. package/src/utils/studioApiFetch.test.ts +75 -0
  254. package/src/utils/studioApiFetch.ts +13 -0
  255. package/src/utils/studioFileHistory.ts +4 -1
  256. package/src/utils/studioHelpers.ts +7 -1
  257. package/src/utils/studioPendingEdits.test.ts +23 -6
  258. package/src/utils/studioPendingEdits.ts +32 -8
  259. package/src/utils/studioTelemetry.test.ts +107 -0
  260. package/src/utils/studioTelemetry.ts +51 -5
  261. package/src/utils/timelineCompositionInsert.ts +3 -2
  262. package/src/webmcp/StudioAgentTools.tsx +4 -3
  263. package/dist/chunk-BIUXP3PG.js.map +0 -1
  264. package/dist/chunk-K47E2ZJA.js.map +0 -1
  265. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  266. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import { ClipContextMenu } from "./ClipContextMenu";
7
7
  import type { TimelineClipMenuItem } from "./TimelineTypes";
8
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -117,6 +118,67 @@ describe("ClipContextMenu host items", () => {
117
118
  });
118
119
  });
119
120
 
121
+ describe("ClipContextMenu tools", () => {
122
+ const video = {
123
+ id: "talk",
124
+ domId: "talk",
125
+ tag: "video",
126
+ start: 0,
127
+ duration: 4,
128
+ track: 0,
129
+ hasAudio: true,
130
+ } as unknown as TimelineElement;
131
+
132
+ function menuWith(clipMenuTools: boolean | undefined) {
133
+ const host = document.createElement("div");
134
+ document.body.appendChild(host);
135
+ const root = createRoot(host);
136
+ act(() =>
137
+ root.render(
138
+ <TimelineEditProvider
139
+ value={{
140
+ onFreezeFrame: vi.fn(),
141
+ onSetElementAttributeQuiet: vi.fn(),
142
+ onLinkEdit: vi.fn(),
143
+ ...(clipMenuTools === undefined ? {} : { clipMenuTools }),
144
+ }}
145
+ >
146
+ <ClipContextMenu
147
+ x={10}
148
+ y={10}
149
+ element={video}
150
+ currentTime={1}
151
+ onClose={vi.fn()}
152
+ onSplit={vi.fn()}
153
+ onDelete={vi.fn()}
154
+ />
155
+ </TimelineEditProvider>,
156
+ ),
157
+ );
158
+ const labels = () =>
159
+ Array.from(document.body.querySelectorAll("[role=menuitem]")).map((item) => item.textContent);
160
+ return { labels, unmount: () => act(() => root.unmount()) };
161
+ }
162
+
163
+ it("offers Freeze frame and the attribute tools by default", () => {
164
+ const { labels, unmount } = menuWith(undefined);
165
+ expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(true);
166
+ expect(labels().some((label) => label?.startsWith("Look"))).toBe(true);
167
+ expect(labels().some((label) => label?.startsWith("Voice"))).toBe(true);
168
+ unmount();
169
+ });
170
+
171
+ it("hides every tool when the host turns them off, keeping Split and Delete", () => {
172
+ const { labels, unmount } = menuWith(false);
173
+ expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(false);
174
+ expect(labels().some((label) => label?.startsWith("Look"))).toBe(false);
175
+ expect(labels().some((label) => label?.startsWith("Voice"))).toBe(false);
176
+ expect(labels().some((label) => label?.startsWith("Split"))).toBe(true);
177
+ expect(labels().some((label) => label?.startsWith("Delete"))).toBe(true);
178
+ unmount();
179
+ });
180
+ });
181
+
120
182
  /** A clip button that opens the real menu, whose Ask moves the focus the way `focus` says. */
121
183
  function FocusHost({ focus }: { focus: "existing" | "mounted" }) {
122
184
  const [open, setOpen] = useState(false);
@@ -186,3 +248,14 @@ describe("ClipContextMenu focus on close", () => {
186
248
  unmount();
187
249
  });
188
250
  });
251
+
252
+ describe("ClipContextMenu row fill", () => {
253
+ it("leaves no padding or margin between its rows and the panel edge or a divider", () => {
254
+ renderMenu([{ id: "ask", label: "Ask", onSelect: vi.fn() }]);
255
+ const menu = document.body.querySelector<HTMLElement>("[role=menu]")!;
256
+ const spacing = /(^| )(p|py|pt|pb|m|my|mt|mb)-\d/;
257
+ expect(menu.className).not.toMatch(spacing);
258
+ for (const group of menu.querySelectorAll<HTMLElement>("[role=group]"))
259
+ expect(group.className).not.toMatch(spacing);
260
+ });
261
+ });
@@ -8,10 +8,10 @@ import type { TimelineClipMenuItem } from "./TimelineTypes";
8
8
  import { ClipMenuToolItems } from "./clipMenuToolItems";
9
9
  import { ClipMenuAudioItems } from "./clipMenuAudioItems";
10
10
  import { ClipMenuLinkItems } from "./clipMenuLinkItems";
11
+ import { menuClasses } from "../../components/ui/menuStyle";
12
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
11
13
 
12
14
  const MENU_MARGIN = 8;
13
- // Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
14
- const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
15
15
 
16
16
  function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
17
17
  const [height, setHeight] = useState(0);
@@ -42,13 +42,8 @@ interface ClipContextMenuProps {
42
42
  hostItems?: readonly TimelineClipMenuItem[] | undefined;
43
43
  }
44
44
 
45
- // Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
46
45
  const itemClass = (enabled: boolean) =>
47
- `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
48
- enabled
49
- ? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
50
- : " text-neutral-600 cursor-not-allowed"
51
- }`;
46
+ `${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
52
47
 
53
48
  /** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
54
49
  function HostItems({
@@ -103,6 +98,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
103
98
  }: ClipContextMenuProps) {
104
99
  const menuRef = useContextMenuDismiss(onClose);
105
100
  useMenuKeyboardNav(menuRef);
101
+ const tools = useTimelineEditContextOptional().clipMenuTools !== false;
106
102
  // The right-clicked clip's own id: a member of the live multi-selection
107
103
  // means Copy/Duplicate act on the whole group, matching onContextMenuClip's
108
104
  // selection-preserving behaviour for a right-click inside it.
@@ -134,15 +130,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
134
130
  ref={menuRef}
135
131
  role="menu"
136
132
  aria-label="Clip actions"
137
- className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
133
+ className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
138
134
  style={{ left: adjustedX, top: adjustedY }}
139
135
  >
140
136
  {hostItems.length > 0 && (
141
- <div role="group" aria-label="Host" className={GROUP_CLASS}>
137
+ <div role="group" aria-label="Host" className={menuClasses.group}>
142
138
  <HostItems items={hostItems} onClose={onClose} />
143
139
  </div>
144
140
  )}
145
- <div role="group" aria-label="Time" className={GROUP_CLASS}>
141
+ <div role="group" aria-label="Time" className={menuClasses.group}>
146
142
  {splitLabel && (
147
143
  <button
148
144
  type="button"
@@ -164,7 +160,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
164
160
  <span className="text-neutral-500 text-[10px] ml-3">S</span>
165
161
  </button>
166
162
  )}
167
- {splitLabel && (
163
+ {tools && splitLabel && (
168
164
  <ClipMenuToolItems
169
165
  group="time"
170
166
  element={element}
@@ -174,28 +170,34 @@ export const ClipContextMenu = memo(function ClipContextMenu({
174
170
  )}
175
171
  </div>
176
172
 
177
- <div role="group" aria-label="Sound" className={GROUP_CLASS}>
178
- <ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
179
- <ClipMenuToolItems
180
- group="sound"
181
- element={element}
182
- currentTime={currentTime}
183
- onClose={onClose}
184
- />
185
- <ClipMenuLinkItems part="link" element={element} onClose={onClose} />
186
- <ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
173
+ <div role="group" aria-label="Sound" className={menuClasses.group}>
174
+ {tools && (
175
+ <>
176
+ <ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
177
+ <ClipMenuToolItems
178
+ group="sound"
179
+ element={element}
180
+ currentTime={currentTime}
181
+ onClose={onClose}
182
+ />
183
+ <ClipMenuLinkItems part="link" element={element} onClose={onClose} />
184
+ <ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
185
+ </>
186
+ )}
187
187
  </div>
188
188
 
189
- <div role="group" aria-label="Picture" className={GROUP_CLASS}>
190
- <ClipMenuToolItems
191
- group="picture"
192
- element={element}
193
- currentTime={currentTime}
194
- onClose={onClose}
195
- />
189
+ <div role="group" aria-label="Picture" className={menuClasses.group}>
190
+ {tools && (
191
+ <ClipMenuToolItems
192
+ group="picture"
193
+ element={element}
194
+ currentTime={currentTime}
195
+ onClose={onClose}
196
+ />
197
+ )}
196
198
  </div>
197
199
 
198
- <div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
200
+ <div role="group" aria-label="Clipboard" className={menuClasses.group}>
199
201
  {onCopy && (
200
202
  <button
201
203
  type="button"
@@ -255,7 +257,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
255
257
  <span>Delete</span>
256
258
  <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
257
259
  </button>
258
- <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
260
+ {tools && <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />}
259
261
  </div>
260
262
  </div>,
261
263
  document.body,
@@ -8,6 +8,7 @@ import {
8
8
  } from "../lib/thumbnailScheduler";
9
9
  import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
10
10
  import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
11
+ import { studioApiFetch } from "../../utils/studioApiFetch";
11
12
 
12
13
  interface CompositionThumbnailProps {
13
14
  previewUrl: string;
@@ -106,7 +107,7 @@ export function compositionThumbnailRequest(
106
107
  }
107
108
 
108
109
  async function loadCompositionImage(url: string, signal: AbortSignal) {
109
- const response = await fetch(url, { signal });
110
+ const response = await studioApiFetch(url, { signal });
110
111
  if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`);
111
112
  const blob = await response.blob();
112
113
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -4,6 +4,7 @@ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
4
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
7
+ import { menuClasses } from "../../components/ui/menuStyle";
7
8
 
8
9
  export interface KeyframeDiamondContextMenuState {
9
10
  x: number;
@@ -39,10 +40,8 @@ interface KeyframeDiamondContextMenuProps {
39
40
  onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
40
41
  }
41
42
 
42
- const ITEM_CLS =
43
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
44
- const DESTRUCTIVE_ITEM_CLS =
45
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
43
+ const ITEM_CLS = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`;
44
+ const DESTRUCTIVE_ITEM_CLS = `${menuClasses.row} text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer flex items-center gap-2`;
46
45
 
47
46
  export function KeyframeDiamondContextMenu({
48
47
  state,
@@ -96,7 +95,7 @@ export function KeyframeDiamondContextMenu({
96
95
  ref={menuRef}
97
96
  role="menu"
98
97
  aria-label="Keyframe actions"
99
- className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
98
+ className={`${menuClasses.panel} fixed z-200 min-w-[180px] overflow-y-auto`}
100
99
  style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
101
100
  >
102
101
  {onMoveToPlayhead && (
@@ -165,14 +164,14 @@ export function KeyframeDiamondContextMenu({
165
164
 
166
165
  {/* Deleting every keyframe sat adjacent to the single delete and styled
167
166
  identically. Separate and mark it so the two cannot be misread. */}
168
- <div className="my-1 border-t border-neutral-700/60" role="separator" />
169
-
170
- <div className="my-1 border-t border-neutral-700/60" role="separator" />
167
+ <div className={menuClasses.divider} role="separator" />
168
+ <div className="h-1" aria-hidden="true" />
169
+ <div className={menuClasses.divider} role="separator" />
171
170
 
172
171
  <button
173
172
  type="button"
174
173
  role="menuitem"
175
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 outline-hidden cursor-pointer text-left"
174
+ className={`${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2`}
176
175
  onClick={() => {
177
176
  onDeleteAll(state.element, state.animationId);
178
177
  onClose();
@@ -7,11 +7,11 @@ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
7
7
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
8
  import { clipSyncState, type ClipSyncState } from "./clipSync";
9
9
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
10
+ import { menuClasses } from "../../components/ui/menuStyle";
10
11
 
11
12
  const stop = (e: SyntheticEvent) => e.stopPropagation();
12
13
 
13
- const MENU_ROW =
14
- "block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
14
+ const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} block disabled:cursor-not-allowed disabled:opacity-40`;
15
15
 
16
16
  function SyncMenu({
17
17
  x,
@@ -39,13 +39,13 @@ function SyncMenu({
39
39
  ref={menuRef}
40
40
  role="menu"
41
41
  aria-label="Out of sync"
42
- className="fixed z-200 min-w-[170px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
42
+ className={`${menuClasses.panel} fixed z-200 min-w-[170px]`}
43
43
  style={{ left, top }}
44
44
  >
45
45
  <button
46
46
  type="button"
47
47
  role="menuitem"
48
- className={MENU_ROW}
48
+ className={ROW}
49
49
  disabled={!onLinkEdit || moveStart === null}
50
50
  title={moveStart === null ? "Would move the clip before 0:00" : undefined}
51
51
  onClick={() => {
@@ -59,7 +59,7 @@ function SyncMenu({
59
59
  <button
60
60
  type="button"
61
61
  role="menuitem"
62
- className={MENU_ROW}
62
+ className={ROW}
63
63
  disabled={!onLinkEdit || slipMediaStart === null}
64
64
  title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
65
65
  onClick={() => {
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { textClipBackground } from "./TextClipContent";
3
+
4
+ describe("textClipBackground", () => {
5
+ it("puts dark text on a light strip and light text on a dark one", () => {
6
+ expect(textClipBackground({ value: "a", color: "rgb(16, 20, 24)" })).toBe(
7
+ "var(--timeline-text-clip-light-bg)",
8
+ );
9
+ expect(textClipBackground({ value: "a", color: "rgb(255, 255, 255)" })).toBe(
10
+ "var(--timeline-text-clip-dark-bg)",
11
+ );
12
+ });
13
+
14
+ it("uses the layer's own background when it paints one", () => {
15
+ expect(
16
+ textClipBackground({ value: "a", color: "rgb(16, 20, 24)", background: "rgb(230, 57, 70)" }),
17
+ ).toBe("rgb(230, 57, 70)");
18
+ });
19
+ });
@@ -0,0 +1,27 @@
1
+ import { parseCssColor } from "../../components/editor/colorValue";
2
+ import type { TimelineText } from "../store/timelineElement";
3
+
4
+ /** The layer's own background, or a strip the text reads on: light behind dark text, dark otherwise. */
5
+ export function textClipBackground(text: TimelineText): string {
6
+ if (text.background) return text.background;
7
+ const color = parseCssColor(text.color ?? "");
8
+ const light = color ? 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue : 255;
9
+ return light < 128 ? "var(--timeline-text-clip-light-bg)" : "var(--timeline-text-clip-dark-bg)";
10
+ }
11
+
12
+ /** A text layer's row: its own words in its own font and colour, live, on one clipped line. */
13
+ export function TextClipContent({ text }: { text: TimelineText }) {
14
+ return (
15
+ <div
16
+ className="absolute inset-0 flex items-center overflow-hidden px-2"
17
+ style={{ background: textClipBackground(text) }}
18
+ >
19
+ <span
20
+ className="whitespace-nowrap text-[15px] leading-none"
21
+ style={{ fontFamily: text.fontFamily, fontWeight: text.fontWeight, color: text.color }}
22
+ >
23
+ {text.value}
24
+ </span>
25
+ </div>
26
+ );
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
+ import { menuClasses } from "../../components/ui/menuStyle";
4
5
 
5
6
  interface TrackGapContextMenuProps {
6
7
  x: number;
@@ -53,11 +54,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
53
54
  const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
54
55
 
55
56
  const itemClass = (enabled: boolean) =>
56
- `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
57
- enabled
58
- ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
59
- : "text-neutral-600 cursor-not-allowed"
60
- }`;
57
+ `${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
61
58
 
62
59
  // Disabled reasons: no gap under the pointer beats the lock reason — a
63
60
  // pointer not on a gap has nothing to close regardless of movability.
@@ -75,7 +72,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
75
72
  return createPortal(
76
73
  <div
77
74
  ref={menuRef}
78
- className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
75
+ className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
79
76
  style={{ left: adjustedX, top: adjustedY }}
80
77
  onPointerLeave={() => onHoverAction(null)}
81
78
  >
@@ -4,6 +4,7 @@ import { resolveMediaPreviewUrl } from "./thumbnailUtils";
4
4
  import type { TimelineElement } from "../store/timelineElement";
5
5
  import { parseAutomation, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
6
6
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
7
+ import { studioApiFetch } from "../../utils/studioApiFetch";
7
8
 
8
9
  export interface NormalizePlan {
9
10
  targetLufs: number;
@@ -74,7 +75,7 @@ function isNormalizePlan(value: unknown): value is NormalizePlan {
74
75
  export async function requestNormalizePlan(
75
76
  projectId: string,
76
77
  el: TimelineElement,
77
- fetchImpl: typeof fetch = fetch,
78
+ fetchImpl: typeof studioApiFetch = studioApiFetch,
78
79
  ): Promise<NormalizePlan> {
79
80
  const origin = window.location.origin;
80
81
  const relative = resolvePreviewRelative(
@@ -99,6 +99,29 @@ describe("ClipMenuToolItems", () => {
99
99
  expect(onClose).toHaveBeenCalled();
100
100
  });
101
101
 
102
+ it("opens its submenu at the row's edge in window pixels, and flips left near the window's right edge", () => {
103
+ const rowAt = (rect: { top: number; left: number; right: number }) =>
104
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
105
+ const spy = rowAt({ top: 50, left: 100, right: 300 });
106
+ renderItems("sound", video);
107
+ openSubmenu("Voice");
108
+ let menu = document.querySelector<HTMLElement>('[role="menu"]')!;
109
+ expect(menu.className).toContain("fixed");
110
+ expect([menu.style.top, menu.style.left]).toEqual(["50px", "300px"]);
111
+ act(() => root?.unmount());
112
+ document.body.innerHTML = "";
113
+ spy.mockReturnValue({
114
+ top: 50,
115
+ left: window.innerWidth - 200,
116
+ right: window.innerWidth - 10,
117
+ } as DOMRect);
118
+ renderItems("sound", video);
119
+ openSubmenu("Voice");
120
+ menu = document.querySelector<HTMLElement>('[role="menu"]')!;
121
+ expect([menu.style.top, menu.style.right, menu.style.left]).toEqual(["50px", "200px", ""]);
122
+ spy.mockRestore();
123
+ });
124
+
102
125
  it("Voice None drops the attribute", () => {
103
126
  const { setQuiet } = renderItems("sound", video);
104
127
  openSubmenu("Voice");
@@ -15,6 +15,7 @@ import {
15
15
  type ClipToolChoice,
16
16
  } from "./clipToolAttrs";
17
17
  import { useClipToolState } from "./useClipToolState";
18
+ import { menuClasses } from "../../components/ui/menuStyle";
18
19
 
19
20
  export type ClipMenuToolGroup = "time" | "sound" | "picture";
20
21
 
@@ -25,10 +26,8 @@ interface ClipMenuToolItemsProps {
25
26
  onClose: () => void;
26
27
  }
27
28
 
28
- const ROW_CLASS =
29
- "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer";
30
- const DISABLED_ROW_CLASS =
31
- "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
29
+ const ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowEnabled}`;
30
+ const DISABLED_ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowDisabled}`;
32
31
  const SUBMENU_WIDTH = 170;
33
32
  const HOVER_PREVIEW_DELAY_MS = 80;
34
33
 
@@ -98,7 +97,7 @@ function ChoiceSubmenu({
98
97
  preview?: HoverPreview;
99
98
  }) {
100
99
  const [open, setOpen] = useState(false);
101
- const [flipLeft, setFlipLeft] = useState(false);
100
+ const [anchor, setAnchor] = useState<{ top: number; left?: number; right?: number }>({ top: 0 });
102
101
  const rowRef = useRef<HTMLButtonElement | null>(null);
103
102
  const submenuRef = useRef<HTMLDivElement | null>(null);
104
103
  const hover = useHoverPreview(preview);
@@ -111,7 +110,12 @@ function ChoiceSubmenu({
111
110
 
112
111
  const show = (focusFirst: boolean) => {
113
112
  const rect = rowRef.current?.getBoundingClientRect();
114
- setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
113
+ if (rect)
114
+ setAnchor(
115
+ rect.right + SUBMENU_WIDTH > window.innerWidth
116
+ ? { top: rect.top, right: window.innerWidth - rect.left }
117
+ : { top: rect.top, left: rect.right },
118
+ );
115
119
  setOpen(true);
116
120
  if (!focusFirst) return;
117
121
  openingFocusRef.current = true;
@@ -184,8 +188,8 @@ function ChoiceSubmenu({
184
188
  ref={submenuRef}
185
189
  role="menu"
186
190
  aria-label={label}
187
- className="absolute top-0 z-10 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1"
188
- style={{ width: SUBMENU_WIDTH, ...(flipLeft ? { right: "100%" } : { left: "100%" }) }}
191
+ className={`${menuClasses.panel} fixed z-10`}
192
+ style={{ width: SUBMENU_WIDTH, ...anchor }}
189
193
  onKeyDown={onSubmenuKeyDown}
190
194
  >
191
195
  {choiceRow(null, "None")}
@@ -193,7 +197,7 @@ function ChoiceSubmenu({
193
197
  <div key={section.heading ?? index}>
194
198
  {section.heading && (
195
199
  <>
196
- <div className="my-1 border-t border-neutral-700/60" />
200
+ <div className={menuClasses.divider} />
197
201
  <div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
198
202
  {section.heading}
199
203
  </div>
@@ -1,4 +1,5 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
  import { resolvePreviewRelative } from "../../utils/previewRelativePath";
3
4
  import type { TimelineElement } from "../store/timelineElement";
4
5
  import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
@@ -10,7 +11,7 @@ const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
10
11
  export function loadPeakMap(url: string): Promise<PeakMap | null> {
11
12
  const pending = peakMapRequests.get(url);
12
13
  if (pending) return pending;
13
- const request = fetch(url)
14
+ const request = studioApiFetch(url)
14
15
  .then((res) => (res.ok ? res.json() : null))
15
16
  .then((body: unknown) => (isPeakMap(body) ? body : null))
16
17
  .catch(() => null);
@@ -137,6 +137,7 @@ export interface TimelineEditCallbacks {
137
137
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
138
138
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
139
139
  onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
140
+ clipMenuTools?: boolean;
140
141
  onNotice?: (message: string, tone?: "error" | "info") => void;
141
142
  onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
142
143
  onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
@@ -195,7 +195,7 @@ export function mountTimelineClipDragGestureLifecycle({
195
195
  event.clientX - blocked.originClientX,
196
196
  event.clientY - blocked.originClientY,
197
197
  );
198
- const threshold = blocked.intent === "move" ? 4 : 2;
198
+ const threshold = blocked.intent === "move" || blocked.intent === "edit-many" ? 4 : 2;
199
199
  if (!blocked.started && distance < threshold) return;
200
200
  if (!blocked.started) {
201
201
  blocked.started = true;
@@ -0,0 +1,94 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { PointerEvent as ReactPointerEvent } from "react";
5
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
6
+ import { createClipGestureHandlers, type ClipGestureDeps } from "./timelineClipGestureHandlers";
7
+ import { MAX_HAND_EDIT_CLIPS } from "./timelineEditing";
8
+
9
+ afterEach(() => usePlayerStore.getState().reset());
10
+
11
+ const clips: TimelineElement[] = Array.from({ length: 6 }, (_, i) => ({
12
+ id: `clip-${i}`,
13
+ domId: `clip-${i}`,
14
+ tag: "div",
15
+ start: i,
16
+ duration: 1,
17
+ track: i,
18
+ }));
19
+ const capabilities = { canMove: true, canTrimStart: true, canTrimEnd: true, readOnly: false };
20
+
21
+ function grabFirstClip(count: number, gesture: "move" | "resize", locked: number[] = []) {
22
+ const store = usePlayerStore.getState();
23
+ store.setElements(
24
+ clips.map((clip, i) => (locked.includes(i) ? { ...clip, timelineLocked: true } : clip)),
25
+ );
26
+ store.setSelectedElementIds(new Set(clips.slice(0, count).map((c) => c.id)));
27
+ const setDraggedClip = vi.fn();
28
+ const setResizingClip = vi.fn();
29
+ const blockedClipRef = { current: null as { intent: string } | null };
30
+ const deps = {
31
+ pps: 100,
32
+ onMoveElement: vi.fn(),
33
+ onResizeElement: vi.fn(),
34
+ blockedClipRef,
35
+ suppressClickRef: { current: false },
36
+ scrollRef: { current: null },
37
+ setShowPopover: vi.fn(),
38
+ setRangeSelection: vi.fn(),
39
+ setResizingClip,
40
+ setDraggedClip,
41
+ setSelectedElementId: vi.fn(),
42
+ } as unknown as ClipGestureDeps;
43
+ const handlers = createClipGestureHandlers(clips[0], clips[0].id, clips[0], capabilities, deps);
44
+ const event = {
45
+ button: 0,
46
+ clientX: 50,
47
+ clientY: 5,
48
+ pointerId: 1,
49
+ stopPropagation: vi.fn(),
50
+ currentTarget: { getBoundingClientRect: () => ({ left: 0, width: 100 }) },
51
+ } as unknown as ReactPointerEvent;
52
+ if (gesture === "move") handlers.onPointerDown(event);
53
+ else handlers.onResizeStart("end", event);
54
+ return { setDraggedClip, setResizingClip, blockedClipRef };
55
+ }
56
+
57
+ describe("hand-editing a multi-selection", () => {
58
+ it("moves up to the limit by hand", () => {
59
+ const { setDraggedClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS, "move");
60
+ expect(setDraggedClip).toHaveBeenCalledOnce();
61
+ expect(blockedClipRef.current).toBeNull();
62
+ });
63
+
64
+ it("refuses to start a drag above the limit and asks for the toast", () => {
65
+ const { setDraggedClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "move");
66
+ expect(setDraggedClip).not.toHaveBeenCalled();
67
+ expect(blockedClipRef.current?.intent).toBe("edit-many");
68
+ });
69
+
70
+ it("resizes up to the limit by hand", () => {
71
+ const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS, "resize");
72
+ expect(setResizingClip).toHaveBeenCalledOnce();
73
+ expect(blockedClipRef.current).toBeNull();
74
+ });
75
+
76
+ it("refuses to start a resize above the limit and asks for the toast", () => {
77
+ const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize");
78
+ expect(setResizingClip).not.toHaveBeenCalled();
79
+ expect(blockedClipRef.current?.intent).toBe("edit-many");
80
+ });
81
+
82
+ it("counts the clips a move would change: locked ones in the selection stay put", () => {
83
+ const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [3, 4]);
84
+ expect(allowed.setDraggedClip).toHaveBeenCalledOnce();
85
+ const refused = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [4]);
86
+ expect(refused.setDraggedClip).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("counts the clips a trim would change: a locked member leaves a trim of the grabbed clip alone", () => {
90
+ const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize", [3]);
91
+ expect(allowed.setResizingClip).toHaveBeenCalledOnce();
92
+ expect(allowed.blockedClipRef.current).toBeNull();
93
+ });
94
+ });