@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,6 +1,4 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
-
import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
|
3
|
-
import { isMainTrackElement } from "./timelineZones";
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
4
|
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
|
@@ -38,26 +36,6 @@ export function isInsertAllowedForZone(
|
|
|
38
36
|
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
/**
|
|
42
|
-
* The full drop-placement decision for a dragged clip — one pure, testable unit.
|
|
43
|
-
* Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
|
|
44
|
-
* a new track is created only when needed. Order of resolution:
|
|
45
|
-
* 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
|
|
46
|
-
* clip's own zone → create a new track there.
|
|
47
|
-
* 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
|
|
48
|
-
* if free at [start, start+duration), else the nearest FREE lane in the zone
|
|
49
|
-
* (prefer up), else auto-create a new track right below the aimed lane.
|
|
50
|
-
* `audioTracks` = the set of track indices that currently hold audio (so the fn
|
|
51
|
-
* needs no element-kind import). Returns the landing `track` and, when a new track
|
|
52
|
-
* should be created, the `insertRow` boundary (else null).
|
|
53
|
-
*
|
|
54
|
-
* `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
|
|
55
|
-
* track) toward the boundary ABOVE the aimed row instead of below it, so the new
|
|
56
|
-
* lane opens on whichever side of the aimed clip the pointer is nearer (the drag
|
|
57
|
-
* preview passes the pointer's sub-row half). A clip whose aimed span is occupied
|
|
58
|
-
* never snaps back to its origin — it relocates to a free lane, or (none free)
|
|
59
|
-
* gets a fresh track next to the aim. Default (below) preserves prior behaviour.
|
|
60
|
-
*/
|
|
61
39
|
/**
|
|
62
40
|
* Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
|
|
63
41
|
* real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
|
|
@@ -84,6 +62,50 @@ function outOfRangeZoneInsertRow(
|
|
|
84
62
|
return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
|
|
85
63
|
}
|
|
86
64
|
|
|
65
|
+
const floorCenti = (v: number) => Math.floor(v * 100 + 1e-6) / 100;
|
|
66
|
+
const ceilCenti = (v: number) => Math.ceil(v * 100 - 1e-6) / 100;
|
|
67
|
+
|
|
68
|
+
/** Whether `candidate` is nearer `start` than `best`; a tie goes to the later time. */
|
|
69
|
+
const isNearer = (candidate: number, best: number, start: number) => {
|
|
70
|
+
const gap = Math.abs(candidate - start) - Math.abs(best - start);
|
|
71
|
+
return gap < 0 || (gap === 0 && candidate > best);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The start nearest `start`, not below `minStart`, at which [start, start + duration) overlaps
|
|
75
|
+
* no clip on `track`. Ties go to the later time. The gap after the row's last clip always fits,
|
|
76
|
+
* so a row with no gap long enough puts the clip right after its last clip. `origin` is the
|
|
77
|
+
* clip's own start on this track: keeping it rewrites nothing, so it fits between any edges. */
|
|
78
|
+
export function resolveNearestFreeStart(
|
|
79
|
+
elements: readonly TimelineElement[],
|
|
80
|
+
track: number,
|
|
81
|
+
start: number,
|
|
82
|
+
duration: number,
|
|
83
|
+
excludeKey: string | null,
|
|
84
|
+
minStart = 0,
|
|
85
|
+
origin: number | null = null,
|
|
86
|
+
): number {
|
|
87
|
+
const busy = elements
|
|
88
|
+
.filter((el) => (el.key ?? el.id) !== excludeKey && el.track === track)
|
|
89
|
+
.sort((a, b) => a.start - b.start);
|
|
90
|
+
let best = Number.POSITIVE_INFINITY;
|
|
91
|
+
let gapStart = minStart;
|
|
92
|
+
for (const el of [...busy, null]) {
|
|
93
|
+
const latest = el ? floorCenti(el.start - duration) : Number.POSITIVE_INFINITY;
|
|
94
|
+
if (latest >= gapStart) {
|
|
95
|
+
const candidate = Math.min(Math.max(start, gapStart), latest);
|
|
96
|
+
if (Math.abs(candidate - start) <= Math.abs(best - start)) best = candidate;
|
|
97
|
+
}
|
|
98
|
+
if (el) gapStart = Math.max(gapStart, ceilCenti(el.start + el.duration));
|
|
99
|
+
}
|
|
100
|
+
if (origin === null || origin < minStart || !isNearer(origin, best, start)) return best;
|
|
101
|
+
// Float slack at both ends: 0.333 + 1.733 is 2.0660000000000003, a hair past a neighbour at 2.066.
|
|
102
|
+
const [from, to] = [origin + 1e-6, origin + duration - 1e-6];
|
|
103
|
+
const clear = busy.every((el) => !timeRangesOverlap(from, to, el.start, el.start + el.duration));
|
|
104
|
+
return clear ? origin : best;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Where a dragged clip lands: on the aimed row of its kind, at the nearest free time there. Only an aim outside all
|
|
108
|
+
// rows, or into a kind with no row yet, opens a track (`insertRow`).
|
|
87
109
|
export function resolveZoneDropPlacement(input: {
|
|
88
110
|
order: number[];
|
|
89
111
|
audioTracks: ReadonlySet<number>;
|
|
@@ -94,99 +116,54 @@ export function resolveZoneDropPlacement(input: {
|
|
|
94
116
|
duration: number;
|
|
95
117
|
dragKey: string;
|
|
96
118
|
isAudio: boolean;
|
|
97
|
-
|
|
98
|
-
|
|
119
|
+
/** Lowest start the clip may take (every moving clip stays at or after its host's start). */
|
|
120
|
+
minStart?: number;
|
|
121
|
+
/** Where the dragged clip sits now. */
|
|
122
|
+
origin?: { track: number; start: number };
|
|
123
|
+
}): { track: number; insertRow: number | null; start: number } {
|
|
99
124
|
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
|
|
100
|
-
const { start, duration, dragKey, isAudio,
|
|
125
|
+
const { start, duration, dragKey, isAudio, minStart } = input;
|
|
101
126
|
const audioRow = order.findIndex((t) => audioTracks.has(t));
|
|
102
127
|
|
|
103
128
|
if (
|
|
104
129
|
deliberateInsertRow !== null &&
|
|
105
130
|
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
|
|
106
131
|
) {
|
|
107
|
-
return { track: desiredTrack, insertRow: deliberateInsertRow };
|
|
132
|
+
return { track: desiredTrack, insertRow: deliberateInsertRow, start };
|
|
108
133
|
}
|
|
109
134
|
|
|
110
135
|
const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
|
|
111
136
|
const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
|
|
112
|
-
|
|
137
|
+
// Only when the aim is outside the rows, or the clip's zone has no row yet.
|
|
138
|
+
if (!zoneTracks.includes(desired)) {
|
|
139
|
+
const desiredRow = order.indexOf(desired);
|
|
140
|
+
const insertRow =
|
|
141
|
+
desiredRow < 0
|
|
142
|
+
? outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired)
|
|
143
|
+
: desiredRow + 1;
|
|
144
|
+
return { track: desired, insertRow, start };
|
|
145
|
+
}
|
|
146
|
+
const origin = input.origin?.track === desired ? input.origin.start : null;
|
|
147
|
+
const freeStart = resolveNearestFreeStart(
|
|
113
148
|
elements,
|
|
114
|
-
|
|
149
|
+
desired,
|
|
115
150
|
start,
|
|
116
151
|
duration,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
originTrack != null && desired !== originTrack && placement.track === originTrack;
|
|
123
|
-
if (placement.needsInsert || snappedBackToOrigin) {
|
|
124
|
-
const desiredRow = order.indexOf(desired);
|
|
125
|
-
if (desiredRow < 0) {
|
|
126
|
-
return {
|
|
127
|
-
track: desired,
|
|
128
|
-
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
// When collision fallback found only the origin lane, insert on the far side
|
|
132
|
-
// of the aimed lane so normalization cannot turn the gesture into a no-op.
|
|
133
|
-
// Otherwise prefer the gap nearest the pointer, preserving normal insertion.
|
|
134
|
-
const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
|
|
135
|
-
const insertAbove = snappedBackToOrigin
|
|
136
|
-
? originRow > desiredRow
|
|
137
|
-
: preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
|
|
138
|
-
const insertRow = insertAbove ? desiredRow : desiredRow + 1;
|
|
139
|
-
return { track: desired, insertRow };
|
|
140
|
-
}
|
|
141
|
-
return { track: placement.track, insertRow: null };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function landsOnMainTrack(landingTrack: number, isAudio: boolean): boolean {
|
|
145
|
-
return isMainTrackElement({
|
|
146
|
-
id: "",
|
|
147
|
-
tag: isAudio ? "audio" : "video",
|
|
148
|
-
start: 0,
|
|
149
|
-
duration: 0,
|
|
150
|
-
track: landingTrack,
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** An existing clip newly landing on an empty main track commits at start=0.
|
|
155
|
-
* No-op once the main track holds another clip, off the main track, or for
|
|
156
|
-
* a clip already resident there. `others` excludes the landing clip itself. */
|
|
157
|
-
export function resolveMainTrackDropStart(
|
|
158
|
-
others: readonly TimelineElement[],
|
|
159
|
-
originTrack: number,
|
|
160
|
-
landingTrack: number,
|
|
161
|
-
isAudio: boolean,
|
|
162
|
-
desiredStart: number,
|
|
163
|
-
): number {
|
|
164
|
-
if (originTrack === landingTrack || !landsOnMainTrack(landingTrack, isAudio)) {
|
|
165
|
-
return desiredStart;
|
|
166
|
-
}
|
|
167
|
-
return others.some(isMainTrackElement) ? desiredStart : 0;
|
|
152
|
+
dragKey,
|
|
153
|
+
minStart,
|
|
154
|
+
origin,
|
|
155
|
+
);
|
|
156
|
+
return { track: desired, insertRow: null, start: freeStart };
|
|
168
157
|
}
|
|
169
158
|
|
|
170
159
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* (0 = above the top lane, `trackCount` = below the bottom), or null when the
|
|
175
|
-
* pointer is over a lane's middle band (a normal move/target). The default band
|
|
176
|
-
* preserves collapsed-row behavior; production passes the concrete row's band.
|
|
160
|
+
* The row boundary a pointer opens a new track at, or null over a row. `rowFloat` is the pointer's
|
|
161
|
+
* position in row units from the top of the first row. Only the empty space above the first row
|
|
162
|
+
* (0) or below the last (`trackCount`) opens one: a drop anywhere on a row stays on that row.
|
|
177
163
|
*/
|
|
178
|
-
export function resolveInsertRow(
|
|
179
|
-
rowFloat
|
|
180
|
-
trackCount: number,
|
|
181
|
-
band: number = INSERT_BOUNDARY_BAND,
|
|
182
|
-
): number | null {
|
|
183
|
-
if (trackCount === 0) return 0;
|
|
184
|
-
if (rowFloat <= 0) return 0;
|
|
164
|
+
export function resolveInsertRow(rowFloat: number, trackCount: number): number | null {
|
|
165
|
+
if (trackCount === 0 || rowFloat < 0) return 0;
|
|
185
166
|
if (rowFloat >= trackCount) return trackCount;
|
|
186
|
-
const lane = Math.floor(rowFloat);
|
|
187
|
-
const frac = rowFloat - lane;
|
|
188
|
-
if (frac < band) return lane;
|
|
189
|
-
if (frac > 1 - band) return lane + 1;
|
|
190
167
|
return null;
|
|
191
168
|
}
|
|
192
169
|
|
|
@@ -218,67 +195,3 @@ export function isLaneFree(
|
|
|
218
195
|
timeRangesOverlap(start, end, el.start, el.start + el.duration),
|
|
219
196
|
);
|
|
220
197
|
}
|
|
221
|
-
|
|
222
|
-
export interface PlacementInput {
|
|
223
|
-
elements: TimelineElement[];
|
|
224
|
-
desiredTrack: number;
|
|
225
|
-
start: number;
|
|
226
|
-
duration: number;
|
|
227
|
-
trackOrder: number[];
|
|
228
|
-
excludeKey: string | null;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export interface PlacementResult {
|
|
232
|
-
/** The lane the clip should land on. */
|
|
233
|
-
track: number;
|
|
234
|
-
/**
|
|
235
|
-
* True when no existing lane was free and the caller should insert a new
|
|
236
|
-
* track instead of landing on `track` (which is then the desired lane as a
|
|
237
|
-
* last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
|
|
238
|
-
*/
|
|
239
|
-
needsInsert: boolean;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Resolve where a dragged clip should land, avoiding overlap. If the desired
|
|
244
|
-
* lane is free, keep it. Otherwise search the nearest free lane, **preferring
|
|
245
|
-
* up** (all lanes above, nearest first), then down. If none is free, signal an
|
|
246
|
-
* insert and fall back to the desired lane.
|
|
247
|
-
*/
|
|
248
|
-
export function resolvePlacement({
|
|
249
|
-
elements,
|
|
250
|
-
desiredTrack,
|
|
251
|
-
start,
|
|
252
|
-
duration,
|
|
253
|
-
trackOrder,
|
|
254
|
-
excludeKey,
|
|
255
|
-
}: PlacementInput): PlacementResult {
|
|
256
|
-
const end = start + duration;
|
|
257
|
-
const idx = trackOrder.indexOf(desiredTrack);
|
|
258
|
-
// desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
|
|
259
|
-
// yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
|
|
260
|
-
// BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
|
|
261
|
-
// lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
|
|
262
|
-
// "free" only because that lane belongs to the wrong zone. Landing there would
|
|
263
|
-
// put the clip in the wrong kind-zone, so signal an insert to create the zone's
|
|
264
|
-
// first lane instead — regardless of whether the aimed span is occupied (#2195).
|
|
265
|
-
if (idx === -1) return { track: desiredTrack, needsInsert: true };
|
|
266
|
-
|
|
267
|
-
if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
|
|
268
|
-
return { track: desiredTrack, needsInsert: false };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Prefer up: nearest lane above first, then the rest above.
|
|
272
|
-
for (let up = idx - 1; up >= 0; up--) {
|
|
273
|
-
if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
|
|
274
|
-
return { track: trackOrder[up], needsInsert: false };
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
// Then down: nearest lane below first.
|
|
278
|
-
for (let down = idx + 1; down < trackOrder.length; down++) {
|
|
279
|
-
if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
|
|
280
|
-
return { track: trackOrder[down], needsInsert: false };
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return { track: desiredTrack, needsInsert: true };
|
|
284
|
-
}
|
|
@@ -256,8 +256,10 @@ describe("useTimelineAssetDrop", () => {
|
|
|
256
256
|
|
|
257
257
|
describe("resolveDropInsertRow", () => {
|
|
258
258
|
const rows = [TRACK_H, TRACK_H, TRACK_H];
|
|
259
|
-
it("
|
|
260
|
-
|
|
259
|
+
it("stays on the row at the boundary between two rows", () => {
|
|
260
|
+
for (const dy of [-1, 0, 1]) {
|
|
261
|
+
expect(resolveDropInsertRow(getTimelineRowTop(1, rows) + dy, rows, 3)).toBeNull();
|
|
262
|
+
}
|
|
261
263
|
});
|
|
262
264
|
it("arms a new track above the first lane", () => {
|
|
263
265
|
expect(resolveDropInsertRow(getTimelineRowTop(0, rows) - 4, rows, 3)).toBe(0);
|
|
@@ -274,20 +276,32 @@ describe("resolveDropInsertRow", () => {
|
|
|
274
276
|
});
|
|
275
277
|
|
|
276
278
|
describe("useTimelineAssetDrop new-track drops", () => {
|
|
277
|
-
it("
|
|
279
|
+
it("drops an asset released at the top edge of a row onto that row, with no new track", () => {
|
|
278
280
|
const onAssetDrop = vi.fn();
|
|
279
281
|
const view = renderHarness(onAssetDrop);
|
|
280
282
|
const transfer = assetTransfer(JSON.stringify({ path: "assets/a.png" }));
|
|
281
283
|
const y = getTimelineRowTop(1) + 1;
|
|
282
284
|
|
|
283
285
|
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, y)));
|
|
284
|
-
expect(view.api.dropPreview).
|
|
286
|
+
expect(view.api.dropPreview).toEqual({ start: 10, track: 1 });
|
|
287
|
+
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, y)));
|
|
288
|
+
expect(onAssetDrop).toHaveBeenCalledWith("assets/a.png", { start: 10, track: 1 });
|
|
289
|
+
act(() => view.root.unmount());
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("opens a new track for an asset released in the empty space above the first row", () => {
|
|
293
|
+
const onAssetDrop = vi.fn();
|
|
294
|
+
const view = renderHarness(onAssetDrop);
|
|
295
|
+
const transfer = assetTransfer(JSON.stringify({ path: "assets/a.png" }));
|
|
296
|
+
const y = getTimelineRowTop(0) - 4;
|
|
297
|
+
|
|
298
|
+
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, y)));
|
|
285
299
|
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, y)));
|
|
286
300
|
expect(onAssetDrop).toHaveBeenCalledWith(
|
|
287
301
|
"assets/a.png",
|
|
288
302
|
expect.objectContaining({
|
|
289
303
|
start: 10,
|
|
290
|
-
insertRow:
|
|
304
|
+
insertRow: 0,
|
|
291
305
|
trackOrder: Array.from({ length: 100 }, (_, index) => index),
|
|
292
306
|
}),
|
|
293
307
|
);
|
|
@@ -5,8 +5,7 @@ import {
|
|
|
5
5
|
TIMELINE_COMPOSITION_MIME,
|
|
6
6
|
} from "../../utils/timelineCompositionDrop";
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
getTimelineRowPositionFromY,
|
|
8
|
+
getTimelineRowFromY,
|
|
10
9
|
resolveTimelineAssetDrop,
|
|
11
10
|
type TimelineRowGeometry,
|
|
12
11
|
} from "./timelineLayout";
|
|
@@ -52,17 +51,17 @@ function canInsertTrackFor(types: readonly string[]): boolean {
|
|
|
52
51
|
return types.includes("Files") || types.includes(TIMELINE_ASSET_MIME);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** The row boundary a pointer at `contentY`
|
|
54
|
+
/** The row boundary a pointer at `contentY` opens a new track at, or null over a row. */
|
|
56
55
|
export function resolveDropInsertRow(
|
|
57
56
|
contentY: number,
|
|
58
57
|
rowHeights: readonly number[] | undefined,
|
|
59
58
|
trackCount: number,
|
|
60
59
|
): number | null {
|
|
61
60
|
if (trackCount === 0) return null;
|
|
62
|
-
const
|
|
61
|
+
const rowFloat = getTimelineRowFromY(contentY, rowHeights);
|
|
63
62
|
// Past the last lane the drop already appends a track (getDefaultDroppedTrack).
|
|
64
63
|
if (rowFloat >= trackCount) return null;
|
|
65
|
-
return resolveInsertRow(rowFloat, trackCount
|
|
64
|
+
return resolveInsertRow(rowFloat, trackCount);
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
function placeDrop(
|
|
@@ -118,10 +117,9 @@ function applyTypedJsonDrop(
|
|
|
118
117
|
}
|
|
119
118
|
|
|
120
119
|
/**
|
|
121
|
-
* Dropping an asset/file/block/composition onto the timeline places it
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* decision (#2291); playhead adds stay available, see useAddAssetAtPlayhead.
|
|
120
|
+
* Dropping an asset/file/block/composition onto the timeline places it on the row
|
|
121
|
+
* and at the time it was dropped on; an asset moves to that row's nearest free time.
|
|
122
|
+
* Supersedes the prior playhead decision (#2291); see useAddAssetAtPlayhead.
|
|
125
123
|
*/
|
|
126
124
|
export function useTimelineAssetDrop({
|
|
127
125
|
scrollRef,
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
|
3
|
-
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
4
3
|
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
5
|
-
import { resolveMainTrackDropStart } from "./timelineCollision";
|
|
6
|
-
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
7
|
-
import { resolveExpandedHostAlias } from "./timelineAuthoredMoveTarget";
|
|
8
4
|
import { sameSourceFile } from "./timelineAuthoredTrack";
|
|
9
|
-
import { round3 } from "./timelineGaps";
|
|
10
5
|
|
|
11
6
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
12
7
|
|
|
@@ -58,47 +53,3 @@ export function layoutAfterTrackInsert(
|
|
|
58
53
|
);
|
|
59
54
|
return { normalized, targetTrack, writable };
|
|
60
55
|
}
|
|
61
|
-
|
|
62
|
-
/** The start a solo clip lands at, for both the ghost and the commit: 0 on an empty
|
|
63
|
-
* main track, judged on the final lane (after the renumber and the host alias).
|
|
64
|
-
* Returned in the dragged clip's own frame. */
|
|
65
|
-
export function resolveDragLandingStart(
|
|
66
|
-
drag: DraggedClipState,
|
|
67
|
-
deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "selectedKeys">,
|
|
68
|
-
): number {
|
|
69
|
-
const alias = resolveExpandedHostAlias(drag, deps);
|
|
70
|
-
const target = alias?.drag ?? drag;
|
|
71
|
-
const keys = alias?.selectedKeys ?? deps.selectedKeys;
|
|
72
|
-
const element = target.element;
|
|
73
|
-
const key = keyOf(element);
|
|
74
|
-
if (keys && keys.size > 1 && keys.has(key)) return drag.previewStart;
|
|
75
|
-
|
|
76
|
-
let landingTrack = target.previewTrack;
|
|
77
|
-
let renumbered = new Map<string, number>();
|
|
78
|
-
if (target.insertRow != null) {
|
|
79
|
-
const layout = layoutAfterTrackInsert(
|
|
80
|
-
element,
|
|
81
|
-
target.previewStart,
|
|
82
|
-
target.insertRow,
|
|
83
|
-
null,
|
|
84
|
-
deps,
|
|
85
|
-
);
|
|
86
|
-
if (layout) {
|
|
87
|
-
renumbered = new Map(layout.normalized.map((n) => [keyOf(n), n.track]));
|
|
88
|
-
landingTrack = renumbered.get(key) ?? layout.targetTrack;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const others = deps.elements
|
|
92
|
-
.filter((e) => keyOf(e) !== key)
|
|
93
|
-
.map((e) => (renumbered.has(keyOf(e)) ? { ...e, track: renumbered.get(keyOf(e))! } : e));
|
|
94
|
-
const snapped = resolveMainTrackDropStart(
|
|
95
|
-
others,
|
|
96
|
-
element.track,
|
|
97
|
-
landingTrack,
|
|
98
|
-
isAudioTimelineElement(element),
|
|
99
|
-
target.previewStart,
|
|
100
|
-
);
|
|
101
|
-
return snapped === target.previewStart
|
|
102
|
-
? drag.previewStart
|
|
103
|
-
: round3(drag.previewStart + snapped - target.previewStart);
|
|
104
|
-
}
|
|
@@ -51,6 +51,7 @@ export interface TimelineMoveInput {
|
|
|
51
51
|
originScrollLeft?: number;
|
|
52
52
|
currentScrollLeft?: number;
|
|
53
53
|
pixelsPerSecond: number;
|
|
54
|
+
minStart?: number;
|
|
54
55
|
maxStart: number;
|
|
55
56
|
trackOrder: number[];
|
|
56
57
|
layerOrder?: TimelineLayerId[];
|
|
@@ -127,7 +128,7 @@ export function resolveTimelineMove(
|
|
|
127
128
|
const deltaTrack = Math.round(trackDeltaRaw);
|
|
128
129
|
const nextStart = clamp(
|
|
129
130
|
roundToCentiseconds(input.start + deltaTime),
|
|
130
|
-
0,
|
|
131
|
+
input.minStart ?? 0,
|
|
131
132
|
Math.max(0, input.maxStart),
|
|
132
133
|
);
|
|
133
134
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clampPlaybackRate } from "@hyperframes/parsers/media-duration";
|
|
2
2
|
import { roundToCenti } from "../../utils/rounding";
|
|
3
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
4
5
|
import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
|
|
@@ -88,6 +89,7 @@ export interface TimelineGroupTimingMember {
|
|
|
88
89
|
duration: number;
|
|
89
90
|
playbackStart?: number;
|
|
90
91
|
playbackRate?: number;
|
|
92
|
+
minStart?: number;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
export type TimelineGroupResizeEdge = "start" | "end";
|
|
@@ -107,7 +109,7 @@ function clampTimelineGroupMoveDelta(
|
|
|
107
109
|
members: readonly TimelineGroupTimingMember[],
|
|
108
110
|
): number {
|
|
109
111
|
if (members.length === 0) return 0;
|
|
110
|
-
const minDelta = Math.max(...members.map((member) => -member.start));
|
|
112
|
+
const minDelta = Math.max(...members.map((member) => -(member.start - (member.minStart ?? 0))));
|
|
111
113
|
return roundTimelineTime(Math.max(rawDelta, minDelta));
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -139,7 +141,9 @@ export function clampTimelineGroupResizeDelta(
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
// Rigid group: the applied delta is bounded by the most-constrained member.
|
|
142
|
-
const bounds = members.map((member) =>
|
|
144
|
+
const bounds = members.map((member) =>
|
|
145
|
+
clipStartTrimDeltaBounds(member, member.minStart ?? 0, minDuration),
|
|
146
|
+
);
|
|
143
147
|
const minDelta = ceilTimelineTime(Math.max(...bounds.map((b) => b.minDelta)));
|
|
144
148
|
const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
|
|
145
149
|
const delta = roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
|
|
@@ -243,6 +247,7 @@ export function buildTimelineGroupResizeMembers(
|
|
|
243
247
|
? (element.playbackStart ?? 0)
|
|
244
248
|
: element.playbackStart,
|
|
245
249
|
playbackRate: element.playbackRate,
|
|
250
|
+
minStart: clampToHostStart(element, 0),
|
|
246
251
|
}));
|
|
247
252
|
}
|
|
248
253
|
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
CLIP_Y,
|
|
4
|
-
INSERT_BOUNDARY_BAND,
|
|
5
|
-
getTimelineInsertBoundaryBand,
|
|
6
3
|
RULER_H,
|
|
7
4
|
TRACK_H,
|
|
8
5
|
LANE_H,
|
|
@@ -294,25 +291,3 @@ describe("getTimelineScrubTime", () => {
|
|
|
294
291
|
expect(at(origin + 250, Number.NaN)).toBe(0);
|
|
295
292
|
});
|
|
296
293
|
});
|
|
297
|
-
|
|
298
|
-
// The only production hook keeping resolveInsertRow's band aligned with the
|
|
299
|
-
// rendered clip inset once rows can be taller than TRACK_H. Pinned directly so a
|
|
300
|
-
// change to CLIP_Y or the invalid-height fallback can't silently drift it.
|
|
301
|
-
describe("getTimelineInsertBoundaryBand", () => {
|
|
302
|
-
it("matches the fixed band for a plain track row", () => {
|
|
303
|
-
expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(INSERT_BOUNDARY_BAND);
|
|
304
|
-
expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(CLIP_Y / TRACK_H);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("shrinks as the row grows, so the band stays CLIP_Y pixels tall", () => {
|
|
308
|
-
const expanded = TRACK_H + 2 * LANE_H;
|
|
309
|
-
expect(getTimelineInsertBoundaryBand(expanded)).toBeCloseTo(CLIP_Y / expanded, 10);
|
|
310
|
-
expect(getTimelineInsertBoundaryBand(expanded)).toBeLessThan(INSERT_BOUNDARY_BAND);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it("falls back to the plain-track band for a height that is not usable", () => {
|
|
314
|
-
for (const height of [0, -10, Number.NaN]) {
|
|
315
|
-
expect(getTimelineInsertBoundaryBand(height)).toBe(INSERT_BOUNDARY_BAND);
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
});
|
|
@@ -54,11 +54,6 @@ export function getTimelineBeatEntries(
|
|
|
54
54
|
export function getTimelineLaneTop(laneIndex: number): number {
|
|
55
55
|
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
|
|
56
56
|
}
|
|
57
|
-
/**
|
|
58
|
-
* Collapsed-row characterization value for the new-track INSERT band. Runtime
|
|
59
|
-
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
|
|
60
|
-
*/
|
|
61
|
-
export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
|
|
62
57
|
/**
|
|
63
58
|
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
|
|
64
59
|
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
|
|
@@ -263,17 +258,6 @@ export function getTimelineRowFromY(
|
|
|
263
258
|
return getTimelineRowGeometry(rowHeights).getRowFromY(contentY);
|
|
264
259
|
}
|
|
265
260
|
|
|
266
|
-
export function getTimelineRowPositionFromY(
|
|
267
|
-
contentY: number,
|
|
268
|
-
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
|
|
269
|
-
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
|
|
270
|
-
return getTimelineRowGeometry(rowHeights).getRowPositionFromY(contentY);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Fractional insert band for the concrete row under a pointer. */
|
|
274
|
-
export function getTimelineInsertBoundaryBand(rowHeight: number): number {
|
|
275
|
-
return CLIP_Y / validRowHeight(rowHeight);
|
|
276
|
-
}
|
|
277
261
|
/**
|
|
278
262
|
* While a clip drag is live, the rendered timeline extends this far past the
|
|
279
263
|
* ghost's end so the right-edge auto-scroll zone always has room to keep
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
+
groupMoveFloor,
|
|
4
5
|
isMultiDragActive,
|
|
5
6
|
isMultiDragPassenger,
|
|
6
7
|
multiDragDeltaSeconds,
|
|
7
8
|
multiDragPassengerOffsetPx,
|
|
9
|
+
resolveGroupMovers,
|
|
8
10
|
type MultiDragPreviewInput,
|
|
9
11
|
} from "./timelineMultiDragPreview";
|
|
10
12
|
|
|
@@ -92,36 +94,45 @@ describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)",
|
|
|
92
94
|
});
|
|
93
95
|
});
|
|
94
96
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
const at = (id: string, start: number, locked = false): TimelineElement => ({
|
|
98
|
+
id,
|
|
99
|
+
key: id,
|
|
100
|
+
tag: "video",
|
|
101
|
+
start,
|
|
102
|
+
duration: 1,
|
|
103
|
+
track: 0,
|
|
104
|
+
domId: id,
|
|
105
|
+
...(locked ? { timelineLocked: true } : {}),
|
|
106
|
+
});
|
|
100
107
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
expect(
|
|
108
|
+
describe("groupMoveFloor (rigid group move)", () => {
|
|
109
|
+
it("lets the grabbed clip go as far left as the leftmost mover allows, and no further", () => {
|
|
110
|
+
expect(groupMoveFloor(at("g", 10), [at("g", 10), at("p", 1), at("q", 4)])).toBe(9);
|
|
111
|
+
expect(groupMoveFloor(at("g", 5), [at("g", 5), at("p", 8)])).toBe(0); // the grabbed clip is leftmost
|
|
104
112
|
});
|
|
105
113
|
|
|
106
|
-
it("
|
|
107
|
-
|
|
108
|
-
// A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
|
|
109
|
-
// formation instead of out-running it.
|
|
110
|
-
expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
|
|
114
|
+
it("forbids any leftward move once a mover sits at 0", () => {
|
|
115
|
+
expect(groupMoveFloor(at("g", 3), [at("g", 3), at("p", 0)])).toBe(3);
|
|
111
116
|
});
|
|
112
117
|
|
|
113
|
-
it("
|
|
114
|
-
|
|
115
|
-
expect(
|
|
118
|
+
it("stops a nested mover at its host composition's start, and a lone clip at its own host", () => {
|
|
119
|
+
const nested = { ...at("p", 3), parentCompositionStart: 2 };
|
|
120
|
+
expect(groupMoveFloor(at("g", 10), [at("g", 10), nested])).toBe(9);
|
|
121
|
+
expect(groupMoveFloor(nested, [])).toBe(2);
|
|
116
122
|
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("resolveGroupMovers (the clips a group drag moves)", () => {
|
|
126
|
+
const elements = [at("g", 5), at("p", 2), at("locked", 0, true), at("other", 1)];
|
|
117
127
|
|
|
118
|
-
it("
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
|
|
128
|
+
it("is every selected clip that can move, never a locked one or an unselected one", () => {
|
|
129
|
+
const movers = resolveGroupMovers(elements, new Set(["g", "p", "locked"]), "g");
|
|
130
|
+
expect(movers?.map((e) => e.id)).toEqual(["g", "p"]);
|
|
122
131
|
});
|
|
123
132
|
|
|
124
|
-
it("
|
|
125
|
-
expect(
|
|
133
|
+
it("is null when the grabbed clip is not part of a multi-selection", () => {
|
|
134
|
+
expect(resolveGroupMovers(elements, new Set(["g"]), "g")).toBeNull();
|
|
135
|
+
expect(resolveGroupMovers(elements, new Set(["p", "other"]), "g")).toBeNull();
|
|
136
|
+
expect(resolveGroupMovers(elements, undefined, "g")).toBeNull();
|
|
126
137
|
});
|
|
127
138
|
});
|