@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,429 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
+
import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard";
|
|
7
|
+
import {
|
|
8
|
+
clearAutomationClipboard,
|
|
9
|
+
copyRange,
|
|
10
|
+
readClipboard,
|
|
11
|
+
} from "../player/components/automationClipboard";
|
|
12
|
+
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
13
|
+
import type {
|
|
14
|
+
AutomationLaneBinding,
|
|
15
|
+
UseAutomationLanesResult,
|
|
16
|
+
} from "../player/components/useAutomationLanes";
|
|
17
|
+
import type { TimelineElement } from "../player/store/timelineElement";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A selection box spanning the lane's whole value axis.
|
|
21
|
+
*
|
|
22
|
+
* What almost every test here is about is the time span — which breakpoints a
|
|
23
|
+
* Delete or a copy covers. The box's value bounds have their own tests; giving
|
|
24
|
+
* these an unbounded axis keeps them testing the one thing they name.
|
|
25
|
+
*/
|
|
26
|
+
function wholeAxis<T extends { t0: number; t1: number }>(sel: T): T & { v0: number; v1: number } {
|
|
27
|
+
return { ...sel, v0: Number.NEGATIVE_INFINITY, v1: Number.POSITIVE_INFINITY };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Minimal valid fixture — TimelineElement only requires these five fields. */
|
|
31
|
+
const bgmElement: TimelineElement = {
|
|
32
|
+
id: "bgm",
|
|
33
|
+
key: "bgm",
|
|
34
|
+
tag: "audio",
|
|
35
|
+
start: 0,
|
|
36
|
+
duration: 6,
|
|
37
|
+
track: 0,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
41
|
+
|
|
42
|
+
function Host({ lanes }: { lanes: UseAutomationLanesResult }) {
|
|
43
|
+
useAutomationSelectionKeyboard({ lanes });
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const key = (k: string) => {
|
|
48
|
+
const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true });
|
|
49
|
+
act(() => void document.dispatchEvent(e));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Cmd/Ctrl-modified key combo, returning the event so tests can inspect
|
|
53
|
+
* `defaultPrevented` for the "falls through" cases. */
|
|
54
|
+
const combo = (k: string) => {
|
|
55
|
+
const e = new KeyboardEvent("keydown", {
|
|
56
|
+
key: k,
|
|
57
|
+
metaKey: true,
|
|
58
|
+
bubbles: true,
|
|
59
|
+
cancelable: true,
|
|
60
|
+
});
|
|
61
|
+
act(() => void document.dispatchEvent(e));
|
|
62
|
+
return e;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe("useAutomationSelectionKeyboard", () => {
|
|
66
|
+
// Each setup() mounts a Host whose effect adds a document-level keydown
|
|
67
|
+
// listener. Without unmounting the previous one, listeners from earlier
|
|
68
|
+
// tests linger and can consume later tests' events first (stopping
|
|
69
|
+
// propagation before the current test's own listener ever runs) — so this
|
|
70
|
+
// must run before every test, not just the ones that call setup() twice.
|
|
71
|
+
let mountedRoot: { root: Root; host: HTMLElement } | null = null;
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
if (!mountedRoot) return;
|
|
74
|
+
act(() => mountedRoot?.root.unmount());
|
|
75
|
+
mountedRoot.host.remove();
|
|
76
|
+
mountedRoot = null;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const setup = (binding: Partial<AutomationLaneBinding>) => {
|
|
80
|
+
const onCommit = vi.fn();
|
|
81
|
+
const automation = {
|
|
82
|
+
version: 1,
|
|
83
|
+
lanes: [
|
|
84
|
+
{
|
|
85
|
+
target: "volume",
|
|
86
|
+
points: [
|
|
87
|
+
{ t: 0, v: 1 },
|
|
88
|
+
{ t: 2, v: 0.5 },
|
|
89
|
+
{ t: 4, v: 0 },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
const lanes: UseAutomationLanesResult = {
|
|
95
|
+
bind: () => ({
|
|
96
|
+
automation,
|
|
97
|
+
// Same list as `automation.lanes`, matching useAutomationLanes' real
|
|
98
|
+
// binding — the paste fallback (no active selection) reads this.
|
|
99
|
+
lanes: automation.lanes,
|
|
100
|
+
chain: null,
|
|
101
|
+
onPreview: vi.fn(),
|
|
102
|
+
onCommit,
|
|
103
|
+
onSelect: vi.fn(),
|
|
104
|
+
readOnly: false,
|
|
105
|
+
commitTargetKey: "bgm",
|
|
106
|
+
selection: null,
|
|
107
|
+
onRangeSelect: vi.fn(),
|
|
108
|
+
onRangeClear: vi.fn(),
|
|
109
|
+
...binding,
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
const host = document.createElement("div");
|
|
113
|
+
document.body.append(host);
|
|
114
|
+
const root = createRoot(host);
|
|
115
|
+
act(() => root.render(<Host lanes={lanes} />));
|
|
116
|
+
mountedRoot = { root, host };
|
|
117
|
+
return { onCommit };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
it("Delete removes every breakpoint the selection covers", () => {
|
|
121
|
+
// Deleted, not emptied. Pinning anchors at the selection's edges keeps the
|
|
122
|
+
// envelope either side from moving, which is right for a shape insert or a
|
|
123
|
+
// paste — but answering "delete these points" with two NEW points at the edges
|
|
124
|
+
// reads as the delete not having worked.
|
|
125
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
126
|
+
usePlayerStore
|
|
127
|
+
.getState()
|
|
128
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
|
|
129
|
+
const { onCommit } = setup({});
|
|
130
|
+
key("Delete");
|
|
131
|
+
const written = onCommit.mock.calls.at(-1)?.[0];
|
|
132
|
+
const points = written?.lanes?.[0]?.points ?? [];
|
|
133
|
+
// The fixture lane is 0, 2, 4: only t=2 was inside.
|
|
134
|
+
expect(points.map((p: { t: number }) => p.t)).toEqual([0, 4]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("Delete leaves a point the box's value bounds exclude", () => {
|
|
138
|
+
// The box spans the whole clip but only its top, so Delete takes the one
|
|
139
|
+
// breakpoint up there and nothing else. A time range could not express this.
|
|
140
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
141
|
+
usePlayerStore.getState().setAutomationSelection({
|
|
142
|
+
elementKey: "bgm",
|
|
143
|
+
target: "volume",
|
|
144
|
+
t0: 0,
|
|
145
|
+
t1: 4,
|
|
146
|
+
v0: 0.9,
|
|
147
|
+
v1: 1,
|
|
148
|
+
});
|
|
149
|
+
const { onCommit } = setup({});
|
|
150
|
+
key("Delete");
|
|
151
|
+
const written = onCommit.mock.calls.at(-1)?.[0];
|
|
152
|
+
const points = written?.lanes?.[0]?.points ?? [];
|
|
153
|
+
// Fixture is (0, v=1), (2, v=0.5), (4, v=0): only the first was in the box.
|
|
154
|
+
expect(points.map((p: { t: number }) => p.t)).toEqual([2, 4]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("Delete takes points sitting exactly on the selection's edges", () => {
|
|
158
|
+
// Endpoint-inclusive, matching the copy path: a point the selection was dragged
|
|
159
|
+
// over is inside it, edge or not. Every range operation leaves a breakpoint
|
|
160
|
+
// exactly on an edge, so excluding them would leave those behind every time.
|
|
161
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
162
|
+
usePlayerStore
|
|
163
|
+
.getState()
|
|
164
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
165
|
+
const { onCommit } = setup({});
|
|
166
|
+
key("Delete");
|
|
167
|
+
const points = onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? [];
|
|
168
|
+
expect(points.map((p: { t: number }) => p.t)).toEqual([0]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("Delete over a stretch with no breakpoints writes nothing at all", () => {
|
|
172
|
+
// A no-op rather than a write: emptying a span that had nothing in it used to
|
|
173
|
+
// push an undo entry that changed nothing but the anchors it invented.
|
|
174
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
175
|
+
usePlayerStore
|
|
176
|
+
.getState()
|
|
177
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2.5, t1: 3.5 }));
|
|
178
|
+
const { onCommit } = setup({});
|
|
179
|
+
const e = new KeyboardEvent("keydown", { key: "Delete", bubbles: true, cancelable: true });
|
|
180
|
+
act(() => void document.dispatchEvent(e));
|
|
181
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
182
|
+
expect(e.defaultPrevented).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("Delete clears the lane when the selection covers all of it", () => {
|
|
186
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
187
|
+
usePlayerStore
|
|
188
|
+
.getState()
|
|
189
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 0, t1: 6 }));
|
|
190
|
+
const { onCommit } = setup({});
|
|
191
|
+
key("Delete");
|
|
192
|
+
const written = onCommit.mock.calls.at(-1)?.[0];
|
|
193
|
+
// withLane drops a lane with no points left, so the attribute goes empty and
|
|
194
|
+
// the clip is back to its plain data-volume.
|
|
195
|
+
expect(written?.lanes ?? []).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("Escape clears the selection", () => {
|
|
199
|
+
usePlayerStore
|
|
200
|
+
.getState()
|
|
201
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
|
|
202
|
+
setup({});
|
|
203
|
+
key("Escape");
|
|
204
|
+
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("is inert while a text input has focus", () => {
|
|
208
|
+
usePlayerStore
|
|
209
|
+
.getState()
|
|
210
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
|
|
211
|
+
const { onCommit } = setup({});
|
|
212
|
+
const input = document.createElement("input");
|
|
213
|
+
document.body.append(input);
|
|
214
|
+
input.focus();
|
|
215
|
+
key("Delete");
|
|
216
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
217
|
+
input.remove();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("Cmd+C copies the active selection", () => {
|
|
221
|
+
clearAutomationClipboard();
|
|
222
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
223
|
+
usePlayerStore
|
|
224
|
+
.getState()
|
|
225
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
226
|
+
setup({});
|
|
227
|
+
combo("c");
|
|
228
|
+
const entry = readClipboard(null);
|
|
229
|
+
expect(entry?.span).toBe(2);
|
|
230
|
+
expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
|
|
234
|
+
clearAutomationClipboard();
|
|
235
|
+
// Duration wide enough that the playhead (5s) is not clamped down by the
|
|
236
|
+
// 0..duration-span bound — this is a paste-at-playhead test, not a
|
|
237
|
+
// clamp-boundary test.
|
|
238
|
+
usePlayerStore.setState({
|
|
239
|
+
elements: [{ ...bgmElement, duration: 10 }],
|
|
240
|
+
selectedElementId: "bgm",
|
|
241
|
+
currentTime: 5,
|
|
242
|
+
});
|
|
243
|
+
usePlayerStore
|
|
244
|
+
.getState()
|
|
245
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
246
|
+
const { onCommit } = setup({});
|
|
247
|
+
combo("c");
|
|
248
|
+
expect(readClipboard(null)?.span).toBe(2);
|
|
249
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
250
|
+
|
|
251
|
+
combo("v");
|
|
252
|
+
const written = onCommit.mock.calls.at(-1)?.[0];
|
|
253
|
+
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
|
|
254
|
+
expect(times).toContain(5); // playhead 5s − element start 0
|
|
255
|
+
expect(times).toContain(7); // + clipboard span 2
|
|
256
|
+
|
|
257
|
+
// Pasting again immediately should land right after the first paste.
|
|
258
|
+
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
259
|
+
elementKey: "bgm",
|
|
260
|
+
target: "volume",
|
|
261
|
+
t0: 5,
|
|
262
|
+
t1: 7,
|
|
263
|
+
// Full height: everything the paste landed is selected, so Delete straight
|
|
264
|
+
// after undoes it in one press.
|
|
265
|
+
v0: 0,
|
|
266
|
+
v1: 1,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("chains a second Cmd+V after the first instead of overwriting it", () => {
|
|
271
|
+
// The regression this pins: paste leaves its own span selected, so anchoring
|
|
272
|
+
// at sel.t0 unconditionally made every later press recompute the same atT.
|
|
273
|
+
clearAutomationClipboard();
|
|
274
|
+
usePlayerStore.setState({
|
|
275
|
+
elements: [{ ...bgmElement, duration: 10 }],
|
|
276
|
+
selectedElementId: "bgm",
|
|
277
|
+
});
|
|
278
|
+
usePlayerStore
|
|
279
|
+
.getState()
|
|
280
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
281
|
+
const { onCommit } = setup({});
|
|
282
|
+
combo("c");
|
|
283
|
+
|
|
284
|
+
combo("v");
|
|
285
|
+
const first = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
|
286
|
+
(p: { t: number }) => p.t,
|
|
287
|
+
);
|
|
288
|
+
expect(first).toContain(2);
|
|
289
|
+
expect(first).toContain(4);
|
|
290
|
+
|
|
291
|
+
combo("v");
|
|
292
|
+
const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
|
293
|
+
(p: { t: number }) => p.t,
|
|
294
|
+
);
|
|
295
|
+
expect(second).toContain(4);
|
|
296
|
+
expect(second).toContain(6);
|
|
297
|
+
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
298
|
+
elementKey: "bgm",
|
|
299
|
+
target: "volume",
|
|
300
|
+
t0: 4,
|
|
301
|
+
t1: 6,
|
|
302
|
+
// Full height: everything the paste landed is selected, so Delete straight
|
|
303
|
+
// after undoes it in one press.
|
|
304
|
+
v0: 0,
|
|
305
|
+
v1: 1,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
|
|
310
|
+
// The playhead branch already clamps to duration - span; the
|
|
311
|
+
// selection-start branch didn't, so pasting a 2s clip at a selection
|
|
312
|
+
// sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
|
|
313
|
+
// past element.duration — and leave the selection itself out of bounds.
|
|
314
|
+
clearAutomationClipboard();
|
|
315
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
316
|
+
usePlayerStore
|
|
317
|
+
.getState()
|
|
318
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
319
|
+
const { onCommit } = setup({});
|
|
320
|
+
combo("c");
|
|
321
|
+
expect(readClipboard(null)?.span).toBe(2);
|
|
322
|
+
|
|
323
|
+
// A 0.1s-wide selection right near the clip's 6s end.
|
|
324
|
+
usePlayerStore
|
|
325
|
+
.getState()
|
|
326
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 }));
|
|
327
|
+
combo("v");
|
|
328
|
+
const written = onCommit.mock.calls.at(-1)?.[0];
|
|
329
|
+
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
|
|
330
|
+
for (const t of times) {
|
|
331
|
+
expect(t).toBeGreaterThanOrEqual(0);
|
|
332
|
+
expect(t).toBeLessThanOrEqual(bgmElement.duration);
|
|
333
|
+
}
|
|
334
|
+
// Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
|
|
335
|
+
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
336
|
+
elementKey: "bgm",
|
|
337
|
+
target: "volume",
|
|
338
|
+
t0: 4,
|
|
339
|
+
t1: 6,
|
|
340
|
+
// Full height: everything the paste landed is selected, so Delete straight
|
|
341
|
+
// after undoes it in one press.
|
|
342
|
+
v0: 0,
|
|
343
|
+
v1: 1,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("refuses to paste when the dom-edit layer would write to a different clip", () => {
|
|
348
|
+
// selectedElementId says "bgm" but the commit channel is still on the
|
|
349
|
+
// previously selected clip — writing here would serialize bgm's automation
|
|
350
|
+
// onto that other clip and leave bgm untouched.
|
|
351
|
+
clearAutomationClipboard();
|
|
352
|
+
copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2);
|
|
353
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
354
|
+
usePlayerStore
|
|
355
|
+
.getState()
|
|
356
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
357
|
+
const { onCommit } = setup({ commitTargetKey: "some-other-clip" });
|
|
358
|
+
const e = combo("v");
|
|
359
|
+
expect(e.defaultPrevented).toBe(false);
|
|
360
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("does not paste from a playhead outside the clip", () => {
|
|
364
|
+
// No selection on this clip, and the playhead is past its end — there is no
|
|
365
|
+
// anchor. This used to collapse to the clip's own t=0.
|
|
366
|
+
clearAutomationClipboard();
|
|
367
|
+
usePlayerStore.setState({
|
|
368
|
+
elements: [bgmElement],
|
|
369
|
+
selectedElementId: "bgm",
|
|
370
|
+
currentTime: 2,
|
|
371
|
+
});
|
|
372
|
+
usePlayerStore
|
|
373
|
+
.getState()
|
|
374
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
375
|
+
const { onCommit } = setup({});
|
|
376
|
+
combo("c");
|
|
377
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
378
|
+
usePlayerStore.setState({ currentTime: 50 });
|
|
379
|
+
onCommit.mockClear();
|
|
380
|
+
|
|
381
|
+
const e = combo("v");
|
|
382
|
+
expect(e.defaultPrevented).toBe(false);
|
|
383
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("Cmd+C over an empty lane leaves an earlier clipboard alone", () => {
|
|
387
|
+
// An empty capture is byte-identical to the Delete payload, so arming the
|
|
388
|
+
// clipboard with it turns every later Cmd+V into a destructive flatten.
|
|
389
|
+
clearAutomationClipboard();
|
|
390
|
+
copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 3);
|
|
391
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
392
|
+
usePlayerStore
|
|
393
|
+
.getState()
|
|
394
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }));
|
|
395
|
+
setup({ automation: { version: 1, lanes: [{ target: "volume", points: [] }] } });
|
|
396
|
+
|
|
397
|
+
const e = combo("c");
|
|
398
|
+
expect(e.defaultPrevented).toBe(false);
|
|
399
|
+
expect(readClipboard(null)?.span).toBe(3);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("pastes with CapsLock on, where e.key is an uppercase V", () => {
|
|
403
|
+
clearAutomationClipboard();
|
|
404
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
405
|
+
usePlayerStore
|
|
406
|
+
.getState()
|
|
407
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
408
|
+
const { onCommit } = setup({});
|
|
409
|
+
combo("C");
|
|
410
|
+
expect(readClipboard(null)?.span).toBe(2);
|
|
411
|
+
|
|
412
|
+
onCommit.mockClear();
|
|
413
|
+
const e = combo("V");
|
|
414
|
+
expect(e.defaultPrevented).toBe(true);
|
|
415
|
+
expect(onCommit).toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("Cmd+V with clipboard content but no resolvable element falls through", () => {
|
|
419
|
+
clearAutomationClipboard();
|
|
420
|
+
copyRange(null, { target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1);
|
|
421
|
+
expect(readClipboard(null)).not.toBeNull();
|
|
422
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null });
|
|
423
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
424
|
+
const { onCommit } = setup({});
|
|
425
|
+
const e = combo("v");
|
|
426
|
+
expect(e.defaultPrevented).toBe(false);
|
|
427
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
428
|
+
});
|
|
429
|
+
});
|