@hyperframes/studio 0.7.76 → 0.7.77
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-DsRdxz7A.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BLICKger.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-Du1RoLiB.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-BdOfFsR8.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -3,26 +3,25 @@ import type { ZoomMode } from "../store/playerStore";
|
|
|
3
3
|
|
|
4
4
|
/* ── Layout constants ──────────────────────────────────────────────── */
|
|
5
5
|
export const GUTTER = 32;
|
|
6
|
+
export const LABEL_COL_W = 232;
|
|
6
7
|
export const TRACK_H = 48;
|
|
8
|
+
export const LANE_H = 28;
|
|
7
9
|
export const RULER_H = 24;
|
|
8
10
|
export const CLIP_Y = 3;
|
|
9
11
|
export const CLIP_HANDLE_W = 18;
|
|
12
|
+
|
|
13
|
+
export function getTimelineLaneTop(laneIndex: number): number {
|
|
14
|
+
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
|
|
15
|
+
}
|
|
10
16
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (`CLIP_Y / TRACK_H`): a clip body fills [CLIP_Y, TRACK_H − CLIP_Y] of its row,
|
|
14
|
-
* so the ONLY region this band covers is the visible empty gutter between two
|
|
15
|
-
* clip bodies (plus the top/bottom breathing pads, handled separately by the
|
|
16
|
-
* rowFloat ≤ 0 / ≥ trackCount extremes). Aiming at a clip body is therefore a
|
|
17
|
-
* move-to-that-lane; only the inter-clip gap arms an insert — see resolveInsertRow.
|
|
18
|
-
* Threaded into resolveInsertRow by the drag preview so the hit band can never
|
|
19
|
-
* drift from the rendered clip geometry.
|
|
17
|
+
* Collapsed-row characterization value for the new-track INSERT band. Runtime
|
|
18
|
+
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
|
|
20
19
|
*/
|
|
21
20
|
export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
|
|
22
21
|
/**
|
|
23
22
|
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
|
|
24
23
|
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
|
|
25
|
-
* offset; a track row's top is always
|
|
24
|
+
* offset; a track row's top is always ruler + top pad + cumulative row heights.
|
|
26
25
|
*
|
|
27
26
|
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
|
|
28
27
|
* (~half a track height) so the first clip isn't jammed under the ruler.
|
|
@@ -43,6 +42,79 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
|
|
|
43
42
|
*/
|
|
44
43
|
export const TRACKS_LEFT_PAD = 48;
|
|
45
44
|
|
|
45
|
+
export interface TimelineTrackHeightClip {
|
|
46
|
+
clipId: string;
|
|
47
|
+
laneCount: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve each track's full height. Without expansion state every row is the
|
|
54
|
+
* legacy TRACK_H; if multiple clips in one track expand, the tallest one owns
|
|
55
|
+
* the shared row height.
|
|
56
|
+
*/
|
|
57
|
+
export function trackHeights(
|
|
58
|
+
tracks: TimelineTrackHeightInput,
|
|
59
|
+
expandedClipIds?: ReadonlySet<string>,
|
|
60
|
+
): number[] {
|
|
61
|
+
return tracks.map((clips) => {
|
|
62
|
+
let laneCount = 0;
|
|
63
|
+
if (expandedClipIds) {
|
|
64
|
+
for (const clip of clips) {
|
|
65
|
+
if (expandedClipIds.has(clip.clipId)) laneCount = Math.max(laneCount, clip.laneCount);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validRowHeight(height: number | undefined): number {
|
|
73
|
+
if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H;
|
|
74
|
+
return height;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Memoized by the rowHeights array identity: a marquee/drag pointer tick calls
|
|
79
|
+
* getTimelineRowTop once per clip, and each call would otherwise rebuild the
|
|
80
|
+
* whole cumulative array (O(clips x rows) allocations per tick). rowHeights is
|
|
81
|
+
* itself memoized upstream (useTimelineTrackLayout), so the identity is stable
|
|
82
|
+
* for the life of a gesture. Callers must treat the result as read-only.
|
|
83
|
+
*/
|
|
84
|
+
const rowOffsetsCache = new WeakMap<readonly number[], number[]>();
|
|
85
|
+
|
|
86
|
+
/** Cumulative top offsets, including the final bottom boundary. */
|
|
87
|
+
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
|
|
88
|
+
const cached = rowOffsetsCache.get(rowHeights);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
const offsets = [0];
|
|
91
|
+
for (const height of rowHeights) {
|
|
92
|
+
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
|
|
93
|
+
}
|
|
94
|
+
rowOffsetsCache.set(rowHeights, offsets);
|
|
95
|
+
return offsets;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number {
|
|
99
|
+
return validRowHeight(rowHeights[row]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
|
|
103
|
+
if (rowHeights.length === 0) return row * TRACK_H;
|
|
104
|
+
const offsets = getTimelineRowOffsets(rowHeights);
|
|
105
|
+
if (row <= 0) return row * getTimelineRowHeight(0, rowHeights);
|
|
106
|
+
if (row >= rowHeights.length) {
|
|
107
|
+
// Deliberately TRACK_H, not the last row's height: rows past the end do not
|
|
108
|
+
// exist yet, and a row created by dropping there starts unexpanded. The
|
|
109
|
+
// pre-first-row branch above uses row 0's concrete height instead because
|
|
110
|
+
// that row DOES exist — the pointer is in the top pad above a real lane.
|
|
111
|
+
return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H;
|
|
112
|
+
}
|
|
113
|
+
const wholeRow = Math.floor(row);
|
|
114
|
+
const fraction = row - wholeRow;
|
|
115
|
+
return (offsets[wholeRow] ?? 0) + fraction * getTimelineRowHeight(wholeRow, rowHeights);
|
|
116
|
+
}
|
|
117
|
+
|
|
46
118
|
/**
|
|
47
119
|
* The y (content-space) of the top edge of track ROW index `row` (0 = first
|
|
48
120
|
* displayed lane). The single source of truth for row→y — the ruler height plus
|
|
@@ -50,17 +122,48 @@ export const TRACKS_LEFT_PAD = 48;
|
|
|
50
122
|
* placeholder/insertion top and every pointer-y→row inversion goes through this
|
|
51
123
|
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
|
|
52
124
|
*/
|
|
53
|
-
export function getTimelineRowTop(row: number): number {
|
|
54
|
-
return RULER_H + TRACKS_TOP_PAD + row
|
|
125
|
+
export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number {
|
|
126
|
+
return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
|
|
55
127
|
}
|
|
56
128
|
|
|
57
129
|
/**
|
|
58
130
|
* Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
|
|
59
|
-
* space y (used for insert-row / drop-lane decisions).
|
|
60
|
-
*
|
|
131
|
+
* space y (used for insert-row / drop-lane decisions). Locates the concrete row
|
|
132
|
+
* from cumulative offsets, then returns its local fractional position.
|
|
61
133
|
*/
|
|
62
|
-
export function getTimelineRowFromY(contentY: number): number {
|
|
63
|
-
|
|
134
|
+
export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number {
|
|
135
|
+
const y = contentY - RULER_H - TRACKS_TOP_PAD;
|
|
136
|
+
if (rowHeights.length === 0) return y / TRACK_H;
|
|
137
|
+
if (y < 0) return y / getTimelineRowHeight(0, rowHeights);
|
|
138
|
+
|
|
139
|
+
const offsets = getTimelineRowOffsets(rowHeights);
|
|
140
|
+
for (let row = 0; row < rowHeights.length; row += 1) {
|
|
141
|
+
const bottom = offsets[row + 1] ?? 0;
|
|
142
|
+
if (y < bottom) {
|
|
143
|
+
const top = offsets[row] ?? 0;
|
|
144
|
+
return row + (y - top) / getTimelineRowHeight(row, rowHeights);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return rowHeights.length + (y - (offsets[rowHeights.length] ?? 0)) / TRACK_H;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getTimelineRowPositionFromY(
|
|
151
|
+
contentY: number,
|
|
152
|
+
rowHeights: readonly number[] = [],
|
|
153
|
+
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
|
|
154
|
+
const rowFloat = getTimelineRowFromY(contentY, rowHeights);
|
|
155
|
+
const row = Math.floor(rowFloat);
|
|
156
|
+
return {
|
|
157
|
+
rowFloat,
|
|
158
|
+
row,
|
|
159
|
+
fraction: rowFloat - row,
|
|
160
|
+
rowHeight: getTimelineRowHeight(row, rowHeights),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Fractional insert band for the concrete row under a pointer. */
|
|
165
|
+
export function getTimelineInsertBoundaryBand(rowHeight: number): number {
|
|
166
|
+
return CLIP_Y / validRowHeight(rowHeight);
|
|
64
167
|
}
|
|
65
168
|
/**
|
|
66
169
|
* While a clip drag is live, the rendered timeline extends this far past the
|
|
@@ -216,12 +319,16 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
|
|
216
319
|
* remaining ruler runs to 1:00.
|
|
217
320
|
* Manual zoom multiplies this base, so the floor only anchors the default.
|
|
218
321
|
*/
|
|
219
|
-
export function getTimelineFitPps(
|
|
322
|
+
export function getTimelineFitPps(
|
|
323
|
+
viewportWidth: number,
|
|
324
|
+
effectiveDuration: number,
|
|
325
|
+
contentOrigin: number,
|
|
326
|
+
): number {
|
|
220
327
|
const safeDuration =
|
|
221
328
|
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
|
222
329
|
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
|
223
|
-
if (!Number.isFinite(viewportWidth) || viewportWidth <=
|
|
224
|
-
return (viewportWidth -
|
|
330
|
+
if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
|
|
331
|
+
return (viewportWidth - contentOrigin - 2) / span;
|
|
225
332
|
}
|
|
226
333
|
|
|
227
334
|
/**
|
|
@@ -234,6 +341,7 @@ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: numb
|
|
|
234
341
|
export function getTimelineDisplayContentWidth(input: {
|
|
235
342
|
trackContentWidth: number;
|
|
236
343
|
viewportWidth: number;
|
|
344
|
+
contentOrigin: number;
|
|
237
345
|
pps: number;
|
|
238
346
|
dragGhostEndPx?: number;
|
|
239
347
|
resizeGhostEndPx?: number;
|
|
@@ -241,7 +349,7 @@ export function getTimelineDisplayContentWidth(input: {
|
|
|
241
349
|
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
|
242
350
|
return Math.max(
|
|
243
351
|
input.trackContentWidth,
|
|
244
|
-
input.viewportWidth -
|
|
352
|
+
input.viewportWidth - input.contentOrigin - 2,
|
|
245
353
|
input.dragGhostEndPx ?? 0,
|
|
246
354
|
input.resizeGhostEndPx ?? 0,
|
|
247
355
|
MIN_TIMELINE_EXTENT_S * safePps,
|
|
@@ -249,6 +357,15 @@ export function getTimelineDisplayContentWidth(input: {
|
|
|
249
357
|
}
|
|
250
358
|
|
|
251
359
|
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
|
|
360
|
+
export function getTimelineContentXFromClient(input: {
|
|
361
|
+
clientX: number;
|
|
362
|
+
rectLeft: number;
|
|
363
|
+
scrollLeft: number;
|
|
364
|
+
contentOrigin: number;
|
|
365
|
+
}): number {
|
|
366
|
+
return input.clientX - input.rectLeft + input.scrollLeft - input.contentOrigin;
|
|
367
|
+
}
|
|
368
|
+
|
|
252
369
|
export function shouldAutoScrollTimeline(
|
|
253
370
|
zoomMode: ZoomMode,
|
|
254
371
|
scrollWidth: number,
|
|
@@ -271,7 +388,7 @@ export function getTimelineScrollLeftForZoomTransition(
|
|
|
271
388
|
export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
272
389
|
pointerX: number;
|
|
273
390
|
currentScrollLeft: number;
|
|
274
|
-
|
|
391
|
+
contentOrigin: number;
|
|
275
392
|
currentPixelsPerSecond: number;
|
|
276
393
|
nextPixelsPerSecond: number;
|
|
277
394
|
duration: number;
|
|
@@ -288,9 +405,17 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
|
288
405
|
) {
|
|
289
406
|
return Math.max(0, input.currentScrollLeft);
|
|
290
407
|
}
|
|
291
|
-
const timelineX = Math.max(
|
|
408
|
+
const timelineX = Math.max(
|
|
409
|
+
0,
|
|
410
|
+
getTimelineContentXFromClient({
|
|
411
|
+
clientX: input.pointerX,
|
|
412
|
+
rectLeft: 0,
|
|
413
|
+
scrollLeft: input.currentScrollLeft,
|
|
414
|
+
contentOrigin: input.contentOrigin,
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
292
417
|
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
|
293
|
-
return Math.max(0, input.
|
|
418
|
+
return Math.max(0, input.contentOrigin + timeAtPointer * nextPps - input.pointerX);
|
|
294
419
|
}
|
|
295
420
|
|
|
296
421
|
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
|
@@ -299,33 +424,62 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
|
299
424
|
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
|
|
300
425
|
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
|
|
301
426
|
* shifted LEFT by half this width for the line's center to land exactly on
|
|
302
|
-
* `
|
|
427
|
+
* `contentOrigin + time * pps` — see {@link getTimelinePlayheadLeft}.
|
|
303
428
|
*/
|
|
304
429
|
export const PLAYHEAD_HEAD_W = 9;
|
|
305
430
|
|
|
306
431
|
/**
|
|
307
432
|
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
|
|
308
|
-
* sits exactly on `
|
|
433
|
+
* sits exactly on `contentOrigin + time * pps` (the same x the ruler ticks center
|
|
309
434
|
* on) at every zoom level. Without the half-head offset the line sat
|
|
310
435
|
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
|
|
311
436
|
*/
|
|
312
|
-
export function getTimelinePlayheadLeft(
|
|
437
|
+
export function getTimelinePlayheadLeft(
|
|
438
|
+
time: number,
|
|
439
|
+
pixelsPerSecond: number,
|
|
440
|
+
contentOrigin: number,
|
|
441
|
+
): number {
|
|
313
442
|
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
|
314
|
-
return
|
|
443
|
+
return contentOrigin - PLAYHEAD_HEAD_W / 2;
|
|
315
444
|
}
|
|
316
|
-
return (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
445
|
+
return contentOrigin + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport
|
|
450
|
+
* clientX. Clamped to [0, duration], NOT rejected — the scrub surface starts
|
|
451
|
+
* `contentOrigin` px right of the viewport edge, so any pointer left of t=0
|
|
452
|
+
* maps to a negative offset. Callers used to bail on that instead of
|
|
453
|
+
* clamping, which made the last 80px of the drag to zero silently do nothing:
|
|
454
|
+
* the playhead stuck wherever the last in-range sample landed, and only a very
|
|
455
|
+
* slow drag that happened to sample inside the sliver before the origin reached
|
|
456
|
+
* 0. Every scrub path shares this so they cannot diverge on the edge again.
|
|
457
|
+
*/
|
|
458
|
+
export function getTimelineScrubTime(input: {
|
|
459
|
+
clientX: number;
|
|
460
|
+
viewportLeft: number;
|
|
461
|
+
scrollLeft: number;
|
|
462
|
+
contentOrigin: number;
|
|
463
|
+
pixelsPerSecond: number;
|
|
464
|
+
duration: number;
|
|
465
|
+
}): number {
|
|
466
|
+
const { clientX, viewportLeft, scrollLeft, contentOrigin, pixelsPerSecond, duration } = input;
|
|
467
|
+
if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0;
|
|
468
|
+
const x = getTimelineContentXFromClient({
|
|
469
|
+
clientX,
|
|
470
|
+
rectLeft: viewportLeft,
|
|
471
|
+
scrollLeft,
|
|
472
|
+
contentOrigin,
|
|
473
|
+
});
|
|
474
|
+
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
|
|
322
475
|
}
|
|
323
476
|
|
|
324
|
-
export function getTimelineCanvasHeight(
|
|
477
|
+
export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
|
|
325
478
|
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
|
|
326
479
|
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
|
|
327
480
|
// below the last lane is real scrollable surface, not a hidden buffer.
|
|
328
|
-
|
|
481
|
+
const rowsHeight = getTimelineRowOffsets(rowHeights).at(-1) ?? 0;
|
|
482
|
+
return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
|
|
329
483
|
}
|
|
330
484
|
|
|
331
485
|
/* ── UI helpers ───────────────────────────────────────────────────── */
|
|
@@ -381,16 +535,22 @@ export function resolveTimelineAssetDrop(
|
|
|
381
535
|
rectTop: number;
|
|
382
536
|
scrollLeft: number;
|
|
383
537
|
scrollTop: number;
|
|
538
|
+
contentOrigin: number;
|
|
384
539
|
pixelsPerSecond: number;
|
|
385
540
|
duration: number;
|
|
386
541
|
clampStartToDuration?: boolean;
|
|
387
|
-
|
|
542
|
+
rowHeights?: readonly number[];
|
|
388
543
|
trackOrder: number[];
|
|
389
544
|
},
|
|
390
545
|
clientX: number,
|
|
391
546
|
clientY: number,
|
|
392
547
|
): { start: number; track: number } {
|
|
393
|
-
const x =
|
|
548
|
+
const x = getTimelineContentXFromClient({
|
|
549
|
+
clientX,
|
|
550
|
+
rectLeft: input.rectLeft,
|
|
551
|
+
scrollLeft: input.scrollLeft,
|
|
552
|
+
contentOrigin: input.contentOrigin,
|
|
553
|
+
});
|
|
394
554
|
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
395
555
|
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
|
396
556
|
const start = Math.max(
|
|
@@ -400,7 +560,7 @@ export function resolveTimelineAssetDrop(
|
|
|
400
560
|
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
|
401
561
|
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
|
402
562
|
// past the last lane (getDefaultDroppedTrack then appends a new track).
|
|
403
|
-
const rowIndex = Math.floor(getTimelineRowFromY(contentY));
|
|
563
|
+
const rowIndex = Math.floor(getTimelineRowFromY(contentY, input.rowHeights));
|
|
404
564
|
return {
|
|
405
565
|
start,
|
|
406
566
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./timelineMarquee";
|
|
10
10
|
import {
|
|
11
11
|
GUTTER,
|
|
12
|
+
LANE_H,
|
|
12
13
|
TRACK_H,
|
|
13
14
|
RULER_H,
|
|
14
15
|
CLIP_Y,
|
|
@@ -16,7 +17,9 @@ import {
|
|
|
16
17
|
getTimelineRowTop,
|
|
17
18
|
} from "./timelineLayout";
|
|
18
19
|
|
|
19
|
-
// Canvas-space time origin
|
|
20
|
+
// Canvas-space time origin used by the breathing-pad (default) test cases: right
|
|
21
|
+
// edge of the sticky gutter + the left pad. Other cases pass GUTTER or LABEL_COL_W
|
|
22
|
+
// directly as contentOrigin to test the plain/keyframe-label-column origins.
|
|
20
23
|
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
|
|
21
24
|
|
|
22
25
|
describe("isTimelineRulerPress", () => {
|
|
@@ -94,9 +97,9 @@ describe("getTimelineClipRect", () => {
|
|
|
94
97
|
const trackOrder = [0, 2, 5];
|
|
95
98
|
|
|
96
99
|
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
|
|
97
|
-
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
|
|
100
|
+
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100, GUTTER);
|
|
98
101
|
expect(rect).toEqual({
|
|
99
|
-
left:
|
|
102
|
+
left: GUTTER + 200,
|
|
100
103
|
top: getTimelineRowTop(1) + CLIP_Y,
|
|
101
104
|
width: 300,
|
|
102
105
|
height: TRACK_H - CLIP_Y * 2,
|
|
@@ -104,25 +107,55 @@ describe("getTimelineClipRect", () => {
|
|
|
104
107
|
});
|
|
105
108
|
|
|
106
109
|
it("places the first visible track below the ruler + top breathing pad", () => {
|
|
107
|
-
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
|
|
110
|
+
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50, GUTTER);
|
|
108
111
|
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
|
|
109
|
-
expect(rect?.left).toBe(
|
|
112
|
+
expect(rect?.left).toBe(GUTTER);
|
|
110
113
|
});
|
|
111
114
|
|
|
112
115
|
it("uses the row index in trackOrder, not the raw track number", () => {
|
|
113
|
-
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50);
|
|
116
|
+
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50, GUTTER);
|
|
114
117
|
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
|
|
115
118
|
});
|
|
116
119
|
|
|
120
|
+
it("uses cumulative tops and the resolved height for an expanded row", () => {
|
|
121
|
+
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
|
122
|
+
const rect = getTimelineClipRect(
|
|
123
|
+
{ start: 0, duration: 1, track: 0 },
|
|
124
|
+
trackOrder,
|
|
125
|
+
50,
|
|
126
|
+
GUTTER,
|
|
127
|
+
rowHeights,
|
|
128
|
+
);
|
|
129
|
+
expect(rect).toMatchObject({
|
|
130
|
+
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
|
|
131
|
+
height: rowHeights[0] - CLIP_Y * 2,
|
|
132
|
+
});
|
|
133
|
+
expect(
|
|
134
|
+
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights)
|
|
135
|
+
?.top,
|
|
136
|
+
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
|
|
137
|
+
});
|
|
138
|
+
|
|
117
139
|
it("enforces the 4px minimum rendered width", () => {
|
|
118
|
-
const rect = getTimelineClipRect(
|
|
140
|
+
const rect = getTimelineClipRect(
|
|
141
|
+
{ start: 0, duration: 0.01, track: 0 },
|
|
142
|
+
trackOrder,
|
|
143
|
+
10,
|
|
144
|
+
GUTTER,
|
|
145
|
+
);
|
|
119
146
|
expect(rect?.width).toBe(4);
|
|
120
147
|
});
|
|
121
148
|
|
|
122
149
|
it("returns null for a track that is not displayed or an invalid pps", () => {
|
|
123
|
-
expect(
|
|
124
|
-
|
|
125
|
-
|
|
150
|
+
expect(
|
|
151
|
+
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER),
|
|
152
|
+
).toBeNull();
|
|
153
|
+
expect(
|
|
154
|
+
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER),
|
|
155
|
+
).toBeNull();
|
|
156
|
+
expect(
|
|
157
|
+
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER),
|
|
158
|
+
).toBeNull();
|
|
126
159
|
});
|
|
127
160
|
});
|
|
128
161
|
|
|
@@ -140,29 +173,48 @@ describe("computeMarqueeSelection", () => {
|
|
|
140
173
|
|
|
141
174
|
it("selects only the clips the marquee rect intersects", () => {
|
|
142
175
|
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
|
|
143
|
-
const { ids, primaryId } = computeMarqueeSelection({
|
|
176
|
+
const { ids, primaryId } = computeMarqueeSelection({
|
|
177
|
+
clips,
|
|
178
|
+
trackOrder,
|
|
179
|
+
pps,
|
|
180
|
+
contentOrigin: ORIGIN,
|
|
181
|
+
marquee,
|
|
182
|
+
});
|
|
144
183
|
expect(ids).toEqual(new Set(["a"]));
|
|
145
184
|
expect(primaryId).toBe("a");
|
|
146
185
|
});
|
|
147
186
|
|
|
148
187
|
it("selects across tracks when the rect spans multiple rows", () => {
|
|
149
188
|
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
|
150
|
-
const { ids } = computeMarqueeSelection({
|
|
189
|
+
const { ids } = computeMarqueeSelection({
|
|
190
|
+
clips,
|
|
191
|
+
trackOrder,
|
|
192
|
+
pps,
|
|
193
|
+
contentOrigin: ORIGIN,
|
|
194
|
+
marquee,
|
|
195
|
+
});
|
|
151
196
|
expect(ids).toEqual(new Set(["a", "c"]));
|
|
152
197
|
});
|
|
153
198
|
|
|
154
199
|
it("excludes clips outside the rect horizontally", () => {
|
|
155
200
|
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
|
156
|
-
const { ids } = computeMarqueeSelection({
|
|
201
|
+
const { ids } = computeMarqueeSelection({
|
|
202
|
+
clips,
|
|
203
|
+
trackOrder,
|
|
204
|
+
pps,
|
|
205
|
+
contentOrigin: ORIGIN,
|
|
206
|
+
marquee,
|
|
207
|
+
});
|
|
157
208
|
expect(ids).toEqual(new Set());
|
|
158
209
|
});
|
|
159
210
|
|
|
160
211
|
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
|
|
161
|
-
const marquee = { left:
|
|
212
|
+
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
|
|
162
213
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
163
214
|
clips,
|
|
164
215
|
trackOrder,
|
|
165
216
|
pps,
|
|
217
|
+
contentOrigin: GUTTER,
|
|
166
218
|
marquee,
|
|
167
219
|
baseSelection: ["b"],
|
|
168
220
|
});
|
|
@@ -171,11 +223,12 @@ describe("computeMarqueeSelection", () => {
|
|
|
171
223
|
});
|
|
172
224
|
|
|
173
225
|
it("unions additive base selection with new hits; primary comes from the marquee", () => {
|
|
174
|
-
const marquee = { left:
|
|
226
|
+
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
|
|
175
227
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
176
228
|
clips,
|
|
177
229
|
trackOrder,
|
|
178
230
|
pps,
|
|
231
|
+
contentOrigin: GUTTER,
|
|
179
232
|
marquee,
|
|
180
233
|
baseSelection: ["b"],
|
|
181
234
|
});
|
|
@@ -186,12 +239,13 @@ describe("computeMarqueeSelection", () => {
|
|
|
186
239
|
it("shrinking the rect live drops clips it no longer covers", () => {
|
|
187
240
|
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
|
|
188
241
|
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
|
|
189
|
-
expect(
|
|
190
|
-
|
|
191
|
-
);
|
|
192
|
-
expect(
|
|
193
|
-
|
|
194
|
-
|
|
242
|
+
expect(
|
|
243
|
+
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids,
|
|
244
|
+
).toEqual(new Set(["a", "b"]));
|
|
245
|
+
expect(
|
|
246
|
+
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow })
|
|
247
|
+
.ids,
|
|
248
|
+
).toEqual(new Set(["a"]));
|
|
195
249
|
});
|
|
196
250
|
|
|
197
251
|
it("ignores clips on hidden/undisplayed tracks", () => {
|
|
@@ -200,6 +254,7 @@ describe("computeMarqueeSelection", () => {
|
|
|
200
254
|
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
|
|
201
255
|
trackOrder,
|
|
202
256
|
pps,
|
|
257
|
+
contentOrigin: GUTTER,
|
|
203
258
|
marquee,
|
|
204
259
|
});
|
|
205
260
|
expect(ids).toEqual(new Set());
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
GUTTER,
|
|
3
|
-
TRACK_H,
|
|
4
|
-
RULER_H,
|
|
5
|
-
CLIP_Y,
|
|
6
|
-
TRACKS_LEFT_PAD,
|
|
7
|
-
getTimelineRowTop,
|
|
8
|
-
} from "./timelineLayout";
|
|
1
|
+
import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout";
|
|
9
2
|
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
|
10
3
|
|
|
11
4
|
/** Pointer must travel at least this far (either axis) before a pointerdown on
|
|
@@ -68,22 +61,24 @@ export function getMarqueeRect(
|
|
|
68
61
|
|
|
69
62
|
/**
|
|
70
63
|
* A clip's rendered rect in canvas/content coordinates (the same space the
|
|
71
|
-
* marquee rect lives in): x from
|
|
72
|
-
* index within the visible track order (
|
|
64
|
+
* marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row
|
|
65
|
+
* index within the visible track order (cumulative row top + CLIP_Y).
|
|
73
66
|
* Returns null when the clip's track is not currently displayed.
|
|
74
67
|
*/
|
|
75
68
|
export function getTimelineClipRect(
|
|
76
69
|
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
|
77
70
|
trackOrder: number[],
|
|
78
71
|
pps: number,
|
|
72
|
+
contentOrigin: number,
|
|
73
|
+
rowHeights: readonly number[] = [],
|
|
79
74
|
): Rect | null {
|
|
80
75
|
const row = trackOrder.indexOf(clip.track);
|
|
81
76
|
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
|
82
77
|
return {
|
|
83
|
-
left:
|
|
84
|
-
top: getTimelineRowTop(row) + CLIP_Y,
|
|
78
|
+
left: contentOrigin + clip.start * pps,
|
|
79
|
+
top: getTimelineRowTop(row, rowHeights) + CLIP_Y,
|
|
85
80
|
width: Math.max(clip.duration * pps, MIN_CLIP_W),
|
|
86
|
-
height:
|
|
81
|
+
height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2,
|
|
87
82
|
};
|
|
88
83
|
}
|
|
89
84
|
|
|
@@ -103,13 +98,21 @@ export function computeMarqueeSelection(input: {
|
|
|
103
98
|
clips: MarqueeClipInput[];
|
|
104
99
|
trackOrder: number[];
|
|
105
100
|
pps: number;
|
|
101
|
+
contentOrigin: number;
|
|
106
102
|
marquee: Rect;
|
|
107
103
|
baseSelection?: Iterable<string>;
|
|
104
|
+
rowHeights?: readonly number[];
|
|
108
105
|
}): MarqueeSelectionResult {
|
|
109
106
|
const ids = new Set<string>(input.baseSelection ?? []);
|
|
110
107
|
let primaryId: string | null = null;
|
|
111
108
|
for (const clip of input.clips) {
|
|
112
|
-
const rect = getTimelineClipRect(
|
|
109
|
+
const rect = getTimelineClipRect(
|
|
110
|
+
clip,
|
|
111
|
+
input.trackOrder,
|
|
112
|
+
input.pps,
|
|
113
|
+
input.contentOrigin,
|
|
114
|
+
input.rowHeights,
|
|
115
|
+
);
|
|
113
116
|
if (rect && rectsOverlap(rect, input.marquee)) {
|
|
114
117
|
ids.add(clip.id);
|
|
115
118
|
primaryId = clip.id;
|