@hyperframes/studio 0.8.48 → 0.8.50
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-CxxDxHTW.js +464 -0
- package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6209 -5924
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +20 -51
- 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 +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- 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/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- 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/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +10 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- 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/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
7
7
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
8
8
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
@@ -65,6 +65,40 @@ describe("TimelineGestureOverlay", () => {
|
|
|
65
65
|
act(() => root.unmount());
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it("asks for the same thumbnail frames as the clip at rest", () => {
|
|
69
|
+
const host = document.createElement("div");
|
|
70
|
+
document.body.append(host);
|
|
71
|
+
const root = createRoot(host);
|
|
72
|
+
const renderClipContent = vi.fn(() => null);
|
|
73
|
+
act(() => {
|
|
74
|
+
root.render(
|
|
75
|
+
<TimelineGestureOverlay
|
|
76
|
+
drag={drag}
|
|
77
|
+
scrollRef={{
|
|
78
|
+
current: {
|
|
79
|
+
scrollLeft: 0,
|
|
80
|
+
scrollTop: 0,
|
|
81
|
+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
82
|
+
} as HTMLDivElement,
|
|
83
|
+
}}
|
|
84
|
+
pixelsPerSecond={100}
|
|
85
|
+
rowHeight={42}
|
|
86
|
+
selectedElementId="hero"
|
|
87
|
+
currentTime={4}
|
|
88
|
+
theme={defaultTimelineTheme}
|
|
89
|
+
getTrackStyle={getTrackStyle}
|
|
90
|
+
renderClipContent={renderClipContent}
|
|
91
|
+
/>,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
expect(renderClipContent).toHaveBeenCalledWith(
|
|
95
|
+
expect.anything(),
|
|
96
|
+
expect.anything(),
|
|
97
|
+
expect.objectContaining({ rich: false }),
|
|
98
|
+
);
|
|
99
|
+
act(() => root.unmount());
|
|
100
|
+
});
|
|
101
|
+
|
|
68
102
|
it("keeps the stable overlay host after terminal cleanup", () => {
|
|
69
103
|
const host = document.createElement("div");
|
|
70
104
|
document.body.append(host);
|
|
@@ -89,7 +89,7 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
|
|
|
89
89
|
getTrackStyle(element.tag),
|
|
90
90
|
renderClipContent,
|
|
91
91
|
renderClipOverlay,
|
|
92
|
-
{ priority: "interaction", rich:
|
|
92
|
+
{ priority: "interaction", rich: false },
|
|
93
93
|
)}
|
|
94
94
|
</TimelineClip>
|
|
95
95
|
</div>
|
|
@@ -70,6 +70,8 @@ interface RenderLanesOptions {
|
|
|
70
70
|
draggedClip?: DraggedClipState | null;
|
|
71
71
|
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
72
72
|
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
73
|
+
hoveredClip?: string | null;
|
|
74
|
+
renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
function renderLanes(options: RenderLanesOptions = {}): {
|
|
@@ -135,7 +137,8 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
135
137
|
laneCounts={laneCounts}
|
|
136
138
|
selectedElementId={null}
|
|
137
139
|
selectedElementIds={next.selectedElementIds ?? new Set()}
|
|
138
|
-
hoveredClip={null}
|
|
140
|
+
hoveredClip={next.hoveredClip ?? null}
|
|
141
|
+
renderClipContent={next.renderClipContent}
|
|
139
142
|
draggedClip={next.draggedClip ?? null}
|
|
140
143
|
draggedElement={null}
|
|
141
144
|
multiDragPreview={next.multiDragPreview ?? null}
|
|
@@ -395,3 +398,21 @@ describe("TimelineLanes selection", () => {
|
|
|
395
398
|
act(() => view.root.unmount());
|
|
396
399
|
});
|
|
397
400
|
});
|
|
401
|
+
|
|
402
|
+
describe("TimelineLanes clip thumbnails", () => {
|
|
403
|
+
it("asks for the same frames at rest, hovered and selected", () => {
|
|
404
|
+
const rich: unknown[] = [];
|
|
405
|
+
const renderClipContent = vi.fn(
|
|
406
|
+
(_el: TimelineElement, _style: unknown, context: { rich: boolean }) => {
|
|
407
|
+
rich.push(context.rich);
|
|
408
|
+
return null;
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
const elements = [element("clip-a", TRACK_A)];
|
|
412
|
+
const view = renderLanes({ elements, renderClipContent });
|
|
413
|
+
view.rerender({ elements, renderClipContent, hoveredClip: "clip-a" });
|
|
414
|
+
view.rerender({ elements, renderClipContent, selectedElementIds: new Set(["clip-a"]) });
|
|
415
|
+
expect(new Set(rich)).toEqual(new Set([false]));
|
|
416
|
+
act(() => view.root.unmount());
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -52,6 +52,10 @@ interface TimelineOverlaysProps {
|
|
|
52
52
|
onSplitElement: TimelineEditCallbacks["onSplitElement"];
|
|
53
53
|
pinZoomBeforeEdit: () => void;
|
|
54
54
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
55
|
+
onCopyClip?: () => boolean;
|
|
56
|
+
onPasteClip?: () => Promise<void>;
|
|
57
|
+
onDuplicateClip?: () => Promise<boolean>;
|
|
58
|
+
canPasteClip?: () => boolean;
|
|
55
59
|
gapContextMenu: TrackGapContextMenuState | null;
|
|
56
60
|
onDismissGapContextMenu: () => void;
|
|
57
61
|
onCloseTrackGap: () => void;
|
|
@@ -119,6 +123,10 @@ export function TimelineOverlays({
|
|
|
119
123
|
onSplitElement,
|
|
120
124
|
pinZoomBeforeEdit,
|
|
121
125
|
onDeleteElement,
|
|
126
|
+
onCopyClip,
|
|
127
|
+
onPasteClip,
|
|
128
|
+
onDuplicateClip,
|
|
129
|
+
canPasteClip,
|
|
122
130
|
gapContextMenu,
|
|
123
131
|
onDismissGapContextMenu,
|
|
124
132
|
onCloseTrackGap,
|
|
@@ -249,6 +257,10 @@ export function TimelineOverlays({
|
|
|
249
257
|
pinZoomBeforeEdit();
|
|
250
258
|
onDeleteElement?.(element);
|
|
251
259
|
}}
|
|
260
|
+
onCopy={onCopyClip}
|
|
261
|
+
onPaste={onPasteClip}
|
|
262
|
+
onDuplicate={onDuplicateClip}
|
|
263
|
+
canPaste={canPasteClip?.() ?? false}
|
|
252
264
|
/>
|
|
253
265
|
)}
|
|
254
266
|
|
|
@@ -22,5 +22,9 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
|
|
|
22
22
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
23
23
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
24
24
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
25
|
+
onCopyClip?: () => boolean;
|
|
26
|
+
onPasteClip?: () => Promise<void>;
|
|
27
|
+
onDuplicateClip?: () => Promise<boolean>;
|
|
28
|
+
canPasteClip?: () => boolean;
|
|
25
29
|
theme?: Partial<TimelineTheme>;
|
|
26
30
|
}
|
|
@@ -3,7 +3,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
|
3
3
|
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
4
4
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
5
|
|
|
6
|
-
/** Whether Studio may write timing to this clip (false for locked
|
|
6
|
+
/** Whether Studio may write timing to this clip (false for locked rows). */
|
|
7
7
|
export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
8
8
|
return getTimelineEditCapabilities({
|
|
9
9
|
tag: element.tag,
|
|
@@ -16,7 +16,6 @@ export function canMoveTimelineElement(element: TimelineElement): boolean {
|
|
|
16
16
|
playbackStart: element.playbackStart,
|
|
17
17
|
playbackStartAttr: element.playbackStartAttr,
|
|
18
18
|
sourceDuration: element.sourceDuration,
|
|
19
|
-
timingSource: element.timingSource,
|
|
20
19
|
timelineLocked: element.timelineLocked,
|
|
21
20
|
}).canMove;
|
|
22
21
|
}
|
|
@@ -11,10 +11,10 @@ const clip: TimelineElement = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
describe("resolveClipRenderContext", () => {
|
|
14
|
-
it("prioritizes interactive clips
|
|
14
|
+
it("prioritizes interactive clips without changing their thumbnail frames", () => {
|
|
15
15
|
expect(resolveClipRenderContext(clip, { start: 0, end: 1 }, true)).toEqual({
|
|
16
16
|
priority: "interaction",
|
|
17
|
-
rich:
|
|
17
|
+
rich: false,
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -9,7 +9,8 @@ export function resolveClipRenderContext(
|
|
|
9
9
|
visibleTimeRange: TimelineTimeRange,
|
|
10
10
|
interactive: boolean,
|
|
11
11
|
): TimelineClipRenderContext {
|
|
12
|
-
|
|
12
|
+
// Interaction only reorders loading; `rich` would swap the frames under the pointer.
|
|
13
|
+
if (interactive) return { priority: "interaction", rich: false };
|
|
13
14
|
const visible =
|
|
14
15
|
element.start < visibleTimeRange.end &&
|
|
15
16
|
element.start + element.duration > visibleTimeRange.start;
|
|
@@ -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,24 +173,11 @@ 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
|
|
191
|
-
* untouched. Returns null when this is not a multi-selection drag. A locked
|
|
192
|
-
*
|
|
179
|
+
* untouched. Returns null when this is not a multi-selection drag. A locked
|
|
180
|
+
* member is dropped from the moving set (a marquee can sweep one in).
|
|
193
181
|
*/
|
|
194
182
|
function resolveMultiSelection(
|
|
195
183
|
drag: DraggedClipState,
|
|
@@ -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 };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
3
|
import {
|
|
4
4
|
computeDragPreview,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type DragPreviewContext,
|
|
8
8
|
} from "./timelineClipDragPreview";
|
|
9
9
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
10
|
+
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
|
10
11
|
import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
|
11
12
|
|
|
12
13
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -198,6 +199,72 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|
|
198
199
|
});
|
|
199
200
|
});
|
|
200
201
|
|
|
202
|
+
describe("computeDragPreview — magnetic first clip on an empty main track", () => {
|
|
203
|
+
// v-lower sits alone on lane 1; lane 0 (the main track) is empty.
|
|
204
|
+
const vLower = clip("v-lower", 1, 10, 4, 5);
|
|
205
|
+
|
|
206
|
+
// Grab v-lower mid-body on lane 1, aim at `targetRowFloat` (same x — no
|
|
207
|
+
// horizontal move), against the given sibling elements and selection.
|
|
208
|
+
function dragUpToMainTrack(
|
|
209
|
+
elements: TimelineElement[],
|
|
210
|
+
targetRowFloat = 0.5,
|
|
211
|
+
selectedKeys: ReadonlySet<string> = new Set(),
|
|
212
|
+
) {
|
|
213
|
+
const originClientY = yForRow(1.5);
|
|
214
|
+
const drag: DraggedClipState = {
|
|
215
|
+
pointerId: 0,
|
|
216
|
+
element: vLower,
|
|
217
|
+
originClientX: 800,
|
|
218
|
+
originClientY,
|
|
219
|
+
originScrollLeft: 0,
|
|
220
|
+
originScrollTop: 0,
|
|
221
|
+
pointerClientX: 800,
|
|
222
|
+
pointerClientY: originClientY,
|
|
223
|
+
pointerOffsetX: 0,
|
|
224
|
+
pointerOffsetY: 0,
|
|
225
|
+
previewStart: vLower.start,
|
|
226
|
+
previewTrack: vLower.track,
|
|
227
|
+
insertRow: null,
|
|
228
|
+
snapTime: null,
|
|
229
|
+
snapType: null,
|
|
230
|
+
started: true,
|
|
231
|
+
};
|
|
232
|
+
return computeDragPreview(drag, 800, yForRow(targetRowFloat), {
|
|
233
|
+
...ctx(undefined, elements),
|
|
234
|
+
trackOrder: [0, 1],
|
|
235
|
+
selectedKeys,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
it("dragging straight up onto the empty main track snaps the preview start to 0", () => {
|
|
240
|
+
const next = dragUpToMainTrack([vLower]);
|
|
241
|
+
expect(next.previewTrack).toBe(0);
|
|
242
|
+
expect(next.insertRow).toBeNull();
|
|
243
|
+
expect(next.previewStart).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("does not touch the start once the main track already holds a clip", () => {
|
|
247
|
+
const vMain = clip("v-main", 0, 0, 3, 5);
|
|
248
|
+
const next = dragUpToMainTrack([vLower, vMain]);
|
|
249
|
+
expect(next.previewStart).toBe(10); // unchanged — main track wasn't empty
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("aiming the top gutter over an empty main track snaps the ghost: the insert renumbers onto track 0", () => {
|
|
253
|
+
const next = dragUpToMainTrack([vLower], -0.6);
|
|
254
|
+
expect(next.insertRow).toBe(0);
|
|
255
|
+
expect(next.previewStart).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not retime the rest of a multi-selection when the grabbed clip lands on the empty main track", () => {
|
|
259
|
+
const vOther = clip("v-other", 1, 15, 3, 5);
|
|
260
|
+
const next = dragUpToMainTrack([vLower, vOther], 0.5, new Set(["v-lower", "v-other"]));
|
|
261
|
+
expect(next.previewTrack).toBe(0);
|
|
262
|
+
// The grabbed clip's OWN vertical-only move must not force a horizontal
|
|
263
|
+
// shift that resolveMultiSelection would then apply to v-other.
|
|
264
|
+
expect(next.previewStart).toBe(10);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
201
268
|
describe("computeResizePreview — composition source continuity", () => {
|
|
202
269
|
it("seeds a legacy composition offset and advances it at playback rate", () => {
|
|
203
270
|
const element = {
|
|
@@ -254,3 +321,108 @@ describe("getTimelineDragOverlayPosition", () => {
|
|
|
254
321
|
expect(getTimelineDragOverlayPosition(drag, null)).toBeNull();
|
|
255
322
|
});
|
|
256
323
|
});
|
|
324
|
+
|
|
325
|
+
describe("computeDragPreview — the ghost start is the committed start", () => {
|
|
326
|
+
function preview(
|
|
327
|
+
element: TimelineElement,
|
|
328
|
+
elements: TimelineElement[],
|
|
329
|
+
originRow: number,
|
|
330
|
+
targetRowFloat: number,
|
|
331
|
+
selectedKeys: ReadonlySet<string> = new Set(),
|
|
332
|
+
): DraggedClipState {
|
|
333
|
+
const originClientY = yForRow(originRow + 0.5);
|
|
334
|
+
const drag: DraggedClipState = {
|
|
335
|
+
pointerId: 0,
|
|
336
|
+
element,
|
|
337
|
+
originClientX: 800,
|
|
338
|
+
originClientY,
|
|
339
|
+
originScrollLeft: 0,
|
|
340
|
+
originScrollTop: 0,
|
|
341
|
+
pointerClientX: 800,
|
|
342
|
+
pointerClientY: originClientY,
|
|
343
|
+
pointerOffsetX: 0,
|
|
344
|
+
pointerOffsetY: 0,
|
|
345
|
+
previewStart: element.start,
|
|
346
|
+
previewTrack: element.track,
|
|
347
|
+
insertRow: null,
|
|
348
|
+
snapTime: null,
|
|
349
|
+
snapType: null,
|
|
350
|
+
started: true,
|
|
351
|
+
};
|
|
352
|
+
return computeDragPreview(drag, 800, yForRow(targetRowFloat), {
|
|
353
|
+
...ctx(undefined, elements),
|
|
354
|
+
trackOrder: [0, 1, 2],
|
|
355
|
+
selectedKeys,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function committedStart(
|
|
360
|
+
ghost: DraggedClipState,
|
|
361
|
+
committed: TimelineElement,
|
|
362
|
+
elements: TimelineElement[],
|
|
363
|
+
selectedKeys: ReadonlySet<string> = new Set(),
|
|
364
|
+
): number | undefined {
|
|
365
|
+
const onMoveElement = vi.fn();
|
|
366
|
+
const onMoveElements = vi.fn();
|
|
367
|
+
commitDraggedClipMove(ghost, {
|
|
368
|
+
elements,
|
|
369
|
+
trackOrder: [0, 1, 2],
|
|
370
|
+
updateElement: vi.fn(),
|
|
371
|
+
onMoveElement,
|
|
372
|
+
onMoveElements,
|
|
373
|
+
selectedKeys,
|
|
374
|
+
});
|
|
375
|
+
const edits = onMoveElements.mock.calls[0]?.[0] as
|
|
376
|
+
| Array<{ element: TimelineElement; updates: { start: number } }>
|
|
377
|
+
| undefined;
|
|
378
|
+
const single = onMoveElement.mock.calls[0];
|
|
379
|
+
if (single) return single[1].start;
|
|
380
|
+
return edits?.find((e) => e.element.id === committed.id)?.updates.start;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const lower = clip("lower", 1, 10, 4, 5);
|
|
384
|
+
|
|
385
|
+
it("plain move onto the empty main track", () => {
|
|
386
|
+
const ghost = preview(lower, [lower], 1, 0.5);
|
|
387
|
+
expect(ghost.previewStart).toBe(0);
|
|
388
|
+
expect(committedStart(ghost, lower, [lower])).toBe(ghost.previewStart);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("top-gutter insert that pushes the old track-0 clip down lands on track 0 at 0", () => {
|
|
392
|
+
const oldMain = clip("old-main", 0, 0, 3, 5);
|
|
393
|
+
const elements = [oldMain, lower];
|
|
394
|
+
const ghost = preview(lower, elements, 1, -0.6);
|
|
395
|
+
expect(ghost.insertRow).toBe(0);
|
|
396
|
+
expect(ghost.previewStart).toBe(0);
|
|
397
|
+
expect(committedStart(ghost, lower, elements)).toBe(0);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("top-gutter insert with a clip staying on track 0 keeps the pointer start", () => {
|
|
401
|
+
const stays = clip("stays", 0, 0, 3, 5);
|
|
402
|
+
const elements = [stays, lower];
|
|
403
|
+
// Insert between lane 0 and 1: the resident track-0 clip does not move.
|
|
404
|
+
const ghost = preview(lower, elements, 1, 1.0);
|
|
405
|
+
expect(ghost.previewStart).toBe(10);
|
|
406
|
+
expect(committedStart(ghost, lower, elements)).toBe(10);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("expanded child dragged with its host: the host commits at 0 and the ghost matches", () => {
|
|
410
|
+
for (const [hostStart, childStart] of [
|
|
411
|
+
[30, 32],
|
|
412
|
+
[20, 22],
|
|
413
|
+
]) {
|
|
414
|
+
const host = clip("host", 1, hostStart, 10, 5);
|
|
415
|
+
const child: TimelineElement = {
|
|
416
|
+
...clip("child", 2, childStart, 4, 5),
|
|
417
|
+
expandedHostKey: "host",
|
|
418
|
+
expandedParentStart: hostStart,
|
|
419
|
+
};
|
|
420
|
+
const elements = [host, child];
|
|
421
|
+
const keys = new Set(["host", "child"]);
|
|
422
|
+
const ghost = preview(child, elements, 2, 0.5, keys);
|
|
423
|
+
expect(ghost.previewTrack).toBe(0);
|
|
424
|
+
expect(ghost.previewStart).toBe(childStart - hostStart);
|
|
425
|
+
expect(committedStart(ghost, host, elements, keys)).toBe(0);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./timelineGroupEditing";
|
|
21
21
|
import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
|
|
22
22
|
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
|
23
|
+
import { resolveDragLandingStart } from "./timelineDragLanding";
|
|
23
24
|
|
|
24
25
|
/** Snap-target builder closure supplied by the hook (closes over refs + store). */
|
|
25
26
|
type BuildSnapTargets = (
|
|
@@ -200,12 +201,18 @@ export function computeDragPreview(
|
|
|
200
201
|
nextMove.track,
|
|
201
202
|
ctx,
|
|
202
203
|
);
|
|
204
|
+
const placed = { ...drag, previewStart, previewTrack, insertRow };
|
|
205
|
+
const snappedStart = resolveDragLandingStart(placed, {
|
|
206
|
+
elements,
|
|
207
|
+
trackOrder,
|
|
208
|
+
selectedKeys,
|
|
209
|
+
});
|
|
203
210
|
return {
|
|
204
211
|
...drag,
|
|
205
212
|
started: true,
|
|
206
213
|
pointerClientX: clientX,
|
|
207
214
|
pointerClientY: clientY,
|
|
208
|
-
previewStart,
|
|
215
|
+
previewStart: snappedStart,
|
|
209
216
|
previewTrack,
|
|
210
217
|
// The lane the POINTER aims at (pre-collision): the commit reads it to tell a
|
|
211
218
|
// deliberate vertical lane change from a horizontal drag merely bumped sideways.
|