@hyperframes/studio 0.7.106 → 0.7.108

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 (175) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +86 -3
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +24325 -17831
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/StudioRightPanel.tsx +18 -1
  19. package/src/components/TimelineToolbar.test.tsx +46 -0
  20. package/src/components/TimelineToolbar.tsx +17 -0
  21. package/src/components/editor/DomEditOverlay.tsx +36 -18
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  23. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  24. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  25. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  26. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  27. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  28. package/src/components/editor/PropertyPanel.tsx +7 -23
  29. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  30. package/src/components/editor/audioFxSummary.test.ts +75 -0
  31. package/src/components/editor/audioFxSummary.ts +36 -0
  32. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  33. package/src/components/editor/audioFxTelemetry.ts +230 -0
  34. package/src/components/editor/domEditInlineText.test.ts +86 -0
  35. package/src/components/editor/domEditInlineText.ts +99 -0
  36. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  37. package/src/components/editor/domEditing.ts +1 -0
  38. package/src/components/editor/domEditingLayers.ts +7 -6
  39. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  40. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  41. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  42. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  43. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  44. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  45. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  46. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  47. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  48. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  49. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  50. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  51. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  52. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  53. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  54. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  55. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  57. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  58. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  59. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  60. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  61. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  62. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  63. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  64. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  65. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  66. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  67. package/src/components/editor/propertyPanelTypes.ts +5 -0
  68. package/src/components/editor/useDomEditNudge.ts +2 -2
  69. package/src/components/editor/useFxAudition.ts +95 -0
  70. package/src/components/editor/useFxCarve.ts +0 -0
  71. package/src/components/editor/useFxChainObserved.ts +83 -0
  72. package/src/components/editor/useFxLevelling.ts +235 -0
  73. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  74. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  75. package/src/components/editor/useVolumeAutomation.ts +54 -0
  76. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  77. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  78. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  79. package/src/components/feedback/feedbackTrigger.ts +269 -0
  80. package/src/components/feedback/projectProvenance.test.ts +120 -0
  81. package/src/components/feedback/projectProvenance.ts +83 -0
  82. package/src/components/renders/useRenderQueue.ts +62 -4
  83. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  84. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  85. package/src/contexts/DomEditContext.tsx +15 -0
  86. package/src/hooks/domEditPersistFailure.ts +0 -7
  87. package/src/hooks/useAppHotkeys.test.ts +198 -0
  88. package/src/hooks/useAppHotkeys.ts +31 -4
  89. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  90. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  91. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  92. package/src/hooks/useDomEditCommits.test.tsx +141 -21
  93. package/src/hooks/useDomEditCommits.ts +4 -0
  94. package/src/hooks/useDomEditSession.ts +4 -0
  95. package/src/hooks/useDomEditTextCommits.ts +112 -11
  96. package/src/hooks/useElementLifecycleOps.ts +3 -0
  97. package/src/hooks/useFileTree.ts +8 -3
  98. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  99. package/src/hooks/useInlineTextEdit.ts +320 -0
  100. package/src/hooks/useLivePlayheadTime.ts +49 -0
  101. package/src/hooks/usePreviewPersistence.ts +7 -0
  102. package/src/hooks/useTimelineEditing.ts +3 -0
  103. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  104. package/src/player/components/AutomationValueInput.tsx +51 -0
  105. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  106. package/src/player/components/Timeline.test.ts +84 -0
  107. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  108. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  109. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  110. package/src/player/components/TimelineLanes.test.tsx +24 -5
  111. package/src/player/components/TimelineLanes.tsx +142 -154
  112. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  113. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  114. package/src/player/components/TimelineTrackRow.tsx +8 -1
  115. package/src/player/components/automationClipboard.test.ts +94 -0
  116. package/src/player/components/automationClipboard.ts +136 -0
  117. package/src/player/components/automationGestureKeys.test.ts +49 -0
  118. package/src/player/components/automationGestureKeys.ts +49 -0
  119. package/src/player/components/automationLaneData.test.ts +270 -0
  120. package/src/player/components/automationLaneData.ts +258 -0
  121. package/src/player/components/automationLaneDragMath.ts +194 -0
  122. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  123. package/src/player/components/automationLaneGeometry.ts +287 -0
  124. package/src/player/components/automationLaneHeight.ts +15 -0
  125. package/src/player/components/automationLanePointer.ts +20 -0
  126. package/src/player/components/automationLaneSelection.test.ts +183 -0
  127. package/src/player/components/automationLaneSelection.ts +135 -0
  128. package/src/player/components/automationShapes.test.ts +82 -0
  129. package/src/player/components/automationShapes.ts +70 -0
  130. package/src/player/components/automationSimplify.test.ts +48 -0
  131. package/src/player/components/automationSimplify.ts +51 -0
  132. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  133. package/src/player/components/timelineLaneProps.ts +19 -0
  134. package/src/player/components/timelineLayout.ts +11 -5
  135. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  136. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  137. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  138. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  139. package/src/player/components/useAutomationLanes.ts +170 -0
  140. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  141. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  142. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  143. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  145. package/src/player/lib/automationStoreSync.test.ts +83 -0
  146. package/src/player/lib/automationStoreSync.ts +56 -0
  147. package/src/player/lib/playbackShortcuts.ts +5 -4
  148. package/src/player/lib/timelineDOM.test.ts +33 -0
  149. package/src/player/lib/timelineDOM.ts +12 -0
  150. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  151. package/src/player/store/automationSelectionSlice.ts +54 -0
  152. package/src/player/store/playerStore.test.ts +15 -0
  153. package/src/player/store/playerStore.ts +54 -65
  154. package/src/player/store/timelineElement.ts +78 -0
  155. package/src/styles/studio.css +89 -0
  156. package/src/telemetry/agentRuntime.test.ts +45 -0
  157. package/src/telemetry/agentRuntime.ts +62 -0
  158. package/src/telemetry/breadcrumbs.test.ts +68 -0
  159. package/src/telemetry/breadcrumbs.ts +70 -0
  160. package/src/telemetry/client.ts +5 -0
  161. package/src/telemetry/events.ts +84 -3
  162. package/src/telemetry/system.ts +8 -0
  163. package/src/utils/sourcePatcher.ts +5 -1
  164. package/src/utils/studioFileHistory.test.ts +49 -0
  165. package/src/utils/studioFileHistory.ts +17 -1
  166. package/src/utils/studioHelpers.ts +2 -5
  167. package/src/utils/studioTelemetry.ts +16 -10
  168. package/src/utils/timelineDiscovery.ts +3 -24
  169. package/src/utils/typingTarget.test.ts +54 -0
  170. package/src/utils/typingTarget.ts +43 -0
  171. package/dist/assets/index-DerI0ikN.js +0 -428
  172. package/dist/assets/index-tBPidglp.css +0 -1
  173. package/dist/chunk-OBAG3GWK.js.map +0 -1
  174. package/src/components/StudioFeedbackBar.tsx +0 -217
  175. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -15,6 +15,7 @@ export interface DomEditActionsValue extends Pick<
