@hyperframes/studio 0.8.111 → 0.8.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DvJeHBHi.js → hyperframes-player-DzJbtQVO.js} +1 -1
- package/dist/assets/{index-SFktf7O1.js → index-DRhF3v4Z.js} +194 -194
- package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
- package/dist/assets/{index--G6c4Q_x.js → index-DgwDQK5G.js} +1 -1
- package/dist/assets/{index-Bt4vDkfw.js → index-n5FUn1xv.js} +1 -1
- package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
- package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
- package/dist/chunk-K47E2ZJA.js.map +1 -0
- package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
- package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
- package/dist/index.d.ts +80 -46
- package/dist/index.html +2 -2
- package/dist/index.js +7294 -6844
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
- package/src/hooks/timelineEditingHelpers.ts +7 -0
- package/src/hooks/timelineMoveAdapter.test.ts +12 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -4
- package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +7 -0
- package/src/main.tsx +4 -0
- package/src/player/components/ClipBadges.tsx +5 -5
- package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
- package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
- package/src/player/components/TimelineCanvas.tsx +6 -2
- package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
- package/src/player/components/TimelineClip.test.tsx +7 -7
- package/src/player/components/TimelineClip.tsx +23 -7
- package/src/player/components/TimelineClipFades.tsx +41 -43
- package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
- package/src/player/components/TimelineGestureOverlay.tsx +59 -21
- package/src/player/components/TimelineLanes.tsx +18 -16
- package/src/player/components/timelineCallbacks.ts +6 -4
- package/src/player/components/timelineClipChildren.tsx +7 -13
- package/src/player/components/timelineClipDragCommit.test.ts +51 -0
- package/src/player/components/timelineClipDragCommit.ts +63 -21
- package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
- package/src/player/components/timelineClipDragPreview.test.ts +47 -31
- package/src/player/components/timelineClipDragPreview.ts +81 -17
- package/src/player/components/timelineClipDragTypes.ts +3 -3
- package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
- package/src/player/components/timelineClipFadeGeometry.ts +74 -0
- package/src/player/components/timelineCollision.ts +51 -11
- package/src/player/components/timelineDragLanding.ts +20 -6
- package/src/player/components/timelineLaneProps.ts +3 -8
- package/src/player/components/timelineMotionStyles.test.ts +6 -13
- package/src/player/components/timelineMountFixtures.tsx +65 -0
- package/src/player/components/timelineOptimisticRevision.ts +15 -4
- package/src/player/components/timelineTheme.ts +2 -0
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
- package/src/player/components/timelineTrackInsertLayout.ts +71 -0
- package/src/player/components/timelineZMirror.ts +5 -11
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
- package/src/player/components/useTimelineClipDrag.ts +116 -12
- package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
- package/src/player/components/useTimelineKeyboardActor.ts +86 -7
- package/src/player/components/useTimelineProviderState.tsx +3 -1
- package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
- package/src/player/components/useTimelineRowVirtualization.ts +3 -1
- package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
- package/src/player/components/useTimelineTrackDerivations.ts +32 -18
- package/src/player/components/useTimelineTrackLayout.ts +43 -14
- package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/automationStoreSync.test.ts +15 -0
- package/src/player/lib/automationStoreSync.ts +13 -5
- package/src/player/lib/storedVolume.ts +7 -0
- package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
- package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
- package/src/player/lib/thumbnailScheduler.ts +20 -2
- package/src/player/lib/timelineElementHelpers.ts +2 -8
- package/src/styles/components.css +71 -19
- package/src/styles/theme.css +4 -1
- package/dist/chunk-6LUNLTHR.js.map +0 -1
- /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
- /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
- /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
|
@@ -6,7 +6,12 @@ import {
|
|
|
6
6
|
} from "./timelineGroupEditing";
|
|
7
7
|
import type { TimelineElement } from "../store/playerStore";
|
|
8
8
|
import { clampToHostStart } from "../store/timelineElement";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
CLIP_Y,
|
|
11
|
+
TRACK_H,
|
|
12
|
+
getTimelineRowGeometry,
|
|
13
|
+
type TimelineRowGeometry,
|
|
14
|
+
} from "./timelineLayout";
|
|
10
15
|
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
11
16
|
import {
|
|
12
17
|
TIMELINE_SNAP_PX,
|
|
@@ -38,6 +43,9 @@ export interface DragPreviewContext {
|
|
|
38
43
|
duration: number;
|
|
39
44
|
trackOrder: number[];
|
|
40
45
|
rowHeights?: readonly number[];
|
|
46
|
+
rowGeometry?: TimelineRowGeometry;
|
|
47
|
+
allowedInsertRows?: ReadonlySet<number>;
|
|
48
|
+
groupTracks?: ReadonlyMap<number, readonly number[]>;
|
|
41
49
|
elements: TimelineElement[];
|
|
42
50
|
selectedKeys: ReadonlySet<string>;
|
|
43
51
|
buildSnapTargets: BuildSnapTargets;
|
|
@@ -50,6 +58,33 @@ export interface DragPreviewContext {
|
|
|
50
58
|
audioTracks?: ReadonlySet<number>;
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
export function createKeyboardClipDrag(
|
|
62
|
+
element: TimelineElement,
|
|
63
|
+
insertRow: number,
|
|
64
|
+
scrollLeft: number,
|
|
65
|
+
scrollTop: number,
|
|
66
|
+
): DraggedClipState {
|
|
67
|
+
return {
|
|
68
|
+
pointerId: null,
|
|
69
|
+
element,
|
|
70
|
+
insertRow,
|
|
71
|
+
started: true,
|
|
72
|
+
previewStart: element.start,
|
|
73
|
+
previewTrack: element.track,
|
|
74
|
+
desiredTrack: element.track,
|
|
75
|
+
originClientX: 0,
|
|
76
|
+
originClientY: 0,
|
|
77
|
+
originScrollLeft: scrollLeft,
|
|
78
|
+
originScrollTop: scrollTop,
|
|
79
|
+
pointerClientX: 0,
|
|
80
|
+
pointerClientY: 0,
|
|
81
|
+
pointerOffsetX: 0,
|
|
82
|
+
pointerOffsetY: 0,
|
|
83
|
+
snapTime: null,
|
|
84
|
+
snapType: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/** Content-space position for the stable viewport drag actor. */
|
|
54
89
|
export function getTimelineDragOverlayPosition(
|
|
55
90
|
drag: DraggedClipState,
|
|
@@ -73,22 +108,50 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
|
|
|
73
108
|
return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
|
|
74
109
|
}
|
|
75
110
|
|
|
111
|
+
const INSERT_SEAM_PX = CLIP_Y;
|
|
112
|
+
|
|
113
|
+
function physicalInsertRow(
|
|
114
|
+
rowFloat: number,
|
|
115
|
+
y: number,
|
|
116
|
+
ctx: Pick<DragPreviewContext, "trackOrder" | "allowedInsertRows">,
|
|
117
|
+
geometry: TimelineRowGeometry,
|
|
118
|
+
) {
|
|
119
|
+
const boundary = Math.round(rowFloat);
|
|
120
|
+
const nearSeam = Math.abs(y - geometry.getRowTop(boundary)) <= INSERT_SEAM_PX;
|
|
121
|
+
const row = nearSeam
|
|
122
|
+
? Math.max(0, Math.min(ctx.trackOrder.length, boundary))
|
|
123
|
+
: resolveInsertRow(rowFloat, ctx.trackOrder.length);
|
|
124
|
+
if (row !== null && ctx.allowedInsertRows && !ctx.allowedInsertRows.has(row)) return null;
|
|
125
|
+
return row;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function dragRowAim(
|
|
129
|
+
drag: DraggedClipState,
|
|
130
|
+
clientY: number,
|
|
131
|
+
ctx: DragPreviewContext,
|
|
132
|
+
geometry: TimelineRowGeometry,
|
|
133
|
+
) {
|
|
134
|
+
let y = clientY - (ctx.scroll?.getBoundingClientRect().top ?? 0) + (ctx.scroll?.scrollTop ?? 0);
|
|
135
|
+
if (drag.started && drag.insertRow !== null) {
|
|
136
|
+
const top = geometry.getRowTop(drag.insertRow);
|
|
137
|
+
if (y >= top - INSERT_SEAM_PX && y <= top + TRACK_H + INSERT_SEAM_PX)
|
|
138
|
+
return { rowFloat: drag.insertRow, insertRow: drag.insertRow };
|
|
139
|
+
if (y > top + TRACK_H) y -= TRACK_H;
|
|
140
|
+
}
|
|
141
|
+
const rowFloat = geometry.getRowFromY(y);
|
|
142
|
+
return { rowFloat, insertRow: physicalInsertRow(rowFloat, y, ctx, geometry) };
|
|
143
|
+
}
|
|
144
|
+
|
|
76
145
|
/** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
|
|
77
146
|
function resolveDropPlacement(
|
|
78
147
|
drag: DraggedClipState,
|
|
79
|
-
|
|
148
|
+
aim: ReturnType<typeof dragRowAim>,
|
|
80
149
|
previewStart: number,
|
|
81
150
|
desiredTrack: number,
|
|
82
151
|
ctx: DragPreviewContext,
|
|
83
152
|
group: GroupDrag,
|
|
84
153
|
): { track: number; insertRow: number | null; start: number } {
|
|
85
|
-
const {
|
|
86
|
-
const rowFloat = scroll
|
|
87
|
-
? getTimelineRowFromY(
|
|
88
|
-
clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
|
|
89
|
-
rowHeights,
|
|
90
|
-
)
|
|
91
|
-
: 0;
|
|
154
|
+
const { trackOrder, elements } = ctx;
|
|
92
155
|
const dragKey = drag.element.key ?? drag.element.id;
|
|
93
156
|
const audioTracks =
|
|
94
157
|
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
|
|
@@ -97,7 +160,9 @@ function resolveDropPlacement(
|
|
|
97
160
|
audioTracks,
|
|
98
161
|
elements: group.obstacles,
|
|
99
162
|
desiredTrack,
|
|
100
|
-
deliberateInsertRow:
|
|
163
|
+
deliberateInsertRow: aim.insertRow,
|
|
164
|
+
allowedInsertRows: ctx.allowedInsertRows,
|
|
165
|
+
groupTracks: ctx.groupTracks,
|
|
101
166
|
start: previewStart,
|
|
102
167
|
duration: drag.element.duration,
|
|
103
168
|
dragKey,
|
|
@@ -144,13 +209,12 @@ export function computeDragPreview(
|
|
|
144
209
|
): DraggedClipState {
|
|
145
210
|
const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
|
|
146
211
|
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
|
|
147
|
-
const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
|
|
148
212
|
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
153
|
-
const currentRow =
|
|
213
|
+
const geometry =
|
|
214
|
+
ctx.rowGeometry ?? getTimelineRowGeometry(ctx.rowHeights ?? ctx.trackOrder.map(() => TRACK_H));
|
|
215
|
+
const originRow = geometry.getRowFromY(drag.originClientY - scrollRectTop + drag.originScrollTop);
|
|
216
|
+
const aim = dragRowAim(drag, clientY, ctx, geometry);
|
|
217
|
+
const currentRow = aim.rowFloat;
|
|
154
218
|
// resolveTimelineMove's vertical axis is row indices, which is why the pointer
|
|
155
219
|
// and scroll pixels are folded into originRow/currentRow above.
|
|
156
220
|
const nextMove = resolveTimelineMove(
|
|
@@ -196,7 +260,7 @@ export function computeDragPreview(
|
|
|
196
260
|
const origin = drag.element.start;
|
|
197
261
|
const floored = Math.max(snap.start, group.floor);
|
|
198
262
|
const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
|
|
199
|
-
const placement = resolveDropPlacement(drag,
|
|
263
|
+
const placement = resolveDropPlacement(drag, aim, previewStart, nextMove.track, ctx, group);
|
|
200
264
|
const placedShift = placement.start - origin;
|
|
201
265
|
if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
|
|
202
266
|
return { ...drag, started: true };
|
|
@@ -4,8 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
|
4
4
|
|
|
5
5
|
/* ── Shared clip-drag state types ───────────────────────────────── */
|
|
6
6
|
export interface DraggedClipState {
|
|
7
|
-
/** Pointer captured by the
|
|
8
|
-
pointerId: number;
|
|
7
|
+
/** Pointer captured by the viewport coordinator; null for a keyboard pickup. */
|
|
8
|
+
pointerId: number | null;
|
|
9
9
|
element: TimelineElement;
|
|
10
10
|
originClientX: number;
|
|
11
11
|
originClientY: number;
|
|
@@ -26,7 +26,7 @@ export interface DraggedClipState {
|
|
|
26
26
|
/**
|
|
27
27
|
* When non-null, the drop inserts a NEW track at this visual row boundary
|
|
28
28
|
* (0 = above the top lane, trackOrder.length = below the bottom) instead of
|
|
29
|
-
* landing on previewTrack. Drives the
|
|
29
|
+
* landing on previewTrack. Drives the opened preview lane.
|
|
30
30
|
*/
|
|
31
31
|
insertRow: number | null;
|
|
32
32
|
/** Snap target the clip will land on, for the guide highlight. */
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fadeHandleBoxes } from "./timelineClipFadeGeometry";
|
|
3
|
+
|
|
4
|
+
const flatClip = (width: number) => ({ width, height: 30, radius: 0, toolsLeft: null });
|
|
5
|
+
|
|
6
|
+
describe("fadeHandleBoxes", () => {
|
|
7
|
+
it("centres each handle on its fade's end", () => {
|
|
8
|
+
const boxes = fadeHandleBoxes({
|
|
9
|
+
widthPx: 1000,
|
|
10
|
+
inPx: 100,
|
|
11
|
+
outPx: 200,
|
|
12
|
+
clipBox: flatClip(1000),
|
|
13
|
+
drawn: { in: true, out: true },
|
|
14
|
+
});
|
|
15
|
+
expect(boxes.in).toEqual({ left: 88, width: 24, height: 24, tabLeft: 12, top: -7 });
|
|
16
|
+
expect(boxes.out).toEqual({ left: 788, width: 24, height: 24, tabLeft: 12, top: -7 });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("splits two overlapping handles at the midpoint between their tabs", () => {
|
|
20
|
+
const input = { widthPx: 100, inPx: 45, outPx: 45, clipBox: flatClip(100) };
|
|
21
|
+
const boxes = fadeHandleBoxes({ ...input, drawn: { in: true, out: true } });
|
|
22
|
+
expect(boxes.in).toMatchObject({ left: 26, width: 24, tabLeft: 19 });
|
|
23
|
+
expect(boxes.out).toMatchObject({ left: 50, width: 24, tabLeft: 5 });
|
|
24
|
+
|
|
25
|
+
// With its twin hidden, a handle keeps its full centred box.
|
|
26
|
+
const alone = fadeHandleBoxes({ ...input, drawn: { in: true, out: false } });
|
|
27
|
+
expect(alone.in).toMatchObject({ left: 33, width: 24 });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { CLIP_TRIM_HIT_PX } from "./timelineTheme";
|
|
2
|
+
|
|
3
|
+
export type FadeEdge = "in" | "out";
|
|
4
|
+
|
|
5
|
+
export const FADE_TAB_WIDTH = 4;
|
|
6
|
+
const FADE_HANDLE_HIT = 24;
|
|
7
|
+
export const FADE_TAB_CENTER_IN_HIT = 8;
|
|
8
|
+
const TAB_INSET = 7;
|
|
9
|
+
|
|
10
|
+
/** The clip as drawn; `toolsLeft` is where its FX badge starts, null when it has none. */
|
|
11
|
+
export interface FadeHandleClipBox {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
radius: number;
|
|
15
|
+
toolsLeft: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A fade handle's hit box in px from the clip's top left; `tabLeft` is its tab's centre within it. */
|
|
19
|
+
export interface FadeHandleBox {
|
|
20
|
+
left: number;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
tabLeft: number;
|
|
24
|
+
top: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function topEdgeY(x: number, widthPx: number, heightPx: number, radiusPx: number): number {
|
|
28
|
+
const r = Math.min(radiusPx, widthPx / 2, heightPx / 2);
|
|
29
|
+
const d = x < r ? r - x : x > widthPx - r ? x - (widthPx - r) : 0;
|
|
30
|
+
return d > 0 ? r - Math.sqrt(Math.max(0, r * r - d * d)) : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where the fade handles sit on a clip `widthPx` wide: each centred on its fade's end, inside the
|
|
35
|
+
* clip and left of its tools; two drawn handles that would overlap split at the midpoint.
|
|
36
|
+
*/
|
|
37
|
+
export function fadeHandleBoxes(input: {
|
|
38
|
+
widthPx: number;
|
|
39
|
+
inPx: number;
|
|
40
|
+
outPx: number;
|
|
41
|
+
clipBox: FadeHandleClipBox;
|
|
42
|
+
drawn: Record<FadeEdge, boolean>;
|
|
43
|
+
}): Record<FadeEdge, FadeHandleBox> {
|
|
44
|
+
const { widthPx, inPx, outPx, clipBox, drawn } = input;
|
|
45
|
+
const clipWidth = clipBox.toolsLeft === null ? widthPx : clipBox.width;
|
|
46
|
+
const edgeInset = clipBox.toolsLeft === null ? 0 : CLIP_TRIM_HIT_PX;
|
|
47
|
+
// Reserve FX horizontally so fades keep their top-edge lane above compact keyframe centres.
|
|
48
|
+
const rightEdge =
|
|
49
|
+
clipBox.toolsLeft === null ? clipWidth : Math.min(clipWidth - edgeInset, clipBox.toolsLeft);
|
|
50
|
+
const hitWidth = Math.min(FADE_HANDLE_HIT, (rightEdge - edgeInset) / 2);
|
|
51
|
+
const tabX = (edge: FadeEdge) => {
|
|
52
|
+
const knee = edge === "in" ? inPx : clipWidth - outPx;
|
|
53
|
+
const tabInset = edgeInset === 0 ? TAB_INSET : FADE_TAB_WIDTH;
|
|
54
|
+
const inset = Math.min(tabInset, (rightEdge - edgeInset) / 2);
|
|
55
|
+
return Math.min(rightEdge - inset, Math.max(edgeInset + inset, knee));
|
|
56
|
+
};
|
|
57
|
+
const boxLeft = (x: number) =>
|
|
58
|
+
Math.min(rightEdge - hitWidth, Math.max(edgeInset, x - hitWidth / 2));
|
|
59
|
+
const [inX, outX] = [tabX("in"), tabX("out")];
|
|
60
|
+
const mid = (inX + outX) / 2;
|
|
61
|
+
const overlap = drawn.in && drawn.out && boxLeft(inX) + hitWidth > boxLeft(outX);
|
|
62
|
+
const box = (edge: FadeEdge): FadeHandleBox => {
|
|
63
|
+
const x = edge === "in" ? inX : outX;
|
|
64
|
+
const [left, right] = !overlap
|
|
65
|
+
? [boxLeft(x), boxLeft(x) + hitWidth]
|
|
66
|
+
: edge === "in"
|
|
67
|
+
? [Math.max(edgeInset, mid - hitWidth), mid]
|
|
68
|
+
: [mid, Math.min(rightEdge, mid + hitWidth)];
|
|
69
|
+
const edgeY = topEdgeY(x, clipWidth, clipBox.height, clipBox.radius);
|
|
70
|
+
const top = edgeY + 1 - FADE_TAB_CENTER_IN_HIT;
|
|
71
|
+
return { left, width: right - left, height: FADE_HANDLE_HIT, tabLeft: x - left, top };
|
|
72
|
+
};
|
|
73
|
+
return { in: box("in"), out: box("out") };
|
|
74
|
+
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
|
|
3
|
+
/** Audio member tracks mark the audio zone, including members hidden by a group header. */
|
|
4
|
+
export function timelineAudioRow(
|
|
5
|
+
order: number[],
|
|
6
|
+
audioTracks: ReadonlySet<number>,
|
|
7
|
+
groupTracks?: ReadonlyMap<number, readonly number[]>,
|
|
8
|
+
): number {
|
|
9
|
+
return order.findIndex(
|
|
10
|
+
(track) =>
|
|
11
|
+
audioTracks.has(track) || groupTracks?.get(track)?.some((member) => audioTracks.has(member)),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
/**
|
|
4
16
|
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
|
5
17
|
* the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
|
|
@@ -31,7 +43,10 @@ export function isInsertAllowedForZone(
|
|
|
31
43
|
insertRow: number,
|
|
32
44
|
audioRow: number,
|
|
33
45
|
isAudio: boolean,
|
|
46
|
+
allowedRows?: ReadonlySet<number>,
|
|
34
47
|
): boolean {
|
|
48
|
+
if (allowedRows && !allowedRows.has(insertRow)) return false;
|
|
49
|
+
while (audioRow > 0 && allowedRows && !allowedRows.has(audioRow)) audioRow--;
|
|
35
50
|
if (audioRow < 0) return true;
|
|
36
51
|
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
|
|
37
52
|
}
|
|
@@ -52,9 +67,8 @@ function outOfRangeZoneInsertRow(
|
|
|
52
67
|
audioRow: number,
|
|
53
68
|
desired: number,
|
|
54
69
|
): number {
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
|
|
70
|
+
// An empty audio zone opens below visual rows; an empty visual zone opens above audio.
|
|
71
|
+
if (zoneTracks.length === 0) return audioRow < 0 ? order.length : audioRow;
|
|
58
72
|
// zoneTracks preserves `order` sequence, so its ends map to the zone boundary
|
|
59
73
|
// rows: above the zone's min lane → its top boundary, else its bottom.
|
|
60
74
|
const zoneTop = order.indexOf(zoneTracks[0]);
|
|
@@ -104,14 +118,31 @@ export function resolveNearestFreeStart(
|
|
|
104
118
|
return clear ? origin : best;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
function groupHeaderAim(
|
|
122
|
+
input: Parameters<typeof resolveZoneDropPlacement>[0],
|
|
123
|
+
):
|
|
124
|
+
| { kind: "ordinary" | "member"; track: number }
|
|
125
|
+
| { kind: "origin"; track: number; start: number } {
|
|
126
|
+
const members = input.groupTracks?.get(input.desiredTrack);
|
|
127
|
+
if (!members) return { kind: "ordinary", track: input.desiredTrack };
|
|
128
|
+
const memberTracks = new Set(members);
|
|
129
|
+
const member = input.order.find(
|
|
130
|
+
(track) => memberTracks.has(track) && input.audioTracks.has(track) === input.isAudio,
|
|
131
|
+
);
|
|
132
|
+
if (member !== undefined) return { kind: "member", track: member };
|
|
133
|
+
if (!input.origin) throw new Error("Group-header drop requires the clip origin");
|
|
134
|
+
return { kind: "origin", ...input.origin };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A deliberate seam opens a track; other aims land at the nearest free time on a row of the clip's kind.
|
|
109
138
|
export function resolveZoneDropPlacement(input: {
|
|
110
139
|
order: number[];
|
|
111
140
|
audioTracks: ReadonlySet<number>;
|
|
112
141
|
elements: TimelineElement[];
|
|
113
142
|
desiredTrack: number;
|
|
114
143
|
deliberateInsertRow: number | null;
|
|
144
|
+
allowedInsertRows?: ReadonlySet<number>;
|
|
145
|
+
groupTracks?: ReadonlyMap<number, readonly number[]>;
|
|
115
146
|
start: number;
|
|
116
147
|
duration: number;
|
|
117
148
|
dragKey: string;
|
|
@@ -123,24 +154,33 @@ export function resolveZoneDropPlacement(input: {
|
|
|
123
154
|
}): { track: number; insertRow: number | null; start: number } {
|
|
124
155
|
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
|
|
125
156
|
const { start, duration, dragKey, isAudio, minStart } = input;
|
|
126
|
-
const audioRow = order
|
|
157
|
+
const audioRow = timelineAudioRow(order, audioTracks, input.groupTracks);
|
|
127
158
|
|
|
128
159
|
if (
|
|
129
160
|
deliberateInsertRow !== null &&
|
|
130
|
-
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
|
|
161
|
+
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio, input.allowedInsertRows)
|
|
131
162
|
) {
|
|
132
163
|
return { track: desiredTrack, insertRow: deliberateInsertRow, start };
|
|
133
164
|
}
|
|
134
165
|
|
|
135
|
-
const
|
|
136
|
-
|
|
166
|
+
const aim = groupHeaderAim(input);
|
|
167
|
+
if (aim.kind === "origin") return { track: aim.track, start: aim.start, insertRow: null };
|
|
168
|
+
const firstVisibleAudioRow = order.findIndex((track) => audioTracks.has(track));
|
|
169
|
+
const zoneBoundary = isAudio ? firstVisibleAudioRow : audioRow;
|
|
170
|
+
const desired =
|
|
171
|
+
aim.kind === "member" ? aim.track : clampTrackToZone(aim.track, order, zoneBoundary, isAudio);
|
|
172
|
+
const zoneTracks = order.filter(
|
|
173
|
+
(t) => !input.groupTracks?.has(t) && audioTracks.has(t) === isAudio,
|
|
174
|
+
);
|
|
137
175
|
// Only when the aim is outside the rows, or the clip's zone has no row yet.
|
|
138
176
|
if (!zoneTracks.includes(desired)) {
|
|
139
177
|
const desiredRow = order.indexOf(desired);
|
|
140
|
-
|
|
141
|
-
desiredRow < 0
|
|
178
|
+
let insertRow =
|
|
179
|
+
desiredRow < 0 || zoneTracks.length === 0
|
|
142
180
|
? outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired)
|
|
143
181
|
: desiredRow + 1;
|
|
182
|
+
while (insertRow > 0 && input.allowedInsertRows && !input.allowedInsertRows.has(insertRow))
|
|
183
|
+
insertRow--;
|
|
144
184
|
return { track: desired, insertRow, start };
|
|
145
185
|
}
|
|
146
186
|
const origin = input.origin?.track === desired ? input.origin.start : null;
|
|
@@ -24,7 +24,7 @@ export function layoutAfterTrackInsert(
|
|
|
24
24
|
keys: ReadonlySet<string>;
|
|
25
25
|
movedStart: (e: TimelineElement) => number;
|
|
26
26
|
} | null,
|
|
27
|
-
deps: Pick<DragCommitDeps, "elements" | "trackOrder">,
|
|
27
|
+
deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "trackInsertLayout">,
|
|
28
28
|
): {
|
|
29
29
|
normalized: TimelineElement[];
|
|
30
30
|
targetTrack: number;
|
|
@@ -37,18 +37,32 @@ export function layoutAfterTrackInsert(
|
|
|
37
37
|
const writableZone = classifyZone(element);
|
|
38
38
|
const writable = (src: TimelineElement): boolean =>
|
|
39
39
|
sameSourceFile(src, element) && classifyZone(src) === writableZone;
|
|
40
|
-
|
|
40
|
+
let topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
|
41
41
|
(a, b) => a - b,
|
|
42
42
|
);
|
|
43
|
-
|
|
44
|
-
const
|
|
43
|
+
let topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
|
|
44
|
+
const grouped = deps.trackInsertLayout;
|
|
45
|
+
let ranks: Map<number, number> | undefined;
|
|
46
|
+
if (grouped) {
|
|
47
|
+
const writableTracks = new Set(topologyOrder);
|
|
48
|
+
topologyOrder = grouped.trackOrder.filter((track) => writableTracks.has(track));
|
|
49
|
+
topologyInsertRow = grouped.trackOrder
|
|
50
|
+
.slice(0, grouped.topologyRows[insertRow])
|
|
51
|
+
.filter((track) => writableTracks.has(track)).length;
|
|
52
|
+
ranks = new Map(topologyOrder.map((track, index) => [track, index]));
|
|
53
|
+
}
|
|
54
|
+
const topologyTargetTrack = ranks
|
|
55
|
+
? topologyInsertRow - 0.5
|
|
56
|
+
: insertTrackValue(topologyOrder, topologyInsertRow);
|
|
45
57
|
const normalized = normalizeToZones(
|
|
46
58
|
elements.filter(writable).map((e) => {
|
|
47
59
|
if (keyOf(e) === editKey) {
|
|
48
60
|
return { ...e, start: previewStart, track: topologyTargetTrack };
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
const track = ranks ? ranks.get(e.track) : e.track;
|
|
63
|
+
if (track === undefined) throw new Error("Writable track missing from insertion topology");
|
|
64
|
+
const start = multi?.keys.has(keyOf(e)) ? multi.movedStart(e) : e.start;
|
|
65
|
+
return track === e.track && start === e.start ? e : { ...e, start, track };
|
|
52
66
|
}),
|
|
53
67
|
);
|
|
54
68
|
return { normalized, targetTrack, writable };
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
2
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
2
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
4
3
|
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
|
@@ -13,7 +12,7 @@ import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
|
|
|
13
12
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
14
13
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
15
14
|
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
16
|
-
import type {
|
|
15
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
17
16
|
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
18
17
|
|
|
19
18
|
/**
|
|
@@ -52,12 +51,8 @@ export interface TimelineLaneBaseProps {
|
|
|
52
51
|
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
53
52
|
suppressClickRef: React.RefObject<boolean>;
|
|
54
53
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
55
|
-
renderClipContent?:
|
|
56
|
-
|
|
57
|
-
style: { clip: string; label: string },
|
|
58
|
-
context: TimelineClipRenderContext,
|
|
59
|
-
) => ReactNode;
|
|
60
|
-
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
54
|
+
renderClipContent?: TimelineProps["renderClipContent"];
|
|
55
|
+
renderClipOverlay?: TimelineProps["renderClipOverlay"];
|
|
61
56
|
onDrillDown?: (element: TimelineElement) => void;
|
|
62
57
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
63
58
|
setHoveredClip: (key: string | null) => void;
|
|
@@ -60,7 +60,7 @@ describe("timeline motion styles", () => {
|
|
|
60
60
|
const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
|
|
61
61
|
|
|
62
62
|
expect(expectDeclaration(timelineClipMotionRule, "transition")).toBe(
|
|
63
|
-
"background-color
|
|
63
|
+
"background-color var(--timeline-clip-hover-duration) ease-out",
|
|
64
64
|
);
|
|
65
65
|
});
|
|
66
66
|
|
|
@@ -72,10 +72,9 @@ describe("timeline motion styles", () => {
|
|
|
72
72
|
const audioClipHoverRule = expectRule(studioCss, ".timeline-clip.is-audio.is-hovered");
|
|
73
73
|
const audioClipDraggingRule = expectRule(studioCss, ".timeline-clip.is-audio.is-dragging");
|
|
74
74
|
const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
|
|
75
|
-
const selectedTimelineClipRule = expectRule(
|
|
76
|
-
const activeSelectedTimelineClipRule = expectRule(
|
|
75
|
+
const selectedTimelineClipRule = expectRule(
|
|
77
76
|
studioCss,
|
|
78
|
-
".timeline-clip[data-active].is-selected",
|
|
77
|
+
".timeline-clip.is-selected,\n.timeline-clip[data-active].is-selected",
|
|
79
78
|
);
|
|
80
79
|
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
|
|
81
80
|
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
|
|
@@ -92,7 +91,7 @@ describe("timeline motion styles", () => {
|
|
|
92
91
|
expect(timelineClipTimecodeRule).toContain("color: var(--timeline-tick-text)");
|
|
93
92
|
expect(themeCss).toContain("--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)");
|
|
94
93
|
expect(themeCss).toContain("--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55)");
|
|
95
|
-
expect(themeCss).toContain("--timeline-clip-radius:
|
|
94
|
+
expect(themeCss).toContain("--timeline-clip-radius: 12px");
|
|
96
95
|
expect(themeCss).toContain("--timeline-clip-audio-radius: 21px");
|
|
97
96
|
expect(themeCss).toContain("--timeline-clip-highlight: rgb(255 255 255 / 0.08)");
|
|
98
97
|
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3)");
|
|
@@ -104,14 +103,8 @@ describe("timeline motion styles", () => {
|
|
|
104
103
|
expect(audioClipRule).toContain("border-color: var(--timeline-clip-audio-border)");
|
|
105
104
|
expect(audioClipHoverRule).toContain("background-color: var(--timeline-clip-audio-bg-hover)");
|
|
106
105
|
expect(audioClipDraggingRule).toContain("var(--timeline-clip-audio-bg-dragging)");
|
|
107
|
-
expect(selectedTimelineClipRule).toContain(
|
|
108
|
-
|
|
109
|
-
);
|
|
110
|
-
expect(selectedTimelineClipRule).toContain("outline-offset: -1.5px");
|
|
111
|
-
expect(activeSelectedTimelineClipRule).toContain(
|
|
112
|
-
"outline: 1.5px solid var(--timeline-clip-selection)",
|
|
113
|
-
);
|
|
114
|
-
expect(activeSelectedTimelineClipRule).toContain("outline-offset: -1.5px");
|
|
106
|
+
expect(selectedTimelineClipRule).toContain("outline: 1px solid var(--timeline-clip-selection)");
|
|
107
|
+
expect(selectedTimelineClipRule).toContain("outline-offset: 0");
|
|
115
108
|
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
|
|
116
109
|
expect(activeTimelineClipRule).toContain("border-color: var(--clip-border-active)");
|
|
117
110
|
expect(activeTimelineClipRule).not.toContain("box-shadow");
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { act } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { Timeline } from "./Timeline";
|
|
4
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
5
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
6
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
7
|
+
|
|
8
|
+
export async function mountThreeTrackTimeline(props: TimelineProps = {}) {
|
|
9
|
+
usePlayerStore.setState({
|
|
10
|
+
duration: 10,
|
|
11
|
+
currentTime: 0,
|
|
12
|
+
timelineReady: true,
|
|
13
|
+
gsapAnimations: new Map(),
|
|
14
|
+
selectedElementId: null,
|
|
15
|
+
selectedElementIds: new Set(),
|
|
16
|
+
elements: [0, 1, 2].map((track) => ({
|
|
17
|
+
id: `c${track}`,
|
|
18
|
+
key: `c${track}`,
|
|
19
|
+
domId: `c${track}`,
|
|
20
|
+
tag: "div",
|
|
21
|
+
start: 0,
|
|
22
|
+
duration: 2,
|
|
23
|
+
track,
|
|
24
|
+
})),
|
|
25
|
+
});
|
|
26
|
+
const host = document.createElement("div");
|
|
27
|
+
document.body.append(host);
|
|
28
|
+
const root = createRoot(host);
|
|
29
|
+
await act(async () => root.render(<Timeline {...props} />));
|
|
30
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
31
|
+
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
|
|
32
|
+
return { host, root, viewport, ghost };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function dragTimelineFixtureAsset(target: EventTarget, type: string, clientY: number): void {
|
|
36
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
37
|
+
Object.defineProperties(event, {
|
|
38
|
+
clientX: { value: 400 },
|
|
39
|
+
clientY: { value: clientY },
|
|
40
|
+
dataTransfer: {
|
|
41
|
+
value: {
|
|
42
|
+
types: [TIMELINE_ASSET_MIME],
|
|
43
|
+
files: [],
|
|
44
|
+
dropEffect: "none",
|
|
45
|
+
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
act(() => void target.dispatchEvent(event));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function pointerTimelineFixture(target: EventTarget, type: string, clientY: number): void {
|
|
53
|
+
act(
|
|
54
|
+
() =>
|
|
55
|
+
void target.dispatchEvent(
|
|
56
|
+
new PointerEvent(type, {
|
|
57
|
+
bubbles: true,
|
|
58
|
+
button: 0,
|
|
59
|
+
pointerId: 1,
|
|
60
|
+
clientX: 400,
|
|
61
|
+
clientY,
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
|
|
3
3
|
type UpdateElement = (key: string, updates: Partial<TimelineElement>) => void;
|
|
4
|
-
|
|
4
|
+
type RevisionScope = "timing" | "membership";
|
|
5
|
+
const revisionsByUpdater = new WeakMap<UpdateElement, Map<RevisionScope, Map<string, number>>>();
|
|
5
6
|
|
|
6
7
|
export function beginTimelineOptimisticGesture(
|
|
7
8
|
updateElement: UpdateElement,
|
|
8
9
|
keys: readonly string[],
|
|
10
|
+
scope: RevisionScope = "timing",
|
|
9
11
|
): Map<string, number> {
|
|
10
|
-
let
|
|
12
|
+
let scopes = revisionsByUpdater.get(updateElement);
|
|
13
|
+
if (!scopes) {
|
|
14
|
+
scopes = new Map();
|
|
15
|
+
revisionsByUpdater.set(updateElement, scopes);
|
|
16
|
+
}
|
|
17
|
+
let revisions = scopes.get(scope);
|
|
11
18
|
if (!revisions) {
|
|
12
19
|
revisions = new Map();
|
|
13
|
-
|
|
20
|
+
scopes.set(scope, revisions);
|
|
14
21
|
}
|
|
15
22
|
const gesture = new Map<string, number>();
|
|
16
23
|
for (const key of keys) {
|
|
@@ -25,8 +32,12 @@ export function isLatestTimelineOptimisticGesture(
|
|
|
25
32
|
updateElement: UpdateElement,
|
|
26
33
|
gesture: ReadonlyMap<string, number>,
|
|
27
34
|
key: string,
|
|
35
|
+
scope: RevisionScope = "timing",
|
|
28
36
|
): boolean {
|
|
29
|
-
return
|
|
37
|
+
return (
|
|
38
|
+
gesture.has(key) &&
|
|
39
|
+
revisionsByUpdater.get(updateElement)?.get(scope)?.get(key) === gesture.get(key)
|
|
40
|
+
);
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
export function rollbackLatestTimelineOptimisticGesture(
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* routes through here; the raw key stays in callbacks and lookups only.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/** Ascending distinct track keys
|
|
9
|
+
/** Ascending distinct track keys; the drawn rows also group audio (`displayTrackOrder`). */
|
|
10
10
|
export function timelineTrackOrder(elements: readonly { track: number }[]): number[] {
|
|
11
11
|
return [...new Set(elements.map((element) => element.track))].sort((a, b) => a - b);
|
|
12
12
|
}
|