@hyperframes/studio 0.7.46 → 0.7.47
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/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-BsLQ2qGn.js +0 -420
- package/dist/assets/index-DoVLXke0.css +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { RULER_H, TRACK_H } from "./timelineLayout";
|
|
4
|
+
import { selectTimelineElementsInMarquee } from "./timelineEditing";
|
|
5
|
+
import type { StackingTimelineLayer } from "./timelineTrackOrder";
|
|
6
|
+
|
|
7
|
+
function element(id: string, start: number, duration: number, track: number): TimelineElement {
|
|
8
|
+
return { id, tag: "div", start, duration, track };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function layer(id: string, elements: TimelineElement[]): StackingTimelineLayer {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
kind: "visual",
|
|
15
|
+
contextKey: "",
|
|
16
|
+
zIndex: 0,
|
|
17
|
+
placementTrack: elements[0]?.track ?? 0,
|
|
18
|
+
elements,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("selectTimelineElementsInMarquee", () => {
|
|
23
|
+
it("selects clips intersecting both the marquee time span and lane span", () => {
|
|
24
|
+
const layers = [
|
|
25
|
+
layer("lane-0", [element("first-hit", 1, 1, 0), element("time-miss", 5, 1, 0)]),
|
|
26
|
+
layer("lane-1", [element("second-hit", 2.25, 1, 1)]),
|
|
27
|
+
layer("lane-2", [element("lane-miss", 1.5, 1, 2)]),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
expect(
|
|
31
|
+
selectTimelineElementsInMarquee({
|
|
32
|
+
rect: {
|
|
33
|
+
startTime: 0.5,
|
|
34
|
+
endTime: 3,
|
|
35
|
+
top: RULER_H,
|
|
36
|
+
bottom: RULER_H + TRACK_H * 2,
|
|
37
|
+
},
|
|
38
|
+
layers,
|
|
39
|
+
layerOrder: layers.map((item) => item.id),
|
|
40
|
+
rulerHeight: RULER_H,
|
|
41
|
+
trackHeight: TRACK_H,
|
|
42
|
+
}),
|
|
43
|
+
).toEqual(["first-hit", "second-hit"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns no ids when the marquee intersects no clip", () => {
|
|
47
|
+
const layers = [layer("lane-0", [element("outside", 4, 1, 0)])];
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
selectTimelineElementsInMarquee({
|
|
51
|
+
rect: {
|
|
52
|
+
startTime: 0,
|
|
53
|
+
endTime: 1,
|
|
54
|
+
top: RULER_H,
|
|
55
|
+
bottom: RULER_H + TRACK_H,
|
|
56
|
+
},
|
|
57
|
+
layers,
|
|
58
|
+
layerOrder: ["lane-0"],
|
|
59
|
+
rulerHeight: RULER_H,
|
|
60
|
+
trackHeight: TRACK_H,
|
|
61
|
+
}),
|
|
62
|
+
).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("accounts for context group headers before row hit testing", () => {
|
|
66
|
+
const layers: StackingTimelineLayer[] = [
|
|
67
|
+
{ ...layer("lane-0", [element("above", 0, 1, 0)]), contextKey: "root" },
|
|
68
|
+
{ ...layer("lane-1", [element("below", 0, 1, 1)]), contextKey: "nested" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
expect(
|
|
72
|
+
selectTimelineElementsInMarquee({
|
|
73
|
+
rect: {
|
|
74
|
+
startTime: 0,
|
|
75
|
+
endTime: 1,
|
|
76
|
+
top: RULER_H + 18 + TRACK_H,
|
|
77
|
+
bottom: RULER_H + 18 + TRACK_H + 18 + TRACK_H,
|
|
78
|
+
},
|
|
79
|
+
layers,
|
|
80
|
+
layerOrder: ["lane-0", "lane-1"],
|
|
81
|
+
rulerHeight: RULER_H,
|
|
82
|
+
trackHeight: TRACK_H,
|
|
83
|
+
groupHeaderHeight: 18,
|
|
84
|
+
}),
|
|
85
|
+
).toEqual(["below"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
buildTimelineSnapTargets,
|
|
5
|
+
snapEdgesToTargets,
|
|
6
|
+
snapResizeEdgeToTargets,
|
|
7
|
+
} from "./timelineSnapTargets";
|
|
8
|
+
|
|
9
|
+
function timelineElement(input: {
|
|
10
|
+
id: string;
|
|
11
|
+
key?: string;
|
|
12
|
+
start: number;
|
|
13
|
+
duration: number;
|
|
14
|
+
}): TimelineElement {
|
|
15
|
+
return {
|
|
16
|
+
id: input.id,
|
|
17
|
+
key: input.key,
|
|
18
|
+
tag: "div",
|
|
19
|
+
start: input.start,
|
|
20
|
+
duration: input.duration,
|
|
21
|
+
track: 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("buildTimelineSnapTargets", () => {
|
|
26
|
+
it("excludes the dragged clip's own edges", () => {
|
|
27
|
+
const dragged = timelineElement({
|
|
28
|
+
id: "dragged-id",
|
|
29
|
+
key: "dragged-key",
|
|
30
|
+
start: 1,
|
|
31
|
+
duration: 2,
|
|
32
|
+
});
|
|
33
|
+
const other = timelineElement({ id: "other", start: 4, duration: 2 });
|
|
34
|
+
|
|
35
|
+
const targets = buildTimelineSnapTargets({
|
|
36
|
+
elements: [dragged, other],
|
|
37
|
+
excludedKeys: new Set(["dragged-key"]),
|
|
38
|
+
playhead: 8,
|
|
39
|
+
compDuration: 10,
|
|
40
|
+
beats: [1.5],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const times = targets.map((target) => target.time);
|
|
44
|
+
expect(times).not.toContain(1);
|
|
45
|
+
expect(times).not.toContain(3);
|
|
46
|
+
expect(times).toContain(4);
|
|
47
|
+
expect(times).toContain(6);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("excludes every moving group member, not just the grabbed clip", () => {
|
|
51
|
+
const a = timelineElement({ id: "a", start: 1, duration: 1 });
|
|
52
|
+
const b = timelineElement({ id: "b", start: 3, duration: 1 });
|
|
53
|
+
const other = timelineElement({ id: "other", start: 6, duration: 1 });
|
|
54
|
+
|
|
55
|
+
const targets = buildTimelineSnapTargets({
|
|
56
|
+
elements: [a, b, other],
|
|
57
|
+
excludedKeys: new Set(["a", "b"]),
|
|
58
|
+
playhead: 9,
|
|
59
|
+
compDuration: 10,
|
|
60
|
+
beats: [],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const times = targets.map((target) => target.time);
|
|
64
|
+
// Both group members' edges are excluded; the non-member's edges remain.
|
|
65
|
+
expect(times).not.toContain(1);
|
|
66
|
+
expect(times).not.toContain(2);
|
|
67
|
+
expect(times).not.toContain(3);
|
|
68
|
+
expect(times).not.toContain(4);
|
|
69
|
+
expect(times).toContain(6);
|
|
70
|
+
expect(times).toContain(7);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("dedupes near-equal times from different sources", () => {
|
|
74
|
+
const dragged = timelineElement({ id: "dragged", start: 2, duration: 2 });
|
|
75
|
+
const other = timelineElement({ id: "other", start: 0.0004, duration: 10 });
|
|
76
|
+
|
|
77
|
+
const targets = buildTimelineSnapTargets({
|
|
78
|
+
elements: [dragged, other],
|
|
79
|
+
excludedKeys: new Set(["dragged"]),
|
|
80
|
+
playhead: 5,
|
|
81
|
+
compDuration: 10,
|
|
82
|
+
beats: [0.0002, 10.0002],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(targets.filter((target) => Math.abs(target.time) < 0.001)).toHaveLength(1);
|
|
86
|
+
expect(targets.filter((target) => Math.abs(target.time - 10) < 0.001)).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("snapEdgesToTargets", () => {
|
|
91
|
+
it("snaps the start edge to another clip's end", () => {
|
|
92
|
+
const snap = snapEdgesToTargets(3.95, 2, [{ time: 4, kind: "edge" }], 100);
|
|
93
|
+
|
|
94
|
+
expect(snap).toEqual({ start: 4, snapTime: 4, snapKind: "edge" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("snaps the end edge to another clip's start", () => {
|
|
98
|
+
const snap = snapEdgesToTargets(2.96, 2, [{ time: 5, kind: "edge" }], 100);
|
|
99
|
+
|
|
100
|
+
expect(snap).toEqual({ start: 3, snapTime: 5, snapKind: "edge" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("snaps to the playhead", () => {
|
|
104
|
+
const snap = snapEdgesToTargets(2.94, 1, [{ time: 3, kind: "playhead" }], 100);
|
|
105
|
+
|
|
106
|
+
expect(snap).toEqual({ start: 3, snapTime: 3, snapKind: "playhead" });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("snaps the start edge to the lower composition bound", () => {
|
|
110
|
+
const snap = snapEdgesToTargets(0.04, 1, [{ time: 0, kind: "bound" }], 100);
|
|
111
|
+
|
|
112
|
+
expect(snap).toEqual({ start: 0, snapTime: 0, snapKind: "bound" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("snaps the end edge to the upper composition bound", () => {
|
|
116
|
+
const snap = snapEdgesToTargets(7.96, 2, [{ time: 10, kind: "bound" }], 100);
|
|
117
|
+
|
|
118
|
+
expect(snap).toEqual({ start: 8, snapTime: 10, snapKind: "bound" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not snap when targets are beyond the pixel threshold", () => {
|
|
122
|
+
const snap = snapEdgesToTargets(3.9, 1, [{ time: 4, kind: "edge" }], 100);
|
|
123
|
+
|
|
124
|
+
expect(snap).toEqual({ start: 3.9, snapTime: null, snapKind: null });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("snapResizeEdgeToTargets", () => {
|
|
129
|
+
it("does not apply end-edge snaps past maxEnd or below minDuration", () => {
|
|
130
|
+
expect(
|
|
131
|
+
snapResizeEdgeToTargets("end", 4, 2.95, [{ time: 7.01, kind: "edge" }], 100, {
|
|
132
|
+
minDuration: 0.05,
|
|
133
|
+
maxEnd: 7,
|
|
134
|
+
}),
|
|
135
|
+
).toEqual({ start: 4, duration: 2.95, snapTime: null, snapKind: null });
|
|
136
|
+
|
|
137
|
+
expect(
|
|
138
|
+
snapResizeEdgeToTargets("end", 4, 0.1, [{ time: 4.03, kind: "edge" }], 100, {
|
|
139
|
+
minDuration: 0.05,
|
|
140
|
+
maxEnd: 10,
|
|
141
|
+
}),
|
|
142
|
+
).toEqual({ start: 4, duration: 0.1, snapTime: null, snapKind: null });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
export type TimelineSnapKind = "beat" | "edge" | "playhead" | "bound";
|
|
4
|
+
|
|
5
|
+
export interface TimelineSnapTarget {
|
|
6
|
+
time: number;
|
|
7
|
+
kind: TimelineSnapKind;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface NearestSnap {
|
|
11
|
+
target: TimelineSnapTarget;
|
|
12
|
+
distance: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SNAP_PX = 8;
|
|
16
|
+
const DEDUPE_EPSILON_SECONDS = 0.001;
|
|
17
|
+
const ROUND_FACTOR = 1000;
|
|
18
|
+
const KIND_PRIORITY: Record<TimelineSnapKind, number> = {
|
|
19
|
+
bound: 0,
|
|
20
|
+
playhead: 1,
|
|
21
|
+
edge: 2,
|
|
22
|
+
beat: 3,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function roundToMillis(value: number): number {
|
|
26
|
+
return Math.round(value * ROUND_FACTOR) / ROUND_FACTOR;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addTarget(targets: TimelineSnapTarget[], candidate: TimelineSnapTarget) {
|
|
30
|
+
if (!Number.isFinite(candidate.time)) return;
|
|
31
|
+
const existingIndex = targets.findIndex(
|
|
32
|
+
(target) => Math.abs(target.time - candidate.time) < DEDUPE_EPSILON_SECONDS,
|
|
33
|
+
);
|
|
34
|
+
if (existingIndex === -1) {
|
|
35
|
+
targets.push(candidate);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const existing = targets[existingIndex];
|
|
40
|
+
if (!existing || KIND_PRIORITY[candidate.kind] >= KIND_PRIORITY[existing.kind]) return;
|
|
41
|
+
targets[existingIndex] = candidate;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildTimelineSnapTargets(input: {
|
|
45
|
+
elements: TimelineElement[];
|
|
46
|
+
/** Keys of every clip moving in this gesture (the whole group), excluded as targets. */
|
|
47
|
+
excludedKeys: ReadonlySet<string>;
|
|
48
|
+
playhead: number;
|
|
49
|
+
compDuration: number;
|
|
50
|
+
beats: number[];
|
|
51
|
+
}): TimelineSnapTarget[] {
|
|
52
|
+
const targets: TimelineSnapTarget[] = [];
|
|
53
|
+
|
|
54
|
+
addTarget(targets, { time: 0, kind: "bound" });
|
|
55
|
+
addTarget(targets, { time: Math.max(0, input.compDuration), kind: "bound" });
|
|
56
|
+
addTarget(targets, { time: Math.max(0, input.playhead), kind: "playhead" });
|
|
57
|
+
|
|
58
|
+
for (const element of input.elements) {
|
|
59
|
+
const elementKey = element.key ?? element.id;
|
|
60
|
+
if (input.excludedKeys.has(elementKey) || input.excludedKeys.has(element.id)) continue;
|
|
61
|
+
addTarget(targets, { time: element.start, kind: "edge" });
|
|
62
|
+
addTarget(targets, { time: element.start + element.duration, kind: "edge" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const beat of input.beats) {
|
|
66
|
+
addTarget(targets, { time: beat, kind: "beat" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return targets.sort((a, b) => a.time - b.time || KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nearestSnap(
|
|
73
|
+
time: number,
|
|
74
|
+
targets: TimelineSnapTarget[],
|
|
75
|
+
thresholdSeconds: number,
|
|
76
|
+
): NearestSnap | null {
|
|
77
|
+
let best: NearestSnap | null = null;
|
|
78
|
+
let bestDistance = thresholdSeconds;
|
|
79
|
+
for (const target of targets) {
|
|
80
|
+
if (target.time === time) continue;
|
|
81
|
+
const distance = Math.abs(target.time - time);
|
|
82
|
+
if (distance < bestDistance) {
|
|
83
|
+
bestDistance = distance;
|
|
84
|
+
best = { target, distance };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function snapEdgesToTargets(
|
|
91
|
+
start: number,
|
|
92
|
+
duration: number,
|
|
93
|
+
targets: TimelineSnapTarget[],
|
|
94
|
+
pixelsPerSecond: number,
|
|
95
|
+
options?: { maxStart?: number },
|
|
96
|
+
): { start: number; snapTime: number | null; snapKind: TimelineSnapKind | null } {
|
|
97
|
+
const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1);
|
|
98
|
+
const startSnap = nearestSnap(start, targets, thresholdSeconds);
|
|
99
|
+
const endSnap = nearestSnap(start + duration, targets, thresholdSeconds);
|
|
100
|
+
|
|
101
|
+
let candidate = start;
|
|
102
|
+
let snapTarget: TimelineSnapTarget | null = null;
|
|
103
|
+
if (startSnap && (!endSnap || startSnap.distance <= endSnap.distance)) {
|
|
104
|
+
candidate = startSnap.target.time;
|
|
105
|
+
snapTarget = startSnap.target;
|
|
106
|
+
} else if (endSnap) {
|
|
107
|
+
candidate = endSnap.target.time - duration;
|
|
108
|
+
snapTarget = endSnap.target;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const maxStart = options?.maxStart ?? Number.POSITIVE_INFINITY;
|
|
112
|
+
const upperStart = Number.isFinite(maxStart) ? Math.max(0, maxStart) : Number.POSITIVE_INFINITY;
|
|
113
|
+
const clamped = Math.max(0, Math.min(upperStart, roundToMillis(candidate)));
|
|
114
|
+
if (snapTarget && Math.abs(clamped - candidate) > 1e-6) {
|
|
115
|
+
return { start: clamped, snapTime: null, snapKind: null };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
start: clamped,
|
|
119
|
+
snapTime: snapTarget?.time ?? null,
|
|
120
|
+
snapKind: snapTarget?.kind ?? null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function snapResizeEdgeToTargets(
|
|
125
|
+
edge: "start" | "end",
|
|
126
|
+
start: number,
|
|
127
|
+
duration: number,
|
|
128
|
+
targets: TimelineSnapTarget[],
|
|
129
|
+
pixelsPerSecond: number,
|
|
130
|
+
limits: { minDuration: number; maxEnd: number; maxLeftDelta?: number },
|
|
131
|
+
): { start: number; duration: number; snapTime: number | null; snapKind: TimelineSnapKind | null } {
|
|
132
|
+
const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1);
|
|
133
|
+
|
|
134
|
+
if (edge === "end") {
|
|
135
|
+
const snap = nearestSnap(start + duration, targets, thresholdSeconds);
|
|
136
|
+
if (!snap) return { start, duration, snapTime: null, snapKind: null };
|
|
137
|
+
const snappedDuration = roundToMillis(snap.target.time - start);
|
|
138
|
+
if (snap.target.time > limits.maxEnd + 1e-6 || snappedDuration < limits.minDuration) {
|
|
139
|
+
return { start, duration, snapTime: null, snapKind: null };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
start,
|
|
143
|
+
duration: snappedDuration,
|
|
144
|
+
snapTime: snap.target.time,
|
|
145
|
+
snapKind: snap.target.kind,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const snap = nearestSnap(start, targets, thresholdSeconds);
|
|
150
|
+
if (!snap) return { start, duration, snapTime: null, snapKind: null };
|
|
151
|
+
const snappedStart = roundToMillis(snap.target.time);
|
|
152
|
+
const delta = start - snappedStart;
|
|
153
|
+
const snappedDuration = roundToMillis(duration + delta);
|
|
154
|
+
const maxLeftDelta = limits.maxLeftDelta ?? Number.POSITIVE_INFINITY;
|
|
155
|
+
if (snappedStart < 0 || delta > maxLeftDelta + 1e-6 || snappedDuration < limits.minDuration) {
|
|
156
|
+
return { start, duration, snapTime: null, snapKind: null };
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
start: snappedStart,
|
|
160
|
+
duration: snappedDuration,
|
|
161
|
+
snapTime: snap.target.time,
|
|
162
|
+
snapKind: snap.target.kind,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
2
|
+
|
|
3
|
+
export interface TimelineStackingElement {
|
|
4
|
+
id: string;
|
|
5
|
+
key?: string;
|
|
6
|
+
tag?: string;
|
|
7
|
+
start: number;
|
|
8
|
+
duration: number;
|
|
9
|
+
track: number;
|
|
10
|
+
zIndex?: number;
|
|
11
|
+
stackingContextId?: string | null;
|
|
12
|
+
parentCompositionId?: string | null;
|
|
13
|
+
compositionAncestors?: string[];
|
|
14
|
+
// Locator for resolving the live element at commit time (sub-comp children
|
|
15
|
+
// aren't in the top-level element list, so the reorder intent must be
|
|
16
|
+
// self-contained rather than re-looked-up by identity).
|
|
17
|
+
domId?: string;
|
|
18
|
+
selector?: string;
|
|
19
|
+
selectorIndex?: number;
|
|
20
|
+
sourceFile?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TimelineStackingOrderItem {
|
|
24
|
+
key: string;
|
|
25
|
+
track: number;
|
|
26
|
+
zIndex: number;
|
|
27
|
+
stackingContextId: string | null;
|
|
28
|
+
parentCompositionId: string | null;
|
|
29
|
+
compositionAncestors: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TimelineLayerDropPlacement =
|
|
33
|
+
| { type: "onto"; layerId: string }
|
|
34
|
+
| { type: "between"; beforeLayerId: string; afterLayerId: string }
|
|
35
|
+
| { type: "above"; layerId: string }
|
|
36
|
+
| { type: "below"; layerId: string };
|
|
37
|
+
|
|
38
|
+
export interface TimelineStackingZIndexChange {
|
|
39
|
+
key: string;
|
|
40
|
+
zIndex: number;
|
|
41
|
+
domId?: string;
|
|
42
|
+
selector?: string;
|
|
43
|
+
selectorIndex?: number;
|
|
44
|
+
sourceFile?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TimelineStackingReorderIntent {
|
|
48
|
+
contextKey: string;
|
|
49
|
+
placement: TimelineLayerDropPlacement;
|
|
50
|
+
zIndexChanges: TimelineStackingZIndexChange[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem {
|
|
54
|
+
return {
|
|
55
|
+
key: getTimelineElementIdentity(element),
|
|
56
|
+
track: element.track,
|
|
57
|
+
zIndex: element.zIndex ?? 0,
|
|
58
|
+
stackingContextId: element.stackingContextId ?? null,
|
|
59
|
+
parentCompositionId: element.parentCompositionId ?? null,
|
|
60
|
+
compositionAncestors: element.compositionAncestors ?? [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
|
|
4
|
+
|
|
5
|
+
// fallow-ignore-next-line complexity
|
|
6
|
+
function rowElement(input: {
|
|
7
|
+
id: string;
|
|
8
|
+
track?: number;
|
|
9
|
+
zIndex?: number;
|
|
10
|
+
hasExplicitZIndex?: boolean;
|
|
11
|
+
start?: number;
|
|
12
|
+
duration?: number;
|
|
13
|
+
tag?: string;
|
|
14
|
+
stackingContextId?: string | null;
|
|
15
|
+
parentCompositionId?: string | null;
|
|
16
|
+
compositionAncestors?: string[];
|
|
17
|
+
}): TimelineElement {
|
|
18
|
+
return {
|
|
19
|
+
id: input.id,
|
|
20
|
+
tag: input.tag ?? "div",
|
|
21
|
+
start: input.start ?? 0,
|
|
22
|
+
duration: input.duration ?? 1,
|
|
23
|
+
track: input.track ?? 0,
|
|
24
|
+
zIndex: input.zIndex ?? 0,
|
|
25
|
+
hasExplicitZIndex: input.hasExplicitZIndex ?? true,
|
|
26
|
+
stackingContextId: input.stackingContextId ?? "root",
|
|
27
|
+
parentCompositionId: input.parentCompositionId ?? null,
|
|
28
|
+
compositionAncestors: input.compositionAncestors ?? ["root"],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rowIds(rows: readonly { elements: readonly TimelineElement[] }[]): string[][] {
|
|
33
|
+
return rows.map((row) => row.elements.map((element) => element.id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("buildStackingTimelineLayers", () => {
|
|
37
|
+
it("splits non-overlapping clips into separate lanes when their z-index differs", () => {
|
|
38
|
+
// A lane is a z-band: differing z must land on different rows even when the
|
|
39
|
+
// clips don't overlap in time, so a vertical (z) restack actually moves the
|
|
40
|
+
// clip's row. Rows are ordered by descending z.
|
|
41
|
+
const result = buildStackingTimelineLayers([
|
|
42
|
+
rowElement({ id: "back", zIndex: 1, start: 0, duration: 1 }),
|
|
43
|
+
rowElement({ id: "front", zIndex: 10, start: 1, duration: 1 }),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
|
|
47
|
+
expect(result.visualLayers[0]?.zIndex).toBe(10);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("packs non-overlapping clips into one lane when they share a z-index", () => {
|
|
51
|
+
const result = buildStackingTimelineLayers([
|
|
52
|
+
rowElement({ id: "back", zIndex: 5, start: 0, duration: 1 }),
|
|
53
|
+
rowElement({ id: "front", zIndex: 5, start: 1, duration: 1 }),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
expect(rowIds(result.visualLayers)).toEqual([["back", "front"]]);
|
|
57
|
+
expect(result.visualLayers[0]?.zIndex).toBe(5);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("splits clips into separate lanes when they overlap in time", () => {
|
|
61
|
+
const result = buildStackingTimelineLayers([
|
|
62
|
+
rowElement({ id: "front", zIndex: 10, start: 0, duration: 2 }),
|
|
63
|
+
rowElement({ id: "back", zIndex: 1, start: 1, duration: 2 }),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("uses DOM order to break stacking ties before lane packing", () => {
|
|
70
|
+
const result = buildStackingTimelineLayers([
|
|
71
|
+
rowElement({ id: "first", track: 2, zIndex: 5, start: 0, duration: 2 }),
|
|
72
|
+
rowElement({ id: "second", track: 0, zIndex: 5, start: 1, duration: 2 }),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
expect(rowIds(result.visualLayers)).toEqual([["first"], ["second"]]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("packs auto-z clips by time instead of forcing one row per clip", () => {
|
|
79
|
+
const result = buildStackingTimelineLayers([
|
|
80
|
+
rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false, start: 0, duration: 1 }),
|
|
81
|
+
rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false, start: 1, duration: 1 }),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
expect(rowIds(result.visualLayers)).toEqual([["a", "b"]]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not merge equal z-index clips across stacking contexts", () => {
|
|
88
|
+
const result = buildStackingTimelineLayers([
|
|
89
|
+
rowElement({ id: "root", zIndex: 4, start: 0, duration: 1 }),
|
|
90
|
+
rowElement({
|
|
91
|
+
id: "nested",
|
|
92
|
+
zIndex: 4,
|
|
93
|
+
start: 1,
|
|
94
|
+
duration: 1,
|
|
95
|
+
stackingContextId: "scene",
|
|
96
|
+
parentCompositionId: "scene",
|
|
97
|
+
compositionAncestors: ["root", "scene"],
|
|
98
|
+
}),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(rowIds(result.visualLayers)).toEqual([["root"], ["nested"]]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns audio clips as separate bottom rows without merging them into z layers", () => {
|
|
105
|
+
const result = buildStackingTimelineLayers([
|
|
106
|
+
rowElement({ id: "front", zIndex: 10 }),
|
|
107
|
+
rowElement({ id: "music-a", tag: "audio", track: 4 }),
|
|
108
|
+
rowElement({ id: "music-b", tag: "audio", track: 2 }),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
expect(rowIds(result.visualLayers)).toEqual([["front"]]);
|
|
112
|
+
expect(rowIds(result.audioLayers)).toEqual([["music-a"], ["music-b"]]);
|
|
113
|
+
expect(rowIds(result.rows)).toEqual([["front"], ["music-a"], ["music-b"]]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("orders rows by descending z-index with auto-z clips ranked at computed zero", () => {
|
|
117
|
+
const result = buildStackingTimelineLayers([
|
|
118
|
+
rowElement({ id: "auto-a", zIndex: 0, hasExplicitZIndex: false }),
|
|
119
|
+
rowElement({ id: "back", zIndex: -1 }),
|
|
120
|
+
rowElement({ id: "front", zIndex: 10 }),
|
|
121
|
+
rowElement({ id: "auto-b", zIndex: 0, hasExplicitZIndex: false }),
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
expect(rowIds(result.visualLayers)).toEqual([["front"], ["auto-a"], ["auto-b"], ["back"]]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("keeps a row key stable when a clip's z-index changes but membership does not", () => {
|
|
128
|
+
const before = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 1 })]);
|
|
129
|
+
const after = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 20 })]);
|
|
130
|
+
|
|
131
|
+
expect(after.visualLayers[0]?.id).toBe(before.visualLayers[0]?.id);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("insertPreviewTrackOrder", () => {
|
|
136
|
+
it("inserts preview layer ids by target row index", () => {
|
|
137
|
+
expect(insertPreviewTrackOrder(["a", "b", "c"], "preview", 1)).toEqual([
|
|
138
|
+
"a",
|
|
139
|
+
"preview",
|
|
140
|
+
"b",
|
|
141
|
+
"c",
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
});
|