15
15
  | "handleDomStyleCommit"
16
16
  | "handleDomAttributeCommit"
17
17
  | "handleDomAttributeLiveCommit"
18
+ | "handleDomAttributeQuietCommit"
18
19
  | "handleDomHtmlAttributeCommit"
19
20
  | "handleDomAttributesCommit"
20
21
  | "handleDomPathOffsetCommit"
@@ -24,6 +25,7 @@ export interface DomEditActionsValue extends Pick<
24
25
  | "handleDomRotationCommit"
25
26
  | "handleDomManualEditsReset"
26
27
  | "handleDomTextCommit"
28
+ | "handleDomRichTextCommit"
27
29
  | "handleDomTextFieldStyleCommit"
28
30
  | "handleDomAddTextField"
29
31
  | "handleDomRemoveTextField"
@@ -115,6 +117,13 @@ export function useDomEditSelectionContext(): DomEditSelectionValue {
115
117
  return ctx;
116
118
  }
117
119
 
120
+ /** Optional counterpart to useDomEditActionsContextOptional — same reason: the
121
+ * player package's own components mount outside a provider in standalone and
122
+ * test trees, where "no dom-edit selection" is the correct answer. */
123
+ export function useDomEditSelectionContextOptional(): DomEditSelectionValue | null {
124
+ return useContext(DomEditSelectionContext);
125
+ }
126
+
118
127
  /** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */
