@hyperframes/studio 0.7.61 → 0.7.62
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-Xvx2hkrc.js → hyperframes-player-CWpmBjzv.js} +1 -1
- package/dist/assets/{index-BCpoiv9S.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-D-uFclFj.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3504 -2955
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -10
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -0
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +18 -1
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/index-2mxh_HSy.js +0 -426
- package/dist/assets/index-BhWig0mx.css +0 -1
|
@@ -28,6 +28,7 @@ import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallb
|
|
|
28
28
|
import type { TimelineProps } from "./TimelineTypes";
|
|
29
29
|
import { useTrackGapMenu } from "./useTrackGapMenu";
|
|
30
30
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
31
|
+
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
|
31
32
|
|
|
32
33
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
33
34
|
export {
|
|
@@ -52,6 +53,7 @@ export const Timeline = memo(function Timeline({
|
|
|
52
53
|
onFileDrop,
|
|
53
54
|
onAssetDrop,
|
|
54
55
|
onBlockDrop,
|
|
56
|
+
onCompositionDrop,
|
|
55
57
|
onDeleteElement: _onDeleteElement,
|
|
56
58
|
onMoveElement: onMoveElementOverride,
|
|
57
59
|
onMoveElements: onMoveElementsOverride,
|
|
@@ -84,6 +86,11 @@ export const Timeline = memo(function Timeline({
|
|
|
84
86
|
onSplitElement: onSplitElementOverride,
|
|
85
87
|
});
|
|
86
88
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
89
|
+
const playbackContext = useStudioPlaybackContextOptional();
|
|
90
|
+
const setRefreshKey = playbackContext?.setRefreshKey;
|
|
91
|
+
const refreshAfterLaneMove = useCallback(() => {
|
|
92
|
+
setRefreshKey?.((key) => key + 1);
|
|
93
|
+
}, [setRefreshKey]);
|
|
87
94
|
useMusicBeatAnalysis();
|
|
88
95
|
const rawElements = usePlayerStore((s) => s.elements);
|
|
89
96
|
const expandedElements = useExpandedTimelineElements();
|
|
@@ -169,6 +176,7 @@ export const Timeline = memo(function Timeline({
|
|
|
169
176
|
pinnedOnFileDrop,
|
|
170
177
|
pinnedOnAssetDrop,
|
|
171
178
|
pinnedOnBlockDrop,
|
|
179
|
+
pinnedOnCompositionDrop,
|
|
172
180
|
} = useTimelineEditPinning({
|
|
173
181
|
ppsRef,
|
|
174
182
|
fitPpsRef,
|
|
@@ -179,6 +187,7 @@ export const Timeline = memo(function Timeline({
|
|
|
179
187
|
onFileDrop,
|
|
180
188
|
onAssetDrop,
|
|
181
189
|
onBlockDrop,
|
|
190
|
+
onCompositionDrop,
|
|
182
191
|
});
|
|
183
192
|
|
|
184
193
|
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
|
@@ -223,6 +232,7 @@ export const Timeline = memo(function Timeline({
|
|
|
223
232
|
setRangeSelectionRef,
|
|
224
233
|
readZIndex: zSyncEnabled ? readClipZIndex : undefined,
|
|
225
234
|
onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
|
|
235
|
+
refreshAfterLaneMove,
|
|
226
236
|
});
|
|
227
237
|
|
|
228
238
|
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
|
|
@@ -234,6 +244,7 @@ export const Timeline = memo(function Timeline({
|
|
|
234
244
|
onFileDrop: pinnedOnFileDrop,
|
|
235
245
|
onAssetDrop: pinnedOnAssetDrop,
|
|
236
246
|
onBlockDrop: pinnedOnBlockDrop,
|
|
247
|
+
onCompositionDrop: pinnedOnCompositionDrop,
|
|
237
248
|
});
|
|
238
249
|
|
|
239
250
|
const displayTrackOrder = useMemo(() => {
|
|
@@ -399,7 +410,7 @@ export const Timeline = memo(function Timeline({
|
|
|
399
410
|
<div
|
|
400
411
|
ref={setContainerRef}
|
|
401
412
|
aria-label="Timeline"
|
|
402
|
-
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
413
|
+
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
403
414
|
onMouseMove={(e) => {
|
|
404
415
|
if (activeTool === "razor" && scrollRef.current) {
|
|
405
416
|
const rect = scrollRef.current.getBoundingClientRect();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
|
+
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
4
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
|
+
|
|
6
|
+
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
|
7
|
+
export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
8
|
+
return getTimelineEditCapabilities({
|
|
9
|
+
tag: element.tag,
|
|
10
|
+
kind: element.kind,
|
|
11
|
+
duration: element.duration,
|
|
12
|
+
domId: element.domId,
|
|
13
|
+
selector: element.selector,
|
|
14
|
+
compositionSrc: element.compositionSrc,
|
|
15
|
+
playbackStart: element.playbackStart,
|
|
16
|
+
playbackStartAttr: element.playbackStartAttr,
|
|
17
|
+
sourceDuration: element.sourceDuration,
|
|
18
|
+
timingSource: element.timingSource,
|
|
19
|
+
timelineLocked: element.timelineLocked,
|
|
20
|
+
}).canMove;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ExpandedHostAliasDeps {
|
|
24
|
+
elements: TimelineElement[];
|
|
25
|
+
selectedKeys?: ReadonlySet<string> | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Expanded children keep their own source-file identity for direct edits, but a
|
|
30
|
+
* selection can briefly contain both a composition host and one of its visible
|
|
31
|
+
* expanded children. That pair is one authored move target, not two. Resolve it
|
|
32
|
+
* to the host before time/lane/collision commit so ordinary clip placement stays
|
|
33
|
+
* the single owner of the gesture semantics.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveExpandedHostAlias(
|
|
36
|
+
drag: DraggedClipState,
|
|
37
|
+
deps: ExpandedHostAliasDeps,
|
|
38
|
+
): { drag: DraggedClipState; selectedKeys: ReadonlySet<string> } | null {
|
|
39
|
+
const selectedKeys = deps.selectedKeys;
|
|
40
|
+
if (!selectedKeys) return null;
|
|
41
|
+
|
|
42
|
+
const collapsedKeys = new Set(selectedKeys);
|
|
43
|
+
const candidates = deps.elements.includes(drag.element)
|
|
44
|
+
? deps.elements
|
|
45
|
+
: [...deps.elements, drag.element];
|
|
46
|
+
for (const element of candidates) {
|
|
47
|
+
const hostKey = element.expandedHostKey;
|
|
48
|
+
const childKey = getTimelineElementIdentity(element);
|
|
49
|
+
if (hostKey && collapsedKeys.has(hostKey) && collapsedKeys.has(childKey)) {
|
|
50
|
+
collapsedKeys.delete(childKey);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hostKey = drag.element.expandedHostKey;
|
|
55
|
+
const childKey = getTimelineElementIdentity(drag.element);
|
|
56
|
+
if (!hostKey || collapsedKeys.has(childKey)) {
|
|
57
|
+
if (collapsedKeys.size === selectedKeys.size) return null;
|
|
58
|
+
return { drag, selectedKeys: collapsedKeys };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const host = deps.elements.find((element) => getTimelineElementIdentity(element) === hostKey);
|
|
62
|
+
if (!host || !canMoveTimelineElement(host)) return null;
|
|
63
|
+
|
|
64
|
+
const delta = drag.previewStart - drag.element.start;
|
|
65
|
+
const mapTrack = (track: number | undefined): number | undefined =>
|
|
66
|
+
track === drag.element.track ? host.track : track;
|
|
67
|
+
return {
|
|
68
|
+
drag: {
|
|
69
|
+
...drag,
|
|
70
|
+
element: host,
|
|
71
|
+
previewStart: Math.max(0, Math.round((host.start + delta) * 1000) / 1000),
|
|
72
|
+
previewTrack: mapTrack(drag.previewTrack) ?? host.track,
|
|
73
|
+
desiredTrack: mapTrack(drag.desiredTrack),
|
|
74
|
+
},
|
|
75
|
+
selectedKeys: collapsedKeys,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
const keyOf = (element: TimelineElement) => element.key ?? element.id;
|
|
4
|
+
|
|
5
|
+
/** Authored track numbers only compare within one source file. */
|
|
6
|
+
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
7
|
+
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
|
8
|
+
|
|
9
|
+
/** Translate a display lane into the source-file track to persist. */
|
|
10
|
+
export function authoredTrackForLane(
|
|
11
|
+
lane: number,
|
|
12
|
+
elements: TimelineElement[],
|
|
13
|
+
dragged: TimelineElement,
|
|
14
|
+
): number {
|
|
15
|
+
const dragKey = keyOf(dragged);
|
|
16
|
+
const peers = elements.filter((element) => {
|
|
17
|
+
return keyOf(element) !== dragKey && sameSourceFile(element, dragged);
|
|
18
|
+
});
|
|
19
|
+
const occupant = peers.find((element) => element.track === lane);
|
|
20
|
+
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
|
21
|
+
|
|
22
|
+
let nearest: TimelineElement | null = null;
|
|
23
|
+
for (const peer of peers) {
|
|
24
|
+
if (!nearest || Math.abs(peer.track - lane) < Math.abs(nearest.track - lane)) nearest = peer;
|
|
25
|
+
}
|
|
26
|
+
if (!nearest) return lane;
|
|
27
|
+
// Synthetic expanded-child display rows can be fractional; authored tracks cannot.
|
|
28
|
+
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
|
|
29
|
+
}
|
|
@@ -22,6 +22,10 @@ export interface TimelineDropCallbacks {
|
|
|
22
22
|
blockName: string,
|
|
23
23
|
placement: { start: number; track: number },
|
|
24
24
|
) => Promise<void> | void;
|
|
25
|
+
onCompositionDrop?: (
|
|
26
|
+
sourcePath: string,
|
|
27
|
+
placement: { start: number; track: number },
|
|
28
|
+
) => Promise<void> | void;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export interface TimelineEditCallbacks {
|
|
@@ -4,6 +4,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
4
4
|
import {
|
|
5
5
|
commitDraggedClipMove,
|
|
6
6
|
commitZMirrorLaneMove,
|
|
7
|
+
persistMoveEdits,
|
|
7
8
|
type DragCommitDeps,
|
|
8
9
|
type TimelineMoveEdit,
|
|
9
10
|
} from "./timelineClipDragCommit";
|
|
@@ -314,6 +315,118 @@ describe("commitDraggedClipMove", () => {
|
|
|
314
315
|
expect(map.c).toBeUndefined(); // unselected clips untouched
|
|
315
316
|
});
|
|
316
317
|
|
|
318
|
+
it("collapses a selected expanded child onto its authored composition host", () => {
|
|
319
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
320
|
+
const child = {
|
|
321
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
322
|
+
sourceFile: "scene.html",
|
|
323
|
+
expandedParentStart: 10,
|
|
324
|
+
expandedHostKey: "host",
|
|
325
|
+
};
|
|
326
|
+
const { updateElement, onMoveElement, onMoveElements } = runClipMove(
|
|
327
|
+
drag(child, { previewStart: 15, previewTrack: child.track }),
|
|
328
|
+
{
|
|
329
|
+
elements: [host],
|
|
330
|
+
trackOrder: [0, child.track],
|
|
331
|
+
selectedKeys: new Set(["host", "scene.html#title"]),
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
336
|
+
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
|
|
337
|
+
expect(updateElement).toHaveBeenCalledWith("host", { start: 13, track: 0 });
|
|
338
|
+
expect(updateElement).not.toHaveBeenCalledWith("scene.html#title", expect.anything());
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("drops a selected expanded child alias when the authored host initiates the drag", () => {
|
|
342
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
343
|
+
const child = {
|
|
344
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
345
|
+
sourceFile: "scene.html",
|
|
346
|
+
expandedParentStart: 10,
|
|
347
|
+
expandedHostKey: "host",
|
|
348
|
+
};
|
|
349
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
350
|
+
drag(host, { previewStart: 13, previewTrack: host.track }),
|
|
351
|
+
{
|
|
352
|
+
elements: [host, child],
|
|
353
|
+
trackOrder: [0, child.track],
|
|
354
|
+
selectedKeys: new Set(["host", "scene.html#title"]),
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
359
|
+
expect(onMoveElement).toHaveBeenCalledOnce();
|
|
360
|
+
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("keeps an expanded child as the edit target when its host is not selected", () => {
|
|
364
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
365
|
+
const child = {
|
|
366
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
367
|
+
sourceFile: "scene.html",
|
|
368
|
+
expandedParentStart: 10,
|
|
369
|
+
expandedHostKey: "host",
|
|
370
|
+
};
|
|
371
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
372
|
+
drag(child, { previewStart: 15, previewTrack: child.track }),
|
|
373
|
+
{
|
|
374
|
+
elements: [host],
|
|
375
|
+
trackOrder: [0, child.track],
|
|
376
|
+
selectedKeys: new Set(["scene.html#title"]),
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(onMoveElements).not.toHaveBeenCalled();
|
|
381
|
+
expect(onMoveElement).toHaveBeenCalledWith(child, { start: 15, track: child.track });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("moves a host alias and an ordinary selected clip once each in one batch", () => {
|
|
385
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
386
|
+
const ordinary = el("ordinary", 1, 20, 3);
|
|
387
|
+
const child = {
|
|
388
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
389
|
+
sourceFile: "scene.html",
|
|
390
|
+
expandedParentStart: 10,
|
|
391
|
+
expandedHostKey: "host",
|
|
392
|
+
};
|
|
393
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
394
|
+
drag(child, { previewStart: 14, previewTrack: child.track }),
|
|
395
|
+
{
|
|
396
|
+
elements: [host, ordinary],
|
|
397
|
+
trackOrder: [0, child.track, 1],
|
|
398
|
+
selectedKeys: new Set(["host", "scene.html#title", "ordinary"]),
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
|
|
403
|
+
expect(map).toEqual({
|
|
404
|
+
host: { start: 12, track: 0 },
|
|
405
|
+
ordinary: { start: 22, track: 1 },
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("applies an expanded-child vertical drag to the selected host lane", () => {
|
|
410
|
+
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
|
411
|
+
const child = {
|
|
412
|
+
...el("scene.html#title", 0.25, 12, 2),
|
|
413
|
+
sourceFile: "scene.html",
|
|
414
|
+
expandedParentStart: 10,
|
|
415
|
+
expandedHostKey: "host",
|
|
416
|
+
};
|
|
417
|
+
const { onMoveElement, onMoveElements } = runClipMove(
|
|
418
|
+
drag(child, { previewStart: 12, previewTrack: 1, desiredTrack: 1 }),
|
|
419
|
+
{
|
|
420
|
+
elements: [host],
|
|
421
|
+
trackOrder: [0, child.track, 1],
|
|
422
|
+
selectedKeys: new Set(["host", "scene.html#title"]),
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
|
|
427
|
+
expect(map).toEqual({ host: { start: 10, track: 1 } });
|
|
428
|
+
});
|
|
429
|
+
|
|
317
430
|
it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
|
|
318
431
|
const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
|
|
319
432
|
// Drag 'a' −5s: b would land at −3 → clamps to 0.
|
|
@@ -959,20 +1072,40 @@ describe("commitDraggedClipMove", () => {
|
|
|
959
1072
|
expectZLiftedToSix(onStackingPatches);
|
|
960
1073
|
});
|
|
961
1074
|
|
|
1075
|
+
it("refreshes the preview only after the complete lane and z transaction", async () => {
|
|
1076
|
+
const order: string[] = [];
|
|
1077
|
+
commitInsertAbove(overlapping(), {
|
|
1078
|
+
onMoveElements: vi.fn(async () => {
|
|
1079
|
+
order.push("lane");
|
|
1080
|
+
}),
|
|
1081
|
+
onStackingPatches: vi.fn(async () => {
|
|
1082
|
+
order.push("z");
|
|
1083
|
+
}),
|
|
1084
|
+
refreshAfterLaneMove: () => order.push("refresh"),
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
await flushMicrotasks();
|
|
1088
|
+
|
|
1089
|
+
expect(order).toEqual(["lane", "z", "refresh"]);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
962
1092
|
it("rolls back the move and skips the z-sync when the persist fails", async () => {
|
|
963
1093
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
964
1094
|
const elements = overlapping();
|
|
965
1095
|
const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
|
|
966
1096
|
const onStackingPatches = vi.fn();
|
|
1097
|
+
const refreshAfterLaneMove = vi.fn();
|
|
967
1098
|
const updateElement = vi.fn();
|
|
968
1099
|
commitInsertAbove(elements, {
|
|
969
1100
|
updateElement,
|
|
970
1101
|
onMoveElements,
|
|
971
1102
|
onStackingPatches,
|
|
1103
|
+
refreshAfterLaneMove,
|
|
972
1104
|
});
|
|
973
1105
|
await flushMicrotasks();
|
|
974
1106
|
// Failed move → z patch never issued (no orphaned z change left behind)...
|
|
975
1107
|
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
1108
|
+
expect(refreshAfterLaneMove).not.toHaveBeenCalled();
|
|
976
1109
|
// ...and the optimistic start/track edit for the dragged clip is rolled back.
|
|
977
1110
|
expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
|
|
978
1111
|
errSpy.mockRestore();
|
|
@@ -1300,3 +1433,46 @@ describe("commitZMirrorLaneMove", () => {
|
|
|
1300
1433
|
expect(onMoveElements).not.toHaveBeenCalled();
|
|
1301
1434
|
});
|
|
1302
1435
|
});
|
|
1436
|
+
|
|
1437
|
+
describe("persistMoveEdits convergence", () => {
|
|
1438
|
+
it("reasserts a saved lane after a stale runtime sync", async () => {
|
|
1439
|
+
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
|
1440
|
+
let releaseSave: (() => void) | undefined;
|
|
1441
|
+
const pendingSave = new Promise<void>((resolve) => {
|
|
1442
|
+
releaseSave = resolve;
|
|
1443
|
+
});
|
|
1444
|
+
let liveTrack = clip.track;
|
|
1445
|
+
let liveAuthoredTrack = clip.authoredTrack;
|
|
1446
|
+
const updateElement = vi.fn((_key: string, updates: Partial<TimelineElement>) => {
|
|
1447
|
+
if (updates.track != null) liveTrack = updates.track;
|
|
1448
|
+
if (updates.authoredTrack != null) liveAuthoredTrack = updates.authoredTrack;
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
const persisted = persistMoveEdits(
|
|
1452
|
+
[
|
|
1453
|
+
{
|
|
1454
|
+
element: clip,
|
|
1455
|
+
updates: { start: clip.start, track: 0 },
|
|
1456
|
+
persistTrack: 0,
|
|
1457
|
+
},
|
|
1458
|
+
],
|
|
1459
|
+
{
|
|
1460
|
+
elements: [clip],
|
|
1461
|
+
trackOrder: [0, 1, 2],
|
|
1462
|
+
updateElement,
|
|
1463
|
+
onMoveElements: () => pendingSave,
|
|
1464
|
+
},
|
|
1465
|
+
);
|
|
1466
|
+
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
|
|
1467
|
+
|
|
1468
|
+
// Reproduce the real failure: the preview emits its cached pre-drag lane
|
|
1469
|
+
// while the file write is still pending.
|
|
1470
|
+
liveTrack = 2;
|
|
1471
|
+
liveAuthoredTrack = 2;
|
|
1472
|
+
releaseSave?.();
|
|
1473
|
+
|
|
1474
|
+
await expect(persisted).resolves.toBe(true);
|
|
1475
|
+
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
|
|
1476
|
+
expect(updateElement).toHaveBeenCalledTimes(2);
|
|
1477
|
+
});
|
|
1478
|
+
});
|
|
@@ -5,13 +5,18 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
5
5
|
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
6
6
|
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
7
7
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
canMoveTimelineElement as canMoveElement,
|
|
10
|
+
resolveExpandedHostAlias,
|
|
11
|
+
} from "./timelineAuthoredMoveTarget";
|
|
9
12
|
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
10
13
|
import {
|
|
11
14
|
beginTimelineOptimisticGesture,
|
|
12
15
|
isLatestTimelineOptimisticGesture,
|
|
13
16
|
} from "./timelineOptimisticRevision";
|
|
14
17
|
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
18
|
+
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
19
|
+
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
|
|
15
20
|
|
|
16
21
|
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
17
22
|
export interface TimelineMoveEdit {
|
|
@@ -68,30 +73,15 @@ export interface DragCommitDeps {
|
|
|
68
73
|
* research/STAGE3-NEEDED-WIRING.md.
|
|
69
74
|
*/
|
|
70
75
|
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
|
|
76
|
+
/** Converge the preview manifest after the complete lane + z transaction. */
|
|
77
|
+
refreshAfterLaneMove?: () => void;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
74
81
|
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
75
|
-
|
|
76
82
|
// One deterministic coalesce key shared by both records in a lane-change gesture.
|
|
77
83
|
let laneChangeGestureSeq = 0;
|
|
78
84
|
|
|
79
|
-
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
|
80
|
-
function canMoveElement(element: TimelineElement): boolean {
|
|
81
|
-
return getTimelineEditCapabilities({
|
|
82
|
-
tag: element.tag,
|
|
83
|
-
duration: element.duration,
|
|
84
|
-
domId: element.domId,
|
|
85
|
-
selector: element.selector,
|
|
86
|
-
compositionSrc: element.compositionSrc,
|
|
87
|
-
playbackStart: element.playbackStart,
|
|
88
|
-
playbackStartAttr: element.playbackStartAttr,
|
|
89
|
-
sourceDuration: element.sourceDuration,
|
|
90
|
-
timingSource: element.timingSource,
|
|
91
|
-
timelineLocked: element.timelineLocked,
|
|
92
|
-
}).canMove;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
85
|
/**
|
|
96
86
|
* Optimistically apply + persist a batch of moves with rollback on failure.
|
|
97
87
|
*
|
|
@@ -139,14 +129,15 @@ export function persistMoveEdits(
|
|
|
139
129
|
// that written value into the store's `authoredTrack` so a SECOND drag before
|
|
140
130
|
// any reload resolves authored tracks from what the file now says, not stale
|
|
141
131
|
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
|
142
|
-
|
|
132
|
+
const applyEdit = (e: TimelineMoveEdit) => {
|
|
143
133
|
const writtenTrack =
|
|
144
134
|
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
|
|
145
135
|
updateElement(
|
|
146
136
|
keyOf(e.element),
|
|
147
137
|
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
|
|
148
138
|
);
|
|
149
|
-
}
|
|
139
|
+
};
|
|
140
|
+
for (const e of edits) applyEdit(e);
|
|
150
141
|
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
|
151
142
|
// track when one was resolved (see TimelineMoveEdit.persistTrack).
|
|
152
143
|
const persistEdits = edits.map((e) =>
|
|
@@ -158,7 +149,17 @@ export function persistMoveEdits(
|
|
|
158
149
|
? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
|
|
159
150
|
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
|
160
151
|
return Promise.resolve(persisted).then(
|
|
161
|
-
() =>
|
|
152
|
+
() => {
|
|
153
|
+
// Runtime timeline messages can arrive while the save is in flight and
|
|
154
|
+
// restore the preview manifest's pre-gesture lane. Reassert the durable
|
|
155
|
+
// result after persistence, but only while this remains the latest
|
|
156
|
+
// optimistic gesture so an older save can never clobber a newer drag.
|
|
157
|
+
for (const e of edits) {
|
|
158
|
+
const key = keyOf(e.element);
|
|
159
|
+
if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
162
163
|
(error) => {
|
|
163
164
|
for (const p of prev) {
|
|
164
165
|
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
|
@@ -177,59 +178,6 @@ export function persistMoveEdits(
|
|
|
177
178
|
* then compacts it to a distinct integer lane between its neighbours, and the
|
|
178
179
|
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
|
179
180
|
*/
|
|
180
|
-
/** Same-source-file predicate: authored track numbers only compare within ONE
|
|
181
|
-
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
|
|
182
|
-
* file, not the host timeline's). `undefined` means the active composition. */
|
|
183
|
-
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
184
|
-
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
|
|
188
|
-
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
|
|
189
|
-
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
|
|
190
|
-
* a host clip next to expanded rows) carries authored values in a different
|
|
191
|
-
* coordinate space, and borrowing them would write a foreign file's numbering.
|
|
192
|
-
*
|
|
193
|
-
* Lane semantics after normalizeToZones: each distinct authored track owns one
|
|
194
|
-
* base lane, and time-overlapping same-track clips spill onto adjacent display
|
|
195
|
-
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
|
|
196
|
-
* trackOrder lists it): its occupants share the base lane's authored track by
|
|
197
|
-
* construction, so the same-file occupant lookup returns that authored track and
|
|
198
|
-
* the drop persists as a same-track join. The clip may then DISPLAY on a
|
|
199
|
-
* different sub-lane than it was dropped on — the spill re-packs
|
|
200
|
-
* deterministically by stable id, first-fit — but the persisted track is
|
|
201
|
-
* correct.
|
|
202
|
-
*
|
|
203
|
-
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
|
|
204
|
-
* dropped on a lane holding only other files' clips — the display-lane integer
|
|
205
|
-
* must NOT be persisted into a sparse file):
|
|
206
|
-
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
|
|
207
|
-
* preserving "one lane up = one authored track up" in the clip's own file.
|
|
208
|
-
* 2. No same-file peers at all → the lane value itself (single-clip files:
|
|
209
|
-
* display and authored spaces coincide for want of any other anchor).
|
|
210
|
-
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
|
|
211
|
-
* never here.
|
|
212
|
-
*/
|
|
213
|
-
export function authoredTrackForLane(
|
|
214
|
-
lane: number,
|
|
215
|
-
elements: TimelineElement[],
|
|
216
|
-
dragged: TimelineElement,
|
|
217
|
-
): number {
|
|
218
|
-
const dragKey = keyOf(dragged);
|
|
219
|
-
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
|
|
220
|
-
const occupant = peers.find((e) => e.track === lane);
|
|
221
|
-
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
|
222
|
-
let nearest: TimelineElement | null = null;
|
|
223
|
-
for (const p of peers) {
|
|
224
|
-
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
|
|
225
|
-
}
|
|
226
|
-
if (!nearest) return lane;
|
|
227
|
-
// Rounded: expanded children live on FRACTIONAL synthetic display rows (see
|
|
228
|
-
// buildChildElements), so a lane distance measured against one can carry a
|
|
229
|
-
// fraction — an authored data-track-index must stay an integer.
|
|
230
|
-
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
181
|
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
234
182
|
if (trackOrder.length === 0) return 0;
|
|
235
183
|
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
@@ -284,6 +232,12 @@ function resolveMultiSelection(
|
|
|
284
232
|
*/
|
|
285
233
|
// fallow-ignore-next-line complexity
|
|
286
234
|
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
|
|
235
|
+
const hostAlias = resolveExpandedHostAlias(drag, deps);
|
|
236
|
+
if (hostAlias) {
|
|
237
|
+
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
287
241
|
const { elements, updateElement, onMoveElement } = deps;
|
|
288
242
|
const dragKey = keyOf(drag.element);
|
|
289
243
|
const isInsert = drag.insertRow != null;
|
|
@@ -362,22 +316,28 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
362
316
|
});
|
|
363
317
|
const multiKeys = multi ? multi.keys : null;
|
|
364
318
|
if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
|
|
365
|
-
void
|
|
319
|
+
void refreshAfterDurableLaneMove(
|
|
320
|
+
persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
|
321
|
+
deps,
|
|
322
|
+
);
|
|
366
323
|
return;
|
|
367
324
|
}
|
|
368
|
-
void
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
325
|
+
void refreshAfterDurableLaneMove(
|
|
326
|
+
runLaneZGesture({
|
|
327
|
+
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
|
328
|
+
commitZ: () =>
|
|
329
|
+
syncStackingForEdit(
|
|
330
|
+
candidate,
|
|
331
|
+
dragKey,
|
|
332
|
+
drag.element.track,
|
|
333
|
+
drag.previewTrack,
|
|
334
|
+
multiKeys,
|
|
335
|
+
deps,
|
|
336
|
+
coalesceKey,
|
|
337
|
+
),
|
|
338
|
+
}),
|
|
339
|
+
deps,
|
|
340
|
+
).catch(() => undefined);
|
|
381
341
|
}
|
|
382
342
|
|
|
383
343
|
/** Build the one sanctioned multi-clip write: atomically insert and compact a
|
|
@@ -486,23 +446,29 @@ function commitTrackInsert(
|
|
|
486
446
|
|
|
487
447
|
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
|
|
488
448
|
if (!deps.readZIndex || !deps.onStackingPatches) {
|
|
489
|
-
void
|
|
449
|
+
void refreshAfterDurableLaneMove(
|
|
450
|
+
persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
|
451
|
+
deps,
|
|
452
|
+
);
|
|
490
453
|
return;
|
|
491
454
|
}
|
|
492
|
-
void
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
455
|
+
void refreshAfterDurableLaneMove(
|
|
456
|
+
runLaneZGesture({
|
|
457
|
+
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
|
458
|
+
commitZ: () =>
|
|
459
|
+
// Sync from the fractional drop intent, not the normalized persisted lanes.
|
|
460
|
+
syncStackingForEdit(
|
|
461
|
+
candidate,
|
|
462
|
+
dragKey,
|
|
463
|
+
drag.element.track,
|
|
464
|
+
drag.insertRow!,
|
|
465
|
+
multi ? multi.keys : null,
|
|
466
|
+
deps,
|
|
467
|
+
coalesceKey,
|
|
468
|
+
),
|
|
469
|
+
}),
|
|
470
|
+
deps,
|
|
471
|
+
).catch(() => undefined);
|
|
506
472
|
}
|
|
507
473
|
|
|
508
474
|
/**
|
|
@@ -544,11 +510,17 @@ export function commitZMirrorLaneMove(
|
|
|
544
510
|
updates: { start: element.start, track: move.displayTrack },
|
|
545
511
|
persistTrack: move.persistTrack,
|
|
546
512
|
};
|
|
547
|
-
return
|
|
513
|
+
return refreshAfterDurableLaneMove(
|
|
514
|
+
persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
|
|
515
|
+
deps,
|
|
516
|
+
);
|
|
548
517
|
}
|
|
549
518
|
const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
|
|
550
519
|
if (!built || built.edits.length === 0) return Promise.resolve(false);
|
|
551
|
-
return
|
|
520
|
+
return refreshAfterDurableLaneMove(
|
|
521
|
+
persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
|
|
522
|
+
deps,
|
|
523
|
+
);
|
|
552
524
|
}
|
|
553
525
|
|
|
554
526
|
/**
|