@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.
- package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +86 -3
- package/dist/index.html +2 -2
- package/dist/index.js +24325 -17831
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +15 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +31 -4
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +141 -21
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +4 -0
- package/src/hooks/useDomEditTextCommits.ts +112 -11
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/telemetry/system.ts +8 -0
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/studioTelemetry.ts +16 -10
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-DerI0ikN.js +0 -428
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /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 =
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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: () =>
|
|
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 (
|
|
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: () =>
|
|
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
|
};
|