119
128
  export function useDomEditContext(): DomEditValue {
120
129
  return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() };
@@ -139,6 +148,7 @@ export function DomEditProvider({
139
148
  handleDomStyleCommit,
140
149
  handleDomAttributeCommit,
141
150
  handleDomAttributeLiveCommit,
151
+ handleDomAttributeQuietCommit,
142
152
  handleDomHtmlAttributeCommit,
143
153
  handleDomAttributesCommit,
144
154
  handleDomPathOffsetCommit,
@@ -149,6 +159,7 @@ export function DomEditProvider({
149
159
  handleDomManualEditsReset,
150
160
 
151
161
  handleDomTextCommit,
162
+ handleDomRichTextCommit,
152
163
  handleDomTextFieldStyleCommit,
153
164
  handleDomAddTextField,
154
165
  handleDomRemoveTextField,
@@ -227,6 +238,7 @@ export function DomEditProvider({
227
238
  handleDomStyleCommit,
228
239
  handleDomAttributeCommit,
229
240
  handleDomAttributeLiveCommit,
241
+ handleDomAttributeQuietCommit,
230
242
  handleDomHtmlAttributeCommit,
231
243
  handleDomAttributesCommit,
232
244
  handleDomPathOffsetCommit,
@@ -236,6 +248,7 @@ export function DomEditProvider({
236
248
  handleDomRotationCommit,
237
249
  handleDomManualEditsReset,
238
250
  handleDomTextCommit,
251
+ handleDomRichTextCommit,
239
252
  handleDomTextFieldStyleCommit,
240
253
  handleDomAddTextField,
241
254
  handleDomRemoveTextField,
@@ -296,6 +309,7 @@ export function DomEditProvider({
296
309
  handleDomStyleCommit,
297
310
  handleDomAttributeCommit,
298
311
  handleDomAttributeLiveCommit,
312
+ handleDomAttributeQuietCommit,
299
313
  handleDomHtmlAttributeCommit,
300
314
  handleDomAttributesCommit,
301
315
  handleDomPathOffsetCommit,
@@ -305,6 +319,7 @@ export function DomEditProvider({
305
319
  handleDomRotationCommit,
306
320
  handleDomManualEditsReset,
307
321
  handleDomTextCommit,
322
+ handleDomRichTextCommit,
308
323
  handleDomTextFieldStyleCommit,
309
324
  handleDomAddTextField,
310
325
  handleDomRemoveTextField,
@@ -19,13 +19,6 @@ export class DomEditPersistUnsafeValueError extends Error {
19
19
  }
20
20
  }
21
21
 
22
- export class DomEditPersistUnsupportedTextStructureError extends Error {
23
- constructor() {
24
- super("Couldn't save this text structure change");
25
- this.name = "DomEditPersistUnsupportedTextStructureError";
26
- }
27
- }
28
-
29
22
  export type DomEditPersistFailureSelection = Pick<
30
23
  DomEditSelection,
31
24
  "label" | "hfId" | "id" | "selector" | "selectorIndex" | "sourceFile"
@@ -0,0 +1,198 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { dispatchModifierKey, dispatchPlainKey } from "./useAppHotkeys";
4
+ import { usePlayerStore } from "../player/store/playerStore";
5
+ import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
6
+ import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
7
+ import type { TimelineElement } from "../player/store/timelineElement";
8
+
9
+ /** Minimal valid fixture — TimelineElement only requires these five fields. */
10
+ const bgmElement: TimelineElement = {
11
+ id: "bgm",
12
+ key: "bgm",
13
+ tag: "audio",
14
+ start: 0,
15
+ duration: 6,
16
+ track: 0,
17
+ };
18
+
19
+ /** Every callback dispatchPlainKey can reach, so a test can assert which one
20
+ * a key resolved to. Unannotated on purpose: the parameter type is not
21
+ * exported, and structural inference checks it at the call site. */
22
+ function callbacks() {
23
+ return {
24
+ handleTimelineElementDelete: vi.fn(async () => {}),
25
+ handleTimelineElementSplit: vi.fn(async () => {}),
26
+ handleDomEditElementDelete: vi.fn(async () => {}),
27
+ handleUndo: vi.fn(async () => {}),
28
+ handleRedo: vi.fn(async () => {}),
29
+ handleCopy: vi.fn(() => false),
30
+ handlePaste: vi.fn(async () => {}),
31
+ handleCut: vi.fn(async () => false),
32
+ onResetKeyframes: vi.fn(() => true),
33
+ onDeleteSelectedKeyframes: vi.fn(),
34
+ showToast: vi.fn(),
35
+ leftSidebarRef: { current: null },
36
+ domEditSelectionRef: { current: null },
37
+ };
38
+ }
39
+
40
+ const press = (key: string) =>
41
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
42
+
43
+ const chord = (key: string) =>
44
+ new KeyboardEvent("keydown", { key, metaKey: true, bubbles: true, cancelable: true });
45
+
46
+ afterEach(() => {
47
+ clearAutomationClipboard();
48
+ usePlayerStore.getState().clearAutomationSelection();
49
+ usePlayerStore.setState({
50
+ elements: [],
51
+ selectedElementId: null,
52
+ selectedElementIds: new Set<string>(),
53
+ selectedKeyframes: new Set<string>(),
54
+ });
55
+ });
56
+
57
+ describe("dispatchPlainKey — Delete arbitration", () => {
58
+ const selectBgm = () =>
59
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
60
+
61
+ const selectRange = () =>
62
+ usePlayerStore
63
+ .getState()
64
+ .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
65
+
66
+ it("deletes the selected clip when no automation range is active", () => {
67
+ selectBgm();
68
+ const cb = callbacks();
69
+ const e = press("Delete");
70
+ dispatchPlainKey(e, "delete", cb);
71
+ // The pre-existing contract, pinned so the new guard cannot widen.
72
+ expect(cb.handleTimelineElementDelete).toHaveBeenCalledTimes(1);
73
+ expect(e.defaultPrevented).toBe(true);
74
+ });
75
+
76
+ it("leaves the clip alone when an automation range is active", () => {
77
+ // The bug: this listener is on window/capture so it runs BEFORE
78
+ // useAutomationSelectionKeyboard's document/capture handler. Without the
79
+ // guard, clearing a 2s automation range deleted the whole audio clip.
80
+ selectBgm();
81
+ selectRange();
82
+ const cb = callbacks();
83
+ const e = press("Delete");
84
+ dispatchPlainKey(e, "delete", cb);
85
+ expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
86
+ // Must NOT be consumed: the automation handler downstream still needs it.
87
+ expect(e.defaultPrevented).toBe(false);
88
+ });
89
+
90
+ it("leaves keyframe reset alone when an automation range is active", () => {
91
+ // Backspace's reset-keyframes branch sits below the guard, so it has to be
92
+ // covered too — otherwise Backspace wiped every keyframe on the clip.
93
+ selectBgm();
94
+ usePlayerStore.setState({
95
+ keyframeCache: new Map([["bgm", { targets: [], version: 0 }]]),
96
+ });
97
+ selectRange();
98
+ const cb = callbacks();
99
+ const e = press("Backspace");
100
+ dispatchPlainKey(e, "backspace", cb);
101
+ expect(cb.onResetKeyframes).not.toHaveBeenCalled();
102
+ expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
103
+ expect(e.defaultPrevented).toBe(false);
104
+ });
105
+
106
+ it("still lets a keyframe selection win over an automation range", () => {
107
+ // Ordering: the keyframe guard precedes the automation one, so a keyframe
108
+ // selection keeps Delete even with a range showing.
109
+ selectBgm();
110
+ selectRange();
111
+ usePlayerStore.setState({ selectedKeyframes: new Set(["bgm:opacity:0"]) });
112
+ const cb = callbacks();
113
+ const e = press("Delete");
114
+ dispatchPlainKey(e, "delete", cb);
115
+ expect(cb.onDeleteSelectedKeyframes).toHaveBeenCalledTimes(1);
116
+ expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
117
+ expect(e.defaultPrevented).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe("dispatchModifierKey — Cmd+C/Cmd+V arbitration", () => {
122
+ const clip: TimelineElement = {
123
+ id: "bgm",
124
+ key: "bgm",
125
+ tag: "audio",
126
+ start: 0,
127
+ duration: 6,
128
+ track: 0,
129
+ };
130
+
131
+ it("lets the clip clipboard have Cmd+C when no automation range is active", () => {
132
+ usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
133
+ const cb = callbacks();
134
+ dispatchModifierKey(chord("c"), "c", cb);
135
+ expect(cb.handleCopy).toHaveBeenCalled();
136
+ });
137
+
138
+ it("keeps Cmd+C from the clip clipboard when an automation range is active", () => {
139
+ // Both clipboards arming on one press double-wrote and toasted "Copied clip".
140
+ usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
141
+ usePlayerStore.getState().setAutomationSelection({
142
+ elementKey: "bgm",
143
+ target: "volume",
144
+ t0: 1,
145
+ t1: 3,
146
+ });
147
+ const cb = callbacks();
148
+ const e = chord("c");
149
+ expect(dispatchModifierKey(e, "c", cb)).toBe(true);
150
+ expect(cb.handleCopy).not.toHaveBeenCalled();
151
+ // No preventDefault: the automation handler downstream still needs the key.
152
+ expect(e.defaultPrevented).toBe(false);
153
+ });
154
+
155
+ it("lets the clip clipboard have Cmd+V when the automation clipboard is empty", () => {
156
+ // Nothing to paste means nothing to claim — the clip paste should still run.
157
+ clearAutomationClipboard();
158
+ usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
159
+ usePlayerStore.getState().setAutomationSelection({
160
+ elementKey: "bgm",
161
+ target: "volume",
162
+ t0: 1,
163
+ t1: 3,
164
+ });
165
+ const cb = callbacks();
166
+ dispatchModifierKey(chord("v"), "v", cb);
167
+ expect(cb.handlePaste).toHaveBeenCalled();
168
+ });
169
+
170
+ it("keeps Cmd+V from duplicating the clip while an automation paste is pending", () => {
171
+ clearAutomationClipboard();
172
+ copyRange(
173
+ null,
174
+ {
175
+ target: "volume",
176
+ points: [
177
+ { t: 1, v: 1 },
178
+ { t: 3, v: 0.25 },
179
+ ],
180
+ },
181
+ VOLUME_RANGE,
182
+ 1,
183
+ 3,
184
+ );
185
+ usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
186
+ usePlayerStore.getState().setAutomationSelection({
187
+ elementKey: "bgm",
188
+ target: "volume",
189
+ t0: 1,
190
+ t1: 3,
191
+ });
192
+ const cb = callbacks();
193
+ const e = chord("v");
194
+ dispatchModifierKey(e, "v", cb);
195
+ expect(cb.handlePaste).not.toHaveBeenCalled();
196
+ expect(e.defaultPrevented).toBe(false);
197
+ });
198
+ });
@@ -1,9 +1,11 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
+ import { automationOwnsKey } from "./useAutomationSelectionKeyboard";
2
3
  import { usePlayerStore } from "../player";
3
4
  import type { TimelineElement } from "../player";
4
5
  import type { DomEditSelection } from "../components/editor/domEditing";
5
6
  import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
7
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
8
+ import { isTypingTarget } from "../utils/typingTarget";
7
9
  import { isEditableTarget } from "../utils/timelineDiscovery";
8
10
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
11
  import { canSplitElement } from "../utils/timelineElementSplit";
@@ -158,7 +160,14 @@ interface HotkeyCallbacks {
158
160
  showToast: (message: string, tone?: "error" | "info") => void;
159
161
  }
160
162
 
161
- function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
163
+ /** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
164
+ * arbitration between an automation range and the clip clipboard be asserted
165
+ * without standing up the whole hook. */
166
+ export function dispatchModifierKey(
167
+ event: KeyboardEvent,
168
+ key: string,
169
+ cb: HotkeyCallbacks,
170
+ ): boolean {
162
171
  if (
163
172
  !shouldIgnoreHistoryShortcut(event.target) &&
164
173
  handleUndoRedoKey(
@@ -188,7 +197,7 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
188
197
  return true;
189
198
  }
190
199
 
191
- if (key === "g" && !event.altKey && !isEditableTarget(event.target)) {
200
+ if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
192
201
  event.preventDefault();
193
202
  if (event.shiftKey) cb.onUngroupSelection?.();
194
203
  else cb.onGroupSelection?.();
@@ -196,6 +205,14 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
196
205
  }
197
206
 
198
207
  if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
208
+ // An active automation range owns Cmd+C/Cmd+V, the same way it owns Delete
209
+ // below. This listener is on window/capture and runs before
210
+ // useAutomationSelectionKeyboard's document/capture handler, so without
211
+ // this the clip clipboard also claimed the key: Cmd+V duplicated the clip
212
+ // while the automation paste wrote the same file, and Cmd+C armed both
213
+ // clipboards and toasted "Copied clip". Return without preventDefault so
214
+ // the downstream handler still sees the key.
215
+ if (automationOwnsKey(event)) return true;
199
216
  if (key === "c") {
200
217
  if (cb.handleCopy()) {
201
218
  event.preventDefault();
@@ -222,7 +239,10 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
222
239
  }
223
240
 
224
241
  // fallow-ignore-next-line complexity
225
- function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
242
+ /** Exported for tests: the unmodified-key half of the dispatcher, so the
243
+ * Delete arbitration between keyframes, an automation range and the clip can
244
+ * be asserted without standing up the whole hook. */
245
+ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
226
246
  if (key === "f" && !event.shiftKey && !event.altKey) {
227
247
  event.preventDefault();
228
248
  if (document.fullscreenElement) void document.exitFullscreen();
@@ -288,6 +308,13 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
288
308
  event.preventDefault();
289
309
  return;
290
310
  }
311
+ // An active automation range owns Delete: useAutomationSelectionKeyboard
312
+ // empties the range in place, pinning the anchors. Fall through WITHOUT
313
+ // preventDefault so that document-level handler still sees the key — this
314
+ // listener is on window/capture, so it runs first and everything below
315
+ // would otherwise win. Without this the press reaches the clip delete
316
+ // below and destroys the whole clip the lane belongs to.
317
+ if (usePlayerStore.getState().automationSelection) return;
291
318
  if (event.key === "Backspace") {
292
319
  const { selectedElementId, keyframeCache } = usePlayerStore.getState();
293
320
  if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) {
@@ -454,7 +481,7 @@ export function useAppHotkeys({
454
481
  dispatchModifierKey(event, key, cb);
455
482
  return;
456
483
  }
457
- if (!isEditableTarget(event.target)) dispatchPlainKey(event, key, cb);
484
+ if (!isTypingTarget(event.target)) dispatchPlainKey(event, key, cb);
458
485
  }, []);
459
486
 
460
487
  // eslint-disable-next-line no-restricted-syntax