@hyperframes/studio 0.7.46 → 0.7.48
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-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
- package/dist/assets/index-DX02Q1Yl.js +423 -0
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.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,200 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { StackingTimelineLayer } from "./timelineTrackOrder";
|
|
4
|
+
import {
|
|
5
|
+
resolveTimelineLayerStackingMove,
|
|
6
|
+
resolveTimelineLayerZIndexChanges,
|
|
7
|
+
} from "./timelineLayerDrag";
|
|
8
|
+
|
|
9
|
+
function element(input: {
|
|
10
|
+
id: string;
|
|
11
|
+
zIndex: number;
|
|
12
|
+
tag?: string;
|
|
13
|
+
start?: number;
|
|
14
|
+
duration?: number;
|
|
15
|
+
}): TimelineElement {
|
|
16
|
+
return {
|
|
17
|
+
id: input.id,
|
|
18
|
+
tag: input.tag ?? "div",
|
|
19
|
+
start: input.start ?? 0,
|
|
20
|
+
duration: input.duration ?? 1,
|
|
21
|
+
track: 0,
|
|
22
|
+
zIndex: input.zIndex,
|
|
23
|
+
hasExplicitZIndex: true,
|
|
24
|
+
stackingContextId: "root",
|
|
25
|
+
parentCompositionId: null,
|
|
26
|
+
compositionAncestors: ["root"],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function layer(
|
|
31
|
+
id: string,
|
|
32
|
+
zIndex: number,
|
|
33
|
+
elements: TimelineElement[] = [element({ id, zIndex })],
|
|
34
|
+
): StackingTimelineLayer {
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
kind: "visual",
|
|
38
|
+
contextKey: "root",
|
|
39
|
+
zIndex,
|
|
40
|
+
placementTrack: 0,
|
|
41
|
+
elements,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("resolveTimelineLayerZIndexChanges", () => {
|
|
46
|
+
it("joins an existing lane by assigning the dragged clip that lane's z-index", () => {
|
|
47
|
+
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
|
|
48
|
+
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
resolveTimelineLayerZIndexChanges({
|
|
52
|
+
element: dragged,
|
|
53
|
+
layers: [layer("front", 10, [front]), layer("back", 1)],
|
|
54
|
+
placement: { type: "onto", layerId: "front" },
|
|
55
|
+
})?.zIndexChanges,
|
|
56
|
+
).toEqual([{ key: "dragged", zIndex: 10 }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects an onto-lane join when the dragged clip would overlap that lane", () => {
|
|
60
|
+
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
|
|
61
|
+
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
resolveTimelineLayerZIndexChanges({
|
|
65
|
+
element: dragged,
|
|
66
|
+
layers: [layer("front", 10, [front]), layer("back", 1)],
|
|
67
|
+
placement: { type: "onto", layerId: "front" },
|
|
68
|
+
}),
|
|
69
|
+
).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("interpolates a new integer z-index strictly between neighboring layers", () => {
|
|
73
|
+
const dragged = element({ id: "dragged", zIndex: 1 });
|
|
74
|
+
|
|
75
|
+
expect(
|
|
76
|
+
resolveTimelineLayerZIndexChanges({
|
|
77
|
+
element: dragged,
|
|
78
|
+
layers: [layer("front", 10), layer("back", 4)],
|
|
79
|
+
placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
|
|
80
|
+
})?.zIndexChanges,
|
|
81
|
+
).toEqual([{ key: "dragged", zIndex: 7 }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("renumbers the minimum sibling set when adjacent layers leave no integer gap", () => {
|
|
85
|
+
const dragged = element({ id: "dragged", zIndex: 0 });
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
resolveTimelineLayerZIndexChanges({
|
|
89
|
+
element: dragged,
|
|
90
|
+
layers: [layer("front", 2), layer("back", 1), layer("lower", 0)],
|
|
91
|
+
placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
|
|
92
|
+
})?.zIndexChanges,
|
|
93
|
+
).toEqual([
|
|
94
|
+
{ key: "dragged", zIndex: 2 },
|
|
95
|
+
{ key: "front", zIndex: 3 },
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("assigns new extreme z-index values above the top and below the bottom layer", () => {
|
|
100
|
+
const dragged = element({ id: "dragged", zIndex: 0 });
|
|
101
|
+
const layers = [layer("front", 10), layer("back", -2)];
|
|
102
|
+
|
|
103
|
+
expect(
|
|
104
|
+
resolveTimelineLayerZIndexChanges({
|
|
105
|
+
element: dragged,
|
|
106
|
+
layers,
|
|
107
|
+
placement: { type: "above", layerId: "front" },
|
|
108
|
+
})?.zIndexChanges,
|
|
109
|
+
).toEqual([{ key: "dragged", zIndex: 11 }]);
|
|
110
|
+
|
|
111
|
+
expect(
|
|
112
|
+
resolveTimelineLayerZIndexChanges({
|
|
113
|
+
element: dragged,
|
|
114
|
+
layers,
|
|
115
|
+
placement: { type: "below", layerId: "back" },
|
|
116
|
+
})?.zIndexChanges,
|
|
117
|
+
).toEqual([{ key: "dragged", zIndex: -3 }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not resolve stacking z-index changes for audio clips", () => {
|
|
121
|
+
expect(
|
|
122
|
+
resolveTimelineLayerZIndexChanges({
|
|
123
|
+
element: element({ id: "music", zIndex: 0, tag: "audio" }),
|
|
124
|
+
layers: [layer("front", 10)],
|
|
125
|
+
placement: { type: "onto", layerId: "front" },
|
|
126
|
+
}),
|
|
127
|
+
).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("resolveTimelineLayerStackingMove", () => {
|
|
132
|
+
it("places an upward onto-lane drop above the overlapping target lane", () => {
|
|
133
|
+
const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 });
|
|
134
|
+
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
|
|
135
|
+
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
|
|
136
|
+
|
|
137
|
+
expect(
|
|
138
|
+
resolveTimelineLayerStackingMove({
|
|
139
|
+
element: dragged,
|
|
140
|
+
layers,
|
|
141
|
+
layerOrder: layers.map((item) => item.id),
|
|
142
|
+
trackDeltaRaw: -0.8,
|
|
143
|
+
}),
|
|
144
|
+
).toEqual({
|
|
145
|
+
previewLayerId: "preview:dragged:above:front",
|
|
146
|
+
previewLayerIndex: 0,
|
|
147
|
+
stackingReorder: {
|
|
148
|
+
contextKey: "root",
|
|
149
|
+
placement: { type: "above", layerId: "front" },
|
|
150
|
+
zIndexChanges: [{ key: "dragged", zIndex: 11 }],
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("places a downward onto-lane drop below the overlapping target lane", () => {
|
|
156
|
+
const dragged = element({ id: "dragged", zIndex: 10, start: 0.5, duration: 1 });
|
|
157
|
+
const back = element({ id: "back", zIndex: 1, start: 0, duration: 2 });
|
|
158
|
+
const layers = [layer("dragged", 10, [dragged]), layer("back", 1, [back])];
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
resolveTimelineLayerStackingMove({
|
|
162
|
+
element: dragged,
|
|
163
|
+
layers,
|
|
164
|
+
layerOrder: layers.map((item) => item.id),
|
|
165
|
+
trackDeltaRaw: 0.8,
|
|
166
|
+
}),
|
|
167
|
+
).toEqual({
|
|
168
|
+
previewLayerId: "preview:dragged:below:back",
|
|
169
|
+
previewLayerIndex: 2,
|
|
170
|
+
stackingReorder: {
|
|
171
|
+
contextKey: "root",
|
|
172
|
+
placement: { type: "below", layerId: "back" },
|
|
173
|
+
zIndexChanges: [{ key: "dragged", zIndex: 0 }],
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps a non-overlapping onto-lane drop joined to the target lane", () => {
|
|
179
|
+
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
|
180
|
+
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
|
|
181
|
+
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
|
|
182
|
+
|
|
183
|
+
expect(
|
|
184
|
+
resolveTimelineLayerStackingMove({
|
|
185
|
+
element: dragged,
|
|
186
|
+
layers,
|
|
187
|
+
layerOrder: layers.map((item) => item.id),
|
|
188
|
+
trackDeltaRaw: -0.8,
|
|
189
|
+
}),
|
|
190
|
+
).toEqual({
|
|
191
|
+
previewLayerId: "front",
|
|
192
|
+
previewLayerIndex: 0,
|
|
193
|
+
stackingReorder: {
|
|
194
|
+
contextKey: "root",
|
|
195
|
+
placement: { type: "onto", layerId: "front" },
|
|
196
|
+
zIndexChanges: [{ key: "dragged", zIndex: 10 }],
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { resolveStackingContextKey } from "../lib/layerOrdering";
|
|
2
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
|
+
import {
|
|
4
|
+
timelineElementsOverlap,
|
|
5
|
+
type StackingTimelineLayer,
|
|
6
|
+
type TimelineLayerId,
|
|
7
|
+
} from "./timelineTrackOrder";
|
|
8
|
+
import {
|
|
9
|
+
toStackingOrderItem,
|
|
10
|
+
type TimelineLayerDropPlacement,
|
|
11
|
+
type TimelineStackingElement,
|
|
12
|
+
type TimelineStackingReorderIntent,
|
|
13
|
+
type TimelineStackingZIndexChange,
|
|
14
|
+
} from "./timelineStacking";
|
|
15
|
+
|
|
16
|
+
const ONTO_ROW_THRESHOLD = 0.35;
|
|
17
|
+
|
|
18
|
+
export interface TimelineLayerStackingMoveResolution {
|
|
19
|
+
previewLayerId: TimelineLayerId;
|
|
20
|
+
previewLayerIndex: number;
|
|
21
|
+
stackingReorder: TimelineStackingReorderIntent | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isAudioElement(element: TimelineStackingElement): boolean {
|
|
25
|
+
return element.tag?.toLowerCase() === "audio";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function layerContainsElement(layer: StackingTimelineLayer, key: string): boolean {
|
|
29
|
+
return layer.elements.some((element) => getTimelineElementIdentity(element) === key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function layerConflictsWithElement(
|
|
33
|
+
layer: StackingTimelineLayer,
|
|
34
|
+
element: TimelineStackingElement,
|
|
35
|
+
draggedKey: string,
|
|
36
|
+
): boolean {
|
|
37
|
+
return layer.elements.some(
|
|
38
|
+
(candidate) =>
|
|
39
|
+
getTimelineElementIdentity(candidate) !== draggedKey &&
|
|
40
|
+
timelineElementsOverlap(candidate, element),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addElementChange(
|
|
45
|
+
changes: TimelineStackingZIndexChange[],
|
|
46
|
+
element: TimelineStackingElement,
|
|
47
|
+
zIndex: number,
|
|
48
|
+
): void {
|
|
49
|
+
if ((element.zIndex ?? 0) === zIndex) return;
|
|
50
|
+
changes.push({
|
|
51
|
+
key: getTimelineElementIdentity(element),
|
|
52
|
+
zIndex,
|
|
53
|
+
domId: element.domId,
|
|
54
|
+
selector: element.selector,
|
|
55
|
+
selectorIndex: element.selectorIndex,
|
|
56
|
+
sourceFile: element.sourceFile,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addLayerChanges(
|
|
61
|
+
changes: TimelineStackingZIndexChange[],
|
|
62
|
+
layer: StackingTimelineLayer,
|
|
63
|
+
zIndex: number,
|
|
64
|
+
excludedKey: string,
|
|
65
|
+
): number {
|
|
66
|
+
let count = 0;
|
|
67
|
+
for (const element of layer.elements) {
|
|
68
|
+
const key = getTimelineElementIdentity(element);
|
|
69
|
+
if (key === excludedKey) continue;
|
|
70
|
+
const before = changes.length;
|
|
71
|
+
addElementChange(changes, element, zIndex);
|
|
72
|
+
if (changes.length > before) count += 1;
|
|
73
|
+
}
|
|
74
|
+
return count;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getContextLayers(
|
|
78
|
+
layers: readonly StackingTimelineLayer[],
|
|
79
|
+
contextKey: string,
|
|
80
|
+
): StackingTimelineLayer[] {
|
|
81
|
+
return layers.filter((layer) => layer.kind === "visual" && layer.contextKey === contextKey);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findLayer(
|
|
85
|
+
layers: readonly StackingTimelineLayer[],
|
|
86
|
+
id: string,
|
|
87
|
+
): StackingTimelineLayer | null {
|
|
88
|
+
return layers.find((layer) => layer.id === id) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolvePlacementZIndexChanges(input: {
|
|
92
|
+
element: TimelineStackingElement;
|
|
93
|
+
targetZIndex: number;
|
|
94
|
+
}): TimelineStackingZIndexChange[] {
|
|
95
|
+
const changes: TimelineStackingZIndexChange[] = [];
|
|
96
|
+
addElementChange(changes, input.element, input.targetZIndex);
|
|
97
|
+
return changes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildPushUpCandidate(input: {
|
|
101
|
+
element: TimelineStackingElement;
|
|
102
|
+
layers: readonly StackingTimelineLayer[];
|
|
103
|
+
beforeIndex: number;
|
|
104
|
+
bottomZIndex: number;
|
|
105
|
+
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
|
|
106
|
+
const draggedKey = getTimelineElementIdentity(input.element);
|
|
107
|
+
const draggedZIndex = input.bottomZIndex + 1;
|
|
108
|
+
const changes = resolvePlacementZIndexChanges({
|
|
109
|
+
element: input.element,
|
|
110
|
+
targetZIndex: draggedZIndex,
|
|
111
|
+
});
|
|
112
|
+
let siblingChanges = 0;
|
|
113
|
+
let requiredZIndex = draggedZIndex + 1;
|
|
114
|
+
for (let index = input.beforeIndex; index >= 0; index -= 1) {
|
|
115
|
+
const layer = input.layers[index];
|
|
116
|
+
if (!layer) continue;
|
|
117
|
+
const nextZIndex = layer.zIndex >= requiredZIndex ? layer.zIndex : requiredZIndex;
|
|
118
|
+
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
|
|
119
|
+
requiredZIndex = nextZIndex + 1;
|
|
120
|
+
}
|
|
121
|
+
return { changes, siblingChanges };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildPushDownCandidate(input: {
|
|
125
|
+
element: TimelineStackingElement;
|
|
126
|
+
layers: readonly StackingTimelineLayer[];
|
|
127
|
+
afterIndex: number;
|
|
128
|
+
topZIndex: number;
|
|
129
|
+
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
|
|
130
|
+
const draggedKey = getTimelineElementIdentity(input.element);
|
|
131
|
+
const draggedZIndex = input.topZIndex - 1;
|
|
132
|
+
const changes = resolvePlacementZIndexChanges({
|
|
133
|
+
element: input.element,
|
|
134
|
+
targetZIndex: draggedZIndex,
|
|
135
|
+
});
|
|
136
|
+
let siblingChanges = 0;
|
|
137
|
+
let requiredZIndex = draggedZIndex - 1;
|
|
138
|
+
for (let index = input.afterIndex; index < input.layers.length; index += 1) {
|
|
139
|
+
const layer = input.layers[index];
|
|
140
|
+
if (!layer) continue;
|
|
141
|
+
const nextZIndex = layer.zIndex <= requiredZIndex ? layer.zIndex : requiredZIndex;
|
|
142
|
+
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
|
|
143
|
+
requiredZIndex = nextZIndex - 1;
|
|
144
|
+
}
|
|
145
|
+
return { changes, siblingChanges };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolveBetweenChanges(input: {
|
|
149
|
+
element: TimelineStackingElement;
|
|
150
|
+
layers: readonly StackingTimelineLayer[];
|
|
151
|
+
beforeLayer: StackingTimelineLayer;
|
|
152
|
+
afterLayer: StackingTimelineLayer;
|
|
153
|
+
}): TimelineStackingZIndexChange[] {
|
|
154
|
+
const topZIndex = input.beforeLayer.zIndex;
|
|
155
|
+
const bottomZIndex = input.afterLayer.zIndex;
|
|
156
|
+
if (topZIndex - bottomZIndex > 1) {
|
|
157
|
+
return resolvePlacementZIndexChanges({
|
|
158
|
+
element: input.element,
|
|
159
|
+
targetZIndex: Math.floor((topZIndex + bottomZIndex) / 2),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const beforeIndex = input.layers.findIndex((layer) => layer.id === input.beforeLayer.id);
|
|
164
|
+
const afterIndex = input.layers.findIndex((layer) => layer.id === input.afterLayer.id);
|
|
165
|
+
const pushUp = buildPushUpCandidate({
|
|
166
|
+
element: input.element,
|
|
167
|
+
layers: input.layers,
|
|
168
|
+
beforeIndex,
|
|
169
|
+
bottomZIndex,
|
|
170
|
+
});
|
|
171
|
+
const pushDown = buildPushDownCandidate({
|
|
172
|
+
element: input.element,
|
|
173
|
+
layers: input.layers,
|
|
174
|
+
afterIndex,
|
|
175
|
+
topZIndex,
|
|
176
|
+
});
|
|
177
|
+
return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveOntoChanges(input: {
|
|
181
|
+
element: TimelineStackingElement;
|
|
182
|
+
layers: readonly StackingTimelineLayer[];
|
|
183
|
+
layerId: string;
|
|
184
|
+
}): TimelineStackingZIndexChange[] | null {
|
|
185
|
+
const target = findLayer(input.layers, input.layerId);
|
|
186
|
+
if (!target) return null;
|
|
187
|
+
if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return resolvePlacementZIndexChanges({
|
|
191
|
+
element: input.element,
|
|
192
|
+
targetZIndex: target.zIndex,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveEdgeChanges(input: {
|
|
197
|
+
element: TimelineStackingElement;
|
|
198
|
+
layers: readonly StackingTimelineLayer[];
|
|
199
|
+
layerId: string;
|
|
200
|
+
offset: number;
|
|
201
|
+
}): TimelineStackingZIndexChange[] | null {
|
|
202
|
+
const target = findLayer(input.layers, input.layerId);
|
|
203
|
+
if (!target) return null;
|
|
204
|
+
return resolvePlacementZIndexChanges({
|
|
205
|
+
element: input.element,
|
|
206
|
+
targetZIndex: target.zIndex + input.offset,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolvePlacementChanges(input: {
|
|
211
|
+
element: TimelineStackingElement;
|
|
212
|
+
layers: readonly StackingTimelineLayer[];
|
|
213
|
+
placement: TimelineLayerDropPlacement;
|
|
214
|
+
}): TimelineStackingZIndexChange[] | null {
|
|
215
|
+
switch (input.placement.type) {
|
|
216
|
+
case "onto":
|
|
217
|
+
return resolveOntoChanges({
|
|
218
|
+
element: input.element,
|
|
219
|
+
layers: input.layers,
|
|
220
|
+
layerId: input.placement.layerId,
|
|
221
|
+
});
|
|
222
|
+
case "above":
|
|
223
|
+
return resolveEdgeChanges({
|
|
224
|
+
element: input.element,
|
|
225
|
+
layers: input.layers,
|
|
226
|
+
layerId: input.placement.layerId,
|
|
227
|
+
offset: 1,
|
|
228
|
+
});
|
|
229
|
+
case "below":
|
|
230
|
+
return resolveEdgeChanges({
|
|
231
|
+
element: input.element,
|
|
232
|
+
layers: input.layers,
|
|
233
|
+
layerId: input.placement.layerId,
|
|
234
|
+
offset: -1,
|
|
235
|
+
});
|
|
236
|
+
case "between": {
|
|
237
|
+
const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId);
|
|
238
|
+
const afterLayer = findLayer(input.layers, input.placement.afterLayerId);
|
|
239
|
+
return beforeLayer && afterLayer
|
|
240
|
+
? resolveBetweenChanges({
|
|
241
|
+
element: input.element,
|
|
242
|
+
layers: input.layers,
|
|
243
|
+
beforeLayer,
|
|
244
|
+
afterLayer,
|
|
245
|
+
})
|
|
246
|
+
: null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function resolveTimelineLayerZIndexChanges(input: {
|
|
252
|
+
element: TimelineStackingElement;
|
|
253
|
+
layers: readonly StackingTimelineLayer[];
|
|
254
|
+
placement: TimelineLayerDropPlacement;
|
|
255
|
+
}): TimelineStackingReorderIntent | null {
|
|
256
|
+
if (isAudioElement(input.element)) return null;
|
|
257
|
+
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
|
|
258
|
+
const layers = getContextLayers(input.layers, contextKey);
|
|
259
|
+
const changes = resolvePlacementChanges({
|
|
260
|
+
element: input.element,
|
|
261
|
+
layers,
|
|
262
|
+
placement: input.placement,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return changes && changes.length > 0
|
|
266
|
+
? { contextKey, placement: input.placement, zIndexChanges: changes }
|
|
267
|
+
: null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// fallow-ignore-next-line complexity
|
|
271
|
+
function resolveDragPlacement(
|
|
272
|
+
layers: readonly StackingTimelineLayer[],
|
|
273
|
+
targetPosition: number,
|
|
274
|
+
): TimelineLayerDropPlacement | null {
|
|
275
|
+
const first = layers[0];
|
|
276
|
+
const last = layers[layers.length - 1];
|
|
277
|
+
if (!first || !last) return null;
|
|
278
|
+
if (targetPosition < -ONTO_ROW_THRESHOLD) return { type: "above", layerId: first.id };
|
|
279
|
+
if (targetPosition > layers.length - 1 + ONTO_ROW_THRESHOLD) {
|
|
280
|
+
return { type: "below", layerId: last.id };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const nearestIndex = Math.round(targetPosition);
|
|
284
|
+
const nearest = layers[nearestIndex];
|
|
285
|
+
if (nearest && Math.abs(targetPosition - nearestIndex) <= ONTO_ROW_THRESHOLD) {
|
|
286
|
+
return { type: "onto", layerId: nearest.id };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const insertionIndex = Math.max(0, Math.min(layers.length, Math.ceil(targetPosition)));
|
|
290
|
+
if (insertionIndex <= 0) return { type: "above", layerId: first.id };
|
|
291
|
+
if (insertionIndex >= layers.length) return { type: "below", layerId: last.id };
|
|
292
|
+
const before = layers[insertionIndex - 1];
|
|
293
|
+
const after = layers[insertionIndex];
|
|
294
|
+
return before && after
|
|
295
|
+
? { type: "between", beforeLayerId: before.id, afterLayerId: after.id }
|
|
296
|
+
: null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function resolveLaneAwareDragPlacement(input: {
|
|
300
|
+
layers: readonly StackingTimelineLayer[];
|
|
301
|
+
element: TimelineStackingElement;
|
|
302
|
+
draggedKey: string;
|
|
303
|
+
placement: TimelineLayerDropPlacement;
|
|
304
|
+
targetPosition: number;
|
|
305
|
+
currentIndex: number;
|
|
306
|
+
}): TimelineLayerDropPlacement | null {
|
|
307
|
+
if (input.placement.type !== "onto") return input.placement;
|
|
308
|
+
const target = findLayer(input.layers, input.placement.layerId);
|
|
309
|
+
if (!target) return null;
|
|
310
|
+
if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement;
|
|
311
|
+
if (input.targetPosition < input.currentIndex) {
|
|
312
|
+
return { type: "above", layerId: target.id };
|
|
313
|
+
}
|
|
314
|
+
if (input.targetPosition > input.currentIndex) {
|
|
315
|
+
return { type: "below", layerId: target.id };
|
|
316
|
+
}
|
|
317
|
+
return input.placement;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getPreviewLayerId(
|
|
321
|
+
draggedKey: string,
|
|
322
|
+
placement: TimelineLayerDropPlacement,
|
|
323
|
+
): TimelineLayerId {
|
|
324
|
+
if (placement.type === "onto") return placement.layerId;
|
|
325
|
+
if (placement.type === "between") {
|
|
326
|
+
return `preview:${draggedKey}:between:${placement.beforeLayerId}:${placement.afterLayerId}`;
|
|
327
|
+
}
|
|
328
|
+
return `preview:${draggedKey}:${placement.type}:${placement.layerId}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function getPreviewLayerIndex(
|
|
332
|
+
layerOrder: readonly TimelineLayerId[],
|
|
333
|
+
placement: TimelineLayerDropPlacement,
|
|
334
|
+
): number {
|
|
335
|
+
if (placement.type === "onto") return Math.max(0, layerOrder.indexOf(placement.layerId));
|
|
336
|
+
if (placement.type === "between") {
|
|
337
|
+
return Math.max(0, layerOrder.indexOf(placement.afterLayerId));
|
|
338
|
+
}
|
|
339
|
+
const targetIndex = Math.max(0, layerOrder.indexOf(placement.layerId));
|
|
340
|
+
return placement.type === "above" ? targetIndex : targetIndex + 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function resolveTimelineLayerStackingMove(input: {
|
|
344
|
+
element: TimelineStackingElement;
|
|
345
|
+
layers: readonly StackingTimelineLayer[];
|
|
346
|
+
layerOrder: readonly TimelineLayerId[];
|
|
347
|
+
trackDeltaRaw: number;
|
|
348
|
+
}): TimelineLayerStackingMoveResolution | null {
|
|
349
|
+
if (isAudioElement(input.element)) return null;
|
|
350
|
+
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
|
|
351
|
+
const layerById = new Map(input.layers.map((layer) => [layer.id, layer]));
|
|
352
|
+
const contextLayers = input.layerOrder
|
|
353
|
+
.map((id) => layerById.get(id) ?? null)
|
|
354
|
+
.filter(
|
|
355
|
+
(layer): layer is StackingTimelineLayer =>
|
|
356
|
+
layer != null && layer.kind === "visual" && layer.contextKey === contextKey,
|
|
357
|
+
);
|
|
358
|
+
const draggedKey = getTimelineElementIdentity(input.element);
|
|
359
|
+
const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey));
|
|
360
|
+
if (currentIndex < 0) return null;
|
|
361
|
+
|
|
362
|
+
const targetPosition = currentIndex + input.trackDeltaRaw;
|
|
363
|
+
const rawPlacement = resolveDragPlacement(contextLayers, targetPosition);
|
|
364
|
+
if (!rawPlacement) return null;
|
|
365
|
+
const placement = resolveLaneAwareDragPlacement({
|
|
366
|
+
layers: contextLayers,
|
|
367
|
+
element: input.element,
|
|
368
|
+
draggedKey,
|
|
369
|
+
placement: rawPlacement,
|
|
370
|
+
targetPosition,
|
|
371
|
+
currentIndex,
|
|
372
|
+
});
|
|
373
|
+
if (!placement) return null;
|
|
374
|
+
return {
|
|
375
|
+
previewLayerId: getPreviewLayerId(draggedKey, placement),
|
|
376
|
+
previewLayerIndex: getPreviewLayerIndex(input.layerOrder, placement),
|
|
377
|
+
stackingReorder: resolveTimelineLayerZIndexChanges({
|
|
378
|
+
element: input.element,
|
|
379
|
+
layers: contextLayers,
|
|
380
|
+
placement,
|
|
381
|
+
}),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeTimelineBasisDuration, computeTimelineEffectiveDuration } from "./timelineLayout";
|
|
3
|
+
|
|
4
|
+
describe("computeTimelineBasisDuration", () => {
|
|
5
|
+
it("uses the root duration when it exceeds every clip end", () => {
|
|
6
|
+
expect(computeTimelineBasisDuration(12, [4, 6, 9])).toBe(12);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("grows to the furthest committed clip end past the root duration", () => {
|
|
10
|
+
expect(computeTimelineBasisDuration(12, [4, 18, 9])).toBe(18);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("falls back to the root duration with no clips / non-finite ends", () => {
|
|
14
|
+
expect(computeTimelineBasisDuration(10, [])).toBe(10);
|
|
15
|
+
expect(computeTimelineBasisDuration(Number.NaN, [])).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("computeTimelineEffectiveDuration", () => {
|
|
20
|
+
it("returns the basis when there is no active preview", () => {
|
|
21
|
+
expect(computeTimelineEffectiveDuration(12, [null, null])).toBe(12);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("extends to a drag/resize preview end beyond the basis", () => {
|
|
25
|
+
expect(computeTimelineEffectiveDuration(12, [20, null])).toBe(20);
|
|
26
|
+
expect(computeTimelineEffectiveDuration(12, [null, 16])).toBe(16);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("never shrinks below the basis for a preview inside the current length", () => {
|
|
30
|
+
// The invariant behind the jump fix: the basis (which drives zoom) is
|
|
31
|
+
// independent of the preview, and a smaller preview end can't reduce it.
|
|
32
|
+
expect(computeTimelineEffectiveDuration(12, [8])).toBe(12);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -9,6 +9,37 @@ export const CLIP_Y = 3;
|
|
|
9
9
|
export const CLIP_HANDLE_W = 18;
|
|
10
10
|
const TIMELINE_SCROLL_BUFFER = 20;
|
|
11
11
|
|
|
12
|
+
/* ── Timeline duration ─────────────────────────────────────────────── */
|
|
13
|
+
|
|
14
|
+
// Committed timeline length: root duration or the furthest committed clip end,
|
|
15
|
+
// with NO live drag/resize preview. This drives the zoom (fit-to-width pps) so
|
|
16
|
+
// the pixels-per-second mapping stays fixed while you drag — otherwise a clip
|
|
17
|
+
// dragged past the end grows the duration, shrinks pps, and jumps under the
|
|
18
|
+
// pointer (a positive-feedback loop). The zoom re-fits once on drop.
|
|
19
|
+
export function computeTimelineBasisDuration(
|
|
20
|
+
rootDuration: number,
|
|
21
|
+
clipEnds: readonly number[],
|
|
22
|
+
): number {
|
|
23
|
+
const safeDur = Number.isFinite(rootDuration) ? rootDuration : 0;
|
|
24
|
+
if (clipEnds.length === 0) return safeDur;
|
|
25
|
+
const maxEnd = Math.max(safeDur, ...clipEnds);
|
|
26
|
+
return Number.isFinite(maxEnd) ? maxEnd : safeDur;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Displayed length: the basis plus any active drag/resize preview end, so the
|
|
30
|
+
// ruler and track width grow to follow a clip dragged past the current end
|
|
31
|
+
// (with the zoom held fixed, the extra length becomes scrollable content).
|
|
32
|
+
export function computeTimelineEffectiveDuration(
|
|
33
|
+
basisDuration: number,
|
|
34
|
+
previewEnds: readonly (number | null)[],
|
|
35
|
+
): number {
|
|
36
|
+
let maxEnd = basisDuration;
|
|
37
|
+
for (const end of previewEnds) {
|
|
38
|
+
if (end != null && Number.isFinite(end)) maxEnd = Math.max(maxEnd, end);
|
|
39
|
+
}
|
|
40
|
+
return maxEnd;
|
|
41
|
+
}
|
|
42
|
+
|
|
12
43
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
13
44
|
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
14
45
|
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
@@ -56,6 +87,22 @@ export function generateTicks(
|
|
|
56
87
|
return { major, minor };
|
|
57
88
|
}
|
|
58
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Ticks spanning the full visible ruler width, not just the composition, so a
|
|
92
|
+
* zoomed-out ruler stays filled with labels instead of ending mid-panel. The
|
|
93
|
+
* major/minor interval is driven by pixelsPerSecond (pixel spacing), so widening
|
|
94
|
+
* the range keeps spacing identical — it only adds ticks past the content end.
|
|
95
|
+
*/
|
|
96
|
+
export function generateVisibleTicks(
|
|
97
|
+
effectiveDuration: number,
|
|
98
|
+
pixelsPerSecond: number,
|
|
99
|
+
viewportWidth: number,
|
|
100
|
+
gutter: number,
|
|
101
|
+
): { major: number[]; minor: number[] } {
|
|
102
|
+
const visible = viewportWidth > gutter ? (viewportWidth - gutter) / pixelsPerSecond : 0;
|
|
103
|
+
return generateTicks(Math.max(effectiveDuration, visible), pixelsPerSecond);
|
|
104
|
+
}
|
|
105
|
+
|
|
59
106
|
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
60
107
|
if (!Number.isFinite(time)) return "0:00";
|
|
61
108
|
const safeTime = Math.max(0, time);
|