@hyperframes/studio 0.8.48 → 0.8.49
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-CqON2Rzo.js +464 -0
- package/dist/assets/{index-B3-orIjW.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-Bgmx2h7W.js} +135 -135
- package/dist/assets/{index-D_j-0PD3.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.html +1 -1
- package/dist/index.js +443 -303
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.49",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/player": "0.8.
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
51
|
+
"@hyperframes/parsers": "0.8.49",
|
|
52
|
+
"@hyperframes/studio-server": "0.8.49",
|
|
53
|
+
"@hyperframes/player": "0.8.49",
|
|
54
|
+
"@hyperframes/sdk": "0.8.49",
|
|
55
|
+
"@hyperframes/core": "0.8.49"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^4.1.11",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.49"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
|
@@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
|
|
|
6
6
|
import type { TimelineElement } from "../player";
|
|
7
7
|
import { usePlayerStore } from "../player";
|
|
8
8
|
import { addBlockToProject } from "../utils/blockInstaller";
|
|
9
|
+
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
9
10
|
import type { BlockParam } from "@hyperframes/core/registry";
|
|
10
11
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
11
12
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
@@ -148,7 +149,9 @@ export function useBlockHandlers({
|
|
|
148
149
|
previewIframe: previewIframeRef.current,
|
|
149
150
|
currentTime: usePlayerStore.getState().currentTime,
|
|
150
151
|
}),
|
|
151
|
-
)
|
|
152
|
+
).then((result) => {
|
|
153
|
+
if (result) selectAndRevealTimelineElement(result.hostKey);
|
|
154
|
+
});
|
|
152
155
|
},
|
|
153
156
|
[projectId, blockCtx, previewIframeRef, runBlockInstall],
|
|
154
157
|
);
|
|
@@ -184,7 +187,9 @@ export function useBlockHandlers({
|
|
|
184
187
|
previewIframe: previewIframeRef.current,
|
|
185
188
|
currentTime: usePlayerStore.getState().currentTime,
|
|
186
189
|
}),
|
|
187
|
-
)
|
|
190
|
+
).then((result) => {
|
|
191
|
+
if (result) selectAndRevealTimelineElement(result.hostKey);
|
|
192
|
+
});
|
|
188
193
|
},
|
|
189
194
|
[projectId, blockCtx, previewIframeRef, runBlockInstall],
|
|
190
195
|
);
|
|
@@ -4,12 +4,14 @@ import React, { act } from "react";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
6
6
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
10
|
|
|
10
11
|
afterEach(() => {
|
|
11
12
|
document.body.innerHTML = "";
|
|
12
13
|
vi.unstubAllGlobals();
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
|
|
@@ -69,4 +71,17 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
69
71
|
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
70
72
|
expect(written).toContain('data-duration="10"');
|
|
71
73
|
});
|
|
74
|
+
|
|
75
|
+
it("selects and reveals the newly dropped clip", async () => {
|
|
76
|
+
const source =
|
|
77
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
|
|
78
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
79
|
+
const getDrop = renderDropHook(source, writeProjectFile);
|
|
80
|
+
|
|
81
|
+
await act(async () => {
|
|
82
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
|
|
86
|
+
});
|
|
72
87
|
});
|
|
@@ -21,7 +21,8 @@ import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
|
21
21
|
import { readFileContent } from "./timelineTimingSync";
|
|
22
22
|
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
23
23
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
24
|
-
import {
|
|
24
|
+
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
25
|
+
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
25
26
|
|
|
26
27
|
interface UseTimelineAssetDropOpsOptions {
|
|
27
28
|
projectIdRef: MutableRefObject<string | null>;
|
|
@@ -121,6 +122,7 @@ export function useTimelineAssetDropOps({
|
|
|
121
122
|
recordEdit,
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
|
|
124
126
|
forceReloadSdkSession?.();
|
|
125
127
|
reloadPreview();
|
|
126
128
|
} catch (error) {
|
|
@@ -194,7 +196,7 @@ export function useTimelineAssetDropOps({
|
|
|
194
196
|
writeFile: writeProjectFile,
|
|
195
197
|
recordEdit,
|
|
196
198
|
observeVersion: observeProjectFileVersion,
|
|
197
|
-
selectHost:
|
|
199
|
+
selectHost: selectAndRevealTimelineElement,
|
|
198
200
|
resync: forceReloadSdkSession,
|
|
199
201
|
refresh: reloadPreview,
|
|
200
202
|
});
|
|
@@ -175,7 +175,70 @@ describe("useTimelineSelectionPreviewSync", () => {
|
|
|
175
175
|
harness.cleanup();
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it("
|
|
178
|
+
it("does not clear the selection when the selected clip is not in timelineElements yet", async () => {
|
|
179
|
+
// A freshly-dropped clip is selected before the reload that adds it to
|
|
180
|
+
// timelineElements has completed. Bailing quietly here (like the
|
|
181
|
+
// DOM-node-not-ready case above) lets the later effect run, once the
|
|
182
|
+
// element exists, apply the selection instead of wiping it.
|
|
183
|
+
const { timelineElements } = makeSyncFixture();
|
|
184
|
+
const applyDomSelection = vi.fn();
|
|
185
|
+
const applyMarqueeSelection = vi.fn();
|
|
186
|
+
const onSelectionNotFound = vi.fn();
|
|
187
|
+
const buildDomSelectionForTimelineElement = vi.fn(async () => null);
|
|
188
|
+
const harness = renderHarness();
|
|
189
|
+
|
|
190
|
+
await harness.rerender({
|
|
191
|
+
selectedElementId: "clip-3",
|
|
192
|
+
selectedElementIds: new Set(["clip-3"]),
|
|
193
|
+
timelineElements,
|
|
194
|
+
domEditSelection: null,
|
|
195
|
+
domEditGroupSelections: [],
|
|
196
|
+
buildDomSelectionForTimelineElement,
|
|
197
|
+
applyDomSelection,
|
|
198
|
+
applyMarqueeSelection,
|
|
199
|
+
onSelectionNotFound,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Still within the retry grace window: quiet, not yet a warning.
|
|
203
|
+
expect(onSelectionNotFound).not.toHaveBeenCalled();
|
|
204
|
+
expect(applyDomSelection).not.toHaveBeenCalled();
|
|
205
|
+
harness.cleanup();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not shrink a pasted or duplicated group's selection when only some members have appeared yet", async () => {
|
|
209
|
+
// Paste/duplicate of a group selects every new id up front; one member
|
|
210
|
+
// (clip-1) is already in timelineElements, the other (a fresh paste/dup
|
|
211
|
+
// target, "clip-3") is not yet. Applying only the resolved member would
|
|
212
|
+
// silently drop the rest of the group from the store.
|
|
213
|
+
const { firstSelection, timelineElements, selectionById } = makeSyncFixture();
|
|
214
|
+
const applyDomSelection = vi.fn();
|
|
215
|
+
const applyMarqueeSelection = vi.fn();
|
|
216
|
+
const onSelectionNotFound = vi.fn();
|
|
217
|
+
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
|
|
218
|
+
return selectionById.get(element.id) ?? firstSelection;
|
|
219
|
+
});
|
|
220
|
+
const harness = renderHarness();
|
|
221
|
+
|
|
222
|
+
await harness.rerender({
|
|
223
|
+
selectedElementId: "clip-1",
|
|
224
|
+
selectedElementIds: new Set(["clip-1", "clip-3"]),
|
|
225
|
+
timelineElements,
|
|
226
|
+
domEditSelection: null,
|
|
227
|
+
domEditGroupSelections: [],
|
|
228
|
+
buildDomSelectionForTimelineElement,
|
|
229
|
+
applyDomSelection,
|
|
230
|
+
applyMarqueeSelection,
|
|
231
|
+
onSelectionNotFound,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Still within the retry grace window: quiet, not yet a warning.
|
|
235
|
+
expect(onSelectionNotFound).not.toHaveBeenCalled();
|
|
236
|
+
expect(applyDomSelection).not.toHaveBeenCalled();
|
|
237
|
+
expect(applyMarqueeSelection).not.toHaveBeenCalled();
|
|
238
|
+
harness.cleanup();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("stays quiet through retries and resolves once the preview refreshes", async () => {
|
|
179
242
|
const { secondSelection, timelineElements } = makeSyncFixture();
|
|
180
243
|
const applyDomSelection = vi.fn();
|
|
181
244
|
const applyMarqueeSelection = vi.fn();
|
|
@@ -199,7 +262,7 @@ describe("useTimelineSelectionPreviewSync", () => {
|
|
|
199
262
|
onSelectionNotFound,
|
|
200
263
|
});
|
|
201
264
|
|
|
202
|
-
expect(onSelectionNotFound).
|
|
265
|
+
expect(onSelectionNotFound).not.toHaveBeenCalled();
|
|
203
266
|
expect(applyDomSelection).not.toHaveBeenCalled();
|
|
204
267
|
|
|
205
268
|
await harness.rerender({
|
|
@@ -214,7 +277,7 @@ describe("useTimelineSelectionPreviewSync", () => {
|
|
|
214
277
|
onSelectionNotFound,
|
|
215
278
|
});
|
|
216
279
|
|
|
217
|
-
expect(onSelectionNotFound).
|
|
280
|
+
expect(onSelectionNotFound).not.toHaveBeenCalled();
|
|
218
281
|
|
|
219
282
|
previewReady = true;
|
|
220
283
|
await harness.rerender({
|
|
@@ -230,6 +293,155 @@ describe("useTimelineSelectionPreviewSync", () => {
|
|
|
230
293
|
});
|
|
231
294
|
|
|
232
295
|
expect(applyDomSelection).toHaveBeenCalledWith(secondSelection);
|
|
296
|
+
expect(onSelectionNotFound).not.toHaveBeenCalled();
|
|
297
|
+
harness.cleanup();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("degrades to the resolvable subset once a group member never resolves", async () => {
|
|
301
|
+
// clip-2 is a permanent hold-out (deleted out of band, a stale id, or
|
|
302
|
+
// anything else that will never build a selection). Retrying forever
|
|
303
|
+
// would leave the whole group's selection stuck; after a few attempts,
|
|
304
|
+
// apply the members that did resolve instead.
|
|
305
|
+
const { firstSelection, timelineElements } = makeSyncFixture();
|
|
306
|
+
const applyDomSelection = vi.fn();
|
|
307
|
+
const applyMarqueeSelection = vi.fn();
|
|
308
|
+
const onSelectionNotFound = vi.fn();
|
|
309
|
+
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) =>
|
|
310
|
+
element.id === "clip-1" ? firstSelection : null,
|
|
311
|
+
);
|
|
312
|
+
const selectedElementIds = new Set(["clip-1", "clip-2"]);
|
|
313
|
+
const harness = renderHarness();
|
|
314
|
+
|
|
315
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
316
|
+
await harness.rerender({
|
|
317
|
+
selectedElementId: "clip-1",
|
|
318
|
+
selectedElementIds,
|
|
319
|
+
timelineElements: [...timelineElements],
|
|
320
|
+
domEditSelection: null,
|
|
321
|
+
domEditGroupSelections: [],
|
|
322
|
+
buildDomSelectionForTimelineElement,
|
|
323
|
+
applyDomSelection,
|
|
324
|
+
applyMarqueeSelection,
|
|
325
|
+
onSelectionNotFound,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
expect(onSelectionNotFound).toHaveBeenCalledOnce();
|
|
330
|
+
expect(applyDomSelection).toHaveBeenCalledWith(firstSelection);
|
|
331
|
+
expect(applyMarqueeSelection).not.toHaveBeenCalled();
|
|
332
|
+
harness.cleanup();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("gives a fresh retry budget after deselecting a permanently-unresolvable selection", async () => {
|
|
336
|
+
// Same permanent hold-out as the test above, but the user deselects and
|
|
337
|
+
// reselects the exact same group afterward. Without resetting the retry
|
|
338
|
+
// budget on deselect, reselecting the same key resumes an already-
|
|
339
|
+
// exhausted count and degrades on the very first attempt with no warning.
|
|
340
|
+
const { firstSelection, timelineElements } = makeSyncFixture();
|
|
341
|
+
const applyDomSelection = vi.fn();
|
|
342
|
+
const applyMarqueeSelection = vi.fn();
|
|
343
|
+
const onSelectionNotFound = vi.fn();
|
|
344
|
+
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) =>
|
|
345
|
+
element.id === "clip-1" ? firstSelection : null,
|
|
346
|
+
);
|
|
347
|
+
const selectedElementIds = new Set(["clip-1", "clip-2"]);
|
|
348
|
+
const harness = renderHarness();
|
|
349
|
+
|
|
350
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
351
|
+
await harness.rerender({
|
|
352
|
+
selectedElementId: "clip-1",
|
|
353
|
+
selectedElementIds,
|
|
354
|
+
timelineElements: [...timelineElements],
|
|
355
|
+
domEditSelection: null,
|
|
356
|
+
domEditGroupSelections: [],
|
|
357
|
+
buildDomSelectionForTimelineElement,
|
|
358
|
+
applyDomSelection,
|
|
359
|
+
applyMarqueeSelection,
|
|
360
|
+
onSelectionNotFound,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
expect(onSelectionNotFound).toHaveBeenCalledOnce();
|
|
364
|
+
|
|
365
|
+
await harness.rerender({
|
|
366
|
+
selectedElementId: null,
|
|
367
|
+
selectedElementIds: new Set(),
|
|
368
|
+
timelineElements: [...timelineElements],
|
|
369
|
+
domEditSelection: null,
|
|
370
|
+
domEditGroupSelections: [],
|
|
371
|
+
buildDomSelectionForTimelineElement,
|
|
372
|
+
applyDomSelection,
|
|
373
|
+
applyMarqueeSelection,
|
|
374
|
+
onSelectionNotFound,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
378
|
+
await harness.rerender({
|
|
379
|
+
selectedElementId: "clip-1",
|
|
380
|
+
selectedElementIds,
|
|
381
|
+
timelineElements: [...timelineElements],
|
|
382
|
+
domEditSelection: null,
|
|
383
|
+
domEditGroupSelections: [],
|
|
384
|
+
buildDomSelectionForTimelineElement,
|
|
385
|
+
applyDomSelection,
|
|
386
|
+
applyMarqueeSelection,
|
|
387
|
+
onSelectionNotFound,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
expect(onSelectionNotFound).toHaveBeenCalledTimes(2);
|
|
392
|
+
harness.cleanup();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("resolves every member of a large selection concurrently, not one network round trip at a time", async () => {
|
|
396
|
+
const elementCount = 30;
|
|
397
|
+
const timelineElements: TimelineElement[] = Array.from({ length: elementCount }, (_, i) => ({
|
|
398
|
+
id: `clip-${i}`,
|
|
399
|
+
domId: `clip-${i}`,
|
|
400
|
+
tag: "div",
|
|
401
|
+
start: i,
|
|
402
|
+
duration: 1,
|
|
403
|
+
track: i % 4,
|
|
404
|
+
}));
|
|
405
|
+
const selectionById = new Map(
|
|
406
|
+
timelineElements.map((el) => {
|
|
407
|
+
const domEl = document.createElement("div");
|
|
408
|
+
domEl.id = el.id;
|
|
409
|
+
return [el.id, makeSelection(el.id, domEl)];
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
let inFlight = 0;
|
|
413
|
+
let maxInFlight = 0;
|
|
414
|
+
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
|
|
415
|
+
inFlight += 1;
|
|
416
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
417
|
+
// Stands in for the network round trip: yields once so every member's
|
|
418
|
+
// call has a chance to start before any of them finish.
|
|
419
|
+
await Promise.resolve();
|
|
420
|
+
inFlight -= 1;
|
|
421
|
+
return selectionById.get(element.id) ?? null;
|
|
422
|
+
});
|
|
423
|
+
const applyDomSelection = vi.fn();
|
|
424
|
+
const applyMarqueeSelection = vi.fn();
|
|
425
|
+
const harness = renderHarness();
|
|
426
|
+
|
|
427
|
+
await harness.rerender({
|
|
428
|
+
selectedElementId: timelineElements[0].id,
|
|
429
|
+
selectedElementIds: new Set(timelineElements.map((el) => el.id)),
|
|
430
|
+
timelineElements,
|
|
431
|
+
domEditSelection: null,
|
|
432
|
+
domEditGroupSelections: [],
|
|
433
|
+
buildDomSelectionForTimelineElement,
|
|
434
|
+
applyDomSelection,
|
|
435
|
+
applyMarqueeSelection,
|
|
436
|
+
onSelectionNotFound: vi.fn(),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(buildDomSelectionForTimelineElement).toHaveBeenCalledTimes(elementCount);
|
|
440
|
+
// A sequential loop never has more than one call in flight at once; this
|
|
441
|
+
// asserts every member's probe started before any of them resolved.
|
|
442
|
+
expect(maxInFlight).toBe(elementCount);
|
|
443
|
+
expect(applyMarqueeSelection).toHaveBeenCalledOnce();
|
|
444
|
+
expect(applyMarqueeSelection.mock.calls[0]?.[0]).toHaveLength(elementCount);
|
|
233
445
|
harness.cleanup();
|
|
234
446
|
});
|
|
235
447
|
});
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../player";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
|
5
5
|
import { logSelect } from "../utils/selectDebug";
|
|
6
|
+
import { recordRetryAttempt, type RetryBudgetState } from "../utils/retryBudget";
|
|
6
7
|
|
|
7
8
|
interface UseTimelineSelectionPreviewSyncParams {
|
|
8
9
|
selectedElementId: string | null;
|
|
@@ -27,6 +28,12 @@ interface UseTimelineSelectionPreviewSyncParams {
|
|
|
27
28
|
onSelectionNotFound: () => void;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// A member still resolving (a just-dropped/pasted/duplicated clip, or a preview
|
|
32
|
+
// still reloading) heals within a few retries. One that never will (deleted out
|
|
33
|
+
// of band, a stale id) would otherwise bail forever; past this cap, apply
|
|
34
|
+
// whatever did resolve instead of leaving the group selection stuck.
|
|
35
|
+
const MAX_UNRESOLVED_SYNC_RETRIES = 3;
|
|
36
|
+
|
|
30
37
|
function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
|
|
31
38
|
const ordered = [...ids];
|
|
32
39
|
if (!anchor || !ids.has(anchor)) return ordered;
|
|
@@ -63,6 +70,36 @@ function anchorIsOutsideSelection(anchor: string | null, selectedIds: string[]):
|
|
|
63
70
|
return anchor !== null && !selectedIds.includes(anchor);
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
async function resolveSelectionsForIds(
|
|
74
|
+
ids: string[],
|
|
75
|
+
timelineElements: TimelineElement[],
|
|
76
|
+
buildDomSelectionForTimelineElement: UseTimelineSelectionPreviewSyncParams["buildDomSelectionForTimelineElement"],
|
|
77
|
+
): Promise<DomEditSelection[]> {
|
|
78
|
+
// Each element's resolution fires its own network probe; a selection of N
|
|
79
|
+
// members used to pay N sequential round trips here instead of one.
|
|
80
|
+
const elements = ids
|
|
81
|
+
.map((id) => timelineElements.find((item) => (item.key ?? item.id) === id))
|
|
82
|
+
.filter((element): element is TimelineElement => Boolean(element));
|
|
83
|
+
const resolved = await Promise.all(
|
|
84
|
+
elements.map((element) => buildDomSelectionForTimelineElement(element)),
|
|
85
|
+
);
|
|
86
|
+
return resolved.filter((selection): selection is DomEditSelection => Boolean(selection));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function applyResolvedSelections(
|
|
90
|
+
selections: DomEditSelection[],
|
|
91
|
+
applyDomSelection: UseTimelineSelectionPreviewSyncParams["applyDomSelection"],
|
|
92
|
+
applyMarqueeSelection: UseTimelineSelectionPreviewSyncParams["applyMarqueeSelection"],
|
|
93
|
+
): void {
|
|
94
|
+
if (selections.length === 0) {
|
|
95
|
+
applyDomSelection(null, { revealPanel: false });
|
|
96
|
+
} else if (selections.length === 1) {
|
|
97
|
+
applyDomSelection(selections[0]);
|
|
98
|
+
} else {
|
|
99
|
+
applyMarqueeSelection(selections, false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
66
103
|
export function useTimelineSelectionPreviewSync({
|
|
67
104
|
selectedElementId,
|
|
68
105
|
selectedElementIds,
|
|
@@ -84,6 +121,7 @@ export function useTimelineSelectionPreviewSync({
|
|
|
84
121
|
const domEditGroupSelectionsRef = useRef(domEditGroupSelections);
|
|
85
122
|
const lastSyncedSelectedKeyRef = useRef("");
|
|
86
123
|
const missingSelectionKeyRef = useRef("");
|
|
124
|
+
const unresolvedAttemptsRef = useRef<RetryBudgetState<string>>({ id: "", count: 0 });
|
|
87
125
|
domEditSelectionRef.current = domEditSelection;
|
|
88
126
|
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
89
127
|
|
|
@@ -109,6 +147,10 @@ export function useTimelineSelectionPreviewSync({
|
|
|
109
147
|
|
|
110
148
|
if (selectedIds.length === 0) {
|
|
111
149
|
missingSelectionKeyRef.current = "";
|
|
150
|
+
// A deselect is the one unambiguous "new attempt" signal: without it, reselecting the
|
|
151
|
+
// same permanently-unresolvable id later picks up an already-exhausted retry budget
|
|
152
|
+
// and skips straight to the degraded fallback instead of getting a fresh grace window.
|
|
153
|
+
unresolvedAttemptsRef.current = { id: "", count: 0 };
|
|
112
154
|
// The timeline holds nothing, so the canvas is about to hold nothing either.
|
|
113
155
|
// This is the path that silently drops a selection the user can still see.
|
|
114
156
|
logSelect("timeline-empty", {
|
|
@@ -134,46 +176,35 @@ export function useTimelineSelectionPreviewSync({
|
|
|
134
176
|
onSelectionNotFound();
|
|
135
177
|
};
|
|
136
178
|
const syncSelection = async () => {
|
|
137
|
-
const selections
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
resolvableCount += 1;
|
|
143
|
-
const selection = await buildDomSelectionForTimelineElement(element);
|
|
144
|
-
if (selection) selections.push(selection);
|
|
145
|
-
}
|
|
179
|
+
const selections = await resolveSelectionsForIds(
|
|
180
|
+
selectedIds,
|
|
181
|
+
timelineElements,
|
|
182
|
+
buildDomSelectionForTimelineElement,
|
|
183
|
+
);
|
|
146
184
|
if (cancelled) return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// damage. Only that goes: a member still resolving has no anchor of its
|
|
157
|
-
// own here and is left for the later run. Quietly, because announcing
|
|
158
|
-
// the clear would deselect the clip that was just picked.
|
|
159
|
-
if (anchorIsOutsideSelection(currentAnchor, selectedIds)) {
|
|
160
|
-
applyDomSelection(null, { revealPanel: false, announce: false });
|
|
185
|
+
if (selections.length < selectedIds.length) {
|
|
186
|
+
if (recordRetryAttempt(unresolvedAttemptsRef, selectedKey, MAX_UNRESOLVED_SYNC_RETRIES)) {
|
|
187
|
+
// Still within the retry grace window: stay quiet (warn only once
|
|
188
|
+
// exhausted, below). Delete acts on the canvas first, so only an
|
|
189
|
+
// anchor OUTSIDE this selection is cleared here, quietly.
|
|
190
|
+
if (anchorIsOutsideSelection(currentAnchor, selectedIds)) {
|
|
191
|
+
applyDomSelection(null, { revealPanel: false, announce: false });
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
161
194
|
}
|
|
162
|
-
|
|
195
|
+
// Budget exhausted: warn once (missingSelectionKeyRef dedupes further
|
|
196
|
+
// reruns of this same still-unresolved key) and apply whatever did resolve.
|
|
197
|
+
warnSelectionMissingOnce();
|
|
198
|
+
} else {
|
|
199
|
+
unresolvedAttemptsRef.current = { id: "", count: 0 };
|
|
200
|
+
missingSelectionKeyRef.current = "";
|
|
163
201
|
}
|
|
164
|
-
missingSelectionKeyRef.current = "";
|
|
165
202
|
logSelect("timeline-sync", {
|
|
166
203
|
wanted: selectedIds.length,
|
|
167
204
|
had: currentIds.length,
|
|
168
205
|
resolved: selections.length,
|
|
169
206
|
});
|
|
170
|
-
|
|
171
|
-
applyDomSelection(null, { revealPanel: false });
|
|
172
|
-
} else if (selections.length === 1) {
|
|
173
|
-
applyDomSelection(selections[0]);
|
|
174
|
-
} else {
|
|
175
|
-
applyMarqueeSelection(selections, false);
|
|
176
|
-
}
|
|
207
|
+
applyResolvedSelections(selections, applyDomSelection, applyMarqueeSelection);
|
|
177
208
|
};
|
|
178
209
|
|
|
179
210
|
void syncSelection();
|
|
@@ -1223,6 +1223,120 @@ describe("commitDraggedClipMove", () => {
|
|
|
1223
1223
|
expect(onStackingPatches).not.toHaveBeenCalled();
|
|
1224
1224
|
});
|
|
1225
1225
|
});
|
|
1226
|
+
|
|
1227
|
+
describe("magnetic main track on a track-insert", () => {
|
|
1228
|
+
it("a top-gutter insert that renumbers to literal track 0 snaps its start to 0", () => {
|
|
1229
|
+
// Sole visual clip sits on track 1 — the real main track (0) is genuinely
|
|
1230
|
+
// empty. Dragging it into the top insert-gutter creates a new lane that
|
|
1231
|
+
// normalizeToZones renumbers to literal 0, so the main-track rule must apply
|
|
1232
|
+
// even though the preview never saw a literal-0 landing track.
|
|
1233
|
+
const elements = [el("v1", 1, 0, 5)];
|
|
1234
|
+
const { onMoveElements } = runClipMove(
|
|
1235
|
+
drag(elements[0], { previewStart: 8, previewTrack: 1, insertRow: 0 }),
|
|
1236
|
+
{ elements, trackOrder: [1] },
|
|
1237
|
+
);
|
|
1238
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1239
|
+
expect(map.v1).toEqual({ start: 0, track: 0 });
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
it("the z-sync candidate reflects the snapped start, not the raw previewStart", async () => {
|
|
1243
|
+
// v1 snaps from previewStart 8 to 0 (see the test above). A foreign-file
|
|
1244
|
+
// sibling at [2, 5) only overlaps the SNAPPED window [0, 5), never the
|
|
1245
|
+
// raw [8, 13) — so a z-sync firing at all proves the candidate the
|
|
1246
|
+
// stacking check reads from carries the snapped start.
|
|
1247
|
+
const v1 = el("v1", 2, 0, 5);
|
|
1248
|
+
const sibling = el("s", 1, 2, 3); // [2, 5) — same source file, same paint scope
|
|
1249
|
+
const elements = [v1, sibling];
|
|
1250
|
+
const onStackingPatches = vi.fn();
|
|
1251
|
+
// v1 lands on lane 0, which paints above lane 1 (lower track paints higher),
|
|
1252
|
+
// yet carries the lower z: a violation only while the two overlap in time.
|
|
1253
|
+
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 2, insertRow: 0 }), {
|
|
1254
|
+
elements,
|
|
1255
|
+
trackOrder: [1, 2],
|
|
1256
|
+
updateElement: vi.fn(),
|
|
1257
|
+
onMoveElement: vi.fn(),
|
|
1258
|
+
onMoveElements: vi.fn(),
|
|
1259
|
+
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1260
|
+
onStackingPatches,
|
|
1261
|
+
});
|
|
1262
|
+
await flushMicrotasks();
|
|
1263
|
+
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it("a plain lane change onto the empty main track snaps its committed start to 0", () => {
|
|
1267
|
+
const elements = [el("v1", 1, 0, 5)];
|
|
1268
|
+
const spies = runClipMove(drag(elements[0], { previewStart: 8, previewTrack: 0 }), {
|
|
1269
|
+
elements,
|
|
1270
|
+
trackOrder: [0, 1],
|
|
1271
|
+
});
|
|
1272
|
+
expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 0, track: 0 });
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("the z-sync candidate of a plain lane change carries the snapped start", async () => {
|
|
1276
|
+
// The sibling [2, 5) overlaps v1 only at the snapped [0, 5), never at the raw [8, 13).
|
|
1277
|
+
const v1 = el("v1", 2, 0, 5);
|
|
1278
|
+
const sibling = el("s", 1, 2, 3);
|
|
1279
|
+
const onStackingPatches = vi.fn();
|
|
1280
|
+
commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 0 }), {
|
|
1281
|
+
elements: [v1, sibling],
|
|
1282
|
+
trackOrder: [0, 1, 2],
|
|
1283
|
+
updateElement: vi.fn(),
|
|
1284
|
+
onMoveElement: vi.fn(),
|
|
1285
|
+
onMoveElements: vi.fn(),
|
|
1286
|
+
readZIndex: (element) => (element.key === "v1" ? 1 : 10),
|
|
1287
|
+
onStackingPatches,
|
|
1288
|
+
});
|
|
1289
|
+
await flushMicrotasks();
|
|
1290
|
+
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it("a top-gutter insert that pushes the old track-0 clip down snaps to the new track 0", () => {
|
|
1294
|
+
const oldMain = el("old", 0, 0, 3);
|
|
1295
|
+
const dragged = el("v1", 2, 0, 5);
|
|
1296
|
+
const { onMoveElements } = runClipMove(
|
|
1297
|
+
drag(dragged, { previewStart: 8, previewTrack: 2, insertRow: 0 }),
|
|
1298
|
+
{ elements: [oldMain, dragged], trackOrder: [0, 2] },
|
|
1299
|
+
);
|
|
1300
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1301
|
+
expect(map.old.track).toBe(1);
|
|
1302
|
+
expect(map.v1).toEqual({ start: 0, track: 0 });
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("an expanded child dragged with its host commits the HOST at 0", () => {
|
|
1306
|
+
for (const [hostStart, childStart] of [
|
|
1307
|
+
[30, 32],
|
|
1308
|
+
[20, 22],
|
|
1309
|
+
]) {
|
|
1310
|
+
const host = el("host", 1, hostStart, 10);
|
|
1311
|
+
const child: TimelineElement = {
|
|
1312
|
+
...el("child", 2, childStart, 4),
|
|
1313
|
+
expandedHostKey: "host",
|
|
1314
|
+
expandedParentStart: hostStart,
|
|
1315
|
+
};
|
|
1316
|
+
const { onMoveElements } = runClipMove(
|
|
1317
|
+
drag(child, { previewStart: childStart, previewTrack: 0 }),
|
|
1318
|
+
{
|
|
1319
|
+
elements: [host, child],
|
|
1320
|
+
trackOrder: [0, 1, 2],
|
|
1321
|
+
selectedKeys: new Set(["host", "child"]),
|
|
1322
|
+
},
|
|
1323
|
+
);
|
|
1324
|
+
expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({ start: 0, track: 0 });
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it("a multi-selection top-gutter insert does NOT snap (siblings key off the unsnapped start)", () => {
|
|
1329
|
+
const dragged = el("v1", 1, 0, 5);
|
|
1330
|
+
const sibling = el("v2", 1, 10, 5);
|
|
1331
|
+
const elements = [dragged, sibling];
|
|
1332
|
+
const { onMoveElements } = runClipMove(
|
|
1333
|
+
drag(dragged, { previewStart: 8, previewTrack: 1, insertRow: 0 }),
|
|
1334
|
+
{ elements, trackOrder: [1], selectedKeys: new Set(["v1", "v2"]) },
|
|
1335
|
+
);
|
|
1336
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
1337
|
+
expect(map.v1.start).toBe(8); // unsnapped — multi-selection guard
|
|
1338
|
+
});
|
|
1339
|
+
});
|
|
1226
1340
|
});
|
|
1227
1341
|
|
|
1228
1342
|
describe("commitZMirrorLaneMove", () => {
|