@hyperframes/studio 0.8.48 → 0.8.50
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-CxxDxHTW.js +464 -0
- package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6209 -5924
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +20 -51
- 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/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- 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/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -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/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +10 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCanvasComposition.test.ts +77 -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/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -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();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import type
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
@@ -13,8 +13,14 @@ interface ClipContextMenuProps {
|
|
|
13
13
|
onClose: () => void;
|
|
14
14
|
onSplit: (element: TimelineElement, splitTime: number) => void;
|
|
15
15
|
onDelete: (element: TimelineElement) => void;
|
|
16
|
+
onCopy?: () => boolean;
|
|
17
|
+
onPaste?: () => Promise<void>;
|
|
18
|
+
onDuplicate?: () => Promise<boolean>;
|
|
19
|
+
canPaste?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
// A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
|
|
23
|
+
// fallow-ignore-next-line complexity
|
|
18
24
|
export const ClipContextMenu = memo(function ClipContextMenu({
|
|
19
25
|
x,
|
|
20
26
|
y,
|
|
@@ -23,15 +29,22 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
23
29
|
onClose,
|
|
24
30
|
onSplit,
|
|
25
31
|
onDelete,
|
|
32
|
+
onCopy,
|
|
33
|
+
onPaste,
|
|
34
|
+
onDuplicate,
|
|
35
|
+
canPaste,
|
|
26
36
|
}: ClipContextMenuProps) {
|
|
27
37
|
const menuRef = useContextMenuDismiss(onClose);
|
|
28
38
|
useMenuKeyboardNav(menuRef);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
// The right-clicked clip's own id: a member of the live multi-selection
|
|
40
|
+
// means Copy/Duplicate act on the whole group, matching onContextMenuClip's
|
|
41
|
+
// selection-preserving behaviour for a right-click inside it.
|
|
42
|
+
const selectionSize = usePlayerStore((s) => {
|
|
43
|
+
const id = element.key ?? element.id;
|
|
44
|
+
return s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)
|
|
45
|
+
? s.selectedElementIds.size
|
|
46
|
+
: 1;
|
|
47
|
+
});
|
|
35
48
|
|
|
36
49
|
const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
|
|
37
50
|
const canSplit =
|
|
@@ -43,6 +56,23 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
43
56
|
? `Split at ${currentTime.toFixed(2)}s`
|
|
44
57
|
: "Split (move playhead inside clip)";
|
|
45
58
|
|
|
59
|
+
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
60
|
+
const rowCount = (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
61
|
+
const dividerCount = (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
62
|
+
const menuWidth = 200;
|
|
63
|
+
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
64
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
65
|
+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
66
|
+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
67
|
+
|
|
68
|
+
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
69
|
+
const itemClass = (enabled: boolean) =>
|
|
70
|
+
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
|
|
71
|
+
enabled
|
|
72
|
+
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
73
|
+
: " text-neutral-600 cursor-not-allowed"
|
|
74
|
+
}`;
|
|
75
|
+
|
|
46
76
|
return createPortal(
|
|
47
77
|
<div
|
|
48
78
|
ref={menuRef}
|
|
@@ -76,6 +106,56 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
76
106
|
</>
|
|
77
107
|
)}
|
|
78
108
|
|
|
109
|
+
{(onCopy || onPaste || onDuplicate) && (
|
|
110
|
+
<>
|
|
111
|
+
{onCopy && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
role="menuitem"
|
|
115
|
+
className={itemClass(true)}
|
|
116
|
+
onClick={() => {
|
|
117
|
+
onCopy();
|
|
118
|
+
onClose();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
|
|
122
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
{onPaste && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
role="menuitem"
|
|
129
|
+
className={itemClass(!!canPaste)}
|
|
130
|
+
disabled={!canPaste}
|
|
131
|
+
onClick={() => {
|
|
132
|
+
if (!canPaste) return;
|
|
133
|
+
void onPaste();
|
|
134
|
+
onClose();
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<span>Paste</span>
|
|
138
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
{onDuplicate && (
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
role="menuitem"
|
|
145
|
+
className={itemClass(true)}
|
|
146
|
+
onClick={() => {
|
|
147
|
+
void onDuplicate();
|
|
148
|
+
onClose();
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
|
152
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
|
|
79
159
|
<button
|
|
80
160
|
type="button"
|
|
81
161
|
role="menuitem"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState, memo } from "react";
|
|
2
2
|
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
3
3
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
|
-
import {
|
|
4
|
+
import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
|
|
5
5
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
6
6
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
7
7
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
@@ -43,6 +43,7 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
|
|
43
43
|
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
44
44
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
|
45
45
|
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
|
46
|
+
import { useClipContextMenu } from "./useTimelineClipContextMenu";
|
|
46
47
|
|
|
47
48
|
export {
|
|
48
49
|
shouldAutoScrollTimeline,
|
|
@@ -79,6 +80,10 @@ export const Timeline = memo(function Timeline({
|
|
|
79
80
|
onBlockedEditAttempt: onBlockedEditAttemptOverride,
|
|
80
81
|
onSplitElement: onSplitElementOverride,
|
|
81
82
|
onSelectElement,
|
|
83
|
+
onCopyClip,
|
|
84
|
+
onPasteClip,
|
|
85
|
+
onDuplicateClip,
|
|
86
|
+
canPasteClip,
|
|
82
87
|
theme: themeOverrides,
|
|
83
88
|
sessionEpoch = 0,
|
|
84
89
|
}: TimelineProps = {}) {
|
|
@@ -106,7 +111,7 @@ export const Timeline = memo(function Timeline({
|
|
|
106
111
|
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
|
107
112
|
useMusicBeatAnalysis();
|
|
108
113
|
const rawElements = usePlayerStore((s) => s.elements);
|
|
109
|
-
const expandedElements =
|
|
114
|
+
const expandedElements = useTimelineRowElements();
|
|
110
115
|
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
|
|
111
116
|
const duration = usePlayerStore((s) => s.duration);
|
|
112
117
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
@@ -208,6 +213,7 @@ export const Timeline = memo(function Timeline({
|
|
|
208
213
|
onMoveElement: pinnedOnMoveElement,
|
|
209
214
|
onMoveElements: pinnedOnMoveElements,
|
|
210
215
|
});
|
|
216
|
+
const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu);
|
|
211
217
|
|
|
212
218
|
const {
|
|
213
219
|
draggedClip,
|
|
@@ -492,6 +498,7 @@ export const Timeline = memo(function Timeline({
|
|
|
492
498
|
rangeSelection={rangeSelection}
|
|
493
499
|
marqueeRect={marqueeRect}
|
|
494
500
|
laneGapStrips={laneGapStrips}
|
|
501
|
+
dropPreview={assetDrop.dropPreview}
|
|
495
502
|
theme={theme}
|
|
496
503
|
displayTrackOrder={displayLayout.displayTrackOrder}
|
|
497
504
|
rowHeights={displayLayout.displayRowHeights}
|
|
@@ -548,18 +555,7 @@ export const Timeline = memo(function Timeline({
|
|
|
548
555
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
549
556
|
onMoveKeyframe={onMoveKeyframe}
|
|
550
557
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
551
|
-
onContextMenuClip={
|
|
552
|
-
e.preventDefault();
|
|
553
|
-
setSelectedElementId(el.key ?? el.id);
|
|
554
|
-
onSelectElement?.(el);
|
|
555
|
-
dismissGapMenu();
|
|
556
|
-
setClipContextMenu({
|
|
557
|
-
x: e.clientX,
|
|
558
|
-
y: e.clientY,
|
|
559
|
-
element: el,
|
|
560
|
-
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
561
|
-
});
|
|
562
|
-
}}
|
|
558
|
+
onContextMenuClip={onContextMenuClip}
|
|
563
559
|
onContextMenuLane={(e, track, time) => {
|
|
564
560
|
if (draggedClip?.started || resizingClip) return;
|
|
565
561
|
setClipContextMenu(null);
|
|
@@ -588,6 +584,10 @@ export const Timeline = memo(function Timeline({
|
|
|
588
584
|
onSplitElement={onSplitElement}
|
|
589
585
|
pinZoomBeforeEdit={pinZoomBeforeEdit}
|
|
590
586
|
onDeleteElement={_onDeleteElement}
|
|
587
|
+
onCopyClip={onCopyClip}
|
|
588
|
+
onPasteClip={onPasteClip}
|
|
589
|
+
onDuplicateClip={onDuplicateClip}
|
|
590
|
+
canPasteClip={canPasteClip}
|
|
591
591
|
gapContextMenu={gapMenuModel}
|
|
592
592
|
onDismissGapContextMenu={dismissGapMenu}
|
|
593
593
|
onCloseTrackGap={closeTrackGap}
|
|
@@ -39,12 +39,22 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
|
|
39
39
|
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
40
40
|
/** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
|
|
41
41
|
laneGapStrips: TimelineLaneGapStrips[];
|
|
42
|
+
/** Landing spot of an outside drag in progress, or null. */
|
|
43
|
+
dropPreview: { start: number; track: number } | null;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
// A dropped clip's length is unknown until it lands; the preview shows a default.
|
|
47
|
+
const DROP_PREVIEW_SECONDS = 3;
|
|
48
|
+
|
|
44
49
|
export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
|
|
45
50
|
const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
|
|
46
51
|
const draggedRowIndex =
|
|
47
52
|
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
|
53
|
+
const dropTrackIndex = props.dropPreview
|
|
54
|
+
? displayTrackOrder.indexOf(props.dropPreview.track)
|
|
55
|
+
: -1;
|
|
56
|
+
// A track not in the order is a new track, drawn one row past the last lane.
|
|
57
|
+
const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
|
|
48
58
|
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
|
49
59
|
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
|
|
50
60
|
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
|
|
@@ -171,6 +181,26 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
171
181
|
/>
|
|
172
182
|
)}
|
|
173
183
|
|
|
184
|
+
{/* Drop preview: where an asset or file dragged in from outside will land
|
|
185
|
+
(a row past the last lane means a new track). */}
|
|
186
|
+
{props.dropPreview && (
|
|
187
|
+
<div
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
data-testid="timeline-drop-preview"
|
|
190
|
+
className="absolute pointer-events-none"
|
|
191
|
+
style={{
|
|
192
|
+
top: getTimelineRowTop(dropRowIndex, props.rowHeights) + CLIP_Y,
|
|
193
|
+
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
|
194
|
+
width: DROP_PREVIEW_SECONDS * props.pps,
|
|
195
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
196
|
+
border: "1px solid rgba(60,230,172,0.55)",
|
|
197
|
+
background: "rgba(60,230,172,0.12)",
|
|
198
|
+
borderRadius: 4,
|
|
199
|
+
zIndex: 30,
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
|
|
174
204
|
{/* Insertion line — a new track will be inserted at this boundary on drop.
|
|
175
205
|
Shown while the pointer is near a lane boundary (insert mode). */}
|
|
176
206
|
{draggedClip?.started && draggedClip.insertRow != null && (
|