@hyperframes/studio 0.7.107 → 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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BSBk5srs.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/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -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
  };
@@ -1338,6 +1338,44 @@ describe("useDomEditCommits attribute persist handling", () => {
1338
1338
  }
1339
1339
  });
1340
1340
 
1341
+ it("applies a preview-only write without persisting it", async () => {
1342
+ // What a drag needs from every pointermove: the preview and the audio graph
1343
+ // follow, the file does not. Persisting each move filled the undo stack with
1344
+ // fragments of one gesture — and because those writes race, a follow-up's
1345
+ // "before" often was not the previous entry's "after", so history refused to
1346
+ // coalesce them and undo took back a few milliseconds of the drag.
1347
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
1348
+ const { iframe, element } = createPreviewElement();
1349
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
1350
+
1351
+ try {
1352
+ await act(async () => {
1353
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7", undefined, {
1354
+ previewOnly: true,
1355
+ });
1356
+ });
1357
+ expect(element.getAttribute("data-volume")).toBe("0.7");
1358
+ expect(fetchSpy).not.toHaveBeenCalled();
1359
+ } finally {
1360
+ rendered.cleanup();
1361
+ }
1362
+ });
1363
+
1364
+ it("still persists a live write that does not ask to be preview-only", async () => {
1365
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
1366
+ const { iframe, element } = createPreviewElement();
1367
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
1368
+
1369
+ try {
1370
+ await act(async () => {
1371
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7");
1372
+ });
1373
+ expect(fetchSpy).toHaveBeenCalled();
1374
+ } finally {
1375
+ rendered.cleanup();
1376
+ }
1377
+ });
1378
+
1341
1379
  it("keeps a data-attribute commit on success", async () => {
1342
1380
  stubPatchFetch({
1343
1381
  ok: true,
@@ -375,6 +375,7 @@ export function useDomEditCommits({
375
375
  handleDomStyleCommit,
376
376
  handleDomAttributeCommit,
377
377
  handleDomAttributeLiveCommit,
378
+ handleDomAttributeQuietCommit,
378
379
  handleDomHtmlAttributeCommit,
379
380
  handleDomAttributesCommit,
380
381
  handleDomTextCommit,
@@ -439,6 +440,7 @@ export function useDomEditCommits({
439
440
  handleDomStyleCommit,
440
441
  handleDomAttributeCommit,
441
442
  handleDomAttributeLiveCommit,
443
+ handleDomAttributeQuietCommit,
442
444
  handleDomHtmlAttributeCommit,
443
445
  handleDomAttributesCommit,
444
446
  handleDomTextCommit,
@@ -228,6 +228,7 @@ export function useDomEditSession({
228
228
  handleDomStyleCommit,
229
229
  handleDomAttributeCommit,
230
230
  handleDomAttributeLiveCommit,
231
+ handleDomAttributeQuietCommit,
231
232
  handleDomHtmlAttributeCommit,
232
233
  handleDomAttributesCommit,
233
234
  handleDomTextCommit,
@@ -493,6 +494,7 @@ export function useDomEditSession({
493
494
  handleDomStyleCommit,
494
495
  handleDomAttributeCommit,
495
496
  handleDomAttributeLiveCommit,
497
+ handleDomAttributeQuietCommit,
496
498
  handleDomHtmlAttributeCommit,
497
499
  handleDomAttributesCommit,
498
500
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
@@ -173,6 +173,7 @@ export function useDomEditTextCommits({
173
173
  const {
174
174
  handleDomAttributeCommit,
175
175
  handleDomAttributeLiveCommit,
176
+ handleDomAttributeQuietCommit,
176
177
  handleDomHtmlAttributeCommit,
177
178
  handleDomAttributesCommit,
178
179
  } = useDomEditAttributeCommits({
@@ -572,6 +573,7 @@ export function useDomEditTextCommits({
572
573
  handleDomStyleCommit,
573
574
  handleDomAttributeCommit,
574
575
  handleDomAttributeLiveCommit,
576
+ handleDomAttributeQuietCommit,
575
577
  handleDomHtmlAttributeCommit,
576
578
  handleDomAttributesCommit,
577
579
  handleDomTextCommit,
@@ -142,6 +142,9 @@ export function useElementLifecycleOps({
142
142
  kind: "timeline",
143
143
  files: { [targetPath]: patchedContent },
144
144
  readFile: async () => originalContent,
145
+ // remove-element already wrote the removal, so disk holds THAT — not
146
+ // the content read at the top. Undo still goes back to the original.
147
+ diskContent: { [targetPath]: patchedContent },
145
148
  writeFile: writeProjectFile,
146
149
  recordEdit: editHistory.recordEdit,
147
150
  });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The playhead in composition seconds, live while the transport runs.
3
+ *
4
+ * The RAF loop deliberately does not push every frame through the store — it
5
+ * notifies `liveTime` instead, so the playhead can move without re-rendering the
6
+ * app. A panel that wants to follow it therefore has to subscribe itself, and
7
+ * throttle: 30 Hz reads as continuous and costs an order of magnitude less than a
8
+ * render per frame.
9
+ *
10
+ * Paused, the store is the truth — a seek or a scrub lands there — so this returns
11
+ * that instead, which is what lets a readout follow the playhead while it is being
12
+ * dragged as well as while it is playing.
13
+ */
14
+ import { useEffect, useRef, useState } from "react";
15
+ import { liveTime, usePlayerStore } from "../player";
16
+
17
+ /** Long enough to be much cheaper than a frame, short enough to read as motion. */
18
+ const THROTTLE_MS = 33;
19
+
20
+ export function useLivePlayheadTime(): number {
21
+ const storeTime = usePlayerStore((s) => s.currentTime);
22
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
23
+ const liveRef = useRef(storeTime);
24
+ const [, forceRender] = useState(0);
25
+
26
+ // Paused, the ref tracks the store so the first frame of playback is never a
27
+ // stale value from the last time the transport ran.
28
+ if (!isPlaying) liveRef.current = storeTime;
29
+
30
+ useEffect(() => {
31
+ if (!isPlaying) return;
32
+ let timerId: ReturnType<typeof setTimeout> | 0 = 0;
33
+ const unsubscribe = liveTime.subscribe((t) => {
34
+ liveRef.current = t;
35
+ if (!timerId) {
36
+ timerId = setTimeout(() => {
37
+ timerId = 0;
38
+ forceRender((v) => v + 1);
39
+ }, THROTTLE_MS);
40
+ }
41
+ });
42
+ return () => {
43
+ unsubscribe();
44
+ if (timerId) clearTimeout(timerId);
45
+ };
46
+ }, [isPlaying]);
47
+
48
+ return isPlaying ? liveRef.current : storeTime;
49
+ }