@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,193 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
|
|
6
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
7
|
+
import { PAD_X } from "./automationLaneGeometry";
|
|
8
|
+
import { getTimelineLaneTop } from "./timelineLayout";
|
|
9
|
+
import { elementAutomation, elementAutomationLanes, elementFxChain } from "./automationLaneData";
|
|
10
|
+
import type { AutomationLaneBinding, UseAutomationLanesResult } from "./useAutomationLanes";
|
|
11
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
12
|
+
|
|
13
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
const element: TimelineElement = {
|
|
16
|
+
id: "bgm",
|
|
17
|
+
key: "bgm",
|
|
18
|
+
tag: "audio",
|
|
19
|
+
start: 0,
|
|
20
|
+
duration: 6,
|
|
21
|
+
track: 0,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function mountSlot(binding: Partial<AutomationLaneBinding>) {
|
|
25
|
+
const onRangeClear = vi.fn();
|
|
26
|
+
const lanes: UseAutomationLanesResult = {
|
|
27
|
+
bind: () => ({
|
|
28
|
+
automation: { version: 1, lanes: [] },
|
|
29
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
30
|
+
chain: null,
|
|
31
|
+
onPreview: vi.fn(),
|
|
32
|
+
onCommit: vi.fn(),
|
|
33
|
+
onSelect: vi.fn(),
|
|
34
|
+
readOnly: false,
|
|
35
|
+
commitTargetKey: "bgm",
|
|
36
|
+
selection: null,
|
|
37
|
+
onRangeSelect: vi.fn(),
|
|
38
|
+
onRangeClear,
|
|
39
|
+
...binding,
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
const host = document.createElement("div");
|
|
43
|
+
document.body.append(host);
|
|
44
|
+
act(() => {
|
|
45
|
+
createRoot(host).render(
|
|
46
|
+
<TimelineAutomationLaneSlot
|
|
47
|
+
elements={[element]}
|
|
48
|
+
isSelected={() => false}
|
|
49
|
+
lanes={lanes}
|
|
50
|
+
pps={100}
|
|
51
|
+
laneCount={0}
|
|
52
|
+
accentColor="#0af"
|
|
53
|
+
currentTime={0}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
return { onRangeClear };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Two narration slices sharing a row, each with its own chain. */
|
|
61
|
+
const chainOf = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
|
|
62
|
+
const lanesOf = (...targets: string[]) =>
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
version: 1,
|
|
65
|
+
lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const narration1: TimelineElement = {
|
|
69
|
+
...element,
|
|
70
|
+
id: "narration-1",
|
|
71
|
+
key: "narration-1",
|
|
72
|
+
start: 0,
|
|
73
|
+
duration: 4,
|
|
74
|
+
fxChain: chainOf([
|
|
75
|
+
{ type: "lowpass", id: "n1", params: { frequency: 8000, q: 0.7, poles: "2" } },
|
|
76
|
+
{ type: "peaking", id: "n2", params: { frequency: 1000, gain: -3, q: 1.4 } },
|
|
77
|
+
]),
|
|
78
|
+
automation: lanesOf("fx.n2.q"),
|
|
79
|
+
};
|
|
80
|
+
const narration2: TimelineElement = {
|
|
81
|
+
...element,
|
|
82
|
+
id: "narration-2",
|
|
83
|
+
key: "narration-2",
|
|
84
|
+
start: 4,
|
|
85
|
+
duration: 4,
|
|
86
|
+
fxChain: chainOf([{ type: "peaking", id: "n1", params: { frequency: 1000, gain: -6, q: 1.4 } }]),
|
|
87
|
+
automation: lanesOf("fx.n1.q", "volume"),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Reads what a clip really carries, the way the live binding does. */
|
|
91
|
+
const readingBind = (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => ({
|
|
92
|
+
automation: elementAutomation(element),
|
|
93
|
+
lanes: elementAutomationLanes(element),
|
|
94
|
+
chain: elementFxChain(element),
|
|
95
|
+
onPreview: vi.fn(),
|
|
96
|
+
onCommit: vi.fn(),
|
|
97
|
+
onSelect: vi.fn(),
|
|
98
|
+
readOnly: !isSelected,
|
|
99
|
+
commitTargetKey: null,
|
|
100
|
+
selection: null,
|
|
101
|
+
onRangeSelect: vi.fn(),
|
|
102
|
+
onRangeClear: vi.fn(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function renderRow(elements: readonly TimelineElement[], selectedKey?: string): HTMLElement {
|
|
106
|
+
const host = document.createElement("div");
|
|
107
|
+
document.body.append(host);
|
|
108
|
+
act(() => {
|
|
109
|
+
createRoot(host).render(
|
|
110
|
+
<TimelineAutomationLaneSlot
|
|
111
|
+
elements={elements}
|
|
112
|
+
isSelected={(el) => el.key === selectedKey}
|
|
113
|
+
lanes={{ bind: readingBind }}
|
|
114
|
+
pps={100}
|
|
115
|
+
laneCount={0}
|
|
116
|
+
accentColor="#0af"
|
|
117
|
+
currentTime={0}
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
return host;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Every drawn envelope as `row @ left`, which is the whole claim under test. */
|
|
125
|
+
function mountRow(elements: readonly TimelineElement[], selectedKey?: string) {
|
|
126
|
+
return [...renderRow(elements, selectedKey).querySelectorAll<HTMLElement>(".hf-automation-lane")]
|
|
127
|
+
.map((lane) => `${lane.style.top} @ ${lane.querySelector("svg")?.style.left}`)
|
|
128
|
+
.sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ROW_0 = `${getTimelineLaneTop(0)}px`;
|
|
132
|
+
const ROW_1 = `${getTimelineLaneTop(0) + AUTOMATION_LANE_H}px`;
|
|
133
|
+
|
|
134
|
+
describe("TimelineAutomationLaneSlot shared rows", () => {
|
|
135
|
+
it("draws two clips' envelopes for one property in the same row", () => {
|
|
136
|
+
// One lane track, two envelopes — same row, each over its own span. The
|
|
137
|
+
// 1 kHz peaking Q is `fx.n2.q` on one clip and `fx.n1.q` on the other.
|
|
138
|
+
expect(mountRow([narration1, narration2])).toEqual(
|
|
139
|
+
[
|
|
140
|
+
`${ROW_0} @ ${0 - PAD_X}px`,
|
|
141
|
+
`${ROW_0} @ ${400 - PAD_X}px`,
|
|
142
|
+
`${ROW_1} @ ${400 - PAD_X}px`,
|
|
143
|
+
].sort(),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("leaves a clip's stretch empty in a row it does not automate", () => {
|
|
148
|
+
// Only narration-2 has a volume envelope, so row 1 carries one curve and
|
|
149
|
+
// narration-1's half of it stays blank rather than drawing a flat line.
|
|
150
|
+
expect(mountRow([narration1, narration2]).filter((row) => row.startsWith(ROW_1))).toEqual([
|
|
151
|
+
`${ROW_1} @ ${400 - PAD_X}px`,
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("lets each clip's envelope be reached, not just the one drawn last", () => {
|
|
156
|
+
// Every clip mounts a band spanning the WHOLE row (the row separator needs
|
|
157
|
+
// the full width), so three bands sit on top of each other here. If a band
|
|
158
|
+
// took the pointer, the last one rendered would swallow hover and drag over
|
|
159
|
+
// its siblings' envelopes — handles appeared only on the final clip, and
|
|
160
|
+
// only on rows that a single clip happened to own.
|
|
161
|
+
const host = renderRow([narration1, narration2]);
|
|
162
|
+
const bands = [...host.querySelectorAll<HTMLElement>(".hf-automation-lane")];
|
|
163
|
+
expect(bands.length).toBeGreaterThan(1);
|
|
164
|
+
for (const band of bands) {
|
|
165
|
+
expect(band.className).toContain("pointer-events-none");
|
|
166
|
+
expect(band.querySelector("svg")?.getAttribute("class")).toContain("pointer-events-auto");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps the same rows whichever clip is selected", () => {
|
|
171
|
+
// The bug this replaces: the row listed only the selected clip's lanes, so
|
|
172
|
+
// clicking a sibling swapped which envelopes existed.
|
|
173
|
+
expect(mountRow([narration1, narration2], "narration-1")).toEqual(
|
|
174
|
+
mountRow([narration1, narration2], "narration-2"),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("TimelineAutomationLaneSlot stale-selection guard", () => {
|
|
180
|
+
it("clears the selection when its lane's target no longer exists", () => {
|
|
181
|
+
const { onRangeClear } = mountSlot({
|
|
182
|
+
selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2, v0: 0, v1: 1 },
|
|
183
|
+
});
|
|
184
|
+
expect(onRangeClear).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("leaves an in-scope selection alone", () => {
|
|
188
|
+
const { onRangeClear } = mountSlot({
|
|
189
|
+
selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2, v0: 0, v1: 1 },
|
|
190
|
+
});
|
|
191
|
+
expect(onRangeClear).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -232,10 +232,23 @@ describe("TimelineLanes track numbering", () => {
|
|
|
232
232
|
describe("TimelineLanes disclosure target", () => {
|
|
233
233
|
const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]);
|
|
234
234
|
|
|
235
|
-
|
|
235
|
+
/**
|
|
236
|
+
* `aria-controls` is an ID LIST, and the caret needs one: it reveals the
|
|
237
|
+
* active clip's keyframe lanes AND the track's automation lanes, which cannot
|
|
238
|
+
* be one element — one belongs to a clip, the other to the row.
|
|
239
|
+
*/
|
|
240
|
+
function ariaControlsIds(host: HTMLElement): string[] {
|
|
236
241
|
const caret = host.querySelector("button[aria-controls]");
|
|
237
|
-
|
|
238
|
-
|
|
242
|
+
return (caret?.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ariaControlsTargets(host: HTMLElement): (HTMLElement | null)[] {
|
|
246
|
+
return ariaControlsIds(host).map((id) => host.querySelector<HTMLElement>(`#${id}`));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The first region named, which is the keyframe lanes. */
|
|
250
|
+
function ariaControlsTarget(host: HTMLElement): HTMLElement | null {
|
|
251
|
+
return ariaControlsTargets(host)[0] ?? null;
|
|
239
252
|
}
|
|
240
253
|
|
|
241
254
|
// aria-controls used to name a div in the sticky label column: it computed to
|
|
@@ -246,6 +259,9 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
246
259
|
|
|
247
260
|
expect(target).not.toBeNull();
|
|
248
261
|
expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0);
|
|
262
|
+
// Every region it names has to exist, or the caret points at nothing.
|
|
263
|
+
expect(ariaControlsTargets(view.host).length).toBeGreaterThan(1);
|
|
264
|
+
expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true);
|
|
249
265
|
act(() => view.root.unmount());
|
|
250
266
|
});
|
|
251
267
|
|
|
@@ -255,6 +271,9 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
255
271
|
|
|
256
272
|
expect(target).not.toBeNull();
|
|
257
273
|
expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
|
|
274
|
+
// Including the automation region, which is mounted empty while collapsed
|
|
275
|
+
// for exactly this reason.
|
|
276
|
+
expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true);
|
|
258
277
|
act(() => view.root.unmount());
|
|
259
278
|
});
|
|
260
279
|
|
|
@@ -266,8 +285,8 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
266
285
|
const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
|
|
267
286
|
|
|
268
287
|
const idsFor = (host: HTMLElement) =>
|
|
269
|
-
Array.from(host.querySelectorAll("button[aria-controls]")).
|
|
270
|
-
caret.getAttribute("aria-controls"),
|
|
288
|
+
Array.from(host.querySelectorAll("button[aria-controls]")).flatMap((caret) =>
|
|
289
|
+
(caret.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean),
|
|
271
290
|
);
|
|
272
291
|
const firstIds = idsFor(first.host);
|
|
273
292
|
const secondIds = idsFor(second.host);
|
|
@@ -3,25 +3,30 @@ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
|
3
3
|
import { TimelineClip } from "./TimelineClip";
|
|
4
4
|
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
|
|
5
5
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
6
|
+
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
|
|
7
|
+
import { useAutomationLanes } from "./useAutomationLanes";
|
|
8
|
+
import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
|
|
6
9
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
isTrackRowExpanded,
|
|
12
|
+
resolveTrackKeyframeClip,
|
|
13
|
+
trackShowsBeatStrip,
|
|
14
|
+
} from "./useTimelineTrackLayout";
|
|
8
15
|
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
9
16
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
10
|
-
import { getTimelineEditCapabilities
|
|
11
|
-
import { CLIP_Y,
|
|
12
|
-
import { usePlayerStore
|
|
17
|
+
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
18
|
+
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
|
19
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
13
20
|
import {
|
|
14
21
|
isMultiDragActive,
|
|
15
22
|
isMultiDragPassenger,
|
|
16
23
|
multiDragDeltaSeconds,
|
|
17
|
-
type MultiDragPreviewInput,
|
|
18
24
|
multiDragPassengerOffsetPx,
|
|
19
25
|
} from "./timelineMultiDragPreview";
|
|
20
|
-
import type {
|
|
21
|
-
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
26
|
+
import type { TimelineLanesProps } from "./timelineLaneProps";
|
|
22
27
|
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
23
|
-
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
24
28
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
29
|
+
import { createClipGestureHandlers } from "./timelineClipGestureHandlers";
|
|
25
30
|
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
|
|
26
31
|
import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
27
32
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
@@ -31,18 +36,6 @@ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
|
31
36
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
32
37
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
33
38
|
|
|
34
|
-
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
35
|
-
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
36
|
-
draggedElement: TimelineElement | null;
|
|
37
|
-
multiDragPreview: MultiDragPreviewInput | null;
|
|
38
|
-
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
39
|
-
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
40
|
-
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
41
|
-
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
42
|
-
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
|
43
|
-
onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
39
|
export function TimelineLanes({
|
|
47
40
|
pps,
|
|
48
41
|
contentOrigin,
|
|
@@ -109,6 +102,10 @@ export function TimelineLanes({
|
|
|
109
102
|
// from resolving into a second timeline that renders the same logical rows.
|
|
110
103
|
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
|
111
104
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
105
|
+
const automationLanes = useAutomationLanes();
|
|
106
|
+
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
107
|
+
const expandClips = usePlayerStore((s) => s.expandClips);
|
|
108
|
+
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
|
|
112
109
|
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
113
110
|
const logicalRowsByTrack = useMemo(() => {
|
|
114
111
|
const byTrack = new Map<number, TimelineLogicalRow[]>();
|
|
@@ -119,6 +116,16 @@ export function TimelineLanes({
|
|
|
119
116
|
}
|
|
120
117
|
return byTrack;
|
|
121
118
|
}, [logicalRows]);
|
|
119
|
+
// The caret belongs to the ROW, so it opens and closes every clip on it at
|
|
120
|
+
// once. Toggling only the active clip left the row's state depending on which
|
|
121
|
+
// sibling happened to be selected: expand one, click another, and the row
|
|
122
|
+
// collapsed under a caret that still pointed down.
|
|
123
|
+
const toggleRowExpandedTracked = (keys: readonly string[]) => {
|
|
124
|
+
const willExpand = !keys.some((key) => expandedClipIds.has(key));
|
|
125
|
+
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
126
|
+
if (willExpand) expandClips(keys);
|
|
127
|
+
else for (const key of keys) setClipExpanded(key, false);
|
|
128
|
+
};
|
|
122
129
|
const toggleClipExpandedTracked = (key: string) => {
|
|
123
130
|
const willExpand = !expandedClipIds.has(key);
|
|
124
131
|
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
@@ -184,12 +191,13 @@ export function TimelineLanes({
|
|
|
184
191
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
185
192
|
// All lanes use the same uniform color — no alternating stripes.
|
|
186
193
|
const rowBackground = theme.rowBackground;
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
194
|
+
// The beat-dot strip occupies the top of this track's lane (active track,
|
|
195
|
+
// or the music track when nothing is selected). When shown, keyframe
|
|
196
|
+
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
|
197
|
+
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
|
|
198
|
+
selectedElementId,
|
|
199
|
+
isMusicTrack,
|
|
200
|
+
});
|
|
193
201
|
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
|
194
202
|
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
|
195
203
|
// Only the selected/most-keyframed clip owns expanded lanes on a shared track.
|
|
@@ -200,10 +208,45 @@ export function TimelineLanes({
|
|
|
200
208
|
selectedElementIds,
|
|
201
209
|
);
|
|
202
210
|
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
//
|
|
211
|
+
const rowExpanded = isTrackRowExpanded(els, expandedClipIds);
|
|
212
|
+
// How tall a clip BAR is drawn. An expanded row is mostly lanes, and a
|
|
213
|
+
// clip left to fill it painted its waveform straight over them — so the
|
|
214
|
+
// bar is capped for every clip on the row, not just the one whose
|
|
215
|
+
// property lanes are showing. Undefined means "fill the row", which is
|
|
216
|
+
// right only while it is collapsed and the row is nothing but bar.
|
|
217
|
+
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
|
|
218
|
+
// The clips whose envelopes this row draws, at their dragged positions.
|
|
219
|
+
// Once per row, not once per clip in the map below.
|
|
220
|
+
const automationElements = els.map(getPreviewElement);
|
|
221
|
+
// Minted here because this is the only place that sees BOTH ends of
|
|
222
|
+
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
223
|
+
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
224
|
+
// fractional sort key and would mint ids like `...-0.16666666666666666`.
|
|
206
225
|
const lanesId = `${lanesIdPrefix}-track-${row}`;
|
|
226
|
+
// The caret reveals two canvas regions now: the active clip's keyframe
|
|
227
|
+
// lanes and the track's automation lanes. They cannot be one element —
|
|
228
|
+
// one belongs to a clip, the other to the row — so the caret names both.
|
|
229
|
+
const automationLanesId = `${lanesId}-automation`;
|
|
230
|
+
// The header's remove buttons write through the same binding the lanes
|
|
231
|
+
// themselves edit through, so a deletion persists exactly like dragging
|
|
232
|
+
// a point does — and the binding reports read-only for an unselected
|
|
233
|
+
// clip, which is what leaves the buttons off rather than offering one
|
|
234
|
+
// that cannot act.
|
|
235
|
+
const headerLanes =
|
|
236
|
+
keyframeClip && keyframeClipKey
|
|
237
|
+
? automationLanes.bind(
|
|
238
|
+
keyframeClip,
|
|
239
|
+
selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey),
|
|
240
|
+
)
|
|
241
|
+
: null;
|
|
242
|
+
const removeAutomationLane =
|
|
243
|
+
headerLanes && !headerLanes.readOnly
|
|
244
|
+
? (target: string) =>
|
|
245
|
+
headerLanes.onCommit({
|
|
246
|
+
version: 1,
|
|
247
|
+
lanes: headerLanes.lanes.filter((lane) => lane.target !== target),
|
|
248
|
+
})
|
|
249
|
+
: undefined;
|
|
207
250
|
return (
|
|
208
251
|
<TimelineTrackRow
|
|
209
252
|
key={rowKey}
|
|
@@ -212,6 +255,7 @@ export function TimelineLanes({
|
|
|
212
255
|
logicalRow={logicalRow}
|
|
213
256
|
propertyRows={trackLogicalRows.slice(1)}
|
|
214
257
|
lanesId={lanesId}
|
|
258
|
+
headerLanesId={`${lanesId} ${automationLanesId}`}
|
|
215
259
|
top={rowGeometry.getRowTop(row)}
|
|
216
260
|
height={rowHeight}
|
|
217
261
|
virtualized={rowsVirtualized}
|
|
@@ -230,23 +274,24 @@ export function TimelineLanes({
|
|
|
230
274
|
els[0]?.id ??
|
|
231
275
|
`Track${trackDisplaySuffix(displayNumber)}`
|
|
232
276
|
}
|
|
233
|
-
lanesId={lanesId}
|
|
277
|
+
lanesId={`${lanesId} ${automationLanesId}`}
|
|
234
278
|
contentOrigin={contentOrigin}
|
|
235
279
|
keyframeClip={keyframeClip}
|
|
280
|
+
trackElements={els}
|
|
236
281
|
clipCount={els.length}
|
|
237
|
-
isExpanded={
|
|
282
|
+
isExpanded={rowExpanded}
|
|
238
283
|
animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
|
|
239
284
|
currentTime={currentTime}
|
|
240
285
|
isTrackHidden={isTrackHidden}
|
|
241
286
|
isAudioTrack={isAudioTrack}
|
|
242
287
|
theme={theme}
|
|
243
288
|
onToggleClipExpanded={() => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
289
|
+
const keys = els.map(getTimelineElementIdentity);
|
|
290
|
+
if (keys.length > 0) toggleRowExpandedTracked(keys);
|
|
247
291
|
}}
|
|
248
292
|
onToggleTrackHidden={onToggleTrackHidden}
|
|
249
293
|
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
294
|
+
onRemoveAutomationLane={removeAutomationLane}
|
|
250
295
|
onSeek={onSeek}
|
|
251
296
|
rovingTargetId={keyboard.rovingTargetId}
|
|
252
297
|
/>
|
|
@@ -319,7 +364,7 @@ export function TimelineLanes({
|
|
|
319
364
|
// other clips (incl. siblings on a shared track) show compact
|
|
320
365
|
// diamonds on their own bar instead.
|
|
321
366
|
const isTrackKeyframeClip = elementKey === keyframeClipKey;
|
|
322
|
-
const showsLanes = isTrackKeyframeClip &&
|
|
367
|
+
const showsLanes = isTrackKeyframeClip && rowExpanded;
|
|
323
368
|
const capabilities = getTimelineEditCapabilities(el);
|
|
324
369
|
const isSelected =
|
|
325
370
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
@@ -344,6 +389,29 @@ export function TimelineLanes({
|
|
|
344
389
|
const passengerOffsetPx = isPassenger
|
|
345
390
|
? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
|
|
346
391
|
: 0;
|
|
392
|
+
const clipGestures = createClipGestureHandlers(
|
|
393
|
+
el,
|
|
394
|
+
elementKey,
|
|
395
|
+
previewElement,
|
|
396
|
+
capabilities,
|
|
397
|
+
{
|
|
398
|
+
pps,
|
|
399
|
+
onResizeElement,
|
|
400
|
+
onMoveElement,
|
|
401
|
+
onRazorSplit,
|
|
402
|
+
onRazorSplitAll,
|
|
403
|
+
blockedClipRef,
|
|
404
|
+
shiftClickClipRef,
|
|
405
|
+
suppressClickRef,
|
|
406
|
+
scrollRef,
|
|
407
|
+
setShowPopover,
|
|
408
|
+
setRangeSelection,
|
|
409
|
+
setResizingClip,
|
|
410
|
+
setDraggedClip,
|
|
411
|
+
setSelectedElementId,
|
|
412
|
+
onSelectElement,
|
|
413
|
+
},
|
|
414
|
+
);
|
|
347
415
|
const clip = (
|
|
348
416
|
<TimelineClip
|
|
349
417
|
key={clipKey}
|
|
@@ -354,7 +422,7 @@ export function TimelineLanes({
|
|
|
354
422
|
el={previewElement}
|
|
355
423
|
pps={pps}
|
|
356
424
|
clipY={CLIP_Y}
|
|
357
|
-
clipHeight={
|
|
425
|
+
clipHeight={clipBarHeight}
|
|
358
426
|
isSelected={isSelected}
|
|
359
427
|
isHovered={hoveredClip === clipKey}
|
|
360
428
|
isDragging={false}
|
|
@@ -368,123 +436,9 @@ export function TimelineLanes({
|
|
|
368
436
|
}
|
|
369
437
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
370
438
|
onHoverEnd={() => setHoveredClip(null)}
|
|
371
|
-
onResizeStart={
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
375
|
-
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
376
|
-
if (edge === "end" && !capabilities.canTrimEnd) return;
|
|
377
|
-
e.stopPropagation();
|
|
378
|
-
blockedClipRef.current = null;
|
|
379
|
-
setShowPopover(false);
|
|
380
|
-
setRangeSelection(null);
|
|
381
|
-
setResizingClip({
|
|
382
|
-
pointerId: e.pointerId,
|
|
383
|
-
element: el,
|
|
384
|
-
edge,
|
|
385
|
-
originClientX: e.clientX,
|
|
386
|
-
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
387
|
-
previewStart: el.start,
|
|
388
|
-
previewDuration: el.duration,
|
|
389
|
-
previewPlaybackStart: el.playbackStart,
|
|
390
|
-
started: false,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
onPointerDown={
|
|
395
|
-
// fallow-ignore-next-line complexity
|
|
396
|
-
(e) => {
|
|
397
|
-
if (e.button !== 0) return;
|
|
398
|
-
if (usePlayerStore.getState().activeTool === "razor") return;
|
|
399
|
-
if (e.shiftKey) {
|
|
400
|
-
shiftClickClipRef.current = {
|
|
401
|
-
element: el,
|
|
402
|
-
anchorX: e.clientX,
|
|
403
|
-
anchorY: e.clientY,
|
|
404
|
-
};
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
const target = e.currentTarget as HTMLElement;
|
|
408
|
-
const rect = target.getBoundingClientRect();
|
|
409
|
-
const blockedIntent = resolveBlockedTimelineEditIntent({
|
|
410
|
-
width: rect.width,
|
|
411
|
-
offsetX: e.clientX - rect.left,
|
|
412
|
-
handleWidth: CLIP_HANDLE_W,
|
|
413
|
-
capabilities,
|
|
414
|
-
});
|
|
415
|
-
if (
|
|
416
|
-
blockedIntent &&
|
|
417
|
-
((blockedIntent === "move" && onMoveElement) ||
|
|
418
|
-
(blockedIntent !== "move" && onResizeElement))
|
|
419
|
-
) {
|
|
420
|
-
blockedClipRef.current = {
|
|
421
|
-
pointerId: e.pointerId,
|
|
422
|
-
element: el,
|
|
423
|
-
intent: blockedIntent,
|
|
424
|
-
originClientX: e.clientX,
|
|
425
|
-
originClientY: e.clientY,
|
|
426
|
-
started: false,
|
|
427
|
-
};
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
if (!onMoveElement || !capabilities.canMove) return;
|
|
431
|
-
blockedClipRef.current = null;
|
|
432
|
-
setShowPopover(false);
|
|
433
|
-
setRangeSelection(null);
|
|
434
|
-
setDraggedClip({
|
|
435
|
-
pointerId: e.pointerId,
|
|
436
|
-
element: el,
|
|
437
|
-
originClientX: e.clientX,
|
|
438
|
-
originClientY: e.clientY,
|
|
439
|
-
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
440
|
-
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
|
441
|
-
pointerClientX: e.clientX,
|
|
442
|
-
pointerClientY: e.clientY,
|
|
443
|
-
pointerOffsetX: e.clientX - rect.left,
|
|
444
|
-
pointerOffsetY: e.clientY - rect.top,
|
|
445
|
-
previewStart: el.start,
|
|
446
|
-
previewTrack: el.track,
|
|
447
|
-
desiredTrack: el.track,
|
|
448
|
-
insertRow: null,
|
|
449
|
-
snapTime: null,
|
|
450
|
-
snapType: null,
|
|
451
|
-
started: false,
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
onClick={
|
|
456
|
-
// fallow-ignore-next-line complexity
|
|
457
|
-
(e) => {
|
|
458
|
-
e.stopPropagation();
|
|
459
|
-
if (suppressClickRef.current) return;
|
|
460
|
-
const { activeTool } = usePlayerStore.getState();
|
|
461
|
-
if (activeTool === "razor" && onRazorSplit) {
|
|
462
|
-
const clipRect = (
|
|
463
|
-
e.currentTarget as HTMLElement
|
|
464
|
-
).getBoundingClientRect();
|
|
465
|
-
const clickOffsetX = e.clientX - clipRect.left;
|
|
466
|
-
const splitTime = previewElement.start + clickOffsetX / pps;
|
|
467
|
-
const clampedTime = Math.max(
|
|
468
|
-
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
|
469
|
-
Math.min(
|
|
470
|
-
previewElement.start +
|
|
471
|
-
previewElement.duration -
|
|
472
|
-
SPLIT_BOUNDARY_EPSILON_S,
|
|
473
|
-
splitTime,
|
|
474
|
-
),
|
|
475
|
-
);
|
|
476
|
-
if (e.shiftKey && onRazorSplitAll) {
|
|
477
|
-
onRazorSplitAll(clampedTime);
|
|
478
|
-
} else {
|
|
479
|
-
onRazorSplit(el, clampedTime);
|
|
480
|
-
}
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
// Clip selection is idempotent; empty timeline space owns deselection.
|
|
484
|
-
setSelectedElementId(elementKey);
|
|
485
|
-
onSelectElement?.(el);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
439
|
+
onResizeStart={clipGestures.onResizeStart}
|
|
440
|
+
onPointerDown={clipGestures.onPointerDown}
|
|
441
|
+
onClick={clipGestures.onClick}
|
|
488
442
|
onDoubleClick={(e) => {
|
|
489
443
|
e.stopPropagation();
|
|
490
444
|
if (suppressClickRef.current) return;
|
|
@@ -562,7 +516,11 @@ export function TimelineLanes({
|
|
|
562
516
|
suppressClickRef={suppressClickRef}
|
|
563
517
|
/>
|
|
564
518
|
);
|
|
565
|
-
|
|
519
|
+
|
|
520
|
+
// Keep one keyed top-level child per element. Returning an
|
|
521
|
+
// array here makes React reconcile the outer array by
|
|
522
|
+
// position, so a window shift remounts otherwise stable
|
|
523
|
+
// clip keys and can tear down focus mid-reveal.
|
|
566
524
|
if (!isPassenger) {
|
|
567
525
|
return (
|
|
568
526
|
<Fragment key={clipKey}>
|
|
@@ -590,6 +548,36 @@ export function TimelineLanes({
|
|
|
590
548
|
);
|
|
591
549
|
})
|
|
592
550
|
}
|
|
551
|
+
{/* The automation lanes belong to the ROW, so they are mounted
|
|
552
|
+
here rather than under the active clip's property lanes.
|
|
553
|
+
Hanging off that clip meant selecting a sibling moved the
|
|
554
|
+
whole subtree into a different clip's element and remounted
|
|
555
|
+
every lane — which threw away each lane's hover state (and
|
|
556
|
+
any gesture mid-flight), so pressing a lane to select its
|
|
557
|
+
clip made the handles you were reaching for disappear.
|
|
558
|
+
|
|
559
|
+
Mounted in BOTH disclosure states, empty while collapsed, so
|
|
560
|
+
the caret's aria-controls resolves either way — same reason
|
|
561
|
+
the keyframe lanes are. Absolute positions inside resolve
|
|
562
|
+
against this same relative row, so the geometry is unchanged
|
|
563
|
+
by the move. */}
|
|
564
|
+
<div id={automationLanesId}>
|
|
565
|
+
{rowExpanded ? (
|
|
566
|
+
<TimelineAutomationLaneSlot
|
|
567
|
+
elements={automationElements}
|
|
568
|
+
isSelected={(element) => {
|
|
569
|
+
const key = getTimelineElementIdentity(element);
|
|
570
|
+
return selectedElementId === key || selectedElementIds.has(key);
|
|
571
|
+
}}
|
|
572
|
+
lanes={automationLanes}
|
|
573
|
+
pps={pps}
|
|
574
|
+
laneCount={keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0}
|
|
575
|
+
accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent}
|
|
576
|
+
currentTime={currentTime}
|
|
577
|
+
beatTimes={beatAnalysis?.beatTimes}
|
|
578
|
+
/>
|
|
579
|
+
) : null}
|
|
580
|
+
</div>
|
|
593
581
|
</div>
|
|
594
582
|
</TimelineTrackRow>
|
|
595
583
|
);
|