@hyperframes/studio 0.8.48 → 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-B3-orIjW.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-Bgmx2h7W.js} +135 -135
- package/dist/assets/{index-D_j-0PD3.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.html +1 -1
- package/dist/index.js +443 -303
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- 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/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/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/lib/timelineElementHelpers.ts +8 -0
- 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/retryBudget.ts +20 -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/timelineCompositionInsert.ts +2 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
|
@@ -3,7 +3,8 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
3
3
|
// Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
|
|
4
4
|
// value-import edge stays acyclic.
|
|
5
5
|
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
6
|
-
import { classifyZone
|
|
6
|
+
import { classifyZone } from "./timelineZones";
|
|
7
|
+
import { layoutAfterTrackInsert, resolveDragLandingStart } from "./timelineDragLanding";
|
|
7
8
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
8
9
|
import {
|
|
9
10
|
canMoveTimelineElement as canMoveElement,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
} from "./timelineOptimisticRevision";
|
|
17
18
|
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
18
19
|
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
19
|
-
import { authoredTrackForLane
|
|
20
|
+
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
|
20
21
|
|
|
21
22
|
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
22
23
|
export interface TimelineMoveEdit {
|
|
@@ -172,19 +173,6 @@ export function persistMoveEdits(
|
|
|
172
173
|
);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
/**
|
|
176
|
-
* A fractional track value for a NEW lane inserted at boundary `insertRow` in
|
|
177
|
-
* `trackOrder` (0 = above the top, `length` = below the bottom). normalizeToZones
|
|
178
|
-
* then compacts it to a distinct integer lane between its neighbours, and the
|
|
179
|
-
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
|
180
|
-
*/
|
|
181
|
-
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
182
|
-
if (trackOrder.length === 0) return 0;
|
|
183
|
-
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
184
|
-
if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5;
|
|
185
|
-
return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
176
|
/**
|
|
189
177
|
* Build the time-shift resolver for a multi-selection drag: every member of the
|
|
190
178
|
* selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
|
|
@@ -231,7 +219,8 @@ function resolveMultiSelection(
|
|
|
231
219
|
* permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
|
|
232
220
|
*/
|
|
233
221
|
// fallow-ignore-next-line complexity
|
|
234
|
-
export function commitDraggedClipMove(
|
|
222
|
+
export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommitDeps): void {
|
|
223
|
+
const drag = { ...rawDrag, previewStart: resolveDragLandingStart(rawDrag, deps) };
|
|
235
224
|
const hostAlias = resolveExpandedHostAlias(drag, deps);
|
|
236
225
|
if (hostAlias) {
|
|
237
226
|
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
|
|
@@ -353,36 +342,11 @@ function buildTrackInsertEdits(
|
|
|
353
342
|
} | null,
|
|
354
343
|
deps: DragCommitDeps,
|
|
355
344
|
): { candidate: TimelineElement[]; edits: TimelineMoveEdit[] } | null {
|
|
356
|
-
const { elements
|
|
345
|
+
const { elements } = deps;
|
|
357
346
|
const editKey = keyOf(element);
|
|
358
|
-
|
|
359
|
-
if (
|
|
360
|
-
const targetTrack =
|
|
361
|
-
const candidate = elements.map((e) => {
|
|
362
|
-
if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
|
|
363
|
-
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
364
|
-
return e;
|
|
365
|
-
});
|
|
366
|
-
// Foreign display rows and the opposite zone must not affect this topology.
|
|
367
|
-
const writableZone = classifyZone(element);
|
|
368
|
-
const writable = (src: TimelineElement): boolean =>
|
|
369
|
-
sameSourceFile(src, element) &&
|
|
370
|
-
classifyZone(src) === writableZone &&
|
|
371
|
-
src.expandedParentStart == null;
|
|
372
|
-
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
|
373
|
-
(a, b) => a - b,
|
|
374
|
-
);
|
|
375
|
-
const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
|
|
376
|
-
const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
|
|
377
|
-
const normalized = normalizeToZones(
|
|
378
|
-
elements.filter(writable).map((e) => {
|
|
379
|
-
if (keyOf(e) === editKey) {
|
|
380
|
-
return { ...e, start: previewStart, track: topologyTargetTrack };
|
|
381
|
-
}
|
|
382
|
-
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
383
|
-
return e;
|
|
384
|
-
}),
|
|
385
|
-
);
|
|
347
|
+
const layout = layoutAfterTrackInsert(element, previewStart, insertRow, multi, deps);
|
|
348
|
+
if (!layout) return null;
|
|
349
|
+
const { normalized, targetTrack, writable } = layout;
|
|
386
350
|
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
|
|
387
351
|
// A partial zone renumber creates collisions; refuse a shifted locked row.
|
|
388
352
|
for (const norm of normalized) {
|
|
@@ -399,6 +363,11 @@ function buildTrackInsertEdits(
|
|
|
399
363
|
return null;
|
|
400
364
|
}
|
|
401
365
|
}
|
|
366
|
+
const candidate = elements.map((e) => {
|
|
367
|
+
if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
|
|
368
|
+
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
369
|
+
return e;
|
|
370
|
+
});
|
|
402
371
|
const edits: TimelineMoveEdit[] = [];
|
|
403
372
|
if (multi) {
|
|
404
373
|
for (const src of elements) {
|
|
@@ -415,10 +384,10 @@ function buildTrackInsertEdits(
|
|
|
415
384
|
for (const norm of normalized) {
|
|
416
385
|
const src = bySrc.get(keyOf(norm));
|
|
417
386
|
if (!src || !canMoveElement(src)) continue;
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
387
|
+
const normKey = keyOf(norm);
|
|
388
|
+
let start = src.start;
|
|
389
|
+
if (normKey === editKey) start = previewStart;
|
|
390
|
+
else if (multi?.keys.has(normKey)) start = multi.movedStart(src);
|
|
422
391
|
edits.push({ element: src, updates: { start, track: norm.track } });
|
|
423
392
|
}
|
|
424
393
|
return { candidate, edits };
|
|
@@ -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
|
|
@@ -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
|
+
}
|
|
@@ -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
|
});
|