@hyperframes/studio 0.8.47 → 0.8.49
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-CqON2Rzo.js +464 -0
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
- package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +70 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1452 -918
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
- package/src/hooks/useTimelineAssetDropOps.ts +24 -18
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../player";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
|
5
5
|
import { logSelect } from "../utils/selectDebug";
|
|
6
|
+
import { recordRetryAttempt, type RetryBudgetState } from "../utils/retryBudget";
|
|
6
7
|
|
|
7
8
|
interface UseTimelineSelectionPreviewSyncParams {
|
|
8
9
|
selectedElementId: string | null;
|
|
@@ -27,6 +28,12 @@ interface UseTimelineSelectionPreviewSyncParams {
|
|
|
27
28
|
onSelectionNotFound: () => void;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// A member still resolving (a just-dropped/pasted/duplicated clip, or a preview
|
|
32
|
+
// still reloading) heals within a few retries. One that never will (deleted out
|
|
33
|
+
// of band, a stale id) would otherwise bail forever; past this cap, apply
|
|
34
|
+
// whatever did resolve instead of leaving the group selection stuck.
|
|
35
|
+
const MAX_UNRESOLVED_SYNC_RETRIES = 3;
|
|
36
|
+
|
|
30
37
|
function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
|
|
31
38
|
const ordered = [...ids];
|
|
32
39
|
if (!anchor || !ids.has(anchor)) return ordered;
|
|
@@ -63,6 +70,36 @@ function anchorIsOutsideSelection(anchor: string | null, selectedIds: string[]):
|
|
|
63
70
|
return anchor !== null && !selectedIds.includes(anchor);
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
async function resolveSelectionsForIds(
|
|
74
|
+
ids: string[],
|
|
75
|
+
timelineElements: TimelineElement[],
|
|
76
|
+
buildDomSelectionForTimelineElement: UseTimelineSelectionPreviewSyncParams["buildDomSelectionForTimelineElement"],
|
|
77
|
+
): Promise<DomEditSelection[]> {
|
|
78
|
+
// Each element's resolution fires its own network probe; a selection of N
|
|
79
|
+
// members used to pay N sequential round trips here instead of one.
|
|
80
|
+
const elements = ids
|
|
81
|
+
.map((id) => timelineElements.find((item) => (item.key ?? item.id) === id))
|
|
82
|
+
.filter((element): element is TimelineElement => Boolean(element));
|
|
83
|
+
const resolved = await Promise.all(
|
|
84
|
+
elements.map((element) => buildDomSelectionForTimelineElement(element)),
|
|
85
|
+
);
|
|
86
|
+
return resolved.filter((selection): selection is DomEditSelection => Boolean(selection));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function applyResolvedSelections(
|
|
90
|
+
selections: DomEditSelection[],
|
|
91
|
+
applyDomSelection: UseTimelineSelectionPreviewSyncParams["applyDomSelection"],
|
|
92
|
+
applyMarqueeSelection: UseTimelineSelectionPreviewSyncParams["applyMarqueeSelection"],
|
|
93
|
+
): void {
|
|
94
|
+
if (selections.length === 0) {
|
|
95
|
+
applyDomSelection(null, { revealPanel: false });
|
|
96
|
+
} else if (selections.length === 1) {
|
|
97
|
+
applyDomSelection(selections[0]);
|
|
98
|
+
} else {
|
|
99
|
+
applyMarqueeSelection(selections, false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
66
103
|
export function useTimelineSelectionPreviewSync({
|
|
67
104
|
selectedElementId,
|
|
68
105
|
selectedElementIds,
|
|
@@ -84,6 +121,7 @@ export function useTimelineSelectionPreviewSync({
|
|
|
84
121
|
const domEditGroupSelectionsRef = useRef(domEditGroupSelections);
|
|
85
122
|
const lastSyncedSelectedKeyRef = useRef("");
|
|
86
123
|
const missingSelectionKeyRef = useRef("");
|
|
124
|
+
const unresolvedAttemptsRef = useRef<RetryBudgetState<string>>({ id: "", count: 0 });
|
|
87
125
|
domEditSelectionRef.current = domEditSelection;
|
|
88
126
|
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
89
127
|
|
|
@@ -109,6 +147,10 @@ export function useTimelineSelectionPreviewSync({
|
|
|
109
147
|
|
|
110
148
|
if (selectedIds.length === 0) {
|
|
111
149
|
missingSelectionKeyRef.current = "";
|
|
150
|
+
// A deselect is the one unambiguous "new attempt" signal: without it, reselecting the
|
|
151
|
+
// same permanently-unresolvable id later picks up an already-exhausted retry budget
|
|
152
|
+
// and skips straight to the degraded fallback instead of getting a fresh grace window.
|
|
153
|
+
unresolvedAttemptsRef.current = { id: "", count: 0 };
|
|
112
154
|
// The timeline holds nothing, so the canvas is about to hold nothing either.
|
|
113
155
|
// This is the path that silently drops a selection the user can still see.
|
|
114
156
|
logSelect("timeline-empty", {
|
|
@@ -134,46 +176,35 @@ export function useTimelineSelectionPreviewSync({
|
|
|
134
176
|
onSelectionNotFound();
|
|
135
177
|
};
|
|
136
178
|
const syncSelection = async () => {
|
|
137
|
-
const selections
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
resolvableCount += 1;
|
|
143
|
-
const selection = await buildDomSelectionForTimelineElement(element);
|
|
144
|
-
if (selection) selections.push(selection);
|
|
145
|
-
}
|
|
179
|
+
const selections = await resolveSelectionsForIds(
|
|
180
|
+
selectedIds,
|
|
181
|
+
timelineElements,
|
|
182
|
+
buildDomSelectionForTimelineElement,
|
|
183
|
+
);
|
|
146
184
|
if (cancelled) return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// damage. Only that goes: a member still resolving has no anchor of its
|
|
157
|
-
// own here and is left for the later run. Quietly, because announcing
|
|
158
|
-
// the clear would deselect the clip that was just picked.
|
|
159
|
-
if (anchorIsOutsideSelection(currentAnchor, selectedIds)) {
|
|
160
|
-
applyDomSelection(null, { revealPanel: false, announce: false });
|
|
185
|
+
if (selections.length < selectedIds.length) {
|
|
186
|
+
if (recordRetryAttempt(unresolvedAttemptsRef, selectedKey, MAX_UNRESOLVED_SYNC_RETRIES)) {
|
|
187
|
+
// Still within the retry grace window: stay quiet (warn only once
|
|
188
|
+
// exhausted, below). Delete acts on the canvas first, so only an
|
|
189
|
+
// anchor OUTSIDE this selection is cleared here, quietly.
|
|
190
|
+
if (anchorIsOutsideSelection(currentAnchor, selectedIds)) {
|
|
191
|
+
applyDomSelection(null, { revealPanel: false, announce: false });
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
161
194
|
}
|
|
162
|
-
|
|
195
|
+
// Budget exhausted: warn once (missingSelectionKeyRef dedupes further
|
|
196
|
+
// reruns of this same still-unresolved key) and apply whatever did resolve.
|
|
197
|
+
warnSelectionMissingOnce();
|
|
198
|
+
} else {
|
|
199
|
+
unresolvedAttemptsRef.current = { id: "", count: 0 };
|
|
200
|
+
missingSelectionKeyRef.current = "";
|
|
163
201
|
}
|
|
164
|
-
missingSelectionKeyRef.current = "";
|
|
165
202
|
logSelect("timeline-sync", {
|
|
166
203
|
wanted: selectedIds.length,
|
|
167
204
|
had: currentIds.length,
|
|
168
205
|
resolved: selections.length,
|
|
169
206
|
});
|
|
170
|
-
|
|
171
|
-
applyDomSelection(null, { revealPanel: false });
|
|
172
|
-
} else if (selections.length === 1) {
|
|
173
|
-
applyDomSelection(selections[0]);
|
|
174
|
-
} else {
|
|
175
|
-
applyMarqueeSelection(selections, false);
|
|
176
|
-
}
|
|
207
|
+
applyResolvedSelections(selections, applyDomSelection, applyMarqueeSelection);
|
|
177
208
|
};
|
|
178
209
|
|
|
179
210
|
void syncSelection();
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,16 @@ export { FileTree } from "./components/editor/FileTree";
|
|
|
27
27
|
// App
|
|
28
28
|
export { StudioApp } from "./App";
|
|
29
29
|
|
|
30
|
+
// Ask-agent flow
|
|
31
|
+
export { AskAgentModal } from "./components/AskAgentModal";
|
|
32
|
+
export type { AskAgentModalProps } from "./components/AskAgentModal";
|
|
33
|
+
export type { AgentModalAnchorPoint } from "./utils/studioHelpers";
|
|
34
|
+
export {
|
|
35
|
+
buildPickerAgentPrompt,
|
|
36
|
+
buildPickerAgentContextPreview,
|
|
37
|
+
} from "./components/editor/domEditingAgentPrompt";
|
|
38
|
+
export type { AgentPromptElementInfo } from "./components/editor/domEditingAgentPrompt";
|
|
39
|
+
|
|
30
40
|
// Hooks
|
|
31
41
|
export { useElementPicker } from "./hooks/useElementPicker";
|
|
32
42
|
export type { PickedElement } from "./hooks/useElementPicker";
|
|
@@ -166,7 +166,6 @@ describe("Timeline provider boundary", () => {
|
|
|
166
166
|
scrollTop: 0,
|
|
167
167
|
contentOrigin: GUTTER,
|
|
168
168
|
pixelsPerSecond: 100,
|
|
169
|
-
duration: 60,
|
|
170
169
|
trackOrder: [0],
|
|
171
170
|
},
|
|
172
171
|
1132,
|
|
@@ -282,7 +281,6 @@ describe("Timeline provider boundary", () => {
|
|
|
282
281
|
scrollTop: 0,
|
|
283
282
|
contentOrigin: LABEL_COL_W + GUTTER,
|
|
284
283
|
pixelsPerSecond: 100,
|
|
285
|
-
duration: 60,
|
|
286
284
|
trackOrder: [0],
|
|
287
285
|
},
|
|
288
286
|
100 + LABEL_COL_W + GUTTER + 1000,
|
|
@@ -1263,7 +1261,6 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
1263
1261
|
scrollTop: 0,
|
|
1264
1262
|
contentOrigin: GUTTER,
|
|
1265
1263
|
pixelsPerSecond: 100,
|
|
1266
|
-
duration: 10,
|
|
1267
1264
|
trackHeight: 72,
|
|
1268
1265
|
trackOrder: [0, 3, 7],
|
|
1269
1266
|
},
|
|
@@ -1285,7 +1282,6 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
1285
1282
|
scrollTop: 0,
|
|
1286
1283
|
contentOrigin: GUTTER,
|
|
1287
1284
|
pixelsPerSecond: 100,
|
|
1288
|
-
duration: 10,
|
|
1289
1285
|
trackHeight: 72,
|
|
1290
1286
|
trackOrder: [0, 3, 7],
|
|
1291
1287
|
},
|
|
@@ -13,7 +13,7 @@ import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
|
|
13
13
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
14
14
|
import { getTimelineLaneTop } from "./timelineLayout";
|
|
15
15
|
import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
|
|
16
|
-
import {
|
|
16
|
+
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
|
|
17
17
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
18
18
|
import type { TimelineElement } from "../store/playerStore";
|
|
19
19
|
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
|
@@ -174,7 +174,9 @@ export function TimelineAutomationLaneSlot({
|
|
|
174
174
|
currentTime,
|
|
175
175
|
beatTimes,
|
|
176
176
|
}: TimelineAutomationLaneSlotProps) {
|
|
177
|
-
|
|
177
|
+
// Broader than a raw-attribute check — a clip mid-edit (see the stale-
|
|
178
|
+
// selection test) has lanes in its live binding before the attribute commits.
|
|
179
|
+
const clips = elements.filter(isAudioOrVideoTimelineElement);
|
|
178
180
|
const rowsByClip = new Map<string, ClipLaneRow[]>();
|
|
179
181
|
groupAutomationLanes(clips).forEach((group, rowIndex) => {
|
|
180
182
|
for (const entry of group.entries) {
|
|
@@ -1275,7 +1275,8 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1275
1275
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
1276
1276
|
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
|
|
1277
1277
|
expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
|
|
1278
|
-
expect(ease.
|
|
1278
|
+
expect(ease.getAttribute("title")).toBe("Edit none easing");
|
|
1279
|
+
expect(ease.classList.contains("opacity-40")).toBe(true);
|
|
1279
1280
|
act(() => ease.click());
|
|
1280
1281
|
expect(onSelectSegment).toHaveBeenCalledOnce();
|
|
1281
1282
|
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
|
|
@@ -1318,6 +1319,17 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1318
1319
|
act(() => root.unmount());
|
|
1319
1320
|
});
|
|
1320
1321
|
|
|
1322
|
+
it("keeps the ease button hover-only on a segment too narrow to show it at rest", () => {
|
|
1323
|
+
// Same narrow clip as above: showing the button at rest here would sit on
|
|
1324
|
+
// top of both diamonds instead of clear of them.
|
|
1325
|
+
const { host, root } = renderSegmentLane(false, 40);
|
|
1326
|
+
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
|
1327
|
+
|
|
1328
|
+
expect(ease?.classList.contains("opacity-0")).toBe(true);
|
|
1329
|
+
expect(ease?.classList.contains("opacity-40")).toBe(false);
|
|
1330
|
+
act(() => root.unmount());
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1321
1333
|
it("hides the inline ease button on a segment with no source animation id", () => {
|
|
1322
1334
|
// A runtime-scanned keyframe has no animationId, so there is no tween to
|
|
1323
1335
|
// target; the segment ending on it must not render a (dead) ease button.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hasRoomForRestingEase } from "./TimelineDiamondConnectors";
|
|
3
|
+
|
|
4
|
+
describe("hasRoomForRestingEase", () => {
|
|
5
|
+
it("is false just under the threshold", () => {
|
|
6
|
+
expect(hasRoomForRestingEase(23)).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("is true exactly at the threshold", () => {
|
|
10
|
+
expect(hasRoomForRestingEase(24)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("is true well above the threshold", () => {
|
|
14
|
+
expect(hasRoomForRestingEase(78)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -13,6 +13,17 @@ export interface TimelineDiamondMarker {
|
|
|
13
13
|
visualSize: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const EASE_BUTTON_PX = 16;
|
|
17
|
+
const EASE_BUTTON_REST_GAP_PX = 4;
|
|
18
|
+
|
|
19
|
+
/** Whether a segment is wide enough to show the ease button at rest (button
|
|
20
|
+
* box plus a clear gap each side) without overlapping either neighboring
|
|
21
|
+
* diamond. 24 coincides with roomForFullTarget's threshold below but is a
|
|
22
|
+
* separate constraint; don't assume changing one keeps the other in sync. */
|
|
23
|
+
export function hasRoomForRestingEase(connectorWidth: number): boolean {
|
|
24
|
+
return connectorWidth >= EASE_BUTTON_PX + EASE_BUTTON_REST_GAP_PX * 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
/**
|
|
17
28
|
* The line between each pair of adjacent diamonds, plus the ease control that
|
|
18
29
|
* sits at the segment's midpoint. Split out of TimelineClipDiamonds only to keep
|
|
@@ -96,6 +107,7 @@ export function TimelineDiamondConnectors({
|
|
|
96
107
|
// the hit test and steal their clicks at fit zoom. Grow the
|
|
97
108
|
// target only where the room exists.
|
|
98
109
|
roomForFullTarget={connectorWidth >= 24}
|
|
110
|
+
restingVisible={hasRoomForRestingEase(connectorWidth)}
|
|
99
111
|
onSelectSegment={onSelectSegment}
|
|
100
112
|
/>
|
|
101
113
|
)}
|
|
@@ -133,6 +145,7 @@ function SegmentEaseControl({
|
|
|
133
145
|
rovingTargetId,
|
|
134
146
|
afterLabel,
|
|
135
147
|
roomForFullTarget,
|
|
148
|
+
restingVisible,
|
|
136
149
|
onSelectSegment,
|
|
137
150
|
}: {
|
|
138
151
|
left: number;
|
|
@@ -146,6 +159,9 @@ function SegmentEaseControl({
|
|
|
146
159
|
/** Time label of the keyframe this segment starts at, for the accessible name. */
|
|
147
160
|
afterLabel: string;
|
|
148
161
|
roomForFullTarget: boolean;
|
|
162
|
+
/** Whether the segment is wide enough to show the button at rest; too
|
|
163
|
+
* narrow and it stays hover-only so it doesn't smear over the diamonds. */
|
|
164
|
+
restingVisible: boolean;
|
|
149
165
|
onSelectSegment: (target: TimelineKeyframeTarget) => void;
|
|
150
166
|
}) {
|
|
151
167
|
// The ease button sits dead centre of its segment, which on a two-keyframe clip
|
|
@@ -187,7 +203,7 @@ function SegmentEaseControl({
|
|
|
187
203
|
// for that overlay the button keeps its 16x16 hit area, which is WCAG's
|
|
188
204
|
// target-spacing exception: the neighbouring diamonds are themselves the
|
|
189
205
|
// reason it cannot grow, and stealing their clicks is the worse failure.
|
|
190
|
-
className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
|
206
|
+
className={`absolute flex items-center justify-center rounded ${restingVisible ? "opacity-40" : "opacity-0"} transition-opacity hover:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
|
191
207
|
style={{
|
|
192
208
|
left: "50%",
|
|
193
209
|
top: "50%",
|
|
@@ -406,7 +406,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
406
406
|
expect(laneDiamonds(host, "position")).toHaveLength(3);
|
|
407
407
|
const buttons = laneEaseButtons(host, "position");
|
|
408
408
|
expect(buttons).toHaveLength(2);
|
|
409
|
-
expect(buttons.every((button) => button.classList.contains("opacity-
|
|
409
|
+
expect(buttons.every((button) => button.classList.contains("opacity-40"))).toBe(true);
|
|
410
410
|
expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe(
|
|
411
411
|
true,
|
|
412
412
|
);
|
|
@@ -260,9 +260,17 @@ describe("groupAutomationLanes", () => {
|
|
|
260
260
|
expect(groups.map((g) => g.entries.length)).toEqual([1, 1]);
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
it("
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
it("draws lanes for a video element, matching the reserved height", () => {
|
|
264
|
+
// A <video>'s volume can be automated from the property panel same as an
|
|
265
|
+
// audio clip's; gating this on `isAudioTimelineElement` alone hid every
|
|
266
|
+
// keyframe the panel wrote for a video clip.
|
|
267
|
+
const video = el({ id: "titles", key: "titles", tag: "video", automation: lanesOf("volume") });
|
|
268
|
+
expect(groupAutomationLanes([video]).map((g) => g.key)).toEqual(["Volume"]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("ignores a clip that is neither audio nor video, the way the reserved height does", () => {
|
|
272
|
+
const div = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
|
|
273
|
+
expect(groupAutomationLanes([div])).toEqual([]);
|
|
266
274
|
});
|
|
267
275
|
|
|
268
276
|
it("skips a target that does not resolve against its clip's chain", () => {
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
type HfAutomationLane,
|
|
23
23
|
} from "@hyperframes/core/audio-automation";
|
|
24
24
|
import { parseAudioFxChain, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
25
|
-
import {
|
|
25
|
+
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
|
|
26
26
|
import type { TimelineElement } from "../store/playerStore";
|
|
27
27
|
|
|
28
28
|
const EMPTY: HfAutomation = { version: 1, lanes: [] };
|
|
@@ -245,13 +245,14 @@ export interface AutomationLaneGroup {
|
|
|
245
245
|
* spectrum, top down), so the first clip to carry a property fixes its row and
|
|
246
246
|
* later clips only append properties nobody has shown yet.
|
|
247
247
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
248
|
+
* Gated on `isAudioOrVideoTimelineElement`, the same predicate `automationLaneCountOf`
|
|
249
|
+
* and `TimelineAutomationLaneSlot`'s clip filter use — the row's reserved height
|
|
250
|
+
* and its drawn lanes have to count the same clips.
|
|
250
251
|
*/
|
|
251
252
|
export function groupAutomationLanes(elements: readonly TimelineElement[]): AutomationLaneGroup[] {
|
|
252
253
|
const groups = new Map<string, AutomationLaneGroup>();
|
|
253
254
|
for (const element of elements) {
|
|
254
|
-
if (!
|
|
255
|
+
if (!isAudioOrVideoTimelineElement(element)) continue;
|
|
255
256
|
const chain = elementFxChain(element);
|
|
256
257
|
for (const lane of elementAutomationLanes(element)) {
|
|
257
258
|
const key = laneGroupKey(lane.target, chain);
|
|
@@ -23,6 +23,21 @@ import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
|
23
23
|
/** Points nearer than this in clip seconds are the same point, not two. */
|
|
24
24
|
export const POINT_MERGE_SEC = 0.02;
|
|
25
25
|
|
|
26
|
+
/** Insert or replace the point nearest `t` — the one merge/seed rule every
|
|
27
|
+
* hand-editing gesture on a lane uses (timeline double-click, panel control). */
|
|
28
|
+
export function mergeInsertPoint(
|
|
29
|
+
points: readonly HfAutomationLane["points"][number][],
|
|
30
|
+
t: number,
|
|
31
|
+
v: number,
|
|
32
|
+
): HfAutomationLane["points"] {
|
|
33
|
+
const kept = points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
|
|
34
|
+
// A lane's first point alone would be a constant, which is not what
|
|
35
|
+
// committing to an empty lane means: seed the far end at the same value so
|
|
36
|
+
// the envelope has somewhere to go.
|
|
37
|
+
const seeded = points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
|
|
38
|
+
return [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t);
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
/**
|
|
27
42
|
* Closest two breakpoints may sit in clip seconds while still being two points.
|
|
28
43
|
*
|
|
@@ -1223,6 +1223,120 @@ describe("commitDraggedClipMove", () => {
|
|
|
1223
1223
|
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
1224
1224
|
});
|
|
1225
1225
|
});
|
|
1226
|
+
|
|
1227
|
+
describe("magnetic main track on a track-insert", () => {
|
|
1228
|
+
it("a top-gutter insert that renumbers to literal track 0 snaps its start to 0", () => {
|
|
1229
|
+
// Sole visual clip sits on track 1 — the real main track (0) is genuinely
|
|
1230
|
+
// empty. Dragging it into the top insert-gutter creates a new lane that
|
|
1231
|
+
// normalizeToZones renumbers to literal 0, so the main-track rule must apply
|
|
1232
|
+
// even though the preview never saw a literal-0 landing track.
|
|
1233
|
+
const elements = [el("v1", 1, 0, 5)];
|
|
1234
|
+
const { onMoveElements } = runClipMove(
|
|
1235
|
+
drag(elements[0], { previewStart: 8, previewTrack: 1, insertRow: 0 }),
|
|
1236
|
+
{ elements, trackOrder: [1] },
|
|
1237
|
+
);
|
|
1238
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1239
|
+
expect(map.v1).toEqual({ start: 0, track: 0 });
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
it("the z-sync candidate reflects the snapped start, not the raw previewStart", async () => {
|
|
1243
|
+
// v1 snaps from previewStart 8 to 0 (see the test above). A foreign-file
|
|
1244
|
+
// sibling at [2, 5) only overlaps the SNAPPED window [0, 5), never the
|
|
1245
|
+
// raw [8, 13) — so a z-sync firing at all proves the candidate the
|
|
1246
|
+
// stacking check reads from carries the snapped start.
|
|
1247
|
+
const v1 = el("v1", 2, 0, 5);
|
|
1248
|
+
const sibling = el("s", 1, 2, 3); // [2, 5) — same source file, same paint scope
|
|
1249
|
+
const elements = [v1, sibling];
|
|
1250
|
+
const onStackingPatches = vi.fn();
|
|
1251
|
+
// v1 lands on lane 0, which paints above lane 1 (lower track paints higher),
|
|
1252
|
+
// yet carries the lower z: a violation only while the two overlap in time.
|
|
1253
|
+
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 2, insertRow: 0 }), {
|
|
1254
|
+
elements,
|
|
1255
|
+
trackOrder: [1, 2],
|
|
1256
|
+
updateElement: vi.fn(),
|
|
1257
|
+
onMoveElement: vi.fn(),
|
|
1258
|
+
onMoveElements: vi.fn(),
|
|
1259
|
+
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1260
|
+
onStackingPatches,
|
|
1261
|
+
});
|
|
1262
|
+
await flushMicrotasks();
|
|
1263
|
+
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it("a plain lane change onto the empty main track snaps its committed start to 0", () => {
|
|
1267
|
+
const elements = [el("v1", 1, 0, 5)];
|
|
1268
|
+
const spies = runClipMove(drag(elements[0], { previewStart: 8, previewTrack: 0 }), {
|
|
1269
|
+
elements,
|
|
1270
|
+
trackOrder: [0, 1],
|
|
1271
|
+
});
|
|
1272
|
+
expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 0, track: 0 });
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("the z-sync candidate of a plain lane change carries the snapped start", async () => {
|
|
1276
|
+
// The sibling [2, 5) overlaps v1 only at the snapped [0, 5), never at the raw [8, 13).
|
|
1277
|
+
const v1 = el("v1", 2, 0, 5);
|
|
1278
|
+
const sibling = el("s", 1, 2, 3);
|
|
1279
|
+
const onStackingPatches = vi.fn();
|
|
1280
|
+
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 0 }), {
|
|
1281
|
+
elements: [v1, sibling],
|
|
1282
|
+
trackOrder: [0, 1, 2],
|
|
1283
|
+
updateElement: vi.fn(),
|
|
1284
|
+
onMoveElement: vi.fn(),
|
|
1285
|
+
onMoveElements: vi.fn(),
|
|
1286
|
+
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1287
|
+
onStackingPatches,
|
|
1288
|
+
});
|
|
1289
|
+
await flushMicrotasks();
|
|
1290
|
+
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it("a top-gutter insert that pushes the old track-0 clip down snaps to the new track 0", () => {
|
|
1294
|
+
const oldMain = el("old", 0, 0, 3);
|
|
1295
|
+
const dragged = el("v1", 2, 0, 5);
|
|
1296
|
+
const { onMoveElements } = runClipMove(
|
|
1297
|
+
drag(dragged, { previewStart: 8, previewTrack: 2, insertRow: 0 }),
|
|
1298
|
+
{ elements: [oldMain, dragged], trackOrder: [0, 2] },
|
|
1299
|
+
);
|
|
1300
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1301
|
+
expect(map.old.track).toBe(1);
|
|
1302
|
+
expect(map.v1).toEqual({ start: 0, track: 0 });
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("an expanded child dragged with its host commits the HOST at 0", () => {
|
|
1306
|
+
for (const [hostStart, childStart] of [
|
|
1307
|
+
[30, 32],
|
|
1308
|
+
[20, 22],
|
|
1309
|
+
]) {
|
|
1310
|
+
const host = el("host", 1, hostStart, 10);
|
|
1311
|
+
const child: TimelineElement = {
|
|
1312
|
+
...el("child", 2, childStart, 4),
|
|
1313
|
+
expandedHostKey: "host",
|
|
1314
|
+
expandedParentStart: hostStart,
|
|
1315
|
+
};
|
|
1316
|
+
const { onMoveElements } = runClipMove(
|
|
1317
|
+
drag(child, { previewStart: childStart, previewTrack: 0 }),
|
|
1318
|
+
{
|
|
1319
|
+
elements: [host, child],
|
|
1320
|
+
trackOrder: [0, 1, 2],
|
|
1321
|
+
selectedKeys: new Set(["host", "child"]),
|
|
1322
|
+
},
|
|
1323
|
+
);
|
|
1324
|
+
expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({ start: 0, track: 0 });
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it("a multi-selection top-gutter insert does NOT snap (siblings key off the unsnapped start)", () => {
|
|
1329
|
+
const dragged = el("v1", 1, 0, 5);
|
|
1330
|
+
const sibling = el("v2", 1, 10, 5);
|
|
1331
|
+
const elements = [dragged, sibling];
|
|
1332
|
+
const { onMoveElements } = runClipMove(
|
|
1333
|
+
drag(dragged, { previewStart: 8, previewTrack: 1, insertRow: 0 }),
|
|
1334
|
+
{ elements, trackOrder: [1], selectedKeys: new Set(["v1", "v2"]) },
|
|
1335
|
+
);
|
|
1336
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1337
|
+
expect(map.v1.start).toBe(8); // unsnapped — multi-selection guard
|
|
1338
|
+
});
|
|
1339
|
+
});
|
|
1226
1340
|
});
|
|
1227
1341
|
|
|
1228
1342
|
describe("commitZMirrorLaneMove", () => {
|
|
@@ -3,7 +3,8 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
3
3
|
// Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
|
|
4
4
|
// value-import edge stays acyclic.
|
|
5
5
|
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
6
|
-
import { classifyZone
|
|
6
|
+
import { classifyZone } from "./timelineZones";
|
|
7
|
+
import { layoutAfterTrackInsert, resolveDragLandingStart } from "./timelineDragLanding";
|
|
7
8
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
8
9
|
import {
|
|
9
10
|
canMoveTimelineElement as canMoveElement,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
} from "./timelineOptimisticRevision";
|
|
17
18
|
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
18
19
|
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
19
|
-
import { authoredTrackForLane
|
|
20
|
+
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
|
20
21
|
|
|
21
22
|
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
22
23
|
export interface TimelineMoveEdit {
|
|
@@ -172,19 +173,6 @@ export function persistMoveEdits(
|
|
|
172
173
|
);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
/**
|
|
176
|
-
* A fractional track value for a NEW lane inserted at boundary `insertRow` in
|
|
177
|
-
* `trackOrder` (0 = above the top, `length` = below the bottom). normalizeToZones
|
|
178
|
-
* then compacts it to a distinct integer lane between its neighbours, and the
|
|
179
|
-
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
|
180
|
-
*/
|
|
181
|
-
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
182
|
-
if (trackOrder.length === 0) return 0;
|
|
183
|
-
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
184
|
-
if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5;
|
|
185
|
-
return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
176
|
/**
|
|
189
177
|
* Build the time-shift resolver for a multi-selection drag: every member of the
|
|
190
178
|
* selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
|
|
@@ -231,7 +219,8 @@ function resolveMultiSelection(
|
|
|
231
219
|
* permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
|
|
232
220
|
*/
|
|
233
221
|
// fallow-ignore-next-line complexity
|
|
234
|
-
export function commitDraggedClipMove(
|
|
222
|
+
export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommitDeps): void {
|
|
223
|
+
const drag = { ...rawDrag, previewStart: resolveDragLandingStart(rawDrag, deps) };
|
|
235
224
|
const hostAlias = resolveExpandedHostAlias(drag, deps);
|
|
236
225
|
if (hostAlias) {
|
|
237
226
|
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
|
|
@@ -353,36 +342,11 @@ function buildTrackInsertEdits(
|
|
|
353
342
|
} | null,
|
|
354
343
|
deps: DragCommitDeps,
|
|
355
344
|
): { candidate: TimelineElement[]; edits: TimelineMoveEdit[] } | null {
|
|
356
|
-
const { elements
|
|
345
|
+
const { elements } = deps;
|
|
357
346
|
const editKey = keyOf(element);
|
|
358
|
-
|
|
359
|
-
if (
|
|
360
|
-
const targetTrack =
|
|
361
|
-
const candidate = elements.map((e) => {
|
|
362
|
-
if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
|
|
363
|
-
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
364
|
-
return e;
|
|
365
|
-
});
|
|
366
|
-
// Foreign display rows and the opposite zone must not affect this topology.
|
|
367
|
-
const writableZone = classifyZone(element);
|
|
368
|
-
const writable = (src: TimelineElement): boolean =>
|
|
369
|
-
sameSourceFile(src, element) &&
|
|
370
|
-
classifyZone(src) === writableZone &&
|
|
371
|
-
src.expandedParentStart == null;
|
|
372
|
-
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
|
373
|
-
(a, b) => a - b,
|
|
374
|
-
);
|
|
375
|
-
const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
|
|
376
|
-
const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
|
|
377
|
-
const normalized = normalizeToZones(
|
|
378
|
-
elements.filter(writable).map((e) => {
|
|
379
|
-
if (keyOf(e) === editKey) {
|
|
380
|
-
return { ...e, start: previewStart, track: topologyTargetTrack };
|
|
381
|
-
}
|
|
382
|
-
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
383
|
-
return e;
|
|
384
|
-
}),
|
|
385
|
-
);
|
|
347
|
+
const layout = layoutAfterTrackInsert(element, previewStart, insertRow, multi, deps);
|
|
348
|
+
if (!layout) return null;
|
|
349
|
+
const { normalized, targetTrack, writable } = layout;
|
|
386
350
|
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
|
|
387
351
|
// A partial zone renumber creates collisions; refuse a shifted locked row.
|
|
388
352
|
for (const norm of normalized) {
|
|
@@ -399,6 +363,11 @@ function buildTrackInsertEdits(
|
|
|
399
363
|
return null;
|
|
400
364
|
}
|
|
401
365
|
}
|
|
366
|
+
const candidate = elements.map((e) => {
|
|
367
|
+
if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
|
|
368
|
+
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
369
|
+
return e;
|
|
370
|
+
});
|
|
402
371
|
const edits: TimelineMoveEdit[] = [];
|
|
403
372
|
if (multi) {
|
|
404
373
|
for (const src of elements) {
|
|
@@ -415,10 +384,10 @@ function buildTrackInsertEdits(
|
|
|
415
384
|
for (const norm of normalized) {
|
|
416
385
|
const src = bySrc.get(keyOf(norm));
|
|
417
386
|
if (!src || !canMoveElement(src)) continue;
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
387
|
+
const normKey = keyOf(norm);
|
|
388
|
+
let start = src.start;
|
|
389
|
+
if (normKey === editKey) start = previewStart;
|
|
390
|
+
else if (multi?.keys.has(normKey)) start = multi.movedStart(src);
|
|
422
391
|
edits.push({ element: src, updates: { start, track: norm.track } });
|
|
423
392
|
}
|
|
424
393
|
return { candidate, edits };
|