@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
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isInsertAllowedForZone,
|
|
6
6
|
isLaneFree,
|
|
7
7
|
resolveInsertRow,
|
|
8
|
+
resolveMainTrackDropStart,
|
|
8
9
|
resolvePlacement,
|
|
9
10
|
resolveZoneDropPlacement,
|
|
10
11
|
timeRangesOverlap,
|
|
@@ -493,3 +494,25 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
|
|
|
493
494
|
expect(result).toEqual({ track: 0, insertRow: 1 });
|
|
494
495
|
});
|
|
495
496
|
});
|
|
497
|
+
|
|
498
|
+
describe("resolveMainTrackDropStart (magnetic first clip on an empty main track)", () => {
|
|
499
|
+
it("snaps to 0 when landing on an empty main track", () => {
|
|
500
|
+
expect(resolveMainTrackDropStart([], 1, 0, false, 7)).toBe(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("leaves the drop start alone once the main track already holds another clip", () => {
|
|
504
|
+
expect(resolveMainTrackDropStart([el("other", 0, 0, 3)], 1, 0, false, 7)).toBe(7);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("leaves the drop start alone when landing off the main track", () => {
|
|
508
|
+
expect(resolveMainTrackDropStart([], 1, 2, false, 7)).toBe(7);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("a clip already resident on the main track doing a horizontal move is a no-op", () => {
|
|
512
|
+
expect(resolveMainTrackDropStart([], 0, 0, false, 7)).toBe(7);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("an audio clip landing on track 0 is not the main track (visual zone only)", () => {
|
|
516
|
+
expect(resolveMainTrackDropStart([], 1, 0, true, 7)).toBe(7);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
|
3
|
+
import { isMainTrackElement } from "./timelineZones";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
|
@@ -140,6 +141,32 @@ export function resolveZoneDropPlacement(input: {
|
|
|
140
141
|
return { track: placement.track, insertRow: null };
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
function landsOnMainTrack(landingTrack: number, isAudio: boolean): boolean {
|
|
145
|
+
return isMainTrackElement({
|
|
146
|
+
id: "",
|
|
147
|
+
tag: isAudio ? "audio" : "video",
|
|
148
|
+
start: 0,
|
|
149
|
+
duration: 0,
|
|
150
|
+
track: landingTrack,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An existing clip newly landing on an empty main track commits at start=0.
|
|
155
|
+
* No-op once the main track holds another clip, off the main track, or for
|
|
156
|
+
* a clip already resident there. `others` excludes the landing clip itself. */
|
|
157
|
+
export function resolveMainTrackDropStart(
|
|
158
|
+
others: readonly TimelineElement[],
|
|
159
|
+
originTrack: number,
|
|
160
|
+
landingTrack: number,
|
|
161
|
+
isAudio: boolean,
|
|
162
|
+
desiredStart: number,
|
|
163
|
+
): number {
|
|
164
|
+
if (originTrack === landingTrack || !landsOnMainTrack(landingTrack, isAudio)) {
|
|
165
|
+
return desiredStart;
|
|
166
|
+
}
|
|
167
|
+
return others.some(isMainTrackElement) ? desiredStart : 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
/**
|
|
144
171
|
* Decide whether a vertical drag is inserting a new track at a lane boundary.
|
|
145
172
|
* `rowFloat` is the pointer's position in track-height units from the top of the
|
|
@@ -220,4 +220,35 @@ describe("useTimelineAssetDrop", () => {
|
|
|
220
220
|
expect(view.api.isDragOver).toBe(false);
|
|
221
221
|
act(() => view.root.unmount());
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
it("previews the exact placement the drop commits, and clears it after", () => {
|
|
225
|
+
const onAssetDrop = vi.fn();
|
|
226
|
+
const view = renderHarness(onAssetDrop);
|
|
227
|
+
const payload = JSON.stringify({ path: "assets/a.png" });
|
|
228
|
+
|
|
229
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
|
|
230
|
+
const preview = view.api.dropPreview;
|
|
231
|
+
expect(preview).toEqual({ start: 10, track: 0 });
|
|
232
|
+
|
|
233
|
+
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
|
|
234
|
+
expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
|
|
235
|
+
expect(view.api.dropPreview).toBeNull();
|
|
236
|
+
act(() => view.root.unmount());
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("moves the preview with the pointer and drops it when the drag leaves", () => {
|
|
240
|
+
const view = renderHarness(vi.fn());
|
|
241
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
|
|
242
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
|
|
243
|
+
expect(view.api.dropPreview?.start).toBe(20);
|
|
244
|
+
|
|
245
|
+
act(() =>
|
|
246
|
+
view.api.handleAssetDragLeave({
|
|
247
|
+
relatedTarget: null,
|
|
248
|
+
currentTarget: document.body,
|
|
249
|
+
} as unknown as React.DragEvent),
|
|
250
|
+
);
|
|
251
|
+
expect(view.api.dropPreview).toBeNull();
|
|
252
|
+
act(() => view.root.unmount());
|
|
253
|
+
});
|
|
223
254
|
});
|
|
@@ -98,6 +98,7 @@ export function useTimelineAssetDrop({
|
|
|
98
98
|
sessionEpoch,
|
|
99
99
|
}: UseTimelineAssetDropOptions) {
|
|
100
100
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
101
|
+
const [dropPreview, setDropPreview] = useState<TimelinePlacement | null>(null);
|
|
101
102
|
const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
|
|
102
103
|
null,
|
|
103
104
|
);
|
|
@@ -142,6 +143,28 @@ export function useTimelineAssetDrop({
|
|
|
142
143
|
[scrollRef, sessionEpoch, stepAutoScroll],
|
|
143
144
|
);
|
|
144
145
|
|
|
146
|
+
const resolveDropPlacement = useCallback(
|
|
147
|
+
(clientX: number, clientY: number): TimelinePlacement => {
|
|
148
|
+
const scroll = scrollRef.current;
|
|
149
|
+
const rect = scroll?.getBoundingClientRect();
|
|
150
|
+
return resolveTimelineAssetDrop(
|
|
151
|
+
{
|
|
152
|
+
rectLeft: rect?.left ?? 0,
|
|
153
|
+
rectTop: rect?.top ?? 0,
|
|
154
|
+
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
155
|
+
scrollTop: scroll?.scrollTop ?? 0,
|
|
156
|
+
contentOrigin,
|
|
157
|
+
pixelsPerSecond: ppsRef.current,
|
|
158
|
+
rowHeights: rowGeometryRef.current.rowHeights,
|
|
159
|
+
trackOrder: trackOrderRef.current,
|
|
160
|
+
},
|
|
161
|
+
clientX,
|
|
162
|
+
clientY,
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
166
|
+
);
|
|
167
|
+
|
|
145
168
|
const handleAssetDragOver = useCallback(
|
|
146
169
|
(e: React.DragEvent) => {
|
|
147
170
|
const types = Array.from(e.dataTransfer.types);
|
|
@@ -154,15 +177,20 @@ export function useTimelineAssetDrop({
|
|
|
154
177
|
e.dataTransfer.dropEffect = "copy";
|
|
155
178
|
activeDropEpochRef.current = sessionEpoch;
|
|
156
179
|
setIsDragOver(true);
|
|
180
|
+
const next = resolveDropPlacement(e.clientX, e.clientY);
|
|
181
|
+
setDropPreview((prev) =>
|
|
182
|
+
prev?.start === next.start && prev.track === next.track ? prev : next,
|
|
183
|
+
);
|
|
157
184
|
syncAutoScroll(e.clientX, e.clientY);
|
|
158
185
|
},
|
|
159
|
-
[sessionEpoch, syncAutoScroll],
|
|
186
|
+
[resolveDropPlacement, sessionEpoch, syncAutoScroll],
|
|
160
187
|
);
|
|
161
188
|
|
|
162
189
|
const clearDropPreview = useCallback(() => {
|
|
163
190
|
activeDropEpochRef.current = null;
|
|
164
191
|
stopAutoScroll();
|
|
165
192
|
setIsDragOver(false);
|
|
193
|
+
setDropPreview(null);
|
|
166
194
|
}, [stopAutoScroll]);
|
|
167
195
|
|
|
168
196
|
const handleAssetDragLeave = useCallback(
|
|
@@ -174,28 +202,6 @@ export function useTimelineAssetDrop({
|
|
|
174
202
|
[clearDropPreview],
|
|
175
203
|
);
|
|
176
204
|
|
|
177
|
-
const resolveDropPlacement = useCallback(
|
|
178
|
-
(clientX: number, clientY: number): TimelinePlacement => {
|
|
179
|
-
const scroll = scrollRef.current;
|
|
180
|
-
const rect = scroll?.getBoundingClientRect();
|
|
181
|
-
return resolveTimelineAssetDrop(
|
|
182
|
-
{
|
|
183
|
-
rectLeft: rect?.left ?? 0,
|
|
184
|
-
rectTop: rect?.top ?? 0,
|
|
185
|
-
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
186
|
-
scrollTop: scroll?.scrollTop ?? 0,
|
|
187
|
-
contentOrigin,
|
|
188
|
-
pixelsPerSecond: ppsRef.current,
|
|
189
|
-
rowHeights: rowGeometryRef.current.rowHeights,
|
|
190
|
-
trackOrder: trackOrderRef.current,
|
|
191
|
-
},
|
|
192
|
-
clientX,
|
|
193
|
-
clientY,
|
|
194
|
-
);
|
|
195
|
-
},
|
|
196
|
-
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
197
|
-
);
|
|
198
|
-
|
|
199
205
|
const handleAssetDrop = useCallback(
|
|
200
206
|
(e: React.DragEvent) => {
|
|
201
207
|
e.preventDefault();
|
|
@@ -240,6 +246,7 @@ export function useTimelineAssetDrop({
|
|
|
240
246
|
|
|
241
247
|
return {
|
|
242
248
|
isDragOver,
|
|
249
|
+
dropPreview,
|
|
243
250
|
handleAssetDragOver,
|
|
244
251
|
handleAssetDragLeave,
|
|
245
252
|
handleAssetDrop,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
|
3
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
4
|
+
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
5
|
+
import { resolveMainTrackDropStart } from "./timelineCollision";
|
|
6
|
+
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
7
|
+
import { resolveExpandedHostAlias } from "./timelineAuthoredMoveTarget";
|
|
8
|
+
import { sameSourceFile } from "./timelineAuthoredTrack";
|
|
9
|
+
import { round3 } from "./timelineGaps";
|
|
10
|
+
|
|
11
|
+
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
12
|
+
|
|
13
|
+
/** A fractional track for a new lane at boundary `insertRow` (0 = above the top);
|
|
14
|
+
* normalizeToZones compacts it to an integer lane and shifts the clips below. */
|
|
15
|
+
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
16
|
+
if (trackOrder.length === 0) return 0;
|
|
17
|
+
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
18
|
+
if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5;
|
|
19
|
+
return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The source-file lane layout after inserting `element` at boundary `insertRow`:
|
|
23
|
+
* the whole-set renumber both the commit and the drag preview read. */
|
|
24
|
+
export function layoutAfterTrackInsert(
|
|
25
|
+
element: TimelineElement,
|
|
26
|
+
previewStart: number,
|
|
27
|
+
insertRow: number,
|
|
28
|
+
multi: {
|
|
29
|
+
keys: ReadonlySet<string>;
|
|
30
|
+
movedStart: (e: TimelineElement) => number;
|
|
31
|
+
} | null,
|
|
32
|
+
deps: Pick<DragCommitDeps, "elements" | "trackOrder">,
|
|
33
|
+
): {
|
|
34
|
+
normalized: TimelineElement[];
|
|
35
|
+
targetTrack: number;
|
|
36
|
+
writable: (src: TimelineElement) => boolean;
|
|
37
|
+
} | null {
|
|
38
|
+
const { elements, trackOrder } = deps;
|
|
39
|
+
const editKey = keyOf(element);
|
|
40
|
+
// Expanded-child rows are synthetic host lanes, not source-file topology.
|
|
41
|
+
if (element.expandedParentStart != null) return null;
|
|
42
|
+
const targetTrack = insertTrackValue(trackOrder, insertRow);
|
|
43
|
+
// Foreign display rows and the opposite zone must not affect this topology.
|
|
44
|
+
const writableZone = classifyZone(element);
|
|
45
|
+
const writable = (src: TimelineElement): boolean =>
|
|
46
|
+
sameSourceFile(src, element) &&
|
|
47
|
+
classifyZone(src) === writableZone &&
|
|
48
|
+
src.expandedParentStart == null;
|
|
49
|
+
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
|
50
|
+
(a, b) => a - b,
|
|
51
|
+
);
|
|
52
|
+
const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
|
|
53
|
+
const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
|
|
54
|
+
const normalized = normalizeToZones(
|
|
55
|
+
elements.filter(writable).map((e) => {
|
|
56
|
+
if (keyOf(e) === editKey) {
|
|
57
|
+
return { ...e, start: previewStart, track: topologyTargetTrack };
|
|
58
|
+
}
|
|
59
|
+
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
60
|
+
return e;
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return { normalized, targetTrack, writable };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The start a solo clip lands at, for both the ghost and the commit: 0 on an empty
|
|
67
|
+
* main track, judged on the final lane (after the renumber and the host alias).
|
|
68
|
+
* Returned in the dragged clip's own frame. */
|
|
69
|
+
export function resolveDragLandingStart(
|
|
70
|
+
drag: DraggedClipState,
|
|
71
|
+
deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "selectedKeys">,
|
|
72
|
+
): number {
|
|
73
|
+
const alias = resolveExpandedHostAlias(drag, deps);
|
|
74
|
+
const target = alias?.drag ?? drag;
|
|
75
|
+
const keys = alias?.selectedKeys ?? deps.selectedKeys;
|
|
76
|
+
const element = target.element;
|
|
77
|
+
const key = keyOf(element);
|
|
78
|
+
if (keys && keys.size > 1 && keys.has(key)) return drag.previewStart;
|
|
79
|
+
|
|
80
|
+
let landingTrack = target.previewTrack;
|
|
81
|
+
let renumbered = new Map<string, number>();
|
|
82
|
+
if (target.insertRow != null) {
|
|
83
|
+
const layout = layoutAfterTrackInsert(
|
|
84
|
+
element,
|
|
85
|
+
target.previewStart,
|
|
86
|
+
target.insertRow,
|
|
87
|
+
null,
|
|
88
|
+
deps,
|
|
89
|
+
);
|
|
90
|
+
if (layout) {
|
|
91
|
+
renumbered = new Map(layout.normalized.map((n) => [keyOf(n), n.track]));
|
|
92
|
+
landingTrack = renumbered.get(key) ?? layout.targetTrack;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const others = deps.elements
|
|
96
|
+
.filter((e) => keyOf(e) !== key)
|
|
97
|
+
.map((e) => (renumbered.has(keyOf(e)) ? { ...e, track: renumbered.get(keyOf(e))! } : e));
|
|
98
|
+
const snapped = resolveMainTrackDropStart(
|
|
99
|
+
others,
|
|
100
|
+
element.track,
|
|
101
|
+
landingTrack,
|
|
102
|
+
isAudioTimelineElement(element),
|
|
103
|
+
target.previewStart,
|
|
104
|
+
);
|
|
105
|
+
return snapped === target.previewStart
|
|
106
|
+
? drag.previewStart
|
|
107
|
+
: round3(drag.previewStart + snapped - target.previewStart);
|
|
108
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
5
|
+
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
6
|
+
import { selectAndRevealTimelineElement } from "./timelineDropReveal";
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
usePlayerStore.getState().reset();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("selectAndRevealTimelineElement", () => {
|
|
13
|
+
it("selects the element and requests a scroll-to-focus on the same key", () => {
|
|
14
|
+
selectAndRevealTimelineElement("index.html#clip-1");
|
|
15
|
+
|
|
16
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip-1");
|
|
17
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(
|
|
18
|
+
timelineClipFocusId("index.html#clip-1"),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("moves the focus request to the new element when called again", () => {
|
|
23
|
+
selectAndRevealTimelineElement("index.html#clip-1");
|
|
24
|
+
const firstNonce = usePlayerStore.getState().timelineFocus?.nonce;
|
|
25
|
+
|
|
26
|
+
selectAndRevealTimelineElement("index.html#clip-2");
|
|
27
|
+
|
|
28
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip-2");
|
|
29
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(
|
|
30
|
+
timelineClipFocusId("index.html#clip-2"),
|
|
31
|
+
);
|
|
32
|
+
expect(usePlayerStore.getState().timelineFocus?.nonce).not.toBe(firstNonce);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
2
|
+
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Select a newly-created clip and scroll the timeline to it, the same pair of
|
|
6
|
+
* calls AssetCard/AudioRow use to reveal an existing clip on activation.
|
|
7
|
+
*/
|
|
8
|
+
export function selectAndRevealTimelineElement(key: string): void {
|
|
9
|
+
const store = usePlayerStore.getState();
|
|
10
|
+
store.setSelectedElementId(key);
|
|
11
|
+
store.requestTimelineFocus(timelineClipFocusId(key));
|
|
12
|
+
}
|
|
@@ -42,10 +42,9 @@ export function getTimelineEditCapabilities(input: {
|
|
|
42
42
|
playbackStart?: number;
|
|
43
43
|
playbackStartAttr?: "media-start" | "playback-start";
|
|
44
44
|
sourceDuration?: number;
|
|
45
|
-
timingSource?: "authored" | "implicit";
|
|
46
45
|
timelineLocked?: boolean;
|
|
47
46
|
}): TimelineEditCapabilities {
|
|
48
|
-
if (input.
|
|
47
|
+
if (input.timelineLocked) {
|
|
49
48
|
return { canMove: false, canTrimStart: false, canTrimEnd: false };
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -412,21 +412,6 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
412
412
|
});
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
-
it("keeps implicit layout layers selectable but not timeline-editable", () => {
|
|
416
|
-
expect(
|
|
417
|
-
getTimelineEditCapabilities({
|
|
418
|
-
duration: 8,
|
|
419
|
-
selector: ".scene-shell",
|
|
420
|
-
tag: "div",
|
|
421
|
-
timingSource: "implicit",
|
|
422
|
-
}),
|
|
423
|
-
).toEqual({
|
|
424
|
-
canMove: false,
|
|
425
|
-
canTrimStart: false,
|
|
426
|
-
canTrimEnd: false,
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
415
|
it("allows move and both trims for patchable media clips with offset support", () => {
|
|
431
416
|
expect(
|
|
432
417
|
getTimelineEditCapabilities({
|
|
@@ -496,7 +481,6 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
496
481
|
tag: "div",
|
|
497
482
|
duration: 4,
|
|
498
483
|
selector: "#hero-card",
|
|
499
|
-
timingSource: "authored",
|
|
500
484
|
}),
|
|
501
485
|
).toEqual({
|
|
502
486
|
canMove: true,
|
|
@@ -34,11 +34,9 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
|
|
|
34
34
|
expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
it("degrades when a member
|
|
37
|
+
it("degrades when a member has no patch target", () => {
|
|
38
38
|
const a = el("a");
|
|
39
|
-
const implicit = el("b", { timingSource: "implicit" });
|
|
40
39
|
const noTarget = el("c", { domId: undefined, selector: undefined });
|
|
41
|
-
expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
|
|
42
40
|
expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
|
|
43
41
|
});
|
|
44
42
|
|
|
@@ -198,7 +198,6 @@ function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): b
|
|
|
198
198
|
playbackStart: element.playbackStart,
|
|
199
199
|
playbackStartAttr: element.playbackStartAttr,
|
|
200
200
|
sourceDuration: element.sourceDuration,
|
|
201
|
-
timingSource: element.timingSource,
|
|
202
201
|
timelineLocked: element.timelineLocked,
|
|
203
202
|
});
|
|
204
203
|
return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
|
|
5
5
|
* sub-composition child gets a synthesized fractional key (`host.track + n /
|
|
6
|
-
* (siblings + 2)`, see `
|
|
6
|
+
* (siblings + 2)`, see `useTimelineRowElements`), so putting it in a string
|
|
7
7
|
* announces "Hide track 0.16666666666666666". Every user-visible track number,
|
|
8
8
|
* whether it is rendered by a component or baked into an undo-history label,
|
|
9
9
|
* routes through here; the raw key stays in callbacks and lookups only.
|
|
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
4
|
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
|
5
|
-
import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
|
|
6
5
|
import { normalizeToZones } from "./timelineZones";
|
|
7
6
|
import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
|
|
8
7
|
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
@@ -113,56 +112,4 @@ describe("track persist pipeline (manifest → factory → lanes → drag commit
|
|
|
113
112
|
const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
|
|
114
113
|
expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
|
|
115
114
|
});
|
|
116
|
-
|
|
117
|
-
it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
|
|
118
|
-
// Host timeline: the sub-comp host plus a root clip, discovered through the
|
|
119
|
-
// factory and lane-normalized like the store does.
|
|
120
|
-
const hostManifest = [
|
|
121
|
-
manifestClip({
|
|
122
|
-
id: "scene",
|
|
123
|
-
kind: "composition",
|
|
124
|
-
tagName: "div",
|
|
125
|
-
track: 0,
|
|
126
|
-
start: 0,
|
|
127
|
-
duration: 10,
|
|
128
|
-
compositionId: "scene",
|
|
129
|
-
compositionSrc: "scene.html",
|
|
130
|
-
}),
|
|
131
|
-
manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
|
|
132
|
-
];
|
|
133
|
-
// scene.html has SPARSE authored tracks 3 and 7.
|
|
134
|
-
const childClips = [
|
|
135
|
-
manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
|
|
136
|
-
manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
|
|
137
|
-
];
|
|
138
|
-
const storeElements = normalizeToZones(fromManifest(hostManifest));
|
|
139
|
-
const parentMap = new Map([
|
|
140
|
-
["c3", "scene"],
|
|
141
|
-
["c7", "scene"],
|
|
142
|
-
]);
|
|
143
|
-
const expanded = buildExpandedElements(
|
|
144
|
-
storeElements,
|
|
145
|
-
[...hostManifest, ...childClips],
|
|
146
|
-
parentMap,
|
|
147
|
-
"scene",
|
|
148
|
-
"scene",
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
// The children replaced the host row: synthetic display lanes, but the
|
|
152
|
-
// authored track (in scene.html's coordinate space) survived the expansion.
|
|
153
|
-
const c3 = expanded.find((e) => e.domId === "c3")!;
|
|
154
|
-
const c7 = expanded.find((e) => e.domId === "c7")!;
|
|
155
|
-
expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
|
|
156
|
-
expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
|
|
157
|
-
expect(c3.stackingContextId).toBe("root");
|
|
158
|
-
expect(c3.track).not.toBe(3); // display row is synthetic
|
|
159
|
-
|
|
160
|
-
// Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
|
|
161
|
-
// the written track must be c7's authored 7 — not the display-lane integer.
|
|
162
|
-
const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
|
|
163
|
-
const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
|
|
164
|
-
expect(edits).toHaveLength(1);
|
|
165
|
-
expect(edits[0].updates.track).toBe(7);
|
|
166
|
-
expect(edits[0].updates.track).not.toBe(c7.track);
|
|
167
|
-
});
|
|
168
115
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { ClipContextMenuState } from "./TimelineOverlays";
|
|
4
|
+
|
|
5
|
+
/** A clicked member of an active multi-selection keeps the whole group
|
|
6
|
+
* selected, so the context menu it opens acts on all of it. */
|
|
7
|
+
export function useClipContextMenu(
|
|
8
|
+
onSelectElement: ((element: TimelineElement | null) => void) | undefined,
|
|
9
|
+
dismissGapMenu: () => void,
|
|
10
|
+
setClipContextMenu: (state: ClipContextMenuState | null) => void,
|
|
11
|
+
) {
|
|
12
|
+
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
13
|
+
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
14
|
+
return useCallback(
|
|
15
|
+
(e: React.MouseEvent, el: TimelineElement) => {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
const id = el.key ?? el.id;
|
|
18
|
+
if (!(selectedElementIds.size > 1 && selectedElementIds.has(id))) {
|
|
19
|
+
setSelectedElementId(id);
|
|
20
|
+
onSelectElement?.(el);
|
|
21
|
+
}
|
|
22
|
+
dismissGapMenu();
|
|
23
|
+
setClipContextMenu({
|
|
24
|
+
x: e.clientX,
|
|
25
|
+
y: e.clientY,
|
|
26
|
+
element: el,
|
|
27
|
+
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
[selectedElementIds, setSelectedElementId, onSelectElement, dismissGapMenu, setClipContextMenu],
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -35,12 +35,16 @@ function Harness({
|
|
|
35
35
|
mountedId,
|
|
36
36
|
logicalRows = rows,
|
|
37
37
|
projectId = "project-a",
|
|
38
|
+
lastScrollLeftRef: sharedLastScrollLeftRef,
|
|
38
39
|
}: {
|
|
39
40
|
mountedId?: string;
|
|
40
41
|
logicalRows?: readonly TimelineLogicalRow[];
|
|
41
42
|
projectId?: string;
|
|
43
|
+
lastScrollLeftRef?: React.RefObject<number>;
|
|
42
44
|
}) {
|
|
43
45
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const ownLastScrollLeftRef = useRef(0);
|
|
47
|
+
const lastScrollLeftRef = sharedLastScrollLeftRef ?? ownLastScrollLeftRef;
|
|
44
48
|
const rowGeometry = useMemo(() => {
|
|
45
49
|
const rowKeys = [...new Set(logicalRows.map((row) => row.physicalTrackKey))];
|
|
46
50
|
return createTimelineRowGeometry(
|
|
@@ -60,6 +64,7 @@ function Harness({
|
|
|
60
64
|
projectId,
|
|
61
65
|
sessionEpoch: 1,
|
|
62
66
|
syncScrollViewport,
|
|
67
|
+
lastScrollLeftRef,
|
|
63
68
|
});
|
|
64
69
|
return (
|
|
65
70
|
<div
|
|
@@ -171,4 +176,65 @@ describe("useTimelineFocusCoordinator", () => {
|
|
|
171
176
|
expect(usePlayerStore.getState().timelineFocus?.id).toBe(nextRowId);
|
|
172
177
|
expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(nextRowId);
|
|
173
178
|
});
|
|
179
|
+
|
|
180
|
+
it("keeps a request alive across a few renders where its target hasn't landed yet", async () => {
|
|
181
|
+
// A request issued the same tick as its own element's creation (a fresh
|
|
182
|
+
// drop) can race logicalRows by a render or two; it must not be dropped
|
|
183
|
+
// before the element actually appears.
|
|
184
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
185
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
186
|
+
const notYetRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
|
|
187
|
+
await act(async () => root.render(<Harness logicalRows={notYetRows} />));
|
|
188
|
+
}
|
|
189
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
|
|
190
|
+
|
|
191
|
+
await act(async () => root.render(<Harness logicalRows={rows} mountedId={clipId} />));
|
|
192
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
|
|
193
|
+
expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("gives up once a target never lands after its retry budget", async () => {
|
|
197
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
198
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
199
|
+
// A fresh array reference each render, like a real re-render caused by
|
|
200
|
+
// unrelated state, or the memoized resolution never recomputes at all.
|
|
201
|
+
const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
|
|
202
|
+
await act(async () => root.render(<Harness logicalRows={neverRows} />));
|
|
203
|
+
}
|
|
204
|
+
expect(usePlayerStore.getState().timelineFocus).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("clears an unresolved request on a timeout even if nothing else ever re-renders", async () => {
|
|
208
|
+
// The render-count retry budget can't advance without a rerun; if the
|
|
209
|
+
// target never lands and nothing else re-renders, only a wall-time
|
|
210
|
+
// backstop clears it — otherwise it sits forever, eligible to resolve
|
|
211
|
+
// against an unrelated element that later reuses its id.
|
|
212
|
+
vi.useFakeTimers();
|
|
213
|
+
try {
|
|
214
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
215
|
+
const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
|
|
216
|
+
await act(async () => root.render(<Harness logicalRows={neverRows} />));
|
|
217
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
vi.advanceTimersByTime(4001);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(usePlayerStore.getState().timelineFocus).toBeNull();
|
|
224
|
+
} finally {
|
|
225
|
+
vi.useRealTimers();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("keeps lastScrollLeftRef in sync with a reveal so a sibling restore effect doesn't undo it", async () => {
|
|
230
|
+
// Timeline.tsx's own "restore scroll after an edit re-derives the elements"
|
|
231
|
+
// effect reads this ref to decide what to scroll back to. A reveal that
|
|
232
|
+
// doesn't update it looks, to that effect, like scroll drift to undo.
|
|
233
|
+
const lastScrollLeftRef = { current: 999 };
|
|
234
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
235
|
+
await act(async () => root.render(<Harness lastScrollLeftRef={lastScrollLeftRef} />));
|
|
236
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
237
|
+
|
|
238
|
+
expect(lastScrollLeftRef.current).toBe(scroll.scrollLeft);
|
|
239
|
+
});
|
|
174
240
|
});
|