@hyperframes/studio 0.8.47 → 0.8.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
- package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +70 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1452 -918
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
- package/src/hooks/useTimelineAssetDropOps.ts +24 -18
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -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.
|
|
@@ -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
|
|
@@ -56,7 +56,6 @@ function renderHarness(
|
|
|
56
56
|
api = useTimelineAssetDrop({
|
|
57
57
|
scrollRef: { current: scroll },
|
|
58
58
|
ppsRef: { current: 40 },
|
|
59
|
-
durationRef: { current: 120 },
|
|
60
59
|
trackOrderRef: { current: tracks },
|
|
61
60
|
rowGeometryRef: { current: geometry },
|
|
62
61
|
contentOrigin: 0,
|
|
@@ -138,7 +137,6 @@ describe("useTimelineAssetDrop", () => {
|
|
|
138
137
|
it("drops once on a model row outside the mounted window and appends below the last row", () => {
|
|
139
138
|
const onAssetDrop = vi.fn();
|
|
140
139
|
const view = renderHarness(onAssetDrop);
|
|
141
|
-
usePlayerStore.getState().setCurrentTime(12.5);
|
|
142
140
|
view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
|
|
143
141
|
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
|
|
144
142
|
|
|
@@ -148,11 +146,29 @@ describe("useTimelineAssetDrop", () => {
|
|
|
148
146
|
});
|
|
149
147
|
|
|
150
148
|
expect(onAssetDrop).toHaveBeenCalledTimes(1);
|
|
151
|
-
|
|
149
|
+
// pps=40, clientX=400 -> 10s at the pointer, not the playhead.
|
|
150
|
+
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 10, track: 100 });
|
|
152
151
|
expect(view.api.isDragOver).toBe(false);
|
|
153
152
|
act(() => view.root.unmount());
|
|
154
153
|
});
|
|
155
154
|
|
|
155
|
+
it("places the drop at the pointer x, ignoring the playhead", () => {
|
|
156
|
+
const onAssetDrop = vi.fn();
|
|
157
|
+
const view = renderHarness(onAssetDrop);
|
|
158
|
+
usePlayerStore.getState().setCurrentTime(50);
|
|
159
|
+
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
|
|
160
|
+
|
|
161
|
+
act(() => {
|
|
162
|
+
view.api.handleAssetDragOver(dragEvent(transfer, 80, 100));
|
|
163
|
+
view.api.handleAssetDrop(dragEvent(transfer, 80, 100));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
|
|
167
|
+
// the drop position, not usePlayerStore.currentTime.
|
|
168
|
+
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 2, track: 0 });
|
|
169
|
+
act(() => view.root.unmount());
|
|
170
|
+
});
|
|
171
|
+
|
|
156
172
|
it("ignores malformed payloads and clears the actor on project reset", () => {
|
|
157
173
|
const onAssetDrop = vi.fn();
|
|
158
174
|
const view = renderHarness(onAssetDrop, 1);
|
|
@@ -190,7 +206,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
190
206
|
});
|
|
191
207
|
|
|
192
208
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
|
193
|
-
|
|
209
|
+
// pps=40, clientX=400 -> 10s at the pointer.
|
|
210
|
+
expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 10, track: 0 });
|
|
194
211
|
act(() => view.root.unmount());
|
|
195
212
|
});
|
|
196
213
|
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
parseTimelineCompositionPayload,
|
|
5
5
|
TIMELINE_COMPOSITION_MIME,
|
|
6
6
|
} from "../../utils/timelineCompositionDrop";
|
|
7
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
8
7
|
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
|
9
8
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
10
9
|
import {
|
|
@@ -15,7 +14,6 @@ import {
|
|
|
15
14
|
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
16
15
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
17
16
|
ppsRef: RefObject<number>;
|
|
18
|
-
durationRef: RefObject<number>;
|
|
19
17
|
trackOrderRef: RefObject<number[]>;
|
|
20
18
|
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
|
21
19
|
contentOrigin: number;
|
|
@@ -53,11 +51,6 @@ function invokeDropCallback(callback: () => Promise<void> | void): void {
|
|
|
53
51
|
}
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
|
|
57
|
-
if (usePointerStart) return pointerStart;
|
|
58
|
-
return Math.max(0, usePlayerStore.getState().currentTime);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
54
|
function applyFileDrop(
|
|
62
55
|
transfer: DataTransfer,
|
|
63
56
|
onFileDrop: TimelineDropCallbacks["onFileDrop"],
|
|
@@ -87,17 +80,14 @@ function applyTypedJsonDrop(
|
|
|
87
80
|
}
|
|
88
81
|
|
|
89
82
|
/**
|
|
90
|
-
* Dropping an asset/file/block onto the timeline places it at the
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* OS file drops and internal asset drops share this same placement path, so
|
|
95
|
-
* both land identically.
|
|
83
|
+
* Dropping an asset/file/block/composition onto the timeline places it at the
|
|
84
|
+
* exact time and track it was dropped on, like CapCut (pointer placement on
|
|
85
|
+
* every track but the magnetic main track). Supersedes the prior playhead
|
|
86
|
+
* decision (#2291); playhead adds stay available, see useAddAssetAtPlayhead.
|
|
96
87
|
*/
|
|
97
88
|
export function useTimelineAssetDrop({
|
|
98
89
|
scrollRef,
|
|
99
90
|
ppsRef,
|
|
100
|
-
durationRef,
|
|
101
91
|
trackOrderRef,
|
|
102
92
|
rowGeometryRef,
|
|
103
93
|
contentOrigin,
|
|
@@ -185,10 +175,10 @@ export function useTimelineAssetDrop({
|
|
|
185
175
|
);
|
|
186
176
|
|
|
187
177
|
const resolveDropPlacement = useCallback(
|
|
188
|
-
(clientX: number, clientY: number
|
|
178
|
+
(clientX: number, clientY: number): TimelinePlacement => {
|
|
189
179
|
const scroll = scrollRef.current;
|
|
190
180
|
const rect = scroll?.getBoundingClientRect();
|
|
191
|
-
|
|
181
|
+
return resolveTimelineAssetDrop(
|
|
192
182
|
{
|
|
193
183
|
rectLeft: rect?.left ?? 0,
|
|
194
184
|
rectTop: rect?.top ?? 0,
|
|
@@ -196,20 +186,14 @@ export function useTimelineAssetDrop({
|
|
|
196
186
|
scrollTop: scroll?.scrollTop ?? 0,
|
|
197
187
|
contentOrigin,
|
|
198
188
|
pixelsPerSecond: ppsRef.current,
|
|
199
|
-
duration: durationRef.current,
|
|
200
|
-
clampStartToDuration: !usePointerStart,
|
|
201
189
|
rowHeights: rowGeometryRef.current.rowHeights,
|
|
202
190
|
trackOrder: trackOrderRef.current,
|
|
203
191
|
},
|
|
204
192
|
clientX,
|
|
205
193
|
clientY,
|
|
206
194
|
);
|
|
207
|
-
return {
|
|
208
|
-
start: resolveDropStart(usePointerStart, pointer.start),
|
|
209
|
-
track: pointer.track,
|
|
210
|
-
};
|
|
211
195
|
},
|
|
212
|
-
[scrollRef, ppsRef,
|
|
196
|
+
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
213
197
|
);
|
|
214
198
|
|
|
215
199
|
const handleAssetDrop = useCallback(
|
|
@@ -218,15 +202,15 @@ export function useTimelineAssetDrop({
|
|
|
218
202
|
const canCommit = activeDropEpochRef.current === sessionEpoch;
|
|
219
203
|
clearDropPreview();
|
|
220
204
|
if (!canCommit) return;
|
|
205
|
+
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
206
|
+
|
|
221
207
|
const compositionPayload = parseTimelineCompositionPayload(
|
|
222
208
|
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
|
223
209
|
);
|
|
224
210
|
if (compositionPayload && onCompositionDrop) {
|
|
225
|
-
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
|
226
211
|
invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
|
|
227
212
|
return;
|
|
228
213
|
}
|
|
229
|
-
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
230
214
|
|
|
231
215
|
if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return;
|
|
232
216
|
if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) {
|
|
@@ -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
|
+
}
|