@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
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Keyboard surface for the active automation selection: Escape clears,
3
+ * Delete/Backspace deletes every breakpoint inside the selection box, Cmd/Ctrl+C
4
+ * copies its span, Cmd/Ctrl+V pastes at the selection's start (or the playhead) onto the
5
+ * selected clip's lane. Sibling of
6
+ * useKeyframeKeyboard and copies its contract: capture phase so playback
7
+ * shortcuts cannot swallow keys we act on, inert while any text input has
8
+ * focus, and a key is only consumed when it does something.
9
+ *
10
+ * Falling through is NOT enough to keep clip-level copy/paste working:
11
+ * useAppHotkeys listens on `window` with capture, so it always runs before this
12
+ * document-level listener and `stopImmediatePropagation` here comes too late.
13
+ * `automationOwnsKey` below is the arbitration that actually works — the
14
+ * central dispatcher asks it first and stands down.
15
+ */
16
+ import { useEffect } from "react";
17
+ import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
18
+ import { laneFor, withLane } from "../player/components/automationLaneGeometry";
19
+ import { pointInSelection, replaceRange } from "../player/components/automationLaneSelection";
20
+ import {
21
+ copyRange,
22
+ isLastPasteSpan,
23
+ markLastPaste,
24
+ pastePoints,
25
+ readClipboard,
26
+ } from "../player/components/automationClipboard";
27
+ import {
28
+ resolveAutomationRange,
29
+ type AutomationRange,
30
+ type HfAutomation,
31
+ type HfAutomationLane,
32
+ } from "@hyperframes/core/audio-automation";
33
+ import { clampNumber } from "../utils/studioHelpers";
34
+ import type { AutomationSelection } from "../player/store/automationSelectionSlice";
35
+ import type {
36
+ AutomationLaneBinding,
37
+ UseAutomationLanesResult,
38
+ } from "../player/components/useAutomationLanes";
39
+
40
+ type PlayerState = ReturnType<typeof usePlayerStore.getState>;
41
+
42
+ function isTextInput(el: Element | null): boolean {
43
+ if (!el) return false;
44
+ const tag = el.tagName;
45
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
46
+ return el instanceof HTMLElement && el.isContentEditable;
47
+ }
48
+
49
+ /**
50
+ * A Cmd/Ctrl+<letter> chord. `e.key` is normalised because CapsLock makes it
51
+ * "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test
52
+ * would silently drop the keystroke here while that dispatcher still acted on
53
+ * it. Shift and Alt are excluded for the same parity reason (useAppHotkeys
54
+ * gates its own copy/paste on `!shiftKey && !altKey`).
55
+ */
56
+ function isChord(e: KeyboardEvent, letter: string): boolean {
57
+ return (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === letter;
58
+ }
59
+
60
+ /** A `TimelineElement`'s identity as the selection and lane bindings key by. */
61
+ function elementKeyOf(element: TimelineElement): string {
62
+ return element.key ?? element.id;
63
+ }
64
+
65
+ function findElement(elements: TimelineElement[], key: string | null): TimelineElement | null {
66
+ if (!key) return null;
67
+ return elements.find((el) => elementKeyOf(el) === key) ?? null;
68
+ }
69
+
70
+ /**
71
+ * A selection's element, binding, lane and range — the resolution Delete and
72
+ * copy both need. Null when the clip is gone, its lane is read-only, or the
73
+ * target no longer resolves to a range.
74
+ */
75
+ function resolveSelectionContext(
76
+ state: PlayerState,
77
+ lanes: UseAutomationLanesResult,
78
+ sel: AutomationSelection,
79
+ ): { binding: AutomationLaneBinding; lane: HfAutomationLane; range: AutomationRange } | null {
80
+ const element = findElement(state.elements, sel.elementKey);
81
+ if (!element) return null;
82
+ const binding = lanes.bind(element, sel.elementKey === state.selectedElementId);
83
+ if (binding.readOnly) return null;
84
+ const range = resolveAutomationRange(sel.target, binding.chain ?? undefined);
85
+ if (!range) return null;
86
+ return { binding, lane: laneFor(binding.automation, sel.target), range };
87
+ }
88
+
89
+ /**
90
+ * The write that deletes the breakpoints inside the active selection, or null when
91
+ * there is nothing to do: the clip is gone, its lane is read-only, the target no
92
+ * longer resolves to a range, or the selection covers no breakpoints.
93
+ *
94
+ * Deletes them outright rather than emptying the span behind anchor points. Anchors
95
+ * are what `replaceRange` exists for, and they are right for a shape insert or a
96
+ * paste — the envelope either side of the edit must not move. But Delete over a
97
+ * selection is the author saying "these points, gone", and answering that with two
98
+ * NEW points at the selection's edges reads as the delete not having worked. The
99
+ * envelope between the surviving neighbours re-interpolates, which is what deleting
100
+ * a breakpoint means everywhere else in the lane (right-clicking one does exactly
101
+ * this).
102
+ *
103
+ * Both axes of the selection box, edges included: what Delete removes is exactly
104
+ * what the lane drew a ring around. A point at the right time but outside the box's
105
+ * value bounds stays — which is the whole reason the box has them.
106
+ */
107
+ function resolveDeleteWrite(
108
+ state: PlayerState,
109
+ lanes: UseAutomationLanesResult,
110
+ sel: AutomationSelection,
111
+ ): { onCommit(next: HfAutomation): void; next: HfAutomation } | null {
112
+ const ctx = resolveSelectionContext(state, lanes, sel);
113
+ if (!ctx) return null;
114
+ const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel));
115
+ // Nothing inside is nothing to do — and it must stay a no-op rather than
116
+ // writing, or Delete over a smooth stretch would push an undo entry that
117
+ // changed nothing.
118
+ if (points.length === ctx.lane.points.length) return null;
119
+ return {
120
+ onCommit: ctx.binding.onCommit,
121
+ next: withLane(ctx.binding.automation, { target: sel.target, points }),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * The lane target Cmd+V writes to: the active selection's, when the
127
+ * selection belongs to the same clip the paste is landing on, else the
128
+ * clip's first automation lane. A selection left over on a different clip
129
+ * does not redirect the paste.
130
+ */
131
+ function pasteTargetName(
132
+ binding: AutomationLaneBinding,
133
+ elementKey: string,
134
+ sel: AutomationSelection | null,
135
+ ): string | undefined {
136
+ if (sel && sel.elementKey === elementKey) return sel.target;
137
+ return binding.lanes[0]?.target;
138
+ }
139
+
140
+ /**
141
+ * Where Cmd+V lands, or null when nothing is selected, the clip's lanes are
142
+ * read-only, it has no automation lane to fall back to, or the dom-edit layer
143
+ * would write the result to a DIFFERENT clip.
144
+ *
145
+ * That last guard is the one with teeth. `binding.onCommit` persists through
146
+ * handleDomAttributeQuietCommit, which targets whatever the dom-edit layer
147
+ * currently has selected — not the element `bind()` was handed (see the
148
+ * doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the
149
+ * timeline sets `selectedElementId` synchronously but resolves the dom-edit
150
+ * selection asynchronously, so clicking clip B and immediately pressing Cmd+V
151
+ * would serialize B's automation onto A. Every other lane path is a pointer
152
+ * gesture on the lane itself, which cannot run before the selection lands;
153
+ * paste is the only one that can, so it refuses rather than write blind.
154
+ */
155
+ function resolvePasteTarget(
156
+ state: PlayerState,
157
+ lanes: UseAutomationLanesResult,
158
+ sel: AutomationSelection | null,
159
+ ): {
160
+ elementKey: string;
161
+ element: TimelineElement;
162
+ target: string;
163
+ binding: AutomationLaneBinding;
164
+ lane: HfAutomationLane;
165
+ range: AutomationRange;
166
+ } | null {
167
+ const element = findElement(state.elements, state.selectedElementId);
168
+ if (!element) return null;
169
+ const elementKey = elementKeyOf(element);
170
+ const binding = lanes.bind(element, true);
171
+ if (binding.readOnly) return null;
172
+ if (binding.commitTargetKey !== elementKey) return null;
173
+ const target = pasteTargetName(binding, elementKey, sel);
174
+ if (!target) return null;
175
+ const range = resolveAutomationRange(target, binding.chain ?? undefined);
176
+ if (!range) return null;
177
+ return { elementKey, element, target, binding, lane: laneFor(binding.automation, target), range };
178
+ }
179
+
180
+ /** Is the playhead over this clip? Mirrors TimelineAutomationLaneSlot's own
181
+ * in-clip test, which is what decides a lane draws a playhead at all. */
182
+ function playheadInClip(state: PlayerState, element: TimelineElement): boolean {
183
+ return (
184
+ state.currentTime >= element.start && state.currentTime <= element.start + element.duration
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Clip-local seconds a `span`-wide paste should start at, or null when nothing
190
+ * can anchor it.
191
+ *
192
+ * Three refusals, all of which used to be silent mispastes:
193
+ * - A span wider than the clip has nowhere to go. Clamping the start to 0 still
194
+ * writes breakpoints past the clip's end and leaves a selection whose far
195
+ * edge can never be grabbed again.
196
+ * - A playhead outside the clip is not an anchor. It used to collapse to the
197
+ * clip's own t=0, so a playhead at 0:00 pasted into the head of a clip
198
+ * starting at 0:30.
199
+ * - No selection on this clip and no in-clip playhead means no anchor at all.
200
+ *
201
+ * A repeated Cmd+V chains. Paste leaves its own span selected (the user's only
202
+ * feedback that it landed), so anchoring at `sel.t0` unconditionally made the
203
+ * second press overwrite the first. When the live selection is exactly the mark
204
+ * the last paste left, anchor at its END; a selection the user drew themselves
205
+ * still pastes at its start.
206
+ */
207
+ function pasteAnchor(
208
+ state: PlayerState,
209
+ element: TimelineElement,
210
+ span: number,
211
+ sel: AutomationSelection | null,
212
+ ): number | null {
213
+ if (span > element.duration) return null;
214
+ const onThisElement = sel !== null && sel.elementKey === elementKeyOf(element);
215
+ const raw = onThisElement
216
+ ? isLastPasteSpan(sel)
217
+ ? sel.t1
218
+ : sel.t0
219
+ : playheadInClip(state, element)
220
+ ? state.currentTime - element.start
221
+ : null;
222
+ if (raw === null) return null;
223
+ // Keeps the whole pasted span inside the clip — including a chain that has
224
+ // walked to the end — so its own selection stays grabbable.
225
+ return clampNumber(raw, 0, element.duration - span);
226
+ }
227
+
228
+ /**
229
+ * Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active
230
+ * selection or the playhead. Returns false (untouched event) when the chord
231
+ * doesn't match, there is nothing to paste, or no lane can take it.
232
+ * Checked ahead of the "no selection" guard in the handler below: paste must
233
+ * work from the playhead with no active selection at all.
234
+ */
235
+ function handlePaste(
236
+ e: KeyboardEvent,
237
+ state: PlayerState,
238
+ lanes: UseAutomationLanesResult,
239
+ ): boolean {
240
+ if (!isChord(e, "v")) return false;
241
+ const clip = readClipboard(state.timelineProjectId);
242
+ if (!clip) return false;
243
+ const sel = state.automationSelection;
244
+ const paste = resolvePasteTarget(state, lanes, sel);
245
+ if (!paste) return false;
246
+ const atT = pasteAnchor(state, paste.element, clip.span, sel);
247
+ if (atT === null) return false;
248
+
249
+ const t1 = atT + clip.span;
250
+ const inner = pastePoints(clip, paste.range, atT);
251
+ const points = replaceRange({ lane: paste.lane, range: paste.range, t0: atT, t1, inner });
252
+
253
+ e.preventDefault();
254
+ e.stopImmediatePropagation();
255
+ paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points }));
256
+ // Select the pasted span — the only feedback that it landed — and mark it, so
257
+ // an immediate second Cmd+V recognises this selection as the paste's own and
258
+ // chains right after it instead of overwriting it.
259
+ // Full-height box over the pasted span: everything that landed is selected, so
260
+ // Delete straight after a paste undoes it in one press.
261
+ const mark = {
262
+ elementKey: paste.elementKey,
263
+ target: paste.target,
264
+ t0: atT,
265
+ t1,
266
+ v0: paste.range.min,
267
+ v1: paste.range.max,
268
+ };
269
+ state.setAutomationSelection(mark);
270
+ markLastPaste(mark);
271
+ return true;
272
+ }
273
+
274
+ /** Cmd/Ctrl+C on the active selection. Returns false when the chord doesn't
275
+ * match, the selection no longer resolves to a copyable lane, or the lane is
276
+ * empty so there is no shape to capture. */
277
+ function handleCopy(
278
+ e: KeyboardEvent,
279
+ state: PlayerState,
280
+ lanes: UseAutomationLanesResult,
281
+ sel: AutomationSelection,
282
+ ): boolean {
283
+ if (!isChord(e, "c")) return false;
284
+ const ctx = resolveSelectionContext(state, lanes, sel);
285
+ if (!ctx) return false;
286
+ if (!copyRange(state.timelineProjectId, ctx.lane, ctx.range, sel.t0, sel.t1)) return false;
287
+ e.preventDefault();
288
+ e.stopImmediatePropagation();
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * Will an automation range claim this keystroke? Asked by useAppHotkeys, which
294
+ * listens on `window` with capture and therefore always runs BEFORE this
295
+ * hook's document listener — so `stopImmediatePropagation` cannot arbitrate and
296
+ * the dispatcher has to stand down of its own accord. Without it Cmd+C armed
297
+ * both clipboards and Cmd+V ran the whole clip-duplication path (read file →
298
+ * insert → save with history → reload preview) alongside the automation write:
299
+ * two async read-modify-writes of one file from one keypress.
300
+ *
301
+ * Store and clipboard only, no lane binding, so the dispatcher can call it
302
+ * without holding the binding factory. That leaves one accepted residual: the
303
+ * predicate cannot see a read-only lane, an unresolvable target, or the
304
+ * dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases
305
+ * the keystroke is a no-op instead of falling through to clip copy/paste. A
306
+ * dead key beats today's double write.
307
+ */
308
+ export function automationOwnsKey(e: KeyboardEvent): boolean {
309
+ if (isTextInput(document.activeElement)) return false;
310
+ const state = usePlayerStore.getState();
311
+ if (isChord(e, "c")) return state.automationSelection !== null;
312
+ if (!isChord(e, "v")) return false;
313
+ const clip = readClipboard(state.timelineProjectId);
314
+ if (!clip) return false;
315
+ const element = findElement(state.elements, state.selectedElementId);
316
+ if (!element) return false;
317
+ // Same anchor resolution the handler uses, so predicate and handler cannot
318
+ // disagree about whether the paste has somewhere to land.
319
+ return pasteAnchor(state, element, clip.span, state.automationSelection) !== null;
320
+ }
321
+
322
+ /** Delete/Backspace on the active selection. Returns false when the key
323
+ * doesn't match or there is nothing to empty. */
324
+ function handleDelete(
325
+ e: KeyboardEvent,
326
+ state: PlayerState,
327
+ lanes: UseAutomationLanesResult,
328
+ sel: AutomationSelection,
329
+ ): boolean {
330
+ const isDeleteKey = e.key === "Delete" || e.key === "Backspace";
331
+ if (!isDeleteKey || e.metaKey || e.ctrlKey) return false;
332
+ const write = resolveDeleteWrite(state, lanes, sel);
333
+ if (!write) return false;
334
+ e.preventDefault();
335
+ e.stopImmediatePropagation();
336
+ write.onCommit(write.next);
337
+ return true;
338
+ }
339
+
340
+ export function useAutomationSelectionKeyboard({
341
+ lanes,
342
+ }: {
343
+ lanes: UseAutomationLanesResult;
344
+ }): void {
345
+ useEffect(() => {
346
+ const handler = (e: KeyboardEvent): void => {
347
+ if (isTextInput(document.activeElement)) return;
348
+ // Somebody upstream already claimed this key. useAppHotkeys is on
349
+ // window/capture so it always runs first, and it deliberately lets a
350
+ // keyframe selection outrank an automation range on Delete — without
351
+ // this, that keystroke deleted the keyframes there AND emptied the range
352
+ // here, two edits from one press. preventDefault does not stop
353
+ // propagation, so the claim has to be read, not assumed.
354
+ if (e.defaultPrevented) return;
355
+ const state = usePlayerStore.getState();
356
+ if (handlePaste(e, state, lanes)) return;
357
+
358
+ const sel = state.automationSelection;
359
+ if (!sel) return;
360
+
361
+ if (e.key === "Escape") {
362
+ state.clearAutomationSelection();
363
+ return;
364
+ }
365
+ if (handleCopy(e, state, lanes, sel)) return;
366
+ handleDelete(e, state, lanes, sel);
367
+ };
368
+ document.addEventListener("keydown", handler, true);
369
+ return () => document.removeEventListener("keydown", handler, true);
370
+ }, [lanes]);
371
+ }
@@ -8,6 +8,7 @@ import {
8
8
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
9
9
  import { reportDomEditPersistFailure } from "./domEditPersistFailure";
10
10
  import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
11
+ import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
11
12
 
12
13
  // ── Types ──
13
14
 
@@ -26,6 +27,26 @@ interface DataAttributeCommitOptions {
26
27
  skipRefresh: boolean;
27
28
  refreshAfter?: boolean;
28
29
  onSettled?: (ok: boolean) => void;
30
+ /**
31
+ * Undo grouping for a gesture that spans several commits.
32
+ *
33
+ * Without it the key is derived from the prefix, attribute and element, and the
34
+ * window is history's own 300ms — so a drag's moves and the release that ends it
35
+ * landed in different entries, and a drag slower than the window split further.
36
+ * A caller that knows a gesture is in progress passes one key for all of it.
37
+ */
38
+ coalesce?: { key: string; ms: number };
39
+ /**
40
+ * Apply to the preview and stop there — no file write, no history entry.
41
+ *
42
+ * What a gesture wants from every pointermove: the preview document and the
43
+ * audio graph following the pointer, with the file written once on release.
44
+ * Persisting each move put a fragment of one drag in the undo stack, and since
45
+ * those writes race, a follow-up's "before" was often not the previous entry's
46
+ * "after" — history refuses to coalesce across that gap, so undo took back a
47
+ * few milliseconds of the gesture and looked like it had done nothing.
48
+ */
49
+ previewOnly?: boolean;
29
50
  }
30
51
 
31
52
  function resolveFullAttrName(attr: string, prefixData: boolean | undefined): string {
@@ -108,7 +129,9 @@ export function useDomEditAttributeCommits({
108
129
  if (!domEditSelection) return;
109
130
  const iframe = previewIframeRef.current;
110
131
  const fullAttr = resolveFullAttrName(attr, true);
111
- const commitKey = `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
132
+ const commitKey =
133
+ options.coalesce?.key ??
134
+ `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
112
135
  const isLatestCommit = bumpDomEditCommitMapVersion(
113
136
  domAttributeCommitVersionRef.current,
114
137
  commitKey,
@@ -134,12 +157,15 @@ export function useDomEditAttributeCommits({
134
157
  const nextValue = value === null || value === "" ? null : value;
135
158
  setOrRemovePreviewAttribute(editedElement, fullAttr, nextValue);
136
159
  },
137
- persist: () =>
138
- persistDomEditOperations(domEditSelection, [op], {
139
- label: options.label,
140
- coalesceKey: commitKey,
141
- skipRefresh: options.skipRefresh,
142
- }),
160
+ persist: options.previewOnly
161
+ ? async () => {}
162
+ : () =>
163
+ persistDomEditOperations(domEditSelection, [op], {
164
+ label: options.label,
165
+ coalesceKey: commitKey,
166
+ ...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}),
167
+ skipRefresh: options.skipRefresh,
168
+ }),
143
169
  shouldRevert: () => isLatestCommit(),
144
170
  revert: () => {
145
171
  if (!editedElement) return;
@@ -147,7 +173,19 @@ export function useDomEditAttributeCommits({
147
173
  },
148
174
  onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
149
175
  shouldResync: () => isLatestCommit() && !!options.refreshAfter,
150
- resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
176
+ resync: () => {
177
+ refreshDomEditSelectionFromPreview(domEditSelection);
178
+ // The player store keeps its own copy of each element's attributes, and
179
+ // that copy is what the timeline's automation lanes draw from. Nothing
180
+ // else refreshes it: a commit patches the preview document and the file,
181
+ // and resyncs the dom-edit SELECTION for the panel. So every writer that
182
+ // did not also update the store by hand — the FX panel's automate and
183
+ // un-automate buttons, the keyboard Delete, a paste — changed the file and
184
+ // the audio while the lane went on drawing what it had, until a reload.
185
+ // One sink here rather than a sync in each writer, because three of them
186
+ // shipped without one.
187
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
188
+ },
151
189
  onSettled: options.onSettled,
152
190
  });
153
191
  },
@@ -273,12 +311,41 @@ export function useDomEditAttributeCommits({
273
311
  );
274
312
 
275
313
  const handleDomAttributeLiveCommit = useCallback(
276
- async (attr: string, value: string | null, onSettled?: (ok: boolean) => void) => {
314
+ async (
315
+ attr: string,
316
+ value: string | null,
317
+ onSettled?: (ok: boolean) => void,
318
+ live?: { coalesce?: { key: string; ms: number }; previewOnly?: boolean },
319
+ ) => {
277
320
  await commitDataAttribute(attr, value, {
278
321
  label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
279
322
  coalescePrefix: "attr-live",
280
323
  skipRefresh: true,
281
324
  onSettled,
325
+ ...(live?.coalesce ? { coalesce: live.coalesce } : {}),
326
+ ...(live?.previewOnly ? { previewOnly: true } : {}),
327
+ });
328
+ },
329
+ [commitDataAttribute],
330
+ );
331
+
332
+ /**
333
+ * Persist without reloading the preview, but re-read the selection afterwards.
334
+ *
335
+ * For attributes the runtime applies to the live graph itself — an audio FX
336
+ * chain, its automation — a reload would only interrupt playback to reach the
337
+ * state the preview already has. The resync is still needed: without it the
338
+ * panel keeps reading the selection snapshot it was built with, so a second
339
+ * edit computes from a pre-edit value and appears to do nothing.
340
+ */
341
+ const handleDomAttributeQuietCommit = useCallback(
342
+ async (attr: string, value: string | null, coalesce?: { key: string; ms: number }) => {
343
+ await commitDataAttribute(attr, value, {
344
+ label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
345
+ coalescePrefix: "attr-quiet",
346
+ skipRefresh: true,
347
+ refreshAfter: true,
348
+ ...(coalesce ? { coalesce } : {}),
282
349
  });
283
350
  },
284
351
  [commitDataAttribute],
@@ -327,7 +394,19 @@ export function useDomEditAttributeCommits({
327
394
  },
328
395
  onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
329
396
  shouldResync: () => isLatestCommit(),
330
- resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
397
+ resync: () => {
398
+ refreshDomEditSelectionFromPreview(domEditSelection);
399
+ // The player store keeps its own copy of each element's attributes, and
400
+ // that copy is what the timeline's automation lanes draw from. Nothing
401
+ // else refreshes it: a commit patches the preview document and the file,
402
+ // and resyncs the dom-edit SELECTION for the panel. So every writer that
403
+ // did not also update the store by hand — the FX panel's automate and
404
+ // un-automate buttons, the keyboard Delete, a paste — changed the file and
405
+ // the audio while the lane went on drawing what it had, until a reload.
406
+ // One sink here rather than a sync in each writer, because three of them
407
+ // shipped without one.
408
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
409
+ },
331
410
  });
332
411
  },
333
412
  [
@@ -343,6 +422,7 @@ export function useDomEditAttributeCommits({
343
422
  return {
344
423
  handleDomAttributeCommit,
345
424
  handleDomAttributeLiveCommit,
425
+ handleDomAttributeQuietCommit,
346
426
  handleDomHtmlAttributeCommit,
347
427
  handleDomAttributesCommit,
348
428
  };