@hyperframes/studio 0.8.90 → 0.8.92
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-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
- package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
- package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
- package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +18 -14
- package/dist/index.html +1 -1
- package/dist/index.js +9190 -8916
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
- package/src/hooks/useTimelineAssetDropOps.ts +40 -18
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +32 -66
- package/src/player/components/timelineClipDragCommit.ts +19 -37
- package/src/player/components/timelineClipDragPreview.test.ts +283 -24
- package/src/player/components/timelineClipDragPreview.ts +51 -83
- package/src/player/components/timelineClipDragTypes.ts +3 -8
- package/src/player/components/timelineCollision.test.ts +100 -302
- package/src/player/components/timelineCollision.ts +73 -160
- package/src/player/components/timelineDragDrop.test.tsx +19 -5
- package/src/player/components/timelineDragDrop.ts +7 -9
- package/src/player/components/timelineDragLanding.ts +0 -49
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineLayout.test.ts +0 -25
- package/src/player/components/timelineLayout.ts +0 -16
- package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
- package/src/player/components/timelineMultiDragPreview.ts +26 -25
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +14 -6
- package/src/player/components/timelineShellMeta.tsx +99 -0
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelineDragPreviewState.ts +34 -0
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +29 -66
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/src/utils/timelineDropTrackInsert.test.ts +60 -1
- package/src/utils/timelineDropTrackInsert.ts +36 -5
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// fallow-ignore-file complexity
|
|
2
2
|
import { useCallback, useRef } from "react";
|
|
3
|
-
import type
|
|
4
|
-
import {
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
|
+
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
|
5
5
|
import { useRazorSplit } from "./useRazorSplit";
|
|
6
6
|
import { selectSplittableElements } from "../utils/timelineElementSplit";
|
|
7
7
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
@@ -187,12 +187,11 @@ export function useTimelineEditing({
|
|
|
187
187
|
// other move — early-returning on !startChanged alone silently dropped
|
|
188
188
|
// the file write, so the lane snapped back on reload.
|
|
189
189
|
const trackChanged = updates.track !== element.track;
|
|
190
|
-
|
|
190
|
+
const authoredStart = toAuthoredStart(element, updates.start);
|
|
191
191
|
if (startChanged || trackChanged) {
|
|
192
192
|
const liveAttrs: Array<[string, string]> = [];
|
|
193
|
-
if (startChanged)
|
|
194
|
-
liveAttrs.push(["data-start", formatTimelineAttributeNumber(
|
|
195
|
-
}
|
|
193
|
+
if (startChanged)
|
|
194
|
+
liveAttrs.push(["data-start", formatTimelineAttributeNumber(authoredStart)]);
|
|
196
195
|
if (trackChanged) {
|
|
197
196
|
liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
|
|
198
197
|
}
|
|
@@ -224,7 +223,7 @@ export function useTimelineEditing({
|
|
|
224
223
|
return buildTimelineMoveTimingPatch(
|
|
225
224
|
original,
|
|
226
225
|
target,
|
|
227
|
-
|
|
226
|
+
authoredStart,
|
|
228
227
|
element.duration,
|
|
229
228
|
track,
|
|
230
229
|
);
|
|
@@ -258,7 +257,7 @@ export function useTimelineEditing({
|
|
|
258
257
|
return sdkTimingPersist(
|
|
259
258
|
element.hfId,
|
|
260
259
|
targetPath,
|
|
261
|
-
{ start:
|
|
260
|
+
{ start: authoredStart },
|
|
262
261
|
sdkSession,
|
|
263
262
|
{
|
|
264
263
|
editHistory: { recordEdit },
|
|
@@ -309,8 +308,9 @@ export function useTimelineEditing({
|
|
|
309
308
|
element: TimelineElement,
|
|
310
309
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
311
310
|
) => {
|
|
311
|
+
const authoredStart = toAuthoredStart(element, updates.start);
|
|
312
312
|
const liveAttrs: Array<[string, string]> = [
|
|
313
|
-
["data-start", formatTimelineAttributeNumber(
|
|
313
|
+
["data-start", formatTimelineAttributeNumber(authoredStart)],
|
|
314
314
|
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
315
315
|
];
|
|
316
316
|
if (updates.playbackStart != null) {
|
|
@@ -350,8 +350,8 @@ export function useTimelineEditing({
|
|
|
350
350
|
writeProjectFile,
|
|
351
351
|
edit: {
|
|
352
352
|
kind: "scale",
|
|
353
|
-
from: { start: element.start, duration: element.duration },
|
|
354
|
-
to: { start: updates.start, duration: updates.duration },
|
|
353
|
+
from: { start: toCompositionTime(element, element.start), duration: element.duration },
|
|
354
|
+
to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
|
|
355
355
|
},
|
|
356
356
|
}).finally(() => invalidateGsapCache?.());
|
|
357
357
|
const resizeFallback = () =>
|
|
@@ -363,7 +363,7 @@ export function useTimelineEditing({
|
|
|
363
363
|
? sdkTimingPersist(
|
|
364
364
|
element.hfId,
|
|
365
365
|
targetPath,
|
|
366
|
-
{ start:
|
|
366
|
+
{ start: authoredStart, duration: updates.duration },
|
|
367
367
|
sdkSession,
|
|
368
368
|
{
|
|
369
369
|
editHistory: { recordEdit },
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
|
4
4
|
import type { Composition } from "@hyperframes/sdk";
|
|
5
5
|
import type { TimelineElement } from "../player";
|
|
6
|
+
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
|
6
7
|
import {
|
|
7
8
|
cutoverCommittedOrThrow,
|
|
8
9
|
sdkTimingBatchPersist,
|
|
@@ -244,7 +245,10 @@ export function useTimelineGroupEditing({
|
|
|
244
245
|
if (changes.length === 0) return Promise.resolve();
|
|
245
246
|
for (const change of changes) {
|
|
246
247
|
const attrs: Array<[string, string]> = [
|
|
247
|
-
[
|
|
248
|
+
[
|
|
249
|
+
"data-start",
|
|
250
|
+
formatTimelineAttributeNumber(toAuthoredStart(change.element, change.start)),
|
|
251
|
+
],
|
|
248
252
|
];
|
|
249
253
|
if (change.track != null) {
|
|
250
254
|
attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
|
|
@@ -287,7 +291,9 @@ export function useTimelineGroupEditing({
|
|
|
287
291
|
await options?.beforeTiming;
|
|
288
292
|
const handledBySdk = await trySdkBatchPersist({
|
|
289
293
|
changes,
|
|
290
|
-
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
294
|
+
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
295
|
+
start: toAuthoredStart(change.element, change.start),
|
|
296
|
+
})),
|
|
291
297
|
eligible: changes.every((change) => change.track == null),
|
|
292
298
|
needsExtension,
|
|
293
299
|
label,
|
|
@@ -304,7 +310,7 @@ export function useTimelineGroupEditing({
|
|
|
304
310
|
buildTimelineMoveTimingPatch(
|
|
305
311
|
original,
|
|
306
312
|
target,
|
|
307
|
-
change.start,
|
|
313
|
+
toAuthoredStart(change.element, change.start),
|
|
308
314
|
change.element.duration,
|
|
309
315
|
change.track,
|
|
310
316
|
),
|
|
@@ -375,7 +381,10 @@ export function useTimelineGroupEditing({
|
|
|
375
381
|
if (changes.length === 0) return Promise.resolve();
|
|
376
382
|
for (const change of changes) {
|
|
377
383
|
const liveAttrs: Array<[string, string]> = [
|
|
378
|
-
[
|
|
384
|
+
[
|
|
385
|
+
"data-start",
|
|
386
|
+
formatTimelineAttributeNumber(toAuthoredStart(change.element, change.start)),
|
|
387
|
+
],
|
|
379
388
|
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
|
380
389
|
];
|
|
381
390
|
if (change.playbackStart != null) {
|
|
@@ -402,7 +411,7 @@ export function useTimelineGroupEditing({
|
|
|
402
411
|
const handledBySdk = await trySdkBatchPersist({
|
|
403
412
|
changes,
|
|
404
413
|
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
405
|
-
start: change.start,
|
|
414
|
+
start: toAuthoredStart(change.element, change.start),
|
|
406
415
|
duration: change.duration,
|
|
407
416
|
})),
|
|
408
417
|
eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
|
|
@@ -453,9 +462,9 @@ export function useTimelineGroupEditing({
|
|
|
453
462
|
projectId,
|
|
454
463
|
changePath,
|
|
455
464
|
domId,
|
|
456
|
-
change.element.start,
|
|
465
|
+
toCompositionTime(change.element, change.element.start),
|
|
457
466
|
change.element.duration,
|
|
458
|
-
change.start,
|
|
467
|
+
toCompositionTime(change.element, change.start),
|
|
459
468
|
change.duration,
|
|
460
469
|
);
|
|
461
470
|
},
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
installTimelineMountEnv();
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
16
|
+
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const KEYFRAMED = new Map([
|
|
24
|
+
[
|
|
25
|
+
"card",
|
|
26
|
+
[
|
|
27
|
+
{
|
|
28
|
+
id: "card-position",
|
|
29
|
+
targetSelector: "#card",
|
|
30
|
+
method: "to" as const,
|
|
31
|
+
position: 0,
|
|
32
|
+
duration: 2,
|
|
33
|
+
properties: {},
|
|
34
|
+
propertyGroup: "position" as const,
|
|
35
|
+
keyframes: {
|
|
36
|
+
format: "percentage" as const,
|
|
37
|
+
keyframes: [
|
|
38
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
39
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** Presses the ruler exactly on the first drawn tick after 0 and returns its time and the seek. */
|
|
48
|
+
async function seekOnFirstTick(
|
|
49
|
+
props: { showKeyframes?: boolean },
|
|
50
|
+
keyframesLate: boolean,
|
|
51
|
+
animations: typeof KEYFRAMED | Map<string, never[]> = KEYFRAMED,
|
|
52
|
+
) {
|
|
53
|
+
usePlayerStore.setState({
|
|
54
|
+
duration: 10,
|
|
55
|
+
currentTime: 0,
|
|
56
|
+
timelineReady: true,
|
|
57
|
+
elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 10, track: 0 }],
|
|
58
|
+
gsapAnimations: keyframesLate ? new Map() : animations,
|
|
59
|
+
});
|
|
60
|
+
const onSeek = vi.fn();
|
|
61
|
+
const host = document.createElement("div");
|
|
62
|
+
document.body.append(host);
|
|
63
|
+
const root = createRoot(host);
|
|
64
|
+
await act(async () => root.render(<Timeline {...props} onSeek={onSeek} />));
|
|
65
|
+
if (keyframesLate) await act(async () => usePlayerStore.setState({ gsapAnimations: animations }));
|
|
66
|
+
|
|
67
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
68
|
+
viewport.getBoundingClientRect = () =>
|
|
69
|
+
({ left: 0, top: 0, right: 900, bottom: 400, width: 900, height: 400 }) as DOMRect;
|
|
70
|
+
const label = host.querySelector<HTMLElement>('[data-timeline-grid-cell="major"] span')!;
|
|
71
|
+
const band = label.parentElement!.parentElement!;
|
|
72
|
+
const tick = band.querySelectorAll<HTMLElement>('[data-timeline-grid-cell="major"]')[1]!;
|
|
73
|
+
const [, mm, ss] = /(\d+):(\d+)/.exec(tick.textContent ?? "")!;
|
|
74
|
+
const tickTime = Number(mm) * 60 + Number(ss);
|
|
75
|
+
// The ruler's corner block is as wide as the origin the ruler draws 0 s at.
|
|
76
|
+
const origin = (band.previousElementSibling as HTMLElement).style.width;
|
|
77
|
+
const clientX = parseFloat(origin) + parseFloat(tick.style.left) + 0.5;
|
|
78
|
+
const at = { bubbles: true, clientX, clientY: 4, button: 0, pointerId: 1 };
|
|
79
|
+
await act(async () => tick.dispatchEvent(new PointerEvent("pointerdown", at)));
|
|
80
|
+
await act(async () => tick.dispatchEvent(new PointerEvent("pointerup", at)));
|
|
81
|
+
act(() => root.unmount());
|
|
82
|
+
return {
|
|
83
|
+
time: onSeek.mock.calls.at(-1)?.[0] as number | undefined,
|
|
84
|
+
tickTime,
|
|
85
|
+
origin,
|
|
86
|
+
published: viewport.getAttribute("data-timeline-content-origin"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("Timeline ruler origin", () => {
|
|
91
|
+
it.each([
|
|
92
|
+
["keyframes shown", { showKeyframes: true }, false, 264],
|
|
93
|
+
["keyframes hidden", { showKeyframes: false }, false, 80],
|
|
94
|
+
["keyframes shown, arriving after mount", { showKeyframes: true }, true, 264],
|
|
95
|
+
["keyframes hidden, arriving after mount", { showKeyframes: false }, true, 80],
|
|
96
|
+
])(
|
|
97
|
+
"publishes where the ruler draws 0 s, and a press seeks by it (%s)",
|
|
98
|
+
async (_case, props, late, expected) => {
|
|
99
|
+
const { time, tickTime, origin, published } = await seekOnFirstTick(props, late);
|
|
100
|
+
expect(origin).toBe(`${expected}px`);
|
|
101
|
+
expect(published).toBe(String(expected));
|
|
102
|
+
expect(time).toBeCloseTo(tickTime, 1);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
it("publishes the narrow origin when no clip needs the label column", async () => {
|
|
107
|
+
const { origin, published } = await seekOnFirstTick({}, false, new Map());
|
|
108
|
+
expect(origin).toBe("80px");
|
|
109
|
+
expect(published).toBe("80");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -84,8 +84,34 @@ describe("Timeline showKeyframes", () => {
|
|
|
84
84
|
expect((await mountKeyframed(false)).lanes).toBe(false);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
it("
|
|
87
|
+
it("opens the track-name column for a keyframed clip only while keyframes are shown", async () => {
|
|
88
88
|
expect((await mountKeyframed()).named).toBe(true);
|
|
89
|
-
expect((await mountKeyframed(false)).named).toBe(
|
|
89
|
+
expect((await mountKeyframed(false)).named).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("still opens the column for an audio group when keyframes are off", async () => {
|
|
93
|
+
usePlayerStore.setState({
|
|
94
|
+
duration: 10,
|
|
95
|
+
timelineReady: true,
|
|
96
|
+
gsapAnimations: new Map(),
|
|
97
|
+
elements: [
|
|
98
|
+
{
|
|
99
|
+
id: "vo",
|
|
100
|
+
label: "Voice",
|
|
101
|
+
tag: "audio",
|
|
102
|
+
start: 0,
|
|
103
|
+
duration: 4,
|
|
104
|
+
track: 0,
|
|
105
|
+
audioGroup: "g",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
const host = document.createElement("div");
|
|
110
|
+
document.body.append(host);
|
|
111
|
+
const root = createRoot(host);
|
|
112
|
+
await act(async () => root.render(<Timeline showKeyframes={false} />));
|
|
113
|
+
const corner = host.querySelector<HTMLElement>(".sticky.top-0.flex > div");
|
|
114
|
+
expect(corner?.style.width).toBe("264px");
|
|
115
|
+
act(() => root.unmount());
|
|
90
116
|
});
|
|
91
117
|
});
|
|
@@ -189,7 +189,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
189
189
|
// host-absolute start while its tweens are parsed from its own file and are
|
|
190
190
|
// local to it. clipTimingStart is what brings the two into one frame.
|
|
191
191
|
it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
|
|
192
|
-
const child = { start: 16.5, duration: 2,
|
|
192
|
+
const child = { start: 16.5, duration: 2, parentCompositionStart: 16 };
|
|
193
193
|
const local = animation("pill-tween", "position", [
|
|
194
194
|
{ percentage: 0, properties: { x: 0 } },
|
|
195
195
|
{ percentage: 100, properties: { x: 100 } },
|
|
@@ -127,6 +127,7 @@ export interface TimelineViewportProps {
|
|
|
127
127
|
ref: RefCallback<HTMLDivElement>;
|
|
128
128
|
"data-timeline-scroll-viewport": boolean;
|
|
129
129
|
"data-timeline-auto-scroll-left-inset": number;
|
|
130
|
+
"data-timeline-content-origin": number;
|
|
130
131
|
tabIndex: number;
|
|
131
132
|
className: string;
|
|
132
133
|
onScroll: (event: UIEvent<HTMLDivElement>) => void;
|
|
@@ -15,7 +15,7 @@ export interface TimelineProps
|
|
|
15
15
|
extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
|
|
16
16
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
17
17
|
sessionEpoch?: number;
|
|
18
|
-
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
18
|
+
onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
|
|
19
19
|
onDrillDown?: (element: TimelineElement) => void;
|
|
20
20
|
/** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
|
|
21
21
|
renderClipContent?: (
|
|
@@ -315,7 +315,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
315
315
|
const child = {
|
|
316
316
|
...el("scene.html#title", 0.25, 12, 2),
|
|
317
317
|
sourceFile: "scene.html",
|
|
318
|
-
expandedParentStart: 10,
|
|
319
318
|
expandedHostKey: "host",
|
|
320
319
|
};
|
|
321
320
|
const { updateElement, onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -338,7 +337,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
338
337
|
const child = {
|
|
339
338
|
...el("scene.html#title", 0.25, 12, 2),
|
|
340
339
|
sourceFile: "scene.html",
|
|
341
|
-
expandedParentStart: 10,
|
|
342
340
|
expandedHostKey: "host",
|
|
343
341
|
};
|
|
344
342
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -360,7 +358,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
360
358
|
const child = {
|
|
361
359
|
...el("scene.html#title", 0.25, 12, 2),
|
|
362
360
|
sourceFile: "scene.html",
|
|
363
|
-
expandedParentStart: 10,
|
|
364
361
|
expandedHostKey: "host",
|
|
365
362
|
};
|
|
366
363
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -382,7 +379,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
382
379
|
const child = {
|
|
383
380
|
...el("scene.html#title", 0.25, 12, 2),
|
|
384
381
|
sourceFile: "scene.html",
|
|
385
|
-
expandedParentStart: 10,
|
|
386
382
|
expandedHostKey: "host",
|
|
387
383
|
};
|
|
388
384
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -406,7 +402,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
406
402
|
const child = {
|
|
407
403
|
...el("scene.html#title", 0.25, 12, 2),
|
|
408
404
|
sourceFile: "scene.html",
|
|
409
|
-
expandedParentStart: 10,
|
|
410
405
|
expandedHostKey: "host",
|
|
411
406
|
};
|
|
412
407
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -1192,73 +1187,26 @@ describe("commitDraggedClipMove", () => {
|
|
|
1192
1187
|
});
|
|
1193
1188
|
});
|
|
1194
1189
|
|
|
1195
|
-
describe("
|
|
1196
|
-
it("a top-gutter insert that renumbers to literal track 0
|
|
1197
|
-
// Sole visual clip sits on track 1 — the real main track (0) is genuinely
|
|
1198
|
-
// empty. Dragging it into the top insert-gutter creates a new lane that
|
|
1199
|
-
// normalizeToZones renumbers to literal 0, so the main-track rule must apply
|
|
1200
|
-
// even though the preview never saw a literal-0 landing track.
|
|
1190
|
+
describe("a clip landing on an empty main track keeps its released start", () => {
|
|
1191
|
+
it("a top-gutter insert that renumbers to literal track 0 commits at the released start", () => {
|
|
1201
1192
|
const elements = [el("v1", 1, 0, 5)];
|
|
1202
1193
|
const { onMoveElements } = runClipMove(
|
|
1203
1194
|
drag(elements[0], { previewStart: 8, previewTrack: 1, insertRow: 0 }),
|
|
1204
1195
|
{ elements, trackOrder: [1] },
|
|
1205
1196
|
);
|
|
1206
|
-
|
|
1207
|
-
expect(map.v1).toEqual({ start: 0, track: 0 });
|
|
1208
|
-
});
|
|
1209
|
-
|
|
1210
|
-
it("the z-sync candidate reflects the snapped start, not the raw previewStart", async () => {
|
|
1211
|
-
// v1 snaps from previewStart 8 to 0 (see the test above). A foreign-file
|
|
1212
|
-
// sibling at [2, 5) only overlaps the SNAPPED window [0, 5), never the
|
|
1213
|
-
// raw [8, 13) — so a z-sync firing at all proves the candidate the
|
|
1214
|
-
// stacking check reads from carries the snapped start.
|
|
1215
|
-
const v1 = el("v1", 2, 0, 5);
|
|
1216
|
-
const sibling = el("s", 1, 2, 3); // [2, 5) — same source file, same paint scope
|
|
1217
|
-
const elements = [v1, sibling];
|
|
1218
|
-
const onStackingPatches = vi.fn();
|
|
1219
|
-
// v1 lands on lane 0, which paints above lane 1 (lower track paints higher),
|
|
1220
|
-
// yet carries the lower z: a violation only while the two overlap in time.
|
|
1221
|
-
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 2, insertRow: 0 }), {
|
|
1222
|
-
elements,
|
|
1223
|
-
trackOrder: [1, 2],
|
|
1224
|
-
updateElement: vi.fn(),
|
|
1225
|
-
onMoveElement: vi.fn(),
|
|
1226
|
-
onMoveElements: vi.fn(),
|
|
1227
|
-
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1228
|
-
onStackingPatches,
|
|
1229
|
-
});
|
|
1230
|
-
await flushMicrotasks();
|
|
1231
|
-
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1197
|
+
expect(editMap(onMoveElements.mock.calls[0][0]).v1).toEqual({ start: 8, track: 0 });
|
|
1232
1198
|
});
|
|
1233
1199
|
|
|
1234
|
-
it("a plain lane change onto the empty main track
|
|
1200
|
+
it("a plain lane change onto the empty main track commits at the released start", () => {
|
|
1235
1201
|
const elements = [el("v1", 1, 0, 5)];
|
|
1236
1202
|
const spies = runClipMove(drag(elements[0], { previewStart: 8, previewTrack: 0 }), {
|
|
1237
1203
|
elements,
|
|
1238
1204
|
trackOrder: [0, 1],
|
|
1239
1205
|
});
|
|
1240
|
-
expect(expectAtomicMoveMap(spies).v1).toEqual({ start:
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1243
|
-
it("the z-sync candidate of a plain lane change carries the snapped start", async () => {
|
|
1244
|
-
// The sibling [2, 5) overlaps v1 only at the snapped [0, 5), never at the raw [8, 13).
|
|
1245
|
-
const v1 = el("v1", 2, 0, 5);
|
|
1246
|
-
const sibling = el("s", 1, 2, 3);
|
|
1247
|
-
const onStackingPatches = vi.fn();
|
|
1248
|
-
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 0 }), {
|
|
1249
|
-
elements: [v1, sibling],
|
|
1250
|
-
trackOrder: [0, 1, 2],
|
|
1251
|
-
updateElement: vi.fn(),
|
|
1252
|
-
onMoveElement: vi.fn(),
|
|
1253
|
-
onMoveElements: vi.fn(),
|
|
1254
|
-
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1255
|
-
onStackingPatches,
|
|
1256
|
-
});
|
|
1257
|
-
await flushMicrotasks();
|
|
1258
|
-
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1206
|
+
expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 8, track: 0 });
|
|
1259
1207
|
});
|
|
1260
1208
|
|
|
1261
|
-
it("a top-gutter insert that pushes the old track-0 clip down
|
|
1209
|
+
it("a top-gutter insert that pushes the old track-0 clip down keeps the released start", () => {
|
|
1262
1210
|
const oldMain = el("old", 0, 0, 3);
|
|
1263
1211
|
const dragged = el("v1", 2, 0, 5);
|
|
1264
1212
|
const { onMoveElements } = runClipMove(
|
|
@@ -1267,10 +1215,10 @@ describe("commitDraggedClipMove", () => {
|
|
|
1267
1215
|
);
|
|
1268
1216
|
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1269
1217
|
expect(map.old.track).toBe(1);
|
|
1270
|
-
expect(map.v1).toEqual({ start:
|
|
1218
|
+
expect(map.v1).toEqual({ start: 8, track: 0 });
|
|
1271
1219
|
});
|
|
1272
1220
|
|
|
1273
|
-
it("an expanded child dragged with its host
|
|
1221
|
+
it("an expanded child dragged with its host onto the main track keeps the host's start", () => {
|
|
1274
1222
|
for (const [hostStart, childStart] of [
|
|
1275
1223
|
[30, 32],
|
|
1276
1224
|
[20, 22],
|
|
@@ -1279,7 +1227,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
1279
1227
|
const child: TimelineElement = {
|
|
1280
1228
|
...el("child", 2, childStart, 4),
|
|
1281
1229
|
expandedHostKey: "host",
|
|
1282
|
-
expandedParentStart: hostStart,
|
|
1283
1230
|
};
|
|
1284
1231
|
const { onMoveElements } = runClipMove(
|
|
1285
1232
|
drag(child, { previewStart: childStart, previewTrack: 0 }),
|
|
@@ -1289,11 +1236,14 @@ describe("commitDraggedClipMove", () => {
|
|
|
1289
1236
|
selectedKeys: new Set(["host", "child"]),
|
|
1290
1237
|
},
|
|
1291
1238
|
);
|
|
1292
|
-
expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({
|
|
1239
|
+
expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({
|
|
1240
|
+
start: hostStart,
|
|
1241
|
+
track: 0,
|
|
1242
|
+
});
|
|
1293
1243
|
}
|
|
1294
1244
|
});
|
|
1295
1245
|
|
|
1296
|
-
it("a multi-selection top-gutter insert
|
|
1246
|
+
it("a multi-selection top-gutter insert keeps the grabbed clip's start", () => {
|
|
1297
1247
|
const dragged = el("v1", 1, 0, 5);
|
|
1298
1248
|
const sibling = el("v2", 1, 10, 5);
|
|
1299
1249
|
const elements = [dragged, sibling];
|
|
@@ -1302,7 +1252,7 @@ describe("commitDraggedClipMove", () => {
|
|
|
1302
1252
|
{ elements, trackOrder: [1], selectedKeys: new Set(["v1", "v2"]) },
|
|
1303
1253
|
);
|
|
1304
1254
|
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1305
|
-
expect(map.v1.start).toBe(8);
|
|
1255
|
+
expect(map.v1.start).toBe(8);
|
|
1306
1256
|
});
|
|
1307
1257
|
});
|
|
1308
1258
|
});
|
|
@@ -1399,12 +1349,10 @@ describe("commitZMirrorLaneMove", () => {
|
|
|
1399
1349
|
const child1 = {
|
|
1400
1350
|
...el("child-1", 0.25, 0, 5),
|
|
1401
1351
|
sourceFile: "scene.html",
|
|
1402
|
-
expandedParentStart: 0,
|
|
1403
1352
|
};
|
|
1404
1353
|
const child2 = {
|
|
1405
1354
|
...el("child-2", 0.5, 0, 5),
|
|
1406
1355
|
sourceFile: "scene.html",
|
|
1407
|
-
expandedParentStart: 0,
|
|
1408
1356
|
};
|
|
1409
1357
|
const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
|
|
1410
1358
|
const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
|
|
@@ -1516,6 +1464,24 @@ describe("commitZMirrorLaneMove", () => {
|
|
|
1516
1464
|
});
|
|
1517
1465
|
});
|
|
1518
1466
|
|
|
1467
|
+
describe("persistMoveEdits: a nested row dropped before its host", () => {
|
|
1468
|
+
it("lands at the host's start in the store and in the persist", async () => {
|
|
1469
|
+
const logo = { ...el("logo", 0, 5, 5, "div"), parentCompositionStart: 2 };
|
|
1470
|
+
const updateElement = vi.fn();
|
|
1471
|
+
const onMoveElements = vi.fn(async (_edits: TimelineMoveEdit[]) => {});
|
|
1472
|
+
await persistMoveEdits([{ element: logo, updates: { start: 1, track: 0 } }], {
|
|
1473
|
+
elements: [logo],
|
|
1474
|
+
trackOrder: [0],
|
|
1475
|
+
updateElement,
|
|
1476
|
+
onMoveElements,
|
|
1477
|
+
});
|
|
1478
|
+
expect(updateElement).toHaveBeenCalledWith("logo", { start: 2, track: 0 });
|
|
1479
|
+
expect(onMoveElements.mock.calls[0]?.[0]).toEqual([
|
|
1480
|
+
{ element: logo, updates: { start: 2, track: 0 } },
|
|
1481
|
+
]);
|
|
1482
|
+
});
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1519
1485
|
describe("persistMoveEdits convergence", () => {
|
|
1520
1486
|
it("reasserts a saved lane after a stale runtime sync", async () => {
|
|
1521
1487
|
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
2
3
|
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
3
4
|
// Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
|
|
4
5
|
// value-import edge stays acyclic.
|
|
5
6
|
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
6
7
|
import { classifyZone } from "./timelineZones";
|
|
7
|
-
import { layoutAfterTrackInsert
|
|
8
|
+
import { layoutAfterTrackInsert } from "./timelineDragLanding";
|
|
8
9
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
9
10
|
import {
|
|
10
11
|
canMoveTimelineElement as canMoveElement,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
19
20
|
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
20
21
|
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
|
22
|
+
import { resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
21
23
|
|
|
22
24
|
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
23
25
|
export interface TimelineMoveEdit {
|
|
@@ -62,9 +64,9 @@ export interface DragCommitDeps {
|
|
|
62
64
|
* the edited clip(s) get z-index patches so their canvas stacking matches lane
|
|
63
65
|
* order (higher lane = on top) relative to time-overlapping clips — see
|
|
64
66
|
* timelineStackingSync. Both deps must be supplied to engage; if either is
|
|
65
|
-
* absent the z-sync is skipped (pure time-moves
|
|
66
|
-
*
|
|
67
|
-
*
|
|
67
|
+
* absent the z-sync is skipped (pure time-moves never restack). `readZIndex`
|
|
68
|
+
* returns the clip's current z-index (from the live DOM inline style / computed;
|
|
69
|
+
* "auto" ⇒ 0).
|
|
68
70
|
*/
|
|
69
71
|
readZIndex?: (element: TimelineElement) => number;
|
|
70
72
|
/**
|
|
@@ -107,6 +109,10 @@ export function persistMoveEdits(
|
|
|
107
109
|
coalesceMs?: number,
|
|
108
110
|
): Promise<boolean> {
|
|
109
111
|
if (edits.length === 0) return Promise.resolve(true);
|
|
112
|
+
edits = edits.map((e) => {
|
|
113
|
+
const start = clampToHostStart(e.element, e.updates.start);
|
|
114
|
+
return start === e.updates.start ? e : { ...e, updates: { ...e.updates, start } };
|
|
115
|
+
});
|
|
110
116
|
const { updateElement, onMoveElement, onMoveElements } = deps;
|
|
111
117
|
if (!onMoveElements) {
|
|
112
118
|
console.warn(
|
|
@@ -186,41 +192,20 @@ function resolveMultiSelection(
|
|
|
186
192
|
keys: ReadonlySet<string>;
|
|
187
193
|
movedStart: (e: TimelineElement) => number;
|
|
188
194
|
} | null {
|
|
189
|
-
const { elements, selectedKeys } = deps;
|
|
190
195
|
const dragKey = keyOf(drag.element);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const el = elements.find((e) => keyOf(e) === k);
|
|
195
|
-
return el ? canMoveElement(el) : false;
|
|
196
|
-
}),
|
|
197
|
-
);
|
|
196
|
+
const movers = resolveGroupMovers(deps.elements, deps.selectedKeys, dragKey);
|
|
197
|
+
if (!movers) return null;
|
|
198
|
+
const keys = new Set(movers.map(keyOf));
|
|
198
199
|
const delta = drag.previewStart - drag.element.start;
|
|
199
200
|
const movedStart = (e: TimelineElement): number =>
|
|
200
201
|
keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
|
|
201
202
|
return { keys, movedStart };
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
*
|
|
207
|
-
* The lane model is CapCut-stable: a clip's display lane is its track, and editing
|
|
208
|
-
* ONE clip must never re-lane or rewrite OTHER clips. Three outcomes:
|
|
209
|
-
*
|
|
210
|
-
* - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the
|
|
211
|
-
* dragged clip's start (multi-selection shifts every selected clip in time).
|
|
212
|
-
* - **Lane change / collision relocation** (the dragged clip's OWN lane changes,
|
|
213
|
-
* no new track): persist ONLY the dragged clip's start + lane. No other clip is
|
|
214
|
-
* touched. z is synced only when the gesture is a DELIBERATE vertical move
|
|
215
|
-
* (the pointer aimed at another lane) — a horizontal drag merely bumped to a
|
|
216
|
-
* free lane never restacks.
|
|
217
|
-
* - **Track insert** (a new lane at a gap boundary): the dragged clip lands on
|
|
218
|
-
* the new lane and the clips at/below the insert are renumbered by +1 (the ONLY
|
|
219
|
-
* permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
|
|
220
|
-
*/
|
|
205
|
+
// A move writes the dragged clip's start (each moving clip's, in a group); a lane change also writes its lane and
|
|
206
|
+
// re-lanes no other clip; only a new track renumbers the rows below it.
|
|
221
207
|
// fallow-ignore-next-line complexity
|
|
222
|
-
export function commitDraggedClipMove(
|
|
223
|
-
const drag = { ...rawDrag, previewStart: resolveDragLandingStart(rawDrag, deps) };
|
|
208
|
+
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
|
|
224
209
|
const hostAlias = resolveExpandedHostAlias(drag, deps);
|
|
225
210
|
if (hostAlias) {
|
|
226
211
|
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
|
|
@@ -231,11 +216,8 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
|
|
|
231
216
|
const dragKey = keyOf(drag.element);
|
|
232
217
|
const isInsert = drag.insertRow != null;
|
|
233
218
|
const laneChanged = drag.previewTrack !== drag.element.track;
|
|
234
|
-
// Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at
|
|
235
|
-
//
|
|
236
|
-
// gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the
|
|
237
|
-
// pointer never left its lane (desiredTrack === element.track) — that is NOT a
|
|
238
|
-
// vertical move: it must neither rewrite other clips nor touch z.
|
|
219
|
+
// Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at the
|
|
220
|
+
// empty space outside the rows (insert). Anything else never touches z.
|
|
239
221
|
const aimTrack = drag.desiredTrack ?? drag.previewTrack;
|
|
240
222
|
const isVertical = isInsert || aimTrack !== drag.element.track;
|
|
241
223
|
const multi = resolveMultiSelection(drag, deps);
|
|
@@ -274,7 +256,7 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
|
|
|
274
256
|
return;
|
|
275
257
|
}
|
|
276
258
|
|
|
277
|
-
// ── Lane change
|
|
259
|
+
// ── Lane change: persist ONLY the dragged clip ──────────────────────────────
|
|
278
260
|
// CapCut invariant — one edit never re-lanes another clip. The dragged clip
|
|
279
261
|
// takes its new lane (previewTrack); the rest of any selection shifts in time
|
|
280
262
|
// only. Nothing else is written.
|