@hyperframes/studio 0.7.60 → 0.7.62
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-CWpmBjzv.js +459 -0
- package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4392 -3314
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -15
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/StudioRightPanel.tsx +34 -26
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +50 -42
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +9 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +26 -12
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
- package/dist/assets/index-DXbu6IPT.css +0 -1
- package/dist/assets/index-cH6NfVV_.js +0 -426
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -5,13 +5,18 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
5
5
|
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
6
6
|
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
7
7
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
canMoveTimelineElement as canMoveElement,
|
|
10
|
+
resolveExpandedHostAlias,
|
|
11
|
+
} from "./timelineAuthoredMoveTarget";
|
|
9
12
|
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
10
13
|
import {
|
|
11
14
|
beginTimelineOptimisticGesture,
|
|
12
15
|
isLatestTimelineOptimisticGesture,
|
|
13
16
|
} from "./timelineOptimisticRevision";
|
|
14
17
|
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
18
|
+
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
19
|
+
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
|
|
15
20
|
|
|
16
21
|
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
17
22
|
export interface TimelineMoveEdit {
|
|
@@ -68,30 +73,15 @@ export interface DragCommitDeps {
|
|
|
68
73
|
* research/STAGE3-NEEDED-WIRING.md.
|
|
69
74
|
*/
|
|
70
75
|
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
|
|
76
|
+
/** Converge the preview manifest after the complete lane + z transaction. */
|
|
77
|
+
refreshAfterLaneMove?: () => void;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
74
81
|
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
75
|
-
|
|
76
82
|
// One deterministic coalesce key shared by both records in a lane-change gesture.
|
|
77
83
|
let laneChangeGestureSeq = 0;
|
|
78
84
|
|
|
79
|
-
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
|
80
|
-
function canMoveElement(element: TimelineElement): boolean {
|
|
81
|
-
return getTimelineEditCapabilities({
|
|
82
|
-
tag: element.tag,
|
|
83
|
-
duration: element.duration,
|
|
84
|
-
domId: element.domId,
|
|
85
|
-
selector: element.selector,
|
|
86
|
-
compositionSrc: element.compositionSrc,
|
|
87
|
-
playbackStart: element.playbackStart,
|
|
88
|
-
playbackStartAttr: element.playbackStartAttr,
|
|
89
|
-
sourceDuration: element.sourceDuration,
|
|
90
|
-
timingSource: element.timingSource,
|
|
91
|
-
timelineLocked: element.timelineLocked,
|
|
92
|
-
}).canMove;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
85
|
/**
|
|
96
86
|
* Optimistically apply + persist a batch of moves with rollback on failure.
|
|
97
87
|
*
|
|
@@ -139,14 +129,15 @@ export function persistMoveEdits(
|
|
|
139
129
|
// that written value into the store's `authoredTrack` so a SECOND drag before
|
|
140
130
|
// any reload resolves authored tracks from what the file now says, not stale
|
|
141
131
|
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
|
142
|
-
|
|
132
|
+
const applyEdit = (e: TimelineMoveEdit) => {
|
|
143
133
|
const writtenTrack =
|
|
144
134
|
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
|
|
145
135
|
updateElement(
|
|
146
136
|
keyOf(e.element),
|
|
147
137
|
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
|
|
148
138
|
);
|
|
149
|
-
}
|
|
139
|
+
};
|
|
140
|
+
for (const e of edits) applyEdit(e);
|
|
150
141
|
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
|
151
142
|
// track when one was resolved (see TimelineMoveEdit.persistTrack).
|
|
152
143
|
const persistEdits = edits.map((e) =>
|
|
@@ -158,7 +149,17 @@ export function persistMoveEdits(
|
|
|
158
149
|
? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
|
|
159
150
|
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
|
160
151
|
return Promise.resolve(persisted).then(
|
|
161
|
-
() =>
|
|
152
|
+
() => {
|
|
153
|
+
// Runtime timeline messages can arrive while the save is in flight and
|
|
154
|
+
// restore the preview manifest's pre-gesture lane. Reassert the durable
|
|
155
|
+
// result after persistence, but only while this remains the latest
|
|
156
|
+
// optimistic gesture so an older save can never clobber a newer drag.
|
|
157
|
+
for (const e of edits) {
|
|
158
|
+
const key = keyOf(e.element);
|
|
159
|
+
if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
162
163
|
(error) => {
|
|
163
164
|
for (const p of prev) {
|
|
164
165
|
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
|
@@ -177,59 +178,6 @@ export function persistMoveEdits(
|
|
|
177
178
|
* then compacts it to a distinct integer lane between its neighbours, and the
|
|
178
179
|
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
|
179
180
|
*/
|
|
180
|
-
/** Same-source-file predicate: authored track numbers only compare within ONE
|
|
181
|
-
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
|
|
182
|
-
* file, not the host timeline's). `undefined` means the active composition. */
|
|
183
|
-
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
184
|
-
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
|
|
188
|
-
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
|
|
189
|
-
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
|
|
190
|
-
* a host clip next to expanded rows) carries authored values in a different
|
|
191
|
-
* coordinate space, and borrowing them would write a foreign file's numbering.
|
|
192
|
-
*
|
|
193
|
-
* Lane semantics after normalizeToZones: each distinct authored track owns one
|
|
194
|
-
* base lane, and time-overlapping same-track clips spill onto adjacent display
|
|
195
|
-
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
|
|
196
|
-
* trackOrder lists it): its occupants share the base lane's authored track by
|
|
197
|
-
* construction, so the same-file occupant lookup returns that authored track and
|
|
198
|
-
* the drop persists as a same-track join. The clip may then DISPLAY on a
|
|
199
|
-
* different sub-lane than it was dropped on — the spill re-packs
|
|
200
|
-
* deterministically by stable id, first-fit — but the persisted track is
|
|
201
|
-
* correct.
|
|
202
|
-
*
|
|
203
|
-
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
|
|
204
|
-
* dropped on a lane holding only other files' clips — the display-lane integer
|
|
205
|
-
* must NOT be persisted into a sparse file):
|
|
206
|
-
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
|
|
207
|
-
* preserving "one lane up = one authored track up" in the clip's own file.
|
|
208
|
-
* 2. No same-file peers at all → the lane value itself (single-clip files:
|
|
209
|
-
* display and authored spaces coincide for want of any other anchor).
|
|
210
|
-
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
|
|
211
|
-
* never here.
|
|
212
|
-
*/
|
|
213
|
-
export function authoredTrackForLane(
|
|
214
|
-
lane: number,
|
|
215
|
-
elements: TimelineElement[],
|
|
216
|
-
dragged: TimelineElement,
|
|
217
|
-
): number {
|
|
218
|
-
const dragKey = keyOf(dragged);
|
|
219
|
-
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
|
|
220
|
-
const occupant = peers.find((e) => e.track === lane);
|
|
221
|
-
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
|
222
|
-
let nearest: TimelineElement | null = null;
|
|
223
|
-
for (const p of peers) {
|
|
224
|
-
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
|
|
225
|
-
}
|
|
226
|
-
if (!nearest) return lane;
|
|
227
|
-
// Rounded: expanded children live on FRACTIONAL synthetic display rows (see
|
|
228
|
-
// buildChildElements), so a lane distance measured against one can carry a
|
|
229
|
-
// fraction — an authored data-track-index must stay an integer.
|
|
230
|
-
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
181
|
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
234
182
|
if (trackOrder.length === 0) return 0;
|
|
235
183
|
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
@@ -284,6 +232,12 @@ function resolveMultiSelection(
|
|
|
284
232
|
*/
|
|
285
233
|
// fallow-ignore-next-line complexity
|
|
286
234
|
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
|
|
235
|
+
const hostAlias = resolveExpandedHostAlias(drag, deps);
|
|
236
|
+
if (hostAlias) {
|
|
237
|
+
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
287
241
|
const { elements, updateElement, onMoveElement } = deps;
|
|
288
242
|
const dragKey = keyOf(drag.element);
|
|
289
243
|
const isInsert = drag.insertRow != null;
|
|
@@ -362,22 +316,28 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
362
316
|
});
|
|
363
317
|
const multiKeys = multi ? multi.keys : null;
|
|
364
318
|
if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
|
|
365
|
-
void
|
|
319
|
+
void refreshAfterDurableLaneMove(
|
|
320
|
+
persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
|
321
|
+
deps,
|
|
322
|
+
);
|
|
366
323
|
return;
|
|
367
324
|
}
|
|
368
|
-
void
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
325
|
+
void refreshAfterDurableLaneMove(
|
|
326
|
+
runLaneZGesture({
|
|
327
|
+
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
|
328
|
+
commitZ: () =>
|
|
329
|
+
syncStackingForEdit(
|
|
330
|
+
candidate,
|
|
331
|
+
dragKey,
|
|
332
|
+
drag.element.track,
|
|
333
|
+
drag.previewTrack,
|
|
334
|
+
multiKeys,
|
|
335
|
+
deps,
|
|
336
|
+
coalesceKey,
|
|
337
|
+
),
|
|
338
|
+
}),
|
|
339
|
+
deps,
|
|
340
|
+
).catch(() => undefined);
|
|
381
341
|
}
|
|
382
342
|
|
|
383
343
|
/** Build the one sanctioned multi-clip write: atomically insert and compact a
|
|
@@ -486,23 +446,29 @@ function commitTrackInsert(
|
|
|
486
446
|
|
|
487
447
|
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
|
|
488
448
|
if (!deps.readZIndex || !deps.onStackingPatches) {
|
|
489
|
-
void
|
|
449
|
+
void refreshAfterDurableLaneMove(
|
|
450
|
+
persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
|
451
|
+
deps,
|
|
452
|
+
);
|
|
490
453
|
return;
|
|
491
454
|
}
|
|
492
|
-
void
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
455
|
+
void refreshAfterDurableLaneMove(
|
|
456
|
+
runLaneZGesture({
|
|
457
|
+
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
|
458
|
+
commitZ: () =>
|
|
459
|
+
// Sync from the fractional drop intent, not the normalized persisted lanes.
|
|
460
|
+
syncStackingForEdit(
|
|
461
|
+
candidate,
|
|
462
|
+
dragKey,
|
|
463
|
+
drag.element.track,
|
|
464
|
+
drag.insertRow!,
|
|
465
|
+
multi ? multi.keys : null,
|
|
466
|
+
deps,
|
|
467
|
+
coalesceKey,
|
|
468
|
+
),
|
|
469
|
+
}),
|
|
470
|
+
deps,
|
|
471
|
+
).catch(() => undefined);
|
|
506
472
|
}
|
|
507
473
|
|
|
508
474
|
/**
|
|
@@ -544,11 +510,17 @@ export function commitZMirrorLaneMove(
|
|
|
544
510
|
updates: { start: element.start, track: move.displayTrack },
|
|
545
511
|
persistTrack: move.persistTrack,
|
|
546
512
|
};
|
|
547
|
-
return
|
|
513
|
+
return refreshAfterDurableLaneMove(
|
|
514
|
+
persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
|
|
515
|
+
deps,
|
|
516
|
+
);
|
|
548
517
|
}
|
|
549
518
|
const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
|
|
550
519
|
if (!built || built.edits.length === 0) return Promise.resolve(false);
|
|
551
|
-
return
|
|
520
|
+
return refreshAfterDurableLaneMove(
|
|
521
|
+
persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
|
|
522
|
+
deps,
|
|
523
|
+
);
|
|
552
524
|
}
|
|
553
525
|
|
|
554
526
|
/**
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
computeDragPreview,
|
|
5
|
+
computeResizePreview,
|
|
6
|
+
type DragPreviewContext,
|
|
7
|
+
} from "./timelineClipDragPreview";
|
|
4
8
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
9
|
import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
|
6
10
|
|
|
@@ -142,3 +146,31 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|
|
142
146
|
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
|
|
143
147
|
});
|
|
144
148
|
});
|
|
149
|
+
|
|
150
|
+
describe("computeResizePreview — composition source continuity", () => {
|
|
151
|
+
it("seeds a legacy composition offset and advances it at playback rate", () => {
|
|
152
|
+
const element = {
|
|
153
|
+
...clip("comp", 0, 2, 4, 0, "div"),
|
|
154
|
+
kind: "composition" as const,
|
|
155
|
+
playbackRate: 2,
|
|
156
|
+
};
|
|
157
|
+
const result = computeResizePreview(
|
|
158
|
+
{
|
|
159
|
+
element,
|
|
160
|
+
edge: "start",
|
|
161
|
+
originClientX: 0,
|
|
162
|
+
previewStart: 2,
|
|
163
|
+
previewDuration: 4,
|
|
164
|
+
started: true,
|
|
165
|
+
},
|
|
166
|
+
100,
|
|
167
|
+
{ scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(result).toMatchObject({
|
|
171
|
+
previewStart: 3,
|
|
172
|
+
previewDuration: 3,
|
|
173
|
+
previewPlaybackStart: 2,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -221,7 +221,8 @@ export function computeResizePreview(
|
|
|
221
221
|
)
|
|
222
222
|
: Number.POSITIVE_INFINITY;
|
|
223
223
|
const normalizedTag = resize.element.tag.toLowerCase();
|
|
224
|
-
const canSeedPlaybackStart =
|
|
224
|
+
const canSeedPlaybackStart =
|
|
225
|
+
resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video";
|
|
225
226
|
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
|
226
227
|
// Trim limit = available source media only — NOT the composition length.
|
|
227
228
|
// Duration is content-driven (the comp grows/shrinks to fit on commit), so
|
|
@@ -308,6 +308,24 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
|
|
|
308
308
|
).toEqual({ track: 2, insertRow: null });
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
it("crosses an occupied aim instead of snapping the dragged clip back to its origin", () => {
|
|
312
|
+
expect(
|
|
313
|
+
resolveZoneDropPlacement({
|
|
314
|
+
...base,
|
|
315
|
+
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("x", 2, 0, 5)],
|
|
316
|
+
desiredTrack: 1,
|
|
317
|
+
}),
|
|
318
|
+
).toEqual({ track: 1, insertRow: 1 });
|
|
319
|
+
|
|
320
|
+
expect(
|
|
321
|
+
resolveZoneDropPlacement({
|
|
322
|
+
...base,
|
|
323
|
+
elements: [el("x", 0, 0, 5), el("b", 1, 0, 5), el("a", 2, 0, 5)],
|
|
324
|
+
desiredTrack: 1,
|
|
325
|
+
}),
|
|
326
|
+
).toEqual({ track: 1, insertRow: 2 });
|
|
327
|
+
});
|
|
328
|
+
|
|
311
329
|
it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
|
|
312
330
|
expect(
|
|
313
331
|
resolveZoneDropPlacement({
|
|
@@ -115,7 +115,10 @@ export function resolveZoneDropPlacement(input: {
|
|
|
115
115
|
trackOrder: zoneTracks,
|
|
116
116
|
excludeKey: dragKey,
|
|
117
117
|
});
|
|
118
|
-
|
|
118
|
+
const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
|
|
119
|
+
const snappedBackToOrigin =
|
|
120
|
+
originTrack != null && desired !== originTrack && placement.track === originTrack;
|
|
121
|
+
if (placement.needsInsert || snappedBackToOrigin) {
|
|
119
122
|
const desiredRow = order.indexOf(desired);
|
|
120
123
|
if (desiredRow < 0) {
|
|
121
124
|
return {
|
|
@@ -123,14 +126,14 @@ export function resolveZoneDropPlacement(input: {
|
|
|
123
126
|
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
|
|
124
127
|
};
|
|
125
128
|
}
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// When collision fallback found only the origin lane, insert on the far side
|
|
130
|
+
// of the aimed lane so normalization cannot turn the gesture into a no-op.
|
|
131
|
+
// Otherwise prefer the gap nearest the pointer, preserving normal insertion.
|
|
132
|
+
const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
|
|
133
|
+
const insertAbove = snappedBackToOrigin
|
|
134
|
+
? originRow > desiredRow
|
|
135
|
+
: preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
|
|
136
|
+
const insertRow = insertAbove ? desiredRow : desiredRow + 1;
|
|
134
137
|
return { track: desired, insertRow };
|
|
135
138
|
}
|
|
136
139
|
return { track: placement.track, insertRow: null };
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useCallback, useState, type RefObject } from "react";
|
|
2
2
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
3
|
+
import {
|
|
4
|
+
parseTimelineCompositionPayload,
|
|
5
|
+
TIMELINE_COMPOSITION_MIME,
|
|
6
|
+
} from "../../utils/timelineCompositionDrop";
|
|
3
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
8
|
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
|
5
9
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
@@ -32,6 +36,11 @@ function applyJsonDropPayload(
|
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
|
|
40
|
+
if (usePointerStart) return pointerStart;
|
|
41
|
+
return Math.max(0, usePlayerStore.getState().currentTime);
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
|
|
37
46
|
* start is the current playhead time, only the track comes from the drop y.
|
|
@@ -48,6 +57,7 @@ export function useTimelineAssetDrop({
|
|
|
48
57
|
onFileDrop,
|
|
49
58
|
onAssetDrop,
|
|
50
59
|
onBlockDrop,
|
|
60
|
+
onCompositionDrop,
|
|
51
61
|
}: UseTimelineAssetDropOptions) {
|
|
52
62
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
53
63
|
|
|
@@ -56,7 +66,8 @@ export function useTimelineAssetDrop({
|
|
|
56
66
|
const hasFiles = types.includes("Files");
|
|
57
67
|
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
|
58
68
|
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
|
59
|
-
|
|
69
|
+
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
|
70
|
+
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
|
60
71
|
e.preventDefault();
|
|
61
72
|
e.dataTransfer.dropEffect = "copy";
|
|
62
73
|
setIsDragOver(true);
|
|
@@ -65,11 +76,10 @@ export function useTimelineAssetDrop({
|
|
|
65
76
|
const clearDropPreview = useCallback(() => setIsDragOver(false), []);
|
|
66
77
|
|
|
67
78
|
const resolveDropPlacement = useCallback(
|
|
68
|
-
(clientX: number, clientY: number): TimelinePlacement => {
|
|
79
|
+
(clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
|
|
69
80
|
const scroll = scrollRef.current;
|
|
70
81
|
const rect = scroll?.getBoundingClientRect();
|
|
71
|
-
|
|
72
|
-
const { track } = resolveTimelineAssetDrop(
|
|
82
|
+
const pointer = resolveTimelineAssetDrop(
|
|
73
83
|
{
|
|
74
84
|
rectLeft: rect?.left ?? 0,
|
|
75
85
|
rectTop: rect?.top ?? 0,
|
|
@@ -77,14 +87,17 @@ export function useTimelineAssetDrop({
|
|
|
77
87
|
scrollTop: scroll?.scrollTop ?? 0,
|
|
78
88
|
pixelsPerSecond: ppsRef.current,
|
|
79
89
|
duration: durationRef.current,
|
|
90
|
+
clampStartToDuration: !usePointerStart,
|
|
80
91
|
trackHeight: TRACK_H,
|
|
81
92
|
trackOrder: trackOrderRef.current,
|
|
82
93
|
},
|
|
83
94
|
clientX,
|
|
84
95
|
clientY,
|
|
85
96
|
);
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
return {
|
|
98
|
+
start: resolveDropStart(usePointerStart, pointer.start),
|
|
99
|
+
track: pointer.track,
|
|
100
|
+
};
|
|
88
101
|
},
|
|
89
102
|
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
|
90
103
|
);
|
|
@@ -93,6 +106,14 @@ export function useTimelineAssetDrop({
|
|
|
93
106
|
(e: React.DragEvent) => {
|
|
94
107
|
e.preventDefault();
|
|
95
108
|
setIsDragOver(false);
|
|
109
|
+
const compositionPayload = parseTimelineCompositionPayload(
|
|
110
|
+
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
|
111
|
+
);
|
|
112
|
+
if (compositionPayload && onCompositionDrop) {
|
|
113
|
+
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
|
114
|
+
void onCompositionDrop(compositionPayload.sourcePath, placement);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
96
117
|
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
97
118
|
|
|
98
119
|
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
@@ -109,7 +130,7 @@ export function useTimelineAssetDrop({
|
|
|
109
130
|
applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
|
|
110
131
|
}
|
|
111
132
|
},
|
|
112
|
-
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
|
|
133
|
+
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
|
|
113
134
|
);
|
|
114
135
|
|
|
115
136
|
return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
|
|
@@ -6,11 +6,13 @@ export interface TimelineEditCapabilities {
|
|
|
6
6
|
|
|
7
7
|
function isDeterministicTimelineWindow(input: {
|
|
8
8
|
tag: string;
|
|
9
|
+
kind?: "video" | "audio" | "image" | "element" | "composition";
|
|
9
10
|
compositionSrc?: string;
|
|
10
11
|
playbackStartAttr?: "media-start" | "playback-start";
|
|
11
12
|
sourceDuration?: number;
|
|
12
13
|
}): boolean {
|
|
13
|
-
if (input.compositionSrc || input.playbackStartAttr != null)
|
|
14
|
+
if (input.kind === "composition" || input.compositionSrc || input.playbackStartAttr != null)
|
|
15
|
+
return true;
|
|
14
16
|
if (
|
|
15
17
|
input.sourceDuration != null &&
|
|
16
18
|
Number.isFinite(input.sourceDuration) &&
|
|
@@ -27,6 +29,7 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
|
|
|
27
29
|
|
|
28
30
|
export function getTimelineEditCapabilities(input: {
|
|
29
31
|
tag: string;
|
|
32
|
+
kind?: "video" | "audio" | "image" | "element" | "composition";
|
|
30
33
|
duration: number;
|
|
31
34
|
domId?: string;
|
|
32
35
|
selector?: string;
|
|
@@ -53,6 +53,16 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
|
|
|
53
53
|
]);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it("seeds legacy composition offsets and advances them at playback rate", () => {
|
|
57
|
+
const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 });
|
|
58
|
+
const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 });
|
|
59
|
+
const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
|
|
60
|
+
|
|
61
|
+
expect(members.map((member) => member.playbackStart)).toEqual([0, 0]);
|
|
62
|
+
const changes = resolveTimelineGroupResizeChanges(members, "start", 1);
|
|
63
|
+
expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]);
|
|
64
|
+
});
|
|
65
|
+
|
|
56
66
|
it("does not seed playbackStart on the END edge", () => {
|
|
57
67
|
const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
|
|
58
68
|
const b = el("b", { tag: "audio", start: 3, duration: 2 });
|
|
@@ -181,14 +181,15 @@ function elementKey(element: TimelineElement): string {
|
|
|
181
181
|
return element.key ?? element.id;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
function
|
|
184
|
+
function hasSourcePlaybackOffset(element: TimelineElement): boolean {
|
|
185
185
|
const tag = element.tag.toLowerCase();
|
|
186
|
-
return tag === "audio" || tag === "video";
|
|
186
|
+
return element.kind === "composition" || tag === "audio" || tag === "video";
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
|
|
190
190
|
const caps = getTimelineEditCapabilities({
|
|
191
191
|
tag: element.tag,
|
|
192
|
+
kind: element.kind,
|
|
192
193
|
duration: element.duration,
|
|
193
194
|
domId: element.domId,
|
|
194
195
|
selector: element.selector,
|
|
@@ -228,7 +229,7 @@ export function buildTimelineGroupResizeMembers(
|
|
|
228
229
|
start: element.start,
|
|
229
230
|
duration: element.duration,
|
|
230
231
|
playbackStart:
|
|
231
|
-
edge === "start" &&
|
|
232
|
+
edge === "start" && hasSourcePlaybackOffset(element)
|
|
232
233
|
? (element.playbackStart ?? 0)
|
|
233
234
|
: element.playbackStart,
|
|
234
235
|
playbackRate: element.playbackRate,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface LaneMoveRefreshDeps {
|
|
2
|
+
refreshAfterLaneMove?: () => void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Refresh only after the complete lane transaction persisted successfully. */
|
|
6
|
+
export function refreshAfterDurableLaneMove(
|
|
7
|
+
pending: Promise<boolean>,
|
|
8
|
+
deps: LaneMoveRefreshDeps,
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
return pending.then((persisted) => {
|
|
11
|
+
if (persisted) deps.refreshAfterLaneMove?.();
|
|
12
|
+
return persisted;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -383,6 +383,7 @@ export function resolveTimelineAssetDrop(
|
|
|
383
383
|
scrollTop: number;
|
|
384
384
|
pixelsPerSecond: number;
|
|
385
385
|
duration: number;
|
|
386
|
+
clampStartToDuration?: boolean;
|
|
386
387
|
trackHeight: number;
|
|
387
388
|
trackOrder: number[];
|
|
388
389
|
},
|
|
@@ -391,9 +392,10 @@ export function resolveTimelineAssetDrop(
|
|
|
391
392
|
): { start: number; track: number } {
|
|
392
393
|
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
|
393
394
|
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
395
|
+
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
|
394
396
|
const start = Math.max(
|
|
395
397
|
0,
|
|
396
|
-
|
|
398
|
+
input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
|
|
397
399
|
);
|
|
398
400
|
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
|
399
401
|
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
import { classifyZone } from "./timelineZones";
|
|
3
3
|
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
|
|
4
|
-
import { authoredTrackForLane, sameSourceFile } from "./
|
|
4
|
+
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
|
|
5
5
|
import { samePaintScope } from "./timelineStackingSync";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -77,6 +77,7 @@ interface UseTimelineClipDragInput {
|
|
|
77
77
|
*/
|
|
78
78
|
readZIndex?: (element: TimelineElement) => number;
|
|
79
79
|
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
|
|
80
|
+
refreshAfterLaneMove?: () => void;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function useTimelineClipDrag({
|
|
@@ -93,6 +94,7 @@ export function useTimelineClipDrag({
|
|
|
93
94
|
setRangeSelectionRef,
|
|
94
95
|
readZIndex,
|
|
95
96
|
onStackingPatches,
|
|
97
|
+
refreshAfterLaneMove,
|
|
96
98
|
}: UseTimelineClipDragInput) {
|
|
97
99
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
98
100
|
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
|
@@ -213,6 +215,8 @@ export function useTimelineClipDrag({
|
|
|
213
215
|
readZIndexRef.current = readZIndex;
|
|
214
216
|
const onStackingPatchesRef = useRef(onStackingPatches);
|
|
215
217
|
onStackingPatchesRef.current = onStackingPatches;
|
|
218
|
+
const refreshAfterLaneMoveRef = useRef(refreshAfterLaneMove);
|
|
219
|
+
refreshAfterLaneMoveRef.current = refreshAfterLaneMove;
|
|
216
220
|
|
|
217
221
|
const clipDragScrollRaf = useRef(0);
|
|
218
222
|
const clipDragPointerRef = useRef<{
|
|
@@ -499,6 +503,7 @@ export function useTimelineClipDrag({
|
|
|
499
503
|
// deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
|
|
500
504
|
readZIndex: readZIndexRef.current,
|
|
501
505
|
onStackingPatches: onStackingPatchesRef.current,
|
|
506
|
+
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
502
507
|
});
|
|
503
508
|
};
|
|
504
509
|
|
|
@@ -12,6 +12,7 @@ interface UseTimelineEditPinningInput {
|
|
|
12
12
|
onFileDrop: TimelineDropCallbacks["onFileDrop"];
|
|
13
13
|
onAssetDrop: TimelineDropCallbacks["onAssetDrop"];
|
|
14
14
|
onBlockDrop: TimelineDropCallbacks["onBlockDrop"];
|
|
15
|
+
onCompositionDrop: TimelineDropCallbacks["onCompositionDrop"];
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
// Wrap every mutating timeline edit so the zoom pins to the current on-screen
|
|
@@ -29,6 +30,7 @@ export function useTimelineEditPinning({
|
|
|
29
30
|
onFileDrop,
|
|
30
31
|
onAssetDrop,
|
|
31
32
|
onBlockDrop,
|
|
33
|
+
onCompositionDrop,
|
|
32
34
|
}: UseTimelineEditPinningInput) {
|
|
33
35
|
const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom);
|
|
34
36
|
// Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a
|
|
@@ -106,6 +108,15 @@ export function useTimelineEditPinning({
|
|
|
106
108
|
}),
|
|
107
109
|
[onBlockDrop, pinZoomBeforeEdit],
|
|
108
110
|
);
|
|
111
|
+
const pinnedOnCompositionDrop = useMemo(
|
|
112
|
+
() =>
|
|
113
|
+
onCompositionDrop &&
|
|
114
|
+
((...args: Parameters<typeof onCompositionDrop>) => {
|
|
115
|
+
pinZoomBeforeEdit();
|
|
116
|
+
return onCompositionDrop(...args);
|
|
117
|
+
}),
|
|
118
|
+
[onCompositionDrop, pinZoomBeforeEdit],
|
|
119
|
+
);
|
|
109
120
|
|
|
110
121
|
return {
|
|
111
122
|
pinZoomBeforeEdit,
|
|
@@ -117,5 +128,6 @@ export function useTimelineEditPinning({
|
|
|
117
128
|
pinnedOnFileDrop,
|
|
118
129
|
pinnedOnAssetDrop,
|
|
119
130
|
pinnedOnBlockDrop,
|
|
131
|
+
pinnedOnCompositionDrop,
|
|
120
132
|
};
|
|
121
133
|
}
|