@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -24,6 +24,7 @@ import {
24
24
  audioGainToText,
25
25
  } from "@hyperframes/core/audio-gain";
26
26
  import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
27
+ import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
27
28
  import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
28
29
 
29
30
  // fallow-ignore-next-line complexity
@@ -56,6 +57,10 @@ export function MediaSection({
56
57
  ) => Promise<BackgroundRemovalResult>;
57
58
  }) {
58
59
  const track = useTrackDesignInput();
60
+ const setRate = withLinkedPlaybackRate(
61
+ onSetAttribute,
62
+ useLinkedSpeedCommit(element, onSetAttributeBatch),
63
+ );
59
64
  const isVideo = element.tagName === "video";
60
65
  const isAudio = element.tagName === "audio";
61
66
  const isImage = element.tagName === "img";
@@ -279,7 +284,7 @@ export function MediaSection({
279
284
  displayValue={`${formatNumericValue(playbackRate)}x`}
280
285
  formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
281
286
  onCommit={(next) => {
282
- void onSetAttribute("playback-rate", formatNumericValue(next / 100));
287
+ void setRate("playback-rate", formatNumericValue(next / 100));
283
288
  }}
284
289
  />
285
290
  </div>
@@ -31,7 +31,7 @@ function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestu
31
31
  };
32
32
  const handlers = createDomEditOverlayGestureHandlers(opts as never);
33
33
  const pointer = (clientX: number, clientY: number, shiftKey: boolean) =>
34
- ({ clientX, clientY, shiftKey, altKey: false }) as never;
34
+ ({ clientX, clientY, shiftKey, altKey: false, pointerId: 1, buttons: 1 }) as never;
35
35
  return { opts, handlers, pointer };
36
36
  }
37
37
 
@@ -41,6 +41,7 @@ function singleDrag() {
41
41
  {
42
42
  kind: "drag",
43
43
  mode: "path-offset",
44
+ pointerId: 1,
44
45
  selection: { element } as unknown as DomEditSelection,
45
46
  startX: 100,
46
47
  startY: 100,
@@ -57,7 +58,13 @@ function singleDrag() {
57
58
  }
58
59
 
59
60
  const groupGesture = () =>
60
- ({ startX: 100, startY: 100, originItems: [], members: [] }) as unknown as GroupGestureState;
61
+ ({
62
+ pointerId: 1,
63
+ startX: 100,
64
+ startY: 100,
65
+ originItems: [],
66
+ members: [],
67
+ }) as unknown as GroupGestureState;
61
68
 
62
69
  describe("shift+drag locks a move to the axis the pointer travels further on", () => {
63
70
  it("keeps a mostly horizontal single drag on its row", () => {
@@ -278,6 +285,7 @@ function singleMemberDrag() {
278
285
  {
279
286
  kind: "drag",
280
287
  mode: "path-offset",
288
+ pointerId: 1,
281
289
  selection,
282
290
  startX: 100,
283
291
  startY: 100,
@@ -300,7 +308,13 @@ function singleMemberDrag() {
300
308
  function groupMemberDrag() {
301
309
  const a = draggableMember("a");
302
310
  const b = draggableMember("b");
303
- const group = { startX: 100, startY: 100, originItems: [], members: [a.member, b.member] };
311
+ const group = {
312
+ pointerId: 1,
313
+ startX: 100,
314
+ startY: 100,
315
+ originItems: [],
316
+ members: [a.member, b.member],
317
+ };
304
318
  const harness = gestureHarness(null, group as unknown as GroupGestureState);
305
319
  return { ...harness, at: () => [a.at(), b.at()] };
306
320
  }
@@ -38,6 +38,7 @@ import {
38
38
  ROTATED_SNAP_BYPASS_DEGREES,
39
39
  hasDomEditRotationChanged,
40
40
  lockDragToDominantAxis,
41
+ movesGesture,
41
42
  resolveDomEditRotationGesture,
42
43
  } from "./domEditOverlayGestures";
43
44
  import { resolveCenterResizeSize } from "./domEditResizeLocal";
@@ -131,6 +132,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
131
132
  if (!blockedMove && !g && !groupG) {
132
133
  opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
133
134
  }
135
+ const held = g ?? groupG;
136
+ if (held && !movesGesture(held, e)) return;
134
137
 
135
138
  if (blockedMove) {
136
139
  const dx = e.clientX - blockedMove.startX;
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, type ReactNode, type RefObject } from "react";
2
+ import { InlineTextCaret } from "./InlineTextCaret";
2
3
  import { InlineTextToolbar } from "./InlineTextToolbar";
3
4
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
5
  import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
@@ -110,10 +111,16 @@ export function useInlineTextEditing(
110
111
  return {
111
112
  editing: inlineText.session !== null,
112
113
  toolbar: (
113
- <InlineTextToolbar
114
- session={inlineText.session}
115
- iframe={actions?.previewIframeRef?.current ?? null}
116
- />
114
+ <>
115
+ <InlineTextToolbar
116
+ session={inlineText.session}
117
+ iframe={actions?.previewIframeRef?.current ?? null}
118
+ />
119
+ <InlineTextCaret
120
+ session={inlineText.session}
121
+ iframe={actions?.previewIframeRef?.current ?? null}
122
+ />
123
+ </>
117
124
  ),
118
125
  handleKeyDown,
119
126
  startFromPress: (event) => {
@@ -26,6 +26,8 @@ import {
26
26
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
27
27
  import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp";
28
28
  import { clampNumber } from "../../utils/studioHelpers";
29
+ import type { HfAutomation } from "@hyperframes/core/audio-automation";
30
+ import type { LinkedSpeedCommit } from "./linkedSpeedEdits";
29
31
 
30
32
  export interface LaneBinding {
31
33
  automated: boolean;
@@ -59,6 +61,7 @@ export function useVolumeAutomation(
59
61
  element: DomEditSelection,
60
62
  currentTime: number,
61
63
  onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
64
+ linkedSpeed: LinkedSpeedCommit | null = null,
62
65
  ): VolumeAutomationBinding {
63
66
  // The chain is not needed to resolve a volume or rate lane: both are always valid
64
67
  // targets, so this deliberately does not parse it.
@@ -77,7 +80,22 @@ export function useVolumeAutomation(
77
80
  // playing track, while the same click on an effect parameter did not.
78
81
  void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
79
82
  };
80
- const laneBinding = (target: string, seed: number): LaneBinding => {
83
+ type Edit = (current: HfAutomation) => HfAutomation;
84
+ const writeOwn = (edit: Edit) => write(edit(automation));
85
+ // Speed moves every data-link member together, so a linked clip's rate lane edits fan out.
86
+ const writeRate = (edit: Edit) => {
87
+ if (!linkedSpeed) return writeOwn(edit);
88
+ void linkedSpeed.commitAttribute(
89
+ HF_AUDIO_AUTOMATION_ATTR,
90
+ (raw) => automationAttrValue(edit(readPanelAutomation(raw, undefined))) || null,
91
+ "Edit speed",
92
+ );
93
+ };
94
+ const laneBinding = (
95
+ target: string,
96
+ seed: number,
97
+ writeLane: (edit: Edit) => void,
98
+ ): LaneBinding => {
81
99
  const lane = automation.lanes.find((l) => l.target === target);
82
100
  return {
83
101
  automated: lane !== undefined,
@@ -85,9 +103,9 @@ export function useVolumeAutomation(
85
103
  ? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale)
86
104
  : undefined,
87
105
  // Seeded at the level the control already shows, so automating does not change it.
88
- onAutomate: () => write(withSeededLane(automation, target, seed)),
89
- onRemoveAutomation: () => write(withoutLane(automation, target)),
90
- onCommitAt: (v: number) => write(withPointAt(automation, target, clipTimeSec, v)),
106
+ onAutomate: () => writeLane((a) => withSeededLane(a, target, seed)),
107
+ onRemoveAutomation: () => writeLane((a) => withoutLane(a, target)),
108
+ onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)),
91
109
  };
92
110
  };
93
111
  // `??` alone would let an empty `data-volume` through as Number("") === 0, so
@@ -96,9 +114,13 @@ export function useVolumeAutomation(
96
114
  const raw = element.dataAttributes?.["volume"];
97
115
  const parsed = raw ? Number(raw) : 1;
98
116
  const current = Number.isFinite(parsed) ? parsed : 1;
99
- const volume = laneBinding(VOLUME_TARGET, current);
117
+ const volume = laneBinding(VOLUME_TARGET, current, writeOwn);
100
118
  const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
101
- const rate = laneBinding(RATE_TARGET, Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1);
119
+ const rate = laneBinding(
120
+ RATE_TARGET,
121
+ Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1,
122
+ writeRate,
123
+ );
102
124
  return {
103
125
  volumeAutomated: volume.automated,
104
126
  automatedVolumeValue: volume.automatedValue,
@@ -109,7 +131,7 @@ export function useVolumeAutomation(
109
131
  ...rate,
110
132
  canApplyPreset: elDuration > 0,
111
133
  onApplyPreset: (id) => {
112
- if (elDuration > 0) write(withLane(automation, speedPresetLane(id, elDuration)));
134
+ if (elDuration > 0) writeRate((a) => withLane(a, speedPresetLane(id, elDuration)));
113
135
  },
114
136
  },
115
137
  };
@@ -54,6 +54,8 @@ export interface TimelineEditCallbackDeps {
54
54
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
55
55
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
56
56
  handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
57
+ handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
58
+ handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
57
59
  /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
58
60
  handleGroupClips?: (
59
61
  clipIds: readonly string[],
@@ -70,6 +72,7 @@ export interface TimelineEditCallbackDeps {
70
72
  value: string | null,
71
73
  label: string,
72
74
  ) => Promise<TimelineEditOutcome | void>;
75
+ setMany?: TimelineEditCallbacks["onSetElementsAttributeQuiet"];
73
76
  };
74
77
  }
75
78
 
@@ -134,6 +137,8 @@ export function useTimelineEditCallbacks({
134
137
  handleRazorSplit,
135
138
  handleRazorSplitAll,
136
139
  handleFreezeFrame,
140
+ handleLinkEdit,
141
+ handleTimelineElementDeleteOnly,
137
142
  handleGroupClips,
138
143
  setElementFxAttribute,
139
144
  }: TimelineEditCallbackDeps): TimelineEditCallbacks {
@@ -224,12 +229,15 @@ export function useTimelineEditCallbacks({
224
229
  onGroupClips: handleGroupClips,
225
230
  onSetElementAttributeLive: setElementFxAttribute?.setLive,
226
231
  onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
232
+ onSetElementsAttributeQuiet: setElementFxAttribute?.setMany,
227
233
  onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
228
234
  onBlockedEditAttempt: handleBlockedTimelineEdit,
229
235
  onSplitElement: handleTimelineElementSplit,
230
236
  onRazorSplit: handleRazorSplit,
231
237
  onRazorSplitAll: handleRazorSplitAll,
232
238
  onFreezeFrame: handleFreezeFrame,
239
+ onLinkEdit: handleLinkEdit,
240
+ onDeleteElementOnly: handleTimelineElementDeleteOnly,
233
241
  onDeleteAllKeyframes: (element, animationId) => {
234
242
  // Hold the element where it is (collapse keyframes to a static set) rather
235
243
  // than deleting the whole animation — deleting strands a stale GSAP base
@@ -427,6 +435,8 @@ export function useTimelineEditCallbacks({
427
435
  handleRazorSplit,
428
436
  handleRazorSplitAll,
429
437
  handleFreezeFrame,
438
+ handleLinkEdit,
439
+ handleTimelineElementDeleteOnly,
430
440
  handleGsapRemoveAllKeyframes,
431
441
  resolveElementAnimations,
432
442
  resolveKeyframeTarget,
@@ -3,7 +3,6 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import React, { act, useState } from "react";
6
- import { createRoot, type Root } from "react-dom/client";
7
6
  import { compile } from "tailwindcss";
8
7
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
9
8
  import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
@@ -11,27 +10,11 @@ import { ContextMenu, Menu, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparat
11
10
  import { Popover } from "./Popover";
12
11
  import { isTypingTarget } from "../../utils/typingTarget";
13
12
  import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
13
+ import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
14
14
 
15
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
16
 
17
- let mounted: { root: Root; host: HTMLElement } | null = null;
18
-
19
- function render(element: React.ReactElement): HTMLElement {
20
- const host = document.createElement("div");
21
- document.body.append(host);
22
- const root = createRoot(host);
23
- mounted = { root, host };
24
- act(() => root.render(element));
25
- return host;
26
- }
27
-
28
- afterEach(() => {
29
- if (!mounted) return;
30
- const { root, host } = mounted;
31
- mounted = null;
32
- act(() => root.unmount());
33
- host.remove();
34
- });
17
+ afterEach(cleanupMounted);
35
18
 
36
19
  /** Base UI moves focus into the popup one task after open, not synchronously. */
37
20
  async function settle(): Promise<void> {
@@ -310,9 +293,7 @@ describe("Popover", () => {
310
293
  renderOpen(false);
311
294
  await settle();
312
295
  expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
313
- act(() => mounted?.root.unmount());
314
- mounted?.host.remove();
315
- mounted = null;
296
+ cleanupMounted();
316
297
 
317
298
  renderOpen(true);
318
299
  await settle();
@@ -52,8 +52,8 @@ export const popupSurface = cn(
52
52
  "data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
53
53
  );
54
54
 
55
- /** The popup's own layer: menus sit above panel chrome and below a modal. */
56
- const POPUP_LAYER = "z-200";
55
+ /** Every floating list, menu, popover and tooltip sits on this layer, above panel chrome and modals. */
56
+ export const POPUP_LAYER = "z-200";
57
57
 
58
58
  const menuPopup = cn(popupSurface, "min-w-36 p-1 shadow-menu");
59
59
 
@@ -6,7 +6,7 @@
6
6
  import { Popover as BasePopover } from "@base-ui/react/popover";
7
7
  import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
8
8
  import { cn } from "./cn";
9
- import { popupSurface, type PopupPreviewState } from "./Menu";
9
+ import { POPUP_LAYER, popupSurface, type PopupPreviewState } from "./Menu";
10
10
 
11
11
  type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
12
12
 
@@ -70,7 +70,7 @@ export function Popover({
70
70
  sideOffset={sideOffset}
71
71
  collisionPadding={VIEWPORT_MARGIN}
72
72
  arrowPadding={ARROW_CORNER_CLEARANCE}
73
- className="z-200"
73
+ className={POPUP_LAYER}
74
74
  >
75
75
  <BasePopover.Popup
76
76
  aria-label={ariaLabel}
@@ -6,7 +6,7 @@
6
6
  import { Select as BaseSelect } from "@base-ui/react/select";
7
7
  import { cn } from "./cn";
8
8
  import { fieldBase } from "./Input";
9
- import { floatingMotion } from "./Menu";
9
+ import { floatingMotion, POPUP_LAYER } from "./Menu";
10
10
  import type { PreviewState } from "./Button";
11
11
 
12
12
  export interface SelectOption {
@@ -77,7 +77,7 @@ export function Select({
77
77
  </BaseSelect.Trigger>
78
78
 
79
79
  <BaseSelect.Portal>
80
- <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
80
+ <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false} className={POPUP_LAYER}>
81
81
  <BaseSelect.Popup
82
82
  className={cn(
83
83
  "min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
@@ -6,7 +6,7 @@
6
6
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
7
7
  import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
8
8
  import { cn } from "./cn";
9
- import { floatingMotion } from "./Menu";
9
+ import { floatingMotion, POPUP_LAYER } from "./Menu";
10
10
 
11
11
  interface TooltipProps {
12
12
  label: string;
@@ -39,7 +39,7 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
39
39
  side={side}
40
40
  sideOffset={SIDE_OFFSET}
41
41
  collisionPadding={VIEWPORT_MARGIN}
42
- className="z-200"
42
+ className={POPUP_LAYER}
43
43
  >
44
44
  <BaseTooltip.Popup
45
45
  id={tooltipId}
@@ -4,6 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
4
4
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
5
5
 
6
6
  let mounted: { root: Root; host: HTMLElement } | null = null;
7
+ const tracked: Root[] = [];
8
+
9
+ export function trackedRoot(host: HTMLElement): Root {
10
+ const root = createRoot(host);
11
+ tracked.push(root);
12
+ return root;
13
+ }
7
14
 
8
15
  /** Renders into a fresh host attached to the body; `cleanupMounted` tears it down. */
9
16
  export function mountHost(element: ReactElement): HTMLElement {
@@ -16,6 +23,7 @@ export function mountHost(element: ReactElement): HTMLElement {
16
23
  }
17
24
 
18
25
  export function cleanupMounted(): void {
26
+ act(() => tracked.splice(0).forEach((root) => root.unmount()));
19
27
  if (!mounted) return;
20
28
  const { root, host } = mounted;
21
29
  mounted = null;
@@ -4,7 +4,6 @@ import { createRequire } from "node:module";
4
4
  import { readFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import React, { act } from "react";
7
- import { createRoot, type Root } from "react-dom/client";
8
7
  import { compile } from "tailwindcss";
9
8
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
10
9
 
@@ -21,33 +20,15 @@ import { Toggle } from "./Toggle";
21
20
  import { isTypingTarget } from "../../utils/typingTarget";
22
21
  import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
23
22
  import { __resetDesignInputThrottle, trackDesignInput } from "../../utils/designInputTracking";
23
+ import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
24
24
 
25
25
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
26
26
 
27
- let mounted: { root: Root; host: HTMLElement } | null = null;
28
-
29
- function render(element: React.ReactElement): HTMLElement {
30
- const host = document.createElement("div");
31
- document.body.append(host);
32
- const root = createRoot(host);
33
- mounted = { root, host };
34
- act(() => root.render(element));
35
- return host;
36
- }
37
-
38
- function unmount() {
39
- if (!mounted) return;
40
- const { root, host } = mounted;
41
- mounted = null;
42
- act(() => root.unmount());
43
- host.remove();
44
- }
45
-
46
27
  beforeEach(() => {
47
28
  trackStudioEvent.mockReset();
48
29
  __resetDesignInputThrottle();
49
30
  });
50
- afterEach(unmount);
31
+ afterEach(cleanupMounted);
51
32
 
52
33
  /** Base UI moves focus a task later than React renders; happy-dom is no faster. */
53
34
  const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
@@ -37,12 +37,16 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
37
37
  onGroupClips: true,
38
38
  onSetElementAttributeLive: true,
39
39
  onSetElementAttributeQuiet: true,
40
+ onSetElementsAttributeQuiet: true,
40
41
  onRevertElementAttributeLive: true,
41
42
  onBlockedEditAttempt: true,
43
+ onLinkEdit: true,
44
+ onDeleteElementOnly: true,
42
45
  onSplitElement: true,
43
46
  onRazorSplit: true,
44
47
  onRazorSplitAll: true,
45
48
  onFreezeFrame: true,
49
+ onNotice: true,
46
50
  onDeleteKeyframe: true,
47
51
  onDeleteAllKeyframes: true,
48
52
  onMoveKeyframeToPlayhead: true,
@@ -82,6 +82,7 @@ function mountMarquee<T>(extra: Partial<MarqueeGesturesDeps<T>> = {}) {
82
82
  clientX: x,
83
83
  clientY: y,
84
84
  pointerId: 7,
85
+ buttons: 1,
85
86
  shiftKey,
86
87
  currentTarget: overlay,
87
88
  }) as unknown as PointerEvent<HTMLDivElement>;
@@ -9,6 +9,8 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { trackStudioEvent } from "../utils/studioTelemetry";
11
11
  import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
12
+ import { openAudioGainDialog } from "../player/components/audioGainDialogStore";
13
+ import type { LinkShortcutCallbacks } from "./linkShortcuts";
12
14
 
13
15
  // Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
14
16
  // following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
@@ -29,7 +31,7 @@ function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: ()
29
31
  return false;
30
32
  }
31
33
 
32
- export interface HotkeyCallbacks {
34
+ export interface HotkeyCallbacks extends LinkShortcutCallbacks {
33
35
  handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
34
36
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
35
37
  handleDomEditElementDelete: (
@@ -189,6 +191,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
189
191
  }
190
192
  }
191
193
 
194
+ // Only with a sound clip selected, so bare G still reaches the canvas grid toggle otherwise.
195
+ if (key === STUDIO_PLAIN_KEYS.audioGain && !event.shiftKey && !event.altKey) {
196
+ if (!cb.readOnlyPreview && openAudioGainDialog()) {
197
+ event.preventDefault();
198
+ return;
199
+ }
200
+ }
201
+
192
202
  if (key === "b" && !event.shiftKey && !event.altKey) {
193
203
  event.preventDefault();
194
204
  const { activeTool, setActiveTool } = usePlayerStore.getState();
@@ -0,0 +1,70 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { usePlayerStore, type TimelineElement } from "../player";
4
+ import { dispatchLinkShortcut } from "./linkShortcuts";
5
+
6
+ const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
7
+ id,
8
+ tag,
9
+ src: "talk.mp4",
10
+ start: 0,
11
+ duration: 4,
12
+ track: 0,
13
+ ...extra,
14
+ });
15
+
16
+ function key(init: KeyboardEventInit): KeyboardEvent {
17
+ const event = new KeyboardEvent("keydown", { cancelable: true, ...init });
18
+ vi.spyOn(event, "preventDefault");
19
+ return event;
20
+ }
21
+
22
+ function select(elements: TimelineElement[], id: string) {
23
+ usePlayerStore.getState().setElements(elements);
24
+ usePlayerStore.getState().setSelection([id], id);
25
+ }
26
+
27
+ describe("dispatchLinkShortcut", () => {
28
+ afterEach(() => vi.restoreAllMocks());
29
+
30
+ it("⌘L unlinks the selected linked clip", () => {
31
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
32
+ const audio = clip("talk-audio", "audio", { link: "lk-1" });
33
+ select([video, audio], "talk");
34
+ const handleLinkEdit = vi.fn();
35
+ const event = key({ metaKey: true, key: "l" });
36
+ expect(dispatchLinkShortcut(event, { handleLinkEdit })).toBe(true);
37
+ expect(handleLinkEdit).toHaveBeenCalledWith({ kind: "unlink", elements: [video, audio] });
38
+ expect(event.preventDefault).toHaveBeenCalled();
39
+ });
40
+
41
+ it("⌥⇧D detaches audio, read by key code since Option changes the character", () => {
42
+ const talk = clip("talk", "video", { hasAudio: true });
43
+ select([talk], "talk");
44
+ const handleLinkEdit = vi.fn();
45
+ expect(
46
+ dispatchLinkShortcut(key({ altKey: true, shiftKey: true, key: "Î", code: "KeyD" }), {
47
+ handleLinkEdit,
48
+ }),
49
+ ).toBe(true);
50
+ expect(handleLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk });
51
+ });
52
+
53
+ it("⌥⌫ deletes one linked member", () => {
54
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
55
+ select([video, clip("talk-audio", "audio", { link: "lk-1" })], "talk");
56
+ const handleTimelineElementDeleteOnly = vi.fn();
57
+ dispatchLinkShortcut(key({ altKey: true, key: "Backspace" }), {
58
+ handleLinkEdit: vi.fn(),
59
+ handleTimelineElementDeleteOnly,
60
+ });
61
+ expect(handleTimelineElementDeleteOnly).toHaveBeenCalledWith(video);
62
+ });
63
+
64
+ it("leaves the key alone when no link item applies", () => {
65
+ select([clip("title", "div")], "title");
66
+ const event = key({ metaKey: true, key: "l" });
67
+ expect(dispatchLinkShortcut(event, { handleLinkEdit: vi.fn() })).toBe(false);
68
+ expect(event.preventDefault).not.toHaveBeenCalled();
69
+ });
70
+ });
@@ -0,0 +1,41 @@
1
+ import { usePlayerStore } from "../player";
2
+ import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
3
+ import { resolveLinkMenuItems } from "../player/components/clipMenuLinkItems";
4
+
5
+ export interface LinkShortcutCallbacks {
6
+ handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
7
+ handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
8
+ }
9
+
10
+ const isDeleteKey = (event: KeyboardEvent) => event.key === "Backspace" || event.key === "Delete";
11
+ const hasMod = (event: KeyboardEvent) => event.metaKey || event.ctrlKey;
12
+
13
+ const SHORTCUTS: ReadonlyArray<[string, (event: KeyboardEvent) => boolean]> = [
14
+ ["⌘L", (e) => hasMod(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "l"],
15
+ ["⌥⇧D", (e) => !hasMod(e) && e.altKey && e.shiftKey && e.code === "KeyD"],
16
+ ["⌥⌫", (e) => !hasMod(e) && e.altKey && !e.shiftKey && isDeleteKey(e)],
17
+ ];
18
+
19
+ function shortcutOf(event: KeyboardEvent): string | null {
20
+ return SHORTCUTS.find(([, matches]) => matches(event))?.[0] ?? null;
21
+ }
22
+
23
+ /** ⌘L unlink/link, ⌥⇧D detach audio, ⌥⌫ delete one linked clip — the clip menu's shortcuts. */
24
+ export function dispatchLinkShortcut(event: KeyboardEvent, cb: LinkShortcutCallbacks): boolean {
25
+ const shortcut = shortcutOf(event);
26
+ if (!shortcut) return false;
27
+ const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState();
28
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
29
+ if (!element) return false;
30
+ const item = resolveLinkMenuItems({
31
+ element,
32
+ elements,
33
+ selectedKeys: selectedElementIds,
34
+ onLinkEdit: cb.handleLinkEdit,
35
+ onDeleteElementOnly: cb.handleTimelineElementDeleteOnly,
36
+ }).find((candidate) => candidate.shortcut === shortcut);
37
+ if (!item) return false;
38
+ event.preventDefault();
39
+ item.run();
40
+ return true;
41
+ }