@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  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 +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -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,
@@ -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
  >
@@ -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>
@@ -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
+ });
@@ -8,7 +8,11 @@ import {
8
8
  import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
9
9
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
10
10
  import { CLIP_HANDLE_W } from "./timelineLayout";
11
- import { selectClipWithLinks, toggleClipWithLinks } from "./timelineLinkSelection";
11
+ import {
12
+ exceedsHandEditLimit,
13
+ selectClipWithLinks,
14
+ toggleClipWithLinks,
15
+ } from "./timelineLinkSelection";
12
16
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
13
17
 
14
18
  export interface ClipGestureDeps {
@@ -73,6 +77,7 @@ function isIntentBlocked(
73
77
  */
74
78
  function resolvePointerDownAction(
75
79
  e: ReactPointerEvent,
80
+ grabbed: TimelineElement,
76
81
  capabilities: TimelineEditCapabilities,
77
82
  onResizeElement: ClipGestureDeps["onResizeElement"],
78
83
  onMoveElement: ClipGestureDeps["onMoveElement"],
@@ -93,6 +98,8 @@ function resolvePointerDownAction(
93
98
  }
94
99
 
95
100
  if (!onMoveElement || !capabilities.canMove) return { kind: "ignore" };
101
+ if (exceedsHandEditLimit(grabbed, e.altKey, null))
102
+ return { kind: "block", intent: "edit-many", rect };
96
103
  return { kind: "move", rect };
97
104
  }
98
105
 
@@ -129,6 +136,17 @@ export function createClipGestureHandlers(
129
136
  const onResizeStart = (edge: "start" | "end", e: ReactPointerEvent): void => {
130
137
  if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
131
138
  e.stopPropagation();
139
+ if (exceedsHandEditLimit(el, e.altKey, edge)) {
140
+ blockedClipRef.current = {
141
+ pointerId: e.pointerId,
142
+ element: el,
143
+ intent: "edit-many",
144
+ originClientX: e.clientX,
145
+ originClientY: e.clientY,
146
+ started: false,
147
+ };
148
+ return;
149
+ }
132
150
  blockedClipRef.current = null;
133
151
  setShowPopover(false);
134
152
  setRangeSelection(null);
@@ -147,7 +165,7 @@ export function createClipGestureHandlers(
147
165
  };
148
166
 
149
167
  const onPointerDown = (e: ReactPointerEvent): void => {
150
- const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
168
+ const action = resolvePointerDownAction(e, el, capabilities, onResizeElement, onMoveElement);
151
169
  if (action.kind === "ignore") return;
152
170
 
153
171
  if (action.kind === "block") {
@@ -248,7 +248,9 @@ export interface TimelinePromptElement {
248
248
  track: number;
249
249
  }
250
250
 
251
- export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
251
+ export type BlockedTimelineEditIntent = "move" | "edit-many" | "resize-start" | "resize-end";
252
+
253
+ export const MAX_HAND_EDIT_CLIPS = 3;
252
254
 
253
255
  export interface TimelineRangeSelection {
254
256
  start: number;
@@ -1,5 +1,13 @@
1
1
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2
- import { expandToLinkedMembers, linkedMembersOf } from "./audioClipLink";
2
+ import {
3
+ dropMisalignedTrimPartners,
4
+ expandToLinkedMembers,
5
+ linkedGestureKeys,
6
+ linkedMembersOf,
7
+ } from "./audioClipLink";
8
+ import { buildTimelineGroupResizeMembers } from "./timelineGroupEditing";
9
+ import { resolveGroupMovers } from "./timelineMultiDragPreview";
10
+ import { MAX_HAND_EDIT_CLIPS } from "./timelineEditing";
3
11
  import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
4
12
 
5
13
  /**
@@ -39,3 +47,29 @@ export function toggleClipWithLinks(key: string, altKey: boolean): TimelineEleme
39
47
  const primary = usePlayerStore.getState().selectedElementId;
40
48
  return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
41
49
  }
50
+
51
+ /** Whether a hand drag on `grabbed` (trimming `edge`, or null to move) would change more clips than allowed. */
52
+ export function exceedsHandEditLimit(
53
+ grabbed: TimelineElement,
54
+ altKey: boolean,
55
+ edge: "start" | "end" | null,
56
+ ): boolean {
57
+ const { selectedElementIds, elements } = usePlayerStore.getState();
58
+ const grabbedKey = grabbed.key ?? grabbed.id;
59
+ const keys = linkedGestureKeys(
60
+ selectedElementIds,
61
+ grabbed,
62
+ elements,
63
+ altKey,
64
+ isLinkedSelectionOn(),
65
+ );
66
+ const changed = edge
67
+ ? buildTimelineGroupResizeMembers(
68
+ elements,
69
+ dropMisalignedTrimPartners(keys, grabbed, elements, edge),
70
+ grabbedKey,
71
+ edge,
72
+ )
73
+ : resolveGroupMovers(elements, keys, grabbedKey);
74
+ return (changed?.length ?? 1) > MAX_HAND_EDIT_CLIPS;
75
+ }
@@ -49,6 +49,7 @@ function mount(order = [0, 1, 2]) {
49
49
  const host = document.createElement("div");
50
50
  const root = createRoot(host);
51
51
  const onMoveElements = vi.fn();
52
+ const onBlockedEditAttempt = vi.fn();
52
53
  const trackOrderRef = { current: order };
53
54
  const trackInsertLayoutRef = { current: buildTimelineTrackInsertLayout(order, groups) };
54
55
  let state: ReturnType<typeof useTimelineClipDrag>;
@@ -68,6 +69,7 @@ function mount(order = [0, 1, 2]) {
68
69
  },
69
70
  onMoveElement: vi.fn(),
70
71
  onMoveElements,
72
+ onBlockedEditAttempt,
71
73
  setShowPopover: () => {},
72
74
  setRangeSelectionRef: { current: null },
73
75
  });
@@ -83,6 +85,7 @@ function mount(order = [0, 1, 2]) {
83
85
  key,
84
86
  clip,
85
87
  onMoveElements,
88
+ onBlockedEditAttempt,
86
89
  root,
87
90
  elements,
88
91
  changeOrder(next: number[]) {
@@ -161,3 +164,14 @@ it("leaves Space on a nested fade slider to the slider", () => {
161
164
  expect(event.defaultPrevented).toBe(false);
162
165
  act(() => view.root.unmount());
163
166
  });
167
+
168
+ it("does not pick up a clip held in a selection above the hand-move limit", () => {
169
+ const view = mount();
170
+ usePlayerStore.getState().setSelection(["top", "hero", "tail", "bottom"], "hero");
171
+ view.key(" ");
172
+ expect(view.drag).toBeNull();
173
+ expect(view.onBlockedEditAttempt).toHaveBeenCalledWith(
174
+ expect.objectContaining({ id: "hero" }),
175
+ "edit-many",
176
+ );
177
+ });