@hyperframes/studio 0.8.111 → 0.8.112
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-DvJeHBHi.js → hyperframes-player-CBpg41JM.js} +1 -1
- package/dist/assets/{index-Bt4vDkfw.js → index-BFitquwi.js} +1 -1
- package/dist/assets/{index-SFktf7O1.js → index-BKy_birQ.js} +194 -194
- package/dist/assets/{index--G6c4Q_x.js → index-CZfrSyU9.js} +1 -1
- package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
- package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
- package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
- package/dist/chunk-K47E2ZJA.js.map +1 -0
- package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
- package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
- package/dist/index.d.ts +80 -46
- package/dist/index.html +2 -2
- package/dist/index.js +7294 -6844
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
- package/src/hooks/timelineEditingHelpers.ts +7 -0
- package/src/hooks/timelineMoveAdapter.test.ts +12 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -4
- package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +7 -0
- package/src/main.tsx +4 -0
- package/src/player/components/ClipBadges.tsx +5 -5
- package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
- package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
- package/src/player/components/TimelineCanvas.tsx +6 -2
- package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
- package/src/player/components/TimelineClip.test.tsx +7 -7
- package/src/player/components/TimelineClip.tsx +23 -7
- package/src/player/components/TimelineClipFades.tsx +41 -43
- package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
- package/src/player/components/TimelineGestureOverlay.tsx +59 -21
- package/src/player/components/TimelineLanes.tsx +18 -16
- package/src/player/components/timelineCallbacks.ts +6 -4
- package/src/player/components/timelineClipChildren.tsx +7 -13
- package/src/player/components/timelineClipDragCommit.test.ts +51 -0
- package/src/player/components/timelineClipDragCommit.ts +63 -21
- package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
- package/src/player/components/timelineClipDragPreview.test.ts +47 -31
- package/src/player/components/timelineClipDragPreview.ts +81 -17
- package/src/player/components/timelineClipDragTypes.ts +3 -3
- package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
- package/src/player/components/timelineClipFadeGeometry.ts +74 -0
- package/src/player/components/timelineCollision.ts +51 -11
- package/src/player/components/timelineDragLanding.ts +20 -6
- package/src/player/components/timelineLaneProps.ts +3 -8
- package/src/player/components/timelineMotionStyles.test.ts +6 -13
- package/src/player/components/timelineMountFixtures.tsx +65 -0
- package/src/player/components/timelineOptimisticRevision.ts +15 -4
- package/src/player/components/timelineTheme.ts +2 -0
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
- package/src/player/components/timelineTrackInsertLayout.ts +71 -0
- package/src/player/components/timelineZMirror.ts +5 -11
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
- package/src/player/components/useTimelineClipDrag.ts +116 -12
- package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
- package/src/player/components/useTimelineKeyboardActor.ts +86 -7
- package/src/player/components/useTimelineProviderState.tsx +3 -1
- package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
- package/src/player/components/useTimelineRowVirtualization.ts +3 -1
- package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
- package/src/player/components/useTimelineTrackDerivations.ts +32 -18
- package/src/player/components/useTimelineTrackLayout.ts +43 -14
- package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/automationStoreSync.test.ts +15 -0
- package/src/player/lib/automationStoreSync.ts +13 -5
- package/src/player/lib/storedVolume.ts +7 -0
- package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
- package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
- package/src/player/lib/thumbnailScheduler.ts +20 -2
- package/src/player/lib/timelineElementHelpers.ts +2 -8
- package/src/styles/components.css +71 -19
- package/src/styles/theme.css +4 -1
- package/dist/chunk-6LUNLTHR.js.map +0 -1
- /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
- /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
- /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
|
@@ -54,7 +54,6 @@ export function TimelineLanes({
|
|
|
54
54
|
pinnedClipIdentities,
|
|
55
55
|
trackOrder,
|
|
56
56
|
tracks,
|
|
57
|
-
trackStyles,
|
|
58
57
|
groups,
|
|
59
58
|
laneCounts,
|
|
60
59
|
selectedElementId,
|
|
@@ -197,9 +196,26 @@ export function TimelineLanes({
|
|
|
197
196
|
actorWindows,
|
|
198
197
|
)
|
|
199
198
|
: els;
|
|
200
|
-
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
201
199
|
const isPendingTrack =
|
|
202
200
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
201
|
+
if (isPendingTrack)
|
|
202
|
+
return (
|
|
203
|
+
<div
|
|
204
|
+
key={rowKey}
|
|
205
|
+
data-timeline-new-track-lane={row}
|
|
206
|
+
aria-hidden="true"
|
|
207
|
+
className={rowsVirtualized ? "absolute" : "relative"}
|
|
208
|
+
style={{
|
|
209
|
+
top: rowsVirtualized ? rowGeometry.getRowTop(row) : undefined,
|
|
210
|
+
marginLeft: contentOrigin,
|
|
211
|
+
width: trackContentWidth,
|
|
212
|
+
height: TRACK_H,
|
|
213
|
+
border: "1px dashed var(--timeline-accent)",
|
|
214
|
+
background: "color-mix(in srgb, var(--timeline-accent) 5%, transparent)",
|
|
215
|
+
pointerEvents: "none",
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
203
219
|
const rowBackground = theme.rowBackground;
|
|
204
220
|
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
|
|
205
221
|
selectedElementId,
|
|
@@ -343,20 +359,6 @@ export function TimelineLanes({
|
|
|
343
359
|
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
344
360
|
/>
|
|
345
361
|
)}
|
|
346
|
-
{isPendingTrack && (
|
|
347
|
-
<div
|
|
348
|
-
className="absolute inset-0 flex items-center"
|
|
349
|
-
style={{
|
|
350
|
-
paddingLeft: 16,
|
|
351
|
-
color: ts.label,
|
|
352
|
-
fontSize: 11,
|
|
353
|
-
letterSpacing: "0.06em",
|
|
354
|
-
textTransform: "uppercase",
|
|
355
|
-
}}
|
|
356
|
-
>
|
|
357
|
-
New track
|
|
358
|
-
</div>
|
|
359
|
-
)}
|
|
360
362
|
{
|
|
361
363
|
// fallow-ignore-next-line complexity
|
|
362
364
|
renderElements.map((el) => {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// fallow-ignore-file code-duplication
|
|
2
|
-
// fallow-ignore-file dead-code
|
|
3
2
|
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
|
4
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
5
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
TimelineMoveOperation,
|
|
6
|
+
TimelineAtomicMoveUpdates,
|
|
7
|
+
} from "../../hooks/timelineMoveAdapter";
|
|
6
8
|
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
7
9
|
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
8
10
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
@@ -50,7 +52,7 @@ export type TimelineLinkEdit =
|
|
|
50
52
|
export interface TimelineEditCallbacks {
|
|
51
53
|
onMoveElement?: (
|
|
52
54
|
element: TimelineElement,
|
|
53
|
-
updates:
|
|
55
|
+
updates: TimelineAtomicMoveUpdates,
|
|
54
56
|
) => Promise<void> | void;
|
|
55
57
|
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
|
|
56
58
|
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
|
|
@@ -58,7 +60,7 @@ export interface TimelineEditCallbacks {
|
|
|
58
60
|
* widens that entry's fold window when a server round-trip separates the
|
|
59
61
|
* gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
|
|
60
62
|
onMoveElements?: (
|
|
61
|
-
edits: Array<{ element: TimelineElement; updates:
|
|
63
|
+
edits: Array<{ element: TimelineElement; updates: TimelineAtomicMoveUpdates }>,
|
|
62
64
|
coalesceKey?: string,
|
|
63
65
|
operation?: TimelineMoveOperation,
|
|
64
66
|
coalesceMs?: number,
|
|
@@ -2,7 +2,7 @@ import { useState, type ReactNode } from "react";
|
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
4
4
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
5
|
-
import type { TimelineClipRenderContext } from "./TimelineTypes";
|
|
5
|
+
import type { TimelineProps, TimelineClipRenderContext } from "./TimelineTypes";
|
|
6
6
|
|
|
7
7
|
export function resolveClipRenderContext(
|
|
8
8
|
element: TimelineElement,
|
|
@@ -23,7 +23,7 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
|
|
|
23
23
|
return (
|
|
24
24
|
<span
|
|
25
25
|
className="absolute w-1.5 h-1.5 rounded-full bg-warning-ink"
|
|
26
|
-
style={{
|
|
26
|
+
style={{ bottom: 7, right: 7 }}
|
|
27
27
|
title={lint.messages.join("\n")}
|
|
28
28
|
/>
|
|
29
29
|
);
|
|
@@ -42,14 +42,8 @@ export function ClipContentOnceShown({ hold, children }: { hold: boolean; childr
|
|
|
42
42
|
export function renderClipChildren(
|
|
43
43
|
element: TimelineElement,
|
|
44
44
|
clipStyle: TrackVisualStyle,
|
|
45
|
-
renderClipContent:
|
|
46
|
-
|
|
47
|
-
element: TimelineElement,
|
|
48
|
-
style: { clip: string; label: string },
|
|
49
|
-
context: TimelineClipRenderContext,
|
|
50
|
-
) => ReactNode)
|
|
51
|
-
| undefined,
|
|
52
|
-
renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
|
|
45
|
+
renderClipContent: TimelineProps["renderClipContent"],
|
|
46
|
+
renderClipOverlay: TimelineProps["renderClipOverlay"],
|
|
53
47
|
context: TimelineClipRenderContext = { priority: "visible", rich: false },
|
|
54
48
|
): ReactNode {
|
|
55
49
|
return (
|
|
@@ -58,11 +52,11 @@ export function renderClipChildren(
|
|
|
58
52
|
{!renderClipContent && <ClipLintDot element={element} />}
|
|
59
53
|
{renderClipContent && (
|
|
60
54
|
// The picture can paint above the trim handles, so it takes no input and presses reach them.
|
|
61
|
-
//
|
|
55
|
+
// The content inherits the clip's rounded corners; the clip itself is
|
|
62
56
|
// overflow-visible because keyframe diamonds hang outside its bounds.
|
|
63
57
|
<div
|
|
64
|
-
className="absolute inset-0 overflow-hidden"
|
|
65
|
-
style={{
|
|
58
|
+
className="timeline-clip__content absolute inset-0 overflow-hidden"
|
|
59
|
+
style={{ pointerEvents: "none" }}
|
|
66
60
|
>
|
|
67
61
|
{renderClipContent(element, clipStyle, context)}
|
|
68
62
|
</div>
|
|
@@ -1483,6 +1483,57 @@ describe("persistMoveEdits: a nested row dropped before its host", () => {
|
|
|
1483
1483
|
});
|
|
1484
1484
|
|
|
1485
1485
|
describe("persistMoveEdits convergence", () => {
|
|
1486
|
+
it.each([
|
|
1487
|
+
{ successor: "timing", detach: false, expectedGroup: "G" },
|
|
1488
|
+
{ successor: "detach", detach: true, expectedGroup: undefined },
|
|
1489
|
+
])(
|
|
1490
|
+
"preserves newer $successor while rolling back a refused detach",
|
|
1491
|
+
async ({ detach, expectedGroup }) => {
|
|
1492
|
+
let current: TimelineElement = { ...el("grouped", 0, 0, 4), audioGroup: "G" };
|
|
1493
|
+
let rejectDetach!: (error: Error) => void;
|
|
1494
|
+
let resolveSuccessor!: () => void;
|
|
1495
|
+
const refused = new Promise<void>((_resolve, reject) => {
|
|
1496
|
+
rejectDetach = reject;
|
|
1497
|
+
});
|
|
1498
|
+
const successor = new Promise<void>((resolve) => {
|
|
1499
|
+
resolveSuccessor = resolve;
|
|
1500
|
+
});
|
|
1501
|
+
const updateElement = (_key: string, updates: Partial<TimelineElement>) => {
|
|
1502
|
+
current = { ...current, ...updates };
|
|
1503
|
+
};
|
|
1504
|
+
const deps = { elements: [current], trackOrder: [0, 1], updateElement };
|
|
1505
|
+
const first = persistMoveEdits(
|
|
1506
|
+
[{ element: current, updates: { start: 0, track: 1, audioGroup: null } }],
|
|
1507
|
+
{ ...deps, onMoveElements: () => refused },
|
|
1508
|
+
undefined,
|
|
1509
|
+
"track-insert",
|
|
1510
|
+
);
|
|
1511
|
+
expect(current.audioGroup).toBeUndefined();
|
|
1512
|
+
const second = persistMoveEdits(
|
|
1513
|
+
[
|
|
1514
|
+
{
|
|
1515
|
+
element: current,
|
|
1516
|
+
updates: { start: 5, track: 1, ...(detach ? { audioGroup: null } : {}) },
|
|
1517
|
+
},
|
|
1518
|
+
],
|
|
1519
|
+
{ ...deps, onMoveElements: () => successor },
|
|
1520
|
+
);
|
|
1521
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1522
|
+
try {
|
|
1523
|
+
rejectDetach(new Error("save refused"));
|
|
1524
|
+
await expect(first).resolves.toBe(false);
|
|
1525
|
+
expect(current).toMatchObject({ start: 5, track: 1 });
|
|
1526
|
+
expect(current.audioGroup).toBe(expectedGroup);
|
|
1527
|
+
resolveSuccessor();
|
|
1528
|
+
await expect(second).resolves.toBe(true);
|
|
1529
|
+
expect(current).toMatchObject({ start: 5, track: 1 });
|
|
1530
|
+
expect(current.audioGroup).toBe(expectedGroup);
|
|
1531
|
+
} finally {
|
|
1532
|
+
consoleError.mockRestore();
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1486
1537
|
it("reasserts a saved lane after a stale runtime sync", async () => {
|
|
1487
1538
|
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
|
1488
1539
|
let releaseSave: (() => void) | undefined;
|
|
@@ -6,12 +6,17 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
6
6
|
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
7
7
|
import { classifyZone } from "./timelineZones";
|
|
8
8
|
import { layoutAfterTrackInsert } from "./timelineDragLanding";
|
|
9
|
+
import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
|
9
10
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
10
11
|
import {
|
|
11
12
|
canMoveTimelineElement as canMoveElement,
|
|
12
13
|
resolveExpandedHostAlias,
|
|
13
14
|
} from "./timelineAuthoredMoveTarget";
|
|
14
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
TimelineMoveOperation,
|
|
17
|
+
TimelineAtomicMoveUpdates,
|
|
18
|
+
TimelineAtomicMoveEdit,
|
|
19
|
+
} from "../../hooks/timelineMoveAdapter";
|
|
15
20
|
import {
|
|
16
21
|
beginTimelineOptimisticGesture,
|
|
17
22
|
isLatestTimelineOptimisticGesture,
|
|
@@ -21,10 +26,8 @@ import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
|
21
26
|
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
|
22
27
|
import { resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
23
28
|
|
|
24
|
-
type StartTrack =
|
|
25
|
-
export interface TimelineMoveEdit {
|
|
26
|
-
element: TimelineElement;
|
|
27
|
-
updates: StartTrack;
|
|
29
|
+
type StartTrack = TimelineAtomicMoveUpdates;
|
|
30
|
+
export interface TimelineMoveEdit extends TimelineAtomicMoveEdit {
|
|
28
31
|
/**
|
|
29
32
|
* File-space track override for the persist. The store's `updates.track` is a
|
|
30
33
|
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
|
|
@@ -37,6 +40,7 @@ export interface TimelineMoveEdit {
|
|
|
37
40
|
export interface DragCommitDeps {
|
|
38
41
|
elements: TimelineElement[];
|
|
39
42
|
trackOrder: number[];
|
|
43
|
+
trackInsertLayout?: TimelineTrackInsertLayout;
|
|
40
44
|
updateElement: (key: string, updates: Partial<TimelineElement>) => void;
|
|
41
45
|
/** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
|
|
42
46
|
onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise<void> | void;
|
|
@@ -85,6 +89,17 @@ const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
|
85
89
|
// One deterministic coalesce key shared by both records in a lane-change gesture.
|
|
86
90
|
let laneChangeGestureSeq = 0;
|
|
87
91
|
|
|
92
|
+
function moveStoreUpdates(edit: TimelineMoveEdit) {
|
|
93
|
+
const writtenTrack =
|
|
94
|
+
edit.persistTrack ??
|
|
95
|
+
(edit.updates.track !== edit.element.track ? edit.updates.track : undefined);
|
|
96
|
+
const { audioGroup, ...timing } = edit.updates;
|
|
97
|
+
return {
|
|
98
|
+
timing: writtenTrack == null ? timing : { ...timing, authoredTrack: writtenTrack },
|
|
99
|
+
detach: audioGroup === null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
/**
|
|
89
104
|
* Optimistically apply + persist a batch of moves with rollback on failure.
|
|
90
105
|
*
|
|
@@ -123,26 +138,42 @@ export function persistMoveEdits(
|
|
|
123
138
|
}
|
|
124
139
|
const prev = edits.map((e) => ({
|
|
125
140
|
key: keyOf(e.element),
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
updates: {
|
|
142
|
+
start: e.element.start,
|
|
143
|
+
track: e.element.track,
|
|
144
|
+
authoredTrack: e.element.authoredTrack,
|
|
145
|
+
...(e.updates.audioGroup === null ? { audioGroup: e.element.audioGroup } : {}),
|
|
146
|
+
},
|
|
129
147
|
}));
|
|
130
148
|
const revision = beginTimelineOptimisticGesture(
|
|
131
149
|
updateElement,
|
|
132
150
|
edits.map((edit) => keyOf(edit.element)),
|
|
133
151
|
);
|
|
152
|
+
const membershipRevision = beginTimelineOptimisticGesture(
|
|
153
|
+
updateElement,
|
|
154
|
+
edits.filter((edit) => edit.updates.audioGroup === null).map((edit) => keyOf(edit.element)),
|
|
155
|
+
"membership",
|
|
156
|
+
);
|
|
134
157
|
// The file write below targets `persistTrack` (authored space) when supplied,
|
|
135
158
|
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
|
|
136
159
|
// that written value into the store's `authoredTrack` so a SECOND drag before
|
|
137
160
|
// any reload resolves authored tracks from what the file now says, not stale
|
|
138
161
|
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
|
139
|
-
const applyEdit = (e: TimelineMoveEdit) => {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
const applyEdit = (e: TimelineMoveEdit, reassert = false) => {
|
|
163
|
+
const { timing, detach } = moveStoreUpdates(e);
|
|
164
|
+
const key = keyOf(e.element);
|
|
165
|
+
const updates: Partial<TimelineElement> = {};
|
|
166
|
+
if (!reassert || isLatestTimelineOptimisticGesture(updateElement, revision, key)) {
|
|
167
|
+
Object.assign(updates, timing);
|
|
168
|
+
}
|
|
169
|
+
if (
|
|
170
|
+
detach &&
|
|
171
|
+
(!reassert ||
|
|
172
|
+
isLatestTimelineOptimisticGesture(updateElement, membershipRevision, key, "membership"))
|
|
173
|
+
) {
|
|
174
|
+
updates.audioGroup = undefined;
|
|
175
|
+
}
|
|
176
|
+
if (Object.keys(updates).length) updateElement(key, updates);
|
|
146
177
|
};
|
|
147
178
|
for (const e of edits) applyEdit(e);
|
|
148
179
|
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
|
@@ -161,17 +192,21 @@ export function persistMoveEdits(
|
|
|
161
192
|
// restore the preview manifest's pre-gesture lane. Reassert the durable
|
|
162
193
|
// result after persistence, but only while this remains the latest
|
|
163
194
|
// optimistic gesture so an older save can never clobber a newer drag.
|
|
164
|
-
for (const e of edits)
|
|
165
|
-
const key = keyOf(e.element);
|
|
166
|
-
if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
|
|
167
|
-
}
|
|
195
|
+
for (const e of edits) applyEdit(e, true);
|
|
168
196
|
return true;
|
|
169
197
|
},
|
|
170
198
|
(error) => {
|
|
171
199
|
for (const p of prev) {
|
|
200
|
+
const { audioGroup, ...timing } = p.updates;
|
|
201
|
+
const updates: Partial<TimelineElement> = {};
|
|
172
202
|
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
|
173
|
-
|
|
203
|
+
Object.assign(updates, timing);
|
|
174
204
|
}
|
|
205
|
+
if (
|
|
206
|
+
isLatestTimelineOptimisticGesture(updateElement, membershipRevision, p.key, "membership")
|
|
207
|
+
)
|
|
208
|
+
updates.audioGroup = audioGroup;
|
|
209
|
+
if (Object.keys(updates).length) updateElement(p.key, updates);
|
|
175
210
|
}
|
|
176
211
|
console.error("[Timeline] Failed to persist clip edits", error);
|
|
177
212
|
return false;
|
|
@@ -370,7 +405,14 @@ function buildTrackInsertEdits(
|
|
|
370
405
|
let start = src.start;
|
|
371
406
|
if (normKey === editKey) start = previewStart;
|
|
372
407
|
else if (multi?.keys.has(normKey)) start = multi.movedStart(src);
|
|
373
|
-
edits.push({
|
|
408
|
+
edits.push({
|
|
409
|
+
element: src,
|
|
410
|
+
updates: {
|
|
411
|
+
start,
|
|
412
|
+
track: norm.track,
|
|
413
|
+
...(normKey === editKey && src.audioGroup ? { audioGroup: null } : {}),
|
|
414
|
+
},
|
|
415
|
+
});
|
|
374
416
|
}
|
|
375
417
|
return { candidate, edits };
|
|
376
418
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
|
1
2
|
import type { Dispatch, RefObject, SetStateAction } from "react";
|
|
2
3
|
import { resolveTimelineDragEscape } from "./timelineEditing";
|
|
3
4
|
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
|
@@ -37,6 +38,8 @@ interface TimelineGestureCommit {
|
|
|
37
38
|
type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
|
|
38
39
|
|
|
39
40
|
interface TimelineClipDragGestureLifecycleInput {
|
|
41
|
+
commitGestureRef?: RefObject<() => void>;
|
|
42
|
+
trackInsertLayoutRef?: RefObject<TimelineTrackInsertLayout | undefined>;
|
|
40
43
|
lifecycleRef: RefObject<TimelineGestureLifecycle>;
|
|
41
44
|
sessionEpochRef: RefObject<number>;
|
|
42
45
|
cancelGestureRef: RefObject<
|
|
@@ -81,6 +84,8 @@ interface TimelineClipDragGestureLifecycleInput {
|
|
|
81
84
|
export function mountTimelineClipDragGestureLifecycle({
|
|
82
85
|
// The explicit destructuring mirrors the single call site's dependency object by design.
|
|
83
86
|
// fallow-ignore-next-line code-duplication
|
|
87
|
+
commitGestureRef,
|
|
88
|
+
trackInsertLayoutRef,
|
|
84
89
|
lifecycleRef,
|
|
85
90
|
sessionEpochRef,
|
|
86
91
|
cancelGestureRef,
|
|
@@ -120,6 +125,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
120
125
|
};
|
|
121
126
|
|
|
122
127
|
const pointerMatchesGesture = (event: PointerEvent): boolean => {
|
|
128
|
+
if (draggedClipRef.current?.pointerId === null) return false;
|
|
123
129
|
const pointerId = lifecycleRef.current.pointerId;
|
|
124
130
|
return pointerId === null || event.pointerId === pointerId;
|
|
125
131
|
};
|
|
@@ -293,6 +299,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
293
299
|
commitDraggedClipMove(drag, {
|
|
294
300
|
elements: elementsRef.current,
|
|
295
301
|
trackOrder: trackOrderRef.current,
|
|
302
|
+
trackInsertLayout: trackInsertLayoutRef?.current,
|
|
296
303
|
updateElement,
|
|
297
304
|
onMoveElement: onMoveElementRef.current,
|
|
298
305
|
onMoveElements: onMoveElementsRef.current,
|
|
@@ -315,10 +322,12 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
315
322
|
);
|
|
316
323
|
};
|
|
317
324
|
|
|
318
|
-
const claimActiveGesture = (
|
|
325
|
+
const claimActiveGesture = (
|
|
326
|
+
event: PointerEvent | null,
|
|
327
|
+
): TimelineGestureCommit | "ignored" | null => {
|
|
319
328
|
const lifecycle = lifecycleRef.current;
|
|
320
329
|
if (lifecycle.phase !== "active") return null;
|
|
321
|
-
if (!pointerMatchesGesture(event)) return "ignored";
|
|
330
|
+
if (event && !pointerMatchesGesture(event)) return "ignored";
|
|
322
331
|
if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
|
|
323
332
|
cancelGesture();
|
|
324
333
|
return "ignored";
|
|
@@ -358,6 +367,12 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
358
367
|
}
|
|
359
368
|
};
|
|
360
369
|
|
|
370
|
+
if (commitGestureRef)
|
|
371
|
+
commitGestureRef.current = () => {
|
|
372
|
+
const claimed = claimActiveGesture(null);
|
|
373
|
+
if (claimed && claimed !== "ignored") commitClaimedGesture(claimed);
|
|
374
|
+
};
|
|
375
|
+
|
|
361
376
|
const handleWindowPointerUp = (event: PointerEvent) => {
|
|
362
377
|
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
|
|
363
378
|
const claimed = claimActiveGesture(event);
|
|
@@ -411,6 +426,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
411
426
|
return () => {
|
|
412
427
|
cancelGesture({ updateReact: false });
|
|
413
428
|
cancelGestureRef.current = () => false;
|
|
429
|
+
if (commitGestureRef) commitGestureRef.current = () => {};
|
|
414
430
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
415
431
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
416
432
|
window.removeEventListener("pointercancel", handleWindowPointerCancel);
|
|
@@ -7,8 +7,18 @@ import {
|
|
|
7
7
|
type DragPreviewContext,
|
|
8
8
|
} from "./timelineClipDragPreview";
|
|
9
9
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
commitDraggedClipMove,
|
|
12
|
+
persistMoveEdits,
|
|
13
|
+
type TimelineMoveEdit,
|
|
14
|
+
} from "./timelineClipDragCommit";
|
|
15
|
+
import {
|
|
16
|
+
LANE_H,
|
|
17
|
+
RULER_H,
|
|
18
|
+
TRACKS_TOP_PAD,
|
|
19
|
+
TRACK_H,
|
|
20
|
+
createTimelineRowGeometry,
|
|
21
|
+
} from "./timelineLayout";
|
|
12
22
|
import { isMultiDragPassenger } from "./timelineMultiDragPreview";
|
|
13
23
|
import { resolveMultiDragPreview } from "./timelineProviderStateBuilders";
|
|
14
24
|
|
|
@@ -110,6 +120,20 @@ function horizontalDrag(
|
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
|
|
123
|
+
it("opens the physical seam below a zero-padding ruler and keeps it armed inside the new lane", () => {
|
|
124
|
+
const geometry = createTimelineRowGeometry([0, 1, 2], [104, 48, 48], { top: 0 });
|
|
125
|
+
const context = { ...ctx(geometry.rowHeights), rowGeometry: geometry };
|
|
126
|
+
const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
|
|
127
|
+
const top = computeDragPreview(drag, clientX, 24, context);
|
|
128
|
+
expect(top.insertRow).toBe(0);
|
|
129
|
+
const between = computeDragPreview(drag, clientX, 128, context);
|
|
130
|
+
expect(between.insertRow).toBe(1);
|
|
131
|
+
const inside = computeDragPreview(between, clientX, 150, context);
|
|
132
|
+
expect(inside.insertRow).toBe(1);
|
|
133
|
+
const below = computeDragPreview(inside, clientX, 202, context);
|
|
134
|
+
expect(below.insertRow).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
113
137
|
it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
|
|
114
138
|
const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
|
|
115
139
|
const next = computeDragPreview(drag, clientX, clientY, ctx());
|
|
@@ -470,11 +494,10 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
|
|
470
494
|
expect(committedStart(ghost, tag, rows)).toBe(10);
|
|
471
495
|
});
|
|
472
496
|
|
|
473
|
-
it("
|
|
497
|
+
it("a clip aimed at a seam opens a new track at that boundary", () => {
|
|
474
498
|
for (const edge of [0.02, 0.98, 1.02]) {
|
|
475
499
|
const ghost = preview(tag, rows, 2, edge);
|
|
476
|
-
expect(ghost.insertRow).
|
|
477
|
-
expect(ghost.previewTrack).toBe(Math.floor(edge));
|
|
500
|
+
expect(ghost.insertRow).toBe(Math.round(edge));
|
|
478
501
|
}
|
|
479
502
|
});
|
|
480
503
|
});
|
|
@@ -550,6 +573,23 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
|
|
550
573
|
});
|
|
551
574
|
|
|
552
575
|
describe("computeDragPreview — a group move keeps its shape", () => {
|
|
576
|
+
function commitPreview(
|
|
577
|
+
ghost: DraggedClipState,
|
|
578
|
+
elements: TimelineElement[],
|
|
579
|
+
selectedKeys: ReadonlySet<string>,
|
|
580
|
+
) {
|
|
581
|
+
const onMoveElements = vi.fn<(edits: TimelineMoveEdit[]) => void>();
|
|
582
|
+
commitDraggedClipMove(ghost, {
|
|
583
|
+
elements,
|
|
584
|
+
trackOrder: [0, 1, 2],
|
|
585
|
+
updateElement: vi.fn(),
|
|
586
|
+
onMoveElement: vi.fn(),
|
|
587
|
+
onMoveElements,
|
|
588
|
+
selectedKeys,
|
|
589
|
+
});
|
|
590
|
+
return onMoveElements.mock.calls[0][0];
|
|
591
|
+
}
|
|
592
|
+
|
|
553
593
|
it("never bumps the grabbed clip further left than the group's 0 limit", () => {
|
|
554
594
|
const a = clip("a", 1, 0.5, 1, 1);
|
|
555
595
|
const b = clip("b", 2, 7, 2, 1);
|
|
@@ -563,19 +603,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
|
|
|
563
603
|
selectedKeys,
|
|
564
604
|
});
|
|
565
605
|
expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 10 });
|
|
566
|
-
const
|
|
567
|
-
commitDraggedClipMove(ghost, {
|
|
568
|
-
elements,
|
|
569
|
-
trackOrder: [0, 1, 2],
|
|
570
|
-
updateElement: vi.fn(),
|
|
571
|
-
onMoveElement: vi.fn(),
|
|
572
|
-
onMoveElements,
|
|
573
|
-
selectedKeys,
|
|
574
|
-
});
|
|
575
|
-
const edits = onMoveElements.mock.calls[0][0] as Array<{
|
|
576
|
-
element: TimelineElement;
|
|
577
|
-
updates: { start: number };
|
|
578
|
-
}>;
|
|
606
|
+
const edits = commitPreview(ghost, elements, selectedKeys);
|
|
579
607
|
const moved = Object.fromEntries(
|
|
580
608
|
edits.map((e) => [e.element.id, e.updates.start - e.element.start]),
|
|
581
609
|
);
|
|
@@ -595,19 +623,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
|
|
|
595
623
|
...ctx(undefined, elements),
|
|
596
624
|
selectedKeys,
|
|
597
625
|
});
|
|
598
|
-
const
|
|
599
|
-
commitDraggedClipMove(ghost, {
|
|
600
|
-
elements,
|
|
601
|
-
trackOrder: [0, 1, 2],
|
|
602
|
-
updateElement: vi.fn(),
|
|
603
|
-
onMoveElement: vi.fn(),
|
|
604
|
-
onMoveElements,
|
|
605
|
-
selectedKeys,
|
|
606
|
-
});
|
|
607
|
-
const edits = onMoveElements.mock.calls[0][0] as Array<{
|
|
608
|
-
element: TimelineElement;
|
|
609
|
-
updates: { start: number; track: number };
|
|
610
|
-
}>;
|
|
626
|
+
const edits = commitPreview(ghost, elements, selectedKeys);
|
|
611
627
|
return Object.fromEntries(edits.map((e) => [e.element.id, e.updates]));
|
|
612
628
|
}
|
|
613
629
|
|