@hyperframes/studio 0.8.91 → 0.8.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-zT2Bdx_l.js → hyperframes-player-DKJPDHUp.js} +1 -1
- package/dist/assets/{index-V-0rfhe6.js → index-C5_j5-3u.js} +153 -153
- package/dist/assets/{index-DSOLcE8X.js → index-CHZ4kFWm.js} +1 -1
- package/dist/assets/{index-DV-z19zB.js → index-DpzpqJ_j.js} +1 -1
- package/dist/index.d.ts +6 -11
- package/dist/index.html +1 -1
- package/dist/index.js +8770 -8791
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
- package/src/hooks/useTimelineAssetDropOps.ts +40 -18
- package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
- package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
- package/src/player/components/timelineClipDragCommit.test.ts +14 -58
- package/src/player/components/timelineClipDragCommit.ts +14 -37
- package/src/player/components/timelineClipDragPreview.test.ts +213 -22
- package/src/player/components/timelineClipDragPreview.ts +46 -81
- package/src/player/components/timelineClipDragTypes.ts +3 -8
- package/src/player/components/timelineCollision.test.ts +100 -302
- package/src/player/components/timelineCollision.ts +73 -160
- package/src/player/components/timelineDragDrop.test.tsx +19 -5
- package/src/player/components/timelineDragDrop.ts +7 -9
- package/src/player/components/timelineDragLanding.ts +0 -49
- package/src/player/components/timelineLayout.test.ts +0 -25
- package/src/player/components/timelineLayout.ts +0 -16
- package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
- package/src/player/components/timelineMultiDragPreview.ts +25 -27
- package/src/player/components/timelineProviderStateBuilders.ts +7 -4
- package/src/player/components/timelineShellMeta.tsx +99 -0
- package/src/player/components/useTimelineDragPreviewState.ts +34 -0
- package/src/player/components/useTimelineProviderState.tsx +29 -67
- package/src/utils/timelineDropTrackInsert.test.ts +60 -1
- package/src/utils/timelineDropTrackInsert.ts +36 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.92",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.92",
|
|
64
|
+
"@hyperframes/sdk": "0.8.92",
|
|
65
|
+
"@hyperframes/parsers": "0.8.92",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.92",
|
|
67
|
+
"@hyperframes/player": "0.8.92",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.92"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.92"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -6,6 +6,7 @@ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
|
6
6
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import type { TimelineElement } from "../player";
|
|
9
|
+
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
|
|
9
10
|
|
|
10
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
12
|
|
|
@@ -140,6 +141,143 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
140
141
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
141
142
|
});
|
|
142
143
|
|
|
144
|
+
// A clip shown on timeline row `row` and written on file track `track`.
|
|
145
|
+
const rowClip = (
|
|
146
|
+
id: string,
|
|
147
|
+
start: number,
|
|
148
|
+
duration: number,
|
|
149
|
+
shown: { tag?: string; row?: number; track?: number } = {},
|
|
150
|
+
): TimelineElement => ({
|
|
151
|
+
id,
|
|
152
|
+
key: id,
|
|
153
|
+
tag: shown.tag ?? "div",
|
|
154
|
+
start,
|
|
155
|
+
duration,
|
|
156
|
+
track: shown.row ?? 0,
|
|
157
|
+
authoredTrack: shown.track ?? shown.row ?? 0,
|
|
158
|
+
hfId: `hf-${id}`,
|
|
159
|
+
domId: id,
|
|
160
|
+
});
|
|
161
|
+
const rowSource = (clips: TimelineElement[]) =>
|
|
162
|
+
[
|
|
163
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080">',
|
|
164
|
+
...clips.map(
|
|
165
|
+
(c) =>
|
|
166
|
+
`<${c.tag} data-hf-id="hf-${c.id}" id="${c.id}" data-start="${c.start}" data-duration="${c.duration}" data-track-index="${c.authoredTrack}"></${c.tag}>`,
|
|
167
|
+
),
|
|
168
|
+
"</main>",
|
|
169
|
+
].join("\n");
|
|
170
|
+
const writtenClip = (written: string, tag: string) =>
|
|
171
|
+
written.match(new RegExp(`<${tag} id="[^"]*"[^>]*>`))?.[0] ?? "";
|
|
172
|
+
|
|
173
|
+
it("lands an asset dropped on an occupied row at that row's nearest free time", async () => {
|
|
174
|
+
const subtitle = rowClip("subtitle", 0, 6);
|
|
175
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
176
|
+
const getDrop = renderDropHook(rowSource([subtitle]), writeProjectFile, [subtitle]);
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
await getDrop()("clip.mp4", { start: 2, track: 0 }, 5);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
183
|
+
const clip = writtenClip(written, "video");
|
|
184
|
+
expect(clip).toContain('data-start="6"');
|
|
185
|
+
expect(clip).toContain('data-track-index="0"');
|
|
186
|
+
expect(written).toContain(
|
|
187
|
+
'id="subtitle" data-start="0" data-duration="6" data-track-index="0"',
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Drops two files (3 s images by default) onto `clips`; returns each written clip's start and track.
|
|
192
|
+
async function twoFileDrop(
|
|
193
|
+
clips: TimelineElement[],
|
|
194
|
+
placement: TimelineDropPlacement,
|
|
195
|
+
[names, tag] = [["one.png", "two.png"], "img"] as [string[], string],
|
|
196
|
+
) {
|
|
197
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
198
|
+
const uploadProjectFiles = vi.fn().mockResolvedValue(names);
|
|
199
|
+
const getDrop = renderDropHook(
|
|
200
|
+
rowSource(clips),
|
|
201
|
+
writeProjectFile,
|
|
202
|
+
clips,
|
|
203
|
+
undefined,
|
|
204
|
+
uploadProjectFiles,
|
|
205
|
+
);
|
|
206
|
+
const files = names.map((name) => new File([name], name));
|
|
207
|
+
await act(async () => {
|
|
208
|
+
await getDrop.file()(files, placement);
|
|
209
|
+
});
|
|
210
|
+
return writeProjectFile.mock.calls.map(([, written]) => {
|
|
211
|
+
const img = writtenClip(written as string, tag);
|
|
212
|
+
return [img.match(/data-start="([^"]*)"/)?.[1], img.match(/data-track-index="([^"]*)"/)?.[1]];
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it("keeps a multi-file drop's clips off each other and off the row's clips", async () => {
|
|
217
|
+
const landed = await twoFileDrop([rowClip("a", 0, 4), rowClip("b", 8, 2)], {
|
|
218
|
+
start: 1,
|
|
219
|
+
track: 0,
|
|
220
|
+
});
|
|
221
|
+
// The first moves past a [0,4); the second is too long for [7,8) and goes after b.
|
|
222
|
+
expect(landed).toEqual([
|
|
223
|
+
["4", "0"],
|
|
224
|
+
["10", "0"],
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("puts a multi-file drop that opens a track back to back on that new track", async () => {
|
|
229
|
+
const landed = await twoFileDrop([rowClip("a", 0, 10)], {
|
|
230
|
+
start: 2,
|
|
231
|
+
track: 0,
|
|
232
|
+
insertRow: 0,
|
|
233
|
+
trackOrder: [0],
|
|
234
|
+
});
|
|
235
|
+
// Clip a moved down a row, so it no longer blocks the second file.
|
|
236
|
+
expect(landed).toEqual([
|
|
237
|
+
["2", "0"],
|
|
238
|
+
["5", "0"],
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("keeps a multi-file drop on the file track of the row it was dropped on", async () => {
|
|
243
|
+
const clips = [
|
|
244
|
+
rowClip("title", 0, 10, { track: 2 }),
|
|
245
|
+
rowClip("mid", 0, 1, { row: 1, track: 3 }),
|
|
246
|
+
rowClip("other", 0, 1, { row: 2, track: 5 }),
|
|
247
|
+
];
|
|
248
|
+
// Row 0 is file track 2; the second file must not read that 2 back as row 2 (track 5).
|
|
249
|
+
expect(await twoFileDrop(clips, { start: 3, track: 0 })).toEqual([
|
|
250
|
+
["10", "2"],
|
|
251
|
+
["13", "2"],
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("reads a row's file track from every clip of the file, whether or not it names the file", async () => {
|
|
256
|
+
const unnamed = rowClip("unnamed", 0, 1, { row: 2, track: 0 });
|
|
257
|
+
const title = { ...rowClip("title", 0, 10), sourceFile: "index.html" };
|
|
258
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
259
|
+
const getDrop = renderDropHook(rowSource([unnamed, title]), writeProjectFile, [unnamed, title]);
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await getDrop()("pic.png", { start: 2, track: 0 }, 3);
|
|
262
|
+
});
|
|
263
|
+
const img = writtenClip(writeProjectFile.mock.calls[0][1] as string, "img");
|
|
264
|
+
expect(img).toContain('data-track-index="0"');
|
|
265
|
+
expect(img).toContain('data-start="10"');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("keeps two audio files dropped on a visual row together, as a single one would go", async () => {
|
|
269
|
+
const clips = [
|
|
270
|
+
rowClip("title", 0, 10, { track: 1 }),
|
|
271
|
+
rowClip("music", 2, 3, { tag: "audio", row: 1, track: 0 }),
|
|
272
|
+
];
|
|
273
|
+
const landed = await twoFileDrop(clips, { start: 12, track: 0 }, [["a.mp3", "b.mp3"], "audio"]);
|
|
274
|
+
// Audio length probing times out in this harness, so each file gets the 5 s default.
|
|
275
|
+
expect(landed).toEqual([
|
|
276
|
+
["12", "0"],
|
|
277
|
+
["17", "0"],
|
|
278
|
+
]);
|
|
279
|
+
}, 30_000);
|
|
280
|
+
|
|
143
281
|
it("refuses an asset drop before reading or writing when editing is blocked", async () => {
|
|
144
282
|
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
145
283
|
const checkEditable = vi.fn(() => false);
|
|
@@ -30,14 +30,20 @@ import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
|
30
30
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
31
31
|
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
32
32
|
|
|
33
|
-
/** The first uploaded file opens the new track (if asked);
|
|
33
|
+
/** The first uploaded file opens the new track (if asked); each next one aims right after the previous. */
|
|
34
34
|
function fileDropPlacement(
|
|
35
35
|
index: number,
|
|
36
36
|
next: { start: number; track: number },
|
|
37
37
|
dropped: TimelineDropPlacement | undefined,
|
|
38
|
-
|
|
38
|
+
previous: TimelineElement | undefined,
|
|
39
39
|
): TimelineDropPlacement {
|
|
40
|
-
|
|
40
|
+
if (index === 0) return { ...dropped, ...next };
|
|
41
|
+
return previous ? { start: previous.start + previous.duration, track: previous.track } : next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DropGesture {
|
|
45
|
+
placed: TimelineElement[];
|
|
46
|
+
onNewTrack: boolean;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
function timelineDropTarget(
|
|
@@ -90,7 +96,8 @@ export function useTimelineAssetDropOps({
|
|
|
90
96
|
assetPath: string,
|
|
91
97
|
placement: TimelineDropPlacement,
|
|
92
98
|
durationOverride?: number,
|
|
93
|
-
|
|
99
|
+
gesture: DropGesture = { placed: [], onNewTrack: false },
|
|
100
|
+
): Promise<TimelineElement | undefined> => {
|
|
94
101
|
if (isRecordingRef?.current) {
|
|
95
102
|
showToast("Cannot edit timeline while recording", "error");
|
|
96
103
|
return undefined;
|
|
@@ -121,28 +128,32 @@ export function useTimelineAssetDropOps({
|
|
|
121
128
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
122
129
|
|
|
123
130
|
const resolvedTargetPath = targetPath || "index.html";
|
|
124
|
-
const
|
|
125
|
-
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath
|
|
126
|
-
|
|
127
|
-
const newElementZIndex = Math.max(1,
|
|
131
|
+
const fileElements = timelineElements
|
|
132
|
+
.filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath)
|
|
133
|
+
.map((te) => ({ ...te, sourceFile: resolvedTargetPath }));
|
|
134
|
+
const newElementZIndex = Math.max(1, fileElements.length + gesture.placed.length + 1);
|
|
135
|
+
const tag = kind === "image" ? "img" : kind;
|
|
128
136
|
|
|
129
137
|
let newId = "";
|
|
130
138
|
let track = 0;
|
|
139
|
+
let start = normalizedStart;
|
|
131
140
|
const insertAsset = (originalContent: string) => {
|
|
132
141
|
newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
133
142
|
const resolved = resolveDropTrack({
|
|
134
143
|
source: originalContent,
|
|
135
|
-
// insertRow
|
|
136
|
-
elements:
|
|
144
|
+
// Rows and insertRow count the rows the timeline shows; a track this drop opened has no others.
|
|
145
|
+
elements: gesture.onNewTrack ? [] : fileElements,
|
|
146
|
+
placed: gesture.placed,
|
|
137
147
|
placement,
|
|
138
148
|
dropped: {
|
|
139
149
|
id: newId,
|
|
140
|
-
tag
|
|
150
|
+
tag,
|
|
141
151
|
start: normalizedStart,
|
|
142
152
|
duration: normalizedDuration,
|
|
143
153
|
},
|
|
144
154
|
});
|
|
145
155
|
track = resolved.track;
|
|
156
|
+
start = resolved.start;
|
|
146
157
|
return extendRootDurationInSource(
|
|
147
158
|
insertTimelineAssetIntoSource(
|
|
148
159
|
resolved.source,
|
|
@@ -151,7 +162,7 @@ export function useTimelineAssetDropOps({
|
|
|
151
162
|
hfId: `hf-${generateId()}`,
|
|
152
163
|
assetPath: resolvedAssetSrc,
|
|
153
164
|
kind,
|
|
154
|
-
start
|
|
165
|
+
start,
|
|
155
166
|
duration: normalizedDuration,
|
|
156
167
|
track,
|
|
157
168
|
zIndex: newElementZIndex,
|
|
@@ -162,7 +173,7 @@ export function useTimelineAssetDropOps({
|
|
|
162
173
|
),
|
|
163
174
|
}),
|
|
164
175
|
),
|
|
165
|
-
|
|
176
|
+
start + normalizedDuration,
|
|
166
177
|
);
|
|
167
178
|
};
|
|
168
179
|
|
|
@@ -178,7 +189,15 @@ export function useTimelineAssetDropOps({
|
|
|
178
189
|
selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
|
|
179
190
|
forceReloadSdkSession?.();
|
|
180
191
|
reloadPreview();
|
|
181
|
-
|
|
192
|
+
const lane = placement.insertRow == null ? placement.track : track;
|
|
193
|
+
return {
|
|
194
|
+
id: newId,
|
|
195
|
+
tag,
|
|
196
|
+
start,
|
|
197
|
+
duration: normalizedDuration,
|
|
198
|
+
track: lane,
|
|
199
|
+
authoredTrack: track,
|
|
200
|
+
};
|
|
182
201
|
} catch (error) {
|
|
183
202
|
const message =
|
|
184
203
|
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
|
@@ -234,15 +253,18 @@ export function useTimelineAssetDropOps({
|
|
|
234
253
|
placement ?? { start: 0, track: 0 },
|
|
235
254
|
durations,
|
|
236
255
|
);
|
|
237
|
-
|
|
256
|
+
const gesture: DropGesture = { placed: [], onNewTrack: false };
|
|
238
257
|
for (const [index, assetPath] of uploaded.entries()) {
|
|
239
258
|
const next = placements[index] ?? placements[0];
|
|
240
|
-
const
|
|
259
|
+
const clip = await dropAssetAt(
|
|
241
260
|
assetPath,
|
|
242
|
-
fileDropPlacement(index, next, placement,
|
|
261
|
+
fileDropPlacement(index, next, placement, gesture.placed.at(-1)),
|
|
243
262
|
durations[index],
|
|
263
|
+
gesture,
|
|
244
264
|
);
|
|
245
|
-
if (
|
|
265
|
+
if (!clip) continue;
|
|
266
|
+
gesture.placed.push(clip);
|
|
267
|
+
if (index === 0 && placement?.insertRow != null) gesture.onNewTrack = true;
|
|
246
268
|
}
|
|
247
269
|
},
|
|
248
270
|
[
|
|
@@ -89,16 +89,16 @@ async function seekOnFirstTick(
|
|
|
89
89
|
|
|
90
90
|
describe("Timeline ruler origin", () => {
|
|
91
91
|
it.each([
|
|
92
|
-
["keyframes shown", { showKeyframes: true }, false],
|
|
93
|
-
["keyframes hidden", { showKeyframes: false }, false],
|
|
94
|
-
["keyframes shown, arriving after mount", { showKeyframes: true }, true],
|
|
95
|
-
["keyframes hidden, arriving after mount", { showKeyframes: false }, true],
|
|
92
|
+
["keyframes shown", { showKeyframes: true }, false, 264],
|
|
93
|
+
["keyframes hidden", { showKeyframes: false }, false, 80],
|
|
94
|
+
["keyframes shown, arriving after mount", { showKeyframes: true }, true, 264],
|
|
95
|
+
["keyframes hidden, arriving after mount", { showKeyframes: false }, true, 80],
|
|
96
96
|
])(
|
|
97
97
|
"publishes where the ruler draws 0 s, and a press seeks by it (%s)",
|
|
98
|
-
async (_case, props, late) => {
|
|
98
|
+
async (_case, props, late, expected) => {
|
|
99
99
|
const { time, tickTime, origin, published } = await seekOnFirstTick(props, late);
|
|
100
|
-
expect(origin).toBe(
|
|
101
|
-
expect(published).toBe(
|
|
100
|
+
expect(origin).toBe(`${expected}px`);
|
|
101
|
+
expect(published).toBe(String(expected));
|
|
102
102
|
expect(time).toBeCloseTo(tickTime, 1);
|
|
103
103
|
},
|
|
104
104
|
);
|
|
@@ -84,8 +84,34 @@ describe("Timeline showKeyframes", () => {
|
|
|
84
84
|
expect((await mountKeyframed(false)).lanes).toBe(false);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
it("
|
|
87
|
+
it("opens the track-name column for a keyframed clip only while keyframes are shown", async () => {
|
|
88
88
|
expect((await mountKeyframed()).named).toBe(true);
|
|
89
|
-
expect((await mountKeyframed(false)).named).toBe(
|
|
89
|
+
expect((await mountKeyframed(false)).named).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("still opens the column for an audio group when keyframes are off", async () => {
|
|
93
|
+
usePlayerStore.setState({
|
|
94
|
+
duration: 10,
|
|
95
|
+
timelineReady: true,
|
|
96
|
+
gsapAnimations: new Map(),
|
|
97
|
+
elements: [
|
|
98
|
+
{
|
|
99
|
+
id: "vo",
|
|
100
|
+
label: "Voice",
|
|
101
|
+
tag: "audio",
|
|
102
|
+
start: 0,
|
|
103
|
+
duration: 4,
|
|
104
|
+
track: 0,
|
|
105
|
+
audioGroup: "g",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
const host = document.createElement("div");
|
|
110
|
+
document.body.append(host);
|
|
111
|
+
const root = createRoot(host);
|
|
112
|
+
await act(async () => root.render(<Timeline showKeyframes={false} />));
|
|
113
|
+
const corner = host.querySelector<HTMLElement>(".sticky.top-0.flex > div");
|
|
114
|
+
expect(corner?.style.width).toBe("264px");
|
|
115
|
+
act(() => root.unmount());
|
|
90
116
|
});
|
|
91
117
|
});
|
|
@@ -1187,73 +1187,26 @@ describe("commitDraggedClipMove", () => {
|
|
|
1187
1187
|
});
|
|
1188
1188
|
});
|
|
1189
1189
|
|
|
1190
|
-
describe("
|
|
1191
|
-
it("a top-gutter insert that renumbers to literal track 0
|
|
1192
|
-
// Sole visual clip sits on track 1 — the real main track (0) is genuinely
|
|
1193
|
-
// empty. Dragging it into the top insert-gutter creates a new lane that
|
|
1194
|
-
// normalizeToZones renumbers to literal 0, so the main-track rule must apply
|
|
1195
|
-
// even though the preview never saw a literal-0 landing track.
|
|
1190
|
+
describe("a clip landing on an empty main track keeps its released start", () => {
|
|
1191
|
+
it("a top-gutter insert that renumbers to literal track 0 commits at the released start", () => {
|
|
1196
1192
|
const elements = [el("v1", 1, 0, 5)];
|
|
1197
1193
|
const { onMoveElements } = runClipMove(
|
|
1198
1194
|
drag(elements[0], { previewStart: 8, previewTrack: 1, insertRow: 0 }),
|
|
1199
1195
|
{ elements, trackOrder: [1] },
|
|
1200
1196
|
);
|
|
1201
|
-
|
|
1202
|
-
expect(map.v1).toEqual({ start: 0, track: 0 });
|
|
1203
|
-
});
|
|
1204
|
-
|
|
1205
|
-
it("the z-sync candidate reflects the snapped start, not the raw previewStart", async () => {
|
|
1206
|
-
// v1 snaps from previewStart 8 to 0 (see the test above). A foreign-file
|
|
1207
|
-
// sibling at [2, 5) only overlaps the SNAPPED window [0, 5), never the
|
|
1208
|
-
// raw [8, 13) — so a z-sync firing at all proves the candidate the
|
|
1209
|
-
// stacking check reads from carries the snapped start.
|
|
1210
|
-
const v1 = el("v1", 2, 0, 5);
|
|
1211
|
-
const sibling = el("s", 1, 2, 3); // [2, 5) — same source file, same paint scope
|
|
1212
|
-
const elements = [v1, sibling];
|
|
1213
|
-
const onStackingPatches = vi.fn();
|
|
1214
|
-
// v1 lands on lane 0, which paints above lane 1 (lower track paints higher),
|
|
1215
|
-
// yet carries the lower z: a violation only while the two overlap in time.
|
|
1216
|
-
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 2, insertRow: 0 }), {
|
|
1217
|
-
elements,
|
|
1218
|
-
trackOrder: [1, 2],
|
|
1219
|
-
updateElement: vi.fn(),
|
|
1220
|
-
onMoveElement: vi.fn(),
|
|
1221
|
-
onMoveElements: vi.fn(),
|
|
1222
|
-
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1223
|
-
onStackingPatches,
|
|
1224
|
-
});
|
|
1225
|
-
await flushMicrotasks();
|
|
1226
|
-
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1197
|
+
expect(editMap(onMoveElements.mock.calls[0][0]).v1).toEqual({ start: 8, track: 0 });
|
|
1227
1198
|
});
|
|
1228
1199
|
|
|
1229
|
-
it("a plain lane change onto the empty main track
|
|
1200
|
+
it("a plain lane change onto the empty main track commits at the released start", () => {
|
|
1230
1201
|
const elements = [el("v1", 1, 0, 5)];
|
|
1231
1202
|
const spies = runClipMove(drag(elements[0], { previewStart: 8, previewTrack: 0 }), {
|
|
1232
1203
|
elements,
|
|
1233
1204
|
trackOrder: [0, 1],
|
|
1234
1205
|
});
|
|
1235
|
-
expect(expectAtomicMoveMap(spies).v1).toEqual({ start:
|
|
1206
|
+
expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 8, track: 0 });
|
|
1236
1207
|
});
|
|
1237
1208
|
|
|
1238
|
-
it("
|
|
1239
|
-
// The sibling [2, 5) overlaps v1 only at the snapped [0, 5), never at the raw [8, 13).
|
|
1240
|
-
const v1 = el("v1", 2, 0, 5);
|
|
1241
|
-
const sibling = el("s", 1, 2, 3);
|
|
1242
|
-
const onStackingPatches = vi.fn();
|
|
1243
|
-
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 0 }), {
|
|
1244
|
-
elements: [v1, sibling],
|
|
1245
|
-
trackOrder: [0, 1, 2],
|
|
1246
|
-
updateElement: vi.fn(),
|
|
1247
|
-
onMoveElement: vi.fn(),
|
|
1248
|
-
onMoveElements: vi.fn(),
|
|
1249
|
-
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1250
|
-
onStackingPatches,
|
|
1251
|
-
});
|
|
1252
|
-
await flushMicrotasks();
|
|
1253
|
-
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
it("a top-gutter insert that pushes the old track-0 clip down snaps to the new track 0", () => {
|
|
1209
|
+
it("a top-gutter insert that pushes the old track-0 clip down keeps the released start", () => {
|
|
1257
1210
|
const oldMain = el("old", 0, 0, 3);
|
|
1258
1211
|
const dragged = el("v1", 2, 0, 5);
|
|
1259
1212
|
const { onMoveElements } = runClipMove(
|
|
@@ -1262,10 +1215,10 @@ describe("commitDraggedClipMove", () => {
|
|
|
1262
1215
|
);
|
|
1263
1216
|
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1264
1217
|
expect(map.old.track).toBe(1);
|
|
1265
|
-
expect(map.v1).toEqual({ start:
|
|
1218
|
+
expect(map.v1).toEqual({ start: 8, track: 0 });
|
|
1266
1219
|
});
|
|
1267
1220
|
|
|
1268
|
-
it("an expanded child dragged with its host
|
|
1221
|
+
it("an expanded child dragged with its host onto the main track keeps the host's start", () => {
|
|
1269
1222
|
for (const [hostStart, childStart] of [
|
|
1270
1223
|
[30, 32],
|
|
1271
1224
|
[20, 22],
|
|
@@ -1283,11 +1236,14 @@ describe("commitDraggedClipMove", () => {
|
|
|
1283
1236
|
selectedKeys: new Set(["host", "child"]),
|
|
1284
1237
|
},
|
|
1285
1238
|
);
|
|
1286
|
-
expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({
|
|
1239
|
+
expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({
|
|
1240
|
+
start: hostStart,
|
|
1241
|
+
track: 0,
|
|
1242
|
+
});
|
|
1287
1243
|
}
|
|
1288
1244
|
});
|
|
1289
1245
|
|
|
1290
|
-
it("a multi-selection top-gutter insert
|
|
1246
|
+
it("a multi-selection top-gutter insert keeps the grabbed clip's start", () => {
|
|
1291
1247
|
const dragged = el("v1", 1, 0, 5);
|
|
1292
1248
|
const sibling = el("v2", 1, 10, 5);
|
|
1293
1249
|
const elements = [dragged, sibling];
|
|
@@ -1296,7 +1252,7 @@ describe("commitDraggedClipMove", () => {
|
|
|
1296
1252
|
{ elements, trackOrder: [1], selectedKeys: new Set(["v1", "v2"]) },
|
|
1297
1253
|
);
|
|
1298
1254
|
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1299
|
-
expect(map.v1.start).toBe(8);
|
|
1255
|
+
expect(map.v1.start).toBe(8);
|
|
1300
1256
|
});
|
|
1301
1257
|
});
|
|
1302
1258
|
});
|
|
@@ -5,7 +5,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
|
5
5
|
// value-import edge stays acyclic.
|
|
6
6
|
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
|
7
7
|
import { classifyZone } from "./timelineZones";
|
|
8
|
-
import { layoutAfterTrackInsert
|
|
8
|
+
import { layoutAfterTrackInsert } from "./timelineDragLanding";
|
|
9
9
|
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
10
10
|
import {
|
|
11
11
|
canMoveTimelineElement as canMoveElement,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
20
20
|
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
21
21
|
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
|
22
|
+
import { resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
22
23
|
|
|
23
24
|
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
24
25
|
export interface TimelineMoveEdit {
|
|
@@ -63,9 +64,9 @@ export interface DragCommitDeps {
|
|
|
63
64
|
* the edited clip(s) get z-index patches so their canvas stacking matches lane
|
|
64
65
|
* order (higher lane = on top) relative to time-overlapping clips — see
|
|
65
66
|
* timelineStackingSync. Both deps must be supplied to engage; if either is
|
|
66
|
-
* absent the z-sync is skipped (pure time-moves
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* absent the z-sync is skipped (pure time-moves never restack). `readZIndex`
|
|
68
|
+
* returns the clip's current z-index (from the live DOM inline style / computed;
|
|
69
|
+
* "auto" ⇒ 0).
|
|
69
70
|
*/
|
|
70
71
|
readZIndex?: (element: TimelineElement) => number;
|
|
71
72
|
/**
|
|
@@ -191,41 +192,20 @@ function resolveMultiSelection(
|
|
|
191
192
|
keys: ReadonlySet<string>;
|
|
192
193
|
movedStart: (e: TimelineElement) => number;
|
|
193
194
|
} | null {
|
|
194
|
-
const { elements, selectedKeys } = deps;
|
|
195
195
|
const dragKey = keyOf(drag.element);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const el = elements.find((e) => keyOf(e) === k);
|
|
200
|
-
return el ? canMoveElement(el) : false;
|
|
201
|
-
}),
|
|
202
|
-
);
|
|
196
|
+
const movers = resolveGroupMovers(deps.elements, deps.selectedKeys, dragKey);
|
|
197
|
+
if (!movers) return null;
|
|
198
|
+
const keys = new Set(movers.map(keyOf));
|
|
203
199
|
const delta = drag.previewStart - drag.element.start;
|
|
204
200
|
const movedStart = (e: TimelineElement): number =>
|
|
205
201
|
keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
|
|
206
202
|
return { keys, movedStart };
|
|
207
203
|
}
|
|
208
204
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*
|
|
212
|
-
* The lane model is CapCut-stable: a clip's display lane is its track, and editing
|
|
213
|
-
* ONE clip must never re-lane or rewrite OTHER clips. Three outcomes:
|
|
214
|
-
*
|
|
215
|
-
* - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the
|
|
216
|
-
* dragged clip's start (multi-selection shifts every selected clip in time).
|
|
217
|
-
* - **Lane change / collision relocation** (the dragged clip's OWN lane changes,
|
|
218
|
-
* no new track): persist ONLY the dragged clip's start + lane. No other clip is
|
|
219
|
-
* touched. z is synced only when the gesture is a DELIBERATE vertical move
|
|
220
|
-
* (the pointer aimed at another lane) — a horizontal drag merely bumped to a
|
|
221
|
-
* free lane never restacks.
|
|
222
|
-
* - **Track insert** (a new lane at a gap boundary): the dragged clip lands on
|
|
223
|
-
* the new lane and the clips at/below the insert are renumbered by +1 (the ONLY
|
|
224
|
-
* permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
|
|
225
|
-
*/
|
|
205
|
+
// A move writes the dragged clip's start (each moving clip's, in a group); a lane change also writes its lane and
|
|
206
|
+
// re-lanes no other clip; only a new track renumbers the rows below it.
|
|
226
207
|
// fallow-ignore-next-line complexity
|
|
227
|
-
export function commitDraggedClipMove(
|
|
228
|
-
const drag = { ...rawDrag, previewStart: resolveDragLandingStart(rawDrag, deps) };
|
|
208
|
+
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
|
|
229
209
|
const hostAlias = resolveExpandedHostAlias(drag, deps);
|
|
230
210
|
if (hostAlias) {
|
|
231
211
|
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
|
|
@@ -236,11 +216,8 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
|
|
|
236
216
|
const dragKey = keyOf(drag.element);
|
|
237
217
|
const isInsert = drag.insertRow != null;
|
|
238
218
|
const laneChanged = drag.previewTrack !== drag.element.track;
|
|
239
|
-
// Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at
|
|
240
|
-
//
|
|
241
|
-
// gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the
|
|
242
|
-
// pointer never left its lane (desiredTrack === element.track) — that is NOT a
|
|
243
|
-
// vertical move: it must neither rewrite other clips nor touch z.
|
|
219
|
+
// Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at the
|
|
220
|
+
// empty space outside the rows (insert). Anything else never touches z.
|
|
244
221
|
const aimTrack = drag.desiredTrack ?? drag.previewTrack;
|
|
245
222
|
const isVertical = isInsert || aimTrack !== drag.element.track;
|
|
246
223
|
const multi = resolveMultiSelection(drag, deps);
|
|
@@ -279,7 +256,7 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
|
|
|
279
256
|
return;
|
|
280
257
|
}
|
|
281
258
|
|
|
282
|
-
// ── Lane change
|
|
259
|
+
// ── Lane change: persist ONLY the dragged clip ──────────────────────────────
|
|
283
260
|
// CapCut invariant — one edit never re-lanes another clip. The dragged clip
|
|
284
261
|
// takes its new lane (previewTrack); the rest of any selection shifts in time
|
|
285
262
|
// only. Nothing else is written.
|