@hyperframes/studio 0.8.127 → 0.8.129
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-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
- package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +1396 -917
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +8 -5
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.ts +5 -6
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +48 -35
- package/src/hooks/gsapShared.ts +30 -32
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
- package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
- package/src/hooks/gsapValueAtPlayhead.ts +3 -4
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +82 -63
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
- package/src/utils/gsapLiveRetime.ts +286 -0
- package/src/utils/gsapSoftReload.ts +16 -7
- package/src/utils/gsapUndoRestore.test.ts +43 -0
- package/src/utils/gsapUndoRestore.ts +6 -3
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
|
@@ -10,7 +10,11 @@ import { defaultTimelineTheme } from "./timelineTheme";
|
|
|
10
10
|
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
|
|
11
11
|
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
12
12
|
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
usePlayerStore,
|
|
15
|
+
type KeyframeCacheEntry,
|
|
16
|
+
type TimelineElement,
|
|
17
|
+
} from "../store/playerStore";
|
|
14
18
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
15
19
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
16
20
|
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
@@ -73,6 +77,7 @@ function positionTween(id: string): GsapAnimation {
|
|
|
73
77
|
|
|
74
78
|
interface RenderLanesOptions {
|
|
75
79
|
elements?: TimelineElement[];
|
|
80
|
+
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
76
81
|
animations?: Map<string, GsapAnimation[]>;
|
|
77
82
|
expandedClipIds?: string[];
|
|
78
83
|
selectedElementIds?: Set<string>;
|
|
@@ -172,6 +177,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
172
177
|
getPreviewElement={(el) => el}
|
|
173
178
|
getTrackStyle={getTrackStyle}
|
|
174
179
|
gsapAnimations={gsapAnimations}
|
|
180
|
+
keyframeCache={next.keyframeCache}
|
|
175
181
|
selectedKeyframes={new Set()}
|
|
176
182
|
currentTime={0}
|
|
177
183
|
onContextMenuLane={next.onContextMenuLane}
|
|
@@ -389,9 +395,54 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
389
395
|
act(() => second.root.unmount());
|
|
390
396
|
});
|
|
391
397
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
398
|
+
it("does not remount a clip when it becomes, then stops being, a drag passenger", () => {
|
|
399
|
+
const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
|
|
400
|
+
const selectedElementIds = new Set(["clip-a", "clip-b"]);
|
|
401
|
+
const dragging: MultiDragPreviewInput = {
|
|
402
|
+
dragStarted: true,
|
|
403
|
+
draggedKey: "clip-b",
|
|
404
|
+
draggedOriginStart: 0,
|
|
405
|
+
draggedPreviewStart: 0.5,
|
|
406
|
+
selectedKeys: selectedElementIds,
|
|
407
|
+
};
|
|
408
|
+
const keyframeCache = new Map<string, KeyframeCacheEntry>([
|
|
409
|
+
[
|
|
410
|
+
"clip-a",
|
|
411
|
+
{
|
|
412
|
+
format: "percentage",
|
|
413
|
+
keyframes: [
|
|
414
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
415
|
+
{ percentage: 100, properties: { x: 50 } },
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
]);
|
|
420
|
+
const view = renderLanes({ elements, selectedElementIds, keyframeCache });
|
|
421
|
+
const clipA = view.host.querySelector<HTMLElement>('[data-el-id="clip-a"]');
|
|
422
|
+
const diamonds = () =>
|
|
423
|
+
view.host
|
|
424
|
+
.querySelector('[data-el-id="clip-a"] ~ div button[aria-label*="keyframe at"]')
|
|
425
|
+
?.closest<HTMLElement>("div.pointer-events-none");
|
|
426
|
+
expect(clipA).not.toBeNull();
|
|
427
|
+
expect(clipA?.parentElement).toBe(
|
|
428
|
+
view.host.querySelector('[data-el-id="clip-b"]')?.parentElement,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
expect(diamonds()).not.toBeNull();
|
|
432
|
+
view.rerender({ elements, selectedElementIds, keyframeCache, multiDragPreview: dragging });
|
|
433
|
+
expect(view.host.querySelector('[data-el-id="clip-a"]')).toBe(clipA);
|
|
434
|
+
expect(clipA?.style.transform).toMatch(/^translateX\([1-9]/);
|
|
435
|
+
// Its keyframe diamonds ride with it.
|
|
436
|
+
expect(diamonds()?.style.transform).toBe(clipA?.style.transform);
|
|
437
|
+
|
|
438
|
+
view.rerender({ elements, selectedElementIds, keyframeCache });
|
|
439
|
+
expect(view.host.querySelector('[data-el-id="clip-a"]')).toBe(clipA);
|
|
440
|
+
expect(clipA?.style.transform).toBe("");
|
|
441
|
+
act(() => view.root.unmount());
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// A passenger re-renders on every pointer move. An unstable key there remounts
|
|
445
|
+
// the lanes and drops the in-flight drag.
|
|
395
446
|
it("does not remount the lanes while a multi-clip drag slides the formation", () => {
|
|
396
447
|
const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
|
|
397
448
|
const selectedElementIds = new Set(["clip-a", "clip-b"]);
|
|
@@ -411,9 +462,11 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
411
462
|
});
|
|
412
463
|
|
|
413
464
|
const before = ariaControlsTarget(view.host);
|
|
414
|
-
const beforeLane = before?.querySelector("[data-timeline-property-lane]");
|
|
465
|
+
const beforeLane = before?.querySelector<HTMLElement>("[data-timeline-property-lane]");
|
|
415
466
|
expect(before).not.toBeNull();
|
|
416
467
|
expect(beforeLane).not.toBeNull();
|
|
468
|
+
const startOffset = beforeLane?.style.transform;
|
|
469
|
+
expect(startOffset).toMatch(/^translateX\([1-9]/);
|
|
417
470
|
|
|
418
471
|
view.rerender({
|
|
419
472
|
elements,
|
|
@@ -426,6 +479,9 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
426
479
|
// Node identity, not just presence: a remount replaces these nodes.
|
|
427
480
|
expect(ariaControlsTarget(view.host)).toBe(before);
|
|
428
481
|
expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane);
|
|
482
|
+
// The lanes ride with the formation, not just the clip.
|
|
483
|
+
expect(beforeLane?.style.transform).toMatch(/^translateX\([1-9]/);
|
|
484
|
+
expect(beforeLane?.style.transform).not.toBe(startOffset);
|
|
429
485
|
act(() => view.root.unmount());
|
|
430
486
|
});
|
|
431
487
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useId, useMemo } from "react";
|
|
1
|
+
import { Fragment, useId, useMemo, type CSSProperties } from "react";
|
|
2
2
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
3
3
|
import { TimelineClip } from "./TimelineClip";
|
|
4
4
|
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
|
|
@@ -35,6 +35,15 @@ import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
|
35
35
|
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
|
|
36
36
|
import { deriveTimelineTransitionSeamsByTrack } from "./timelineTransitionSeams";
|
|
37
37
|
|
|
38
|
+
function passengerStyleAt(offsetPx: number): CSSProperties {
|
|
39
|
+
return {
|
|
40
|
+
transform: `translateX(${offsetPx}px)`,
|
|
41
|
+
opacity: 0.85,
|
|
42
|
+
zIndex: 20,
|
|
43
|
+
pointerEvents: "none",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
export function TimelineLanes({
|
|
39
48
|
pps,
|
|
40
49
|
contentOrigin,
|
|
@@ -390,9 +399,9 @@ export function TimelineLanes({
|
|
|
390
399
|
// the passenger's timeline data until the owning drag commits.
|
|
391
400
|
const isPassenger =
|
|
392
401
|
multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
|
|
393
|
-
const
|
|
394
|
-
? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
|
|
395
|
-
:
|
|
402
|
+
const passengerStyle = isPassenger
|
|
403
|
+
? passengerStyleAt(multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview))
|
|
404
|
+
: undefined;
|
|
396
405
|
const clipGestures = createClipGestureHandlers(
|
|
397
406
|
el,
|
|
398
407
|
elementKey,
|
|
@@ -424,6 +433,7 @@ export function TimelineLanes({
|
|
|
424
433
|
}}
|
|
425
434
|
el={previewElement}
|
|
426
435
|
pps={pps}
|
|
436
|
+
passengerStyle={passengerStyle}
|
|
427
437
|
clipY={CLIP_Y}
|
|
428
438
|
clipHeight={clipBarHeight}
|
|
429
439
|
isSelected={isSelected}
|
|
@@ -469,6 +479,7 @@ export function TimelineLanes({
|
|
|
469
479
|
beatsActive={beatStripOnTrack}
|
|
470
480
|
accentColor={clipStyle.accent}
|
|
471
481
|
isSelected={isSelected}
|
|
482
|
+
passengerStyle={passengerStyle}
|
|
472
483
|
currentTime={currentTime}
|
|
473
484
|
selectedKeyframes={selectedKeyframes}
|
|
474
485
|
rovingTargetId={keyboard.rovingTargetId}
|
|
@@ -494,6 +505,7 @@ export function TimelineLanes({
|
|
|
494
505
|
clipDuration={previewElement.duration}
|
|
495
506
|
clipLeftPx={previewElement.start * pps}
|
|
496
507
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
508
|
+
passengerStyle={passengerStyle}
|
|
497
509
|
accentColor={clipStyle.accent}
|
|
498
510
|
isSelected={isSelected}
|
|
499
511
|
currentPercentage={
|
|
@@ -520,34 +532,14 @@ export function TimelineLanes({
|
|
|
520
532
|
/>
|
|
521
533
|
);
|
|
522
534
|
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
// position, so a window shift remounts otherwise stable
|
|
526
|
-
// clip keys and can tear down focus mid-reveal.
|
|
527
|
-
if (!isPassenger) {
|
|
528
|
-
return (
|
|
529
|
-
<Fragment key={clipKey}>
|
|
530
|
-
{clip}
|
|
531
|
-
{compactDiamonds}
|
|
532
|
-
{propertyLanes}
|
|
533
|
-
</Fragment>
|
|
534
|
-
);
|
|
535
|
-
}
|
|
535
|
+
// No wrapper node per clip, and the same Fragment whether or not it rides a
|
|
536
|
+
// drag, so joining or leaving one restyles it, never remounts it.
|
|
536
537
|
return (
|
|
537
|
-
<
|
|
538
|
-
key={clipKey}
|
|
539
|
-
className="absolute inset-0"
|
|
540
|
-
style={{
|
|
541
|
-
transform: `translateX(${passengerOffsetPx}px)`,
|
|
542
|
-
opacity: 0.85,
|
|
543
|
-
zIndex: 20,
|
|
544
|
-
pointerEvents: "none",
|
|
545
|
-
}}
|
|
546
|
-
>
|
|
538
|
+
<Fragment key={clipKey}>
|
|
547
539
|
{clip}
|
|
548
540
|
{compactDiamonds}
|
|
549
541
|
{propertyLanes}
|
|
550
|
-
</
|
|
542
|
+
</Fragment>
|
|
551
543
|
);
|
|
552
544
|
})
|
|
553
545
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useMemo,
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
type MouseEvent as ReactMouseEvent,
|
|
5
|
+
type RefObject,
|
|
6
|
+
} from "react";
|
|
2
7
|
import {
|
|
3
8
|
classifyPropertyGroup,
|
|
4
9
|
type GsapAnimation,
|
|
@@ -23,6 +28,7 @@ export interface TimelinePropertyLanesProps {
|
|
|
23
28
|
clipDuration: number;
|
|
24
29
|
clipLeftPx: number;
|
|
25
30
|
clipWidthPx: number;
|
|
31
|
+
passengerStyle?: CSSProperties;
|
|
26
32
|
accentColor: string;
|
|
27
33
|
isSelected: boolean;
|
|
28
34
|
currentPercentage: number;
|
|
@@ -173,6 +179,7 @@ export function getTimelinePropertyLanes(
|
|
|
173
179
|
return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
|
|
174
180
|
group,
|
|
175
181
|
animations: groupAnimations,
|
|
182
|
+
elementAnimations: animations,
|
|
176
183
|
keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
|
|
177
184
|
})).filter((lane) => lane.keyframes.length > 0);
|
|
178
185
|
}
|
|
@@ -184,6 +191,7 @@ export function TimelinePropertyLanes({
|
|
|
184
191
|
clipDuration,
|
|
185
192
|
clipLeftPx,
|
|
186
193
|
clipWidthPx,
|
|
194
|
+
passengerStyle,
|
|
187
195
|
accentColor,
|
|
188
196
|
isSelected,
|
|
189
197
|
currentPercentage,
|
|
@@ -239,6 +247,7 @@ export function TimelinePropertyLanes({
|
|
|
239
247
|
top: getTimelineLaneTop(laneIndex),
|
|
240
248
|
width: clipWidthPx,
|
|
241
249
|
height: LANE_H,
|
|
250
|
+
...passengerStyle,
|
|
242
251
|
}}
|
|
243
252
|
>
|
|
244
253
|
<TimelineDiamondLane
|
|
@@ -56,14 +56,11 @@ export interface TimelineEditCallbacks {
|
|
|
56
56
|
) => Promise<void> | void;
|
|
57
57
|
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
|
|
58
58
|
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
|
|
59
|
-
* lane change's follow-up z-reorder entry into one undo step
|
|
60
|
-
* widens that entry's fold window when a server round-trip separates the
|
|
61
|
-
* gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
|
|
59
|
+
* lane change's follow-up z-reorder entry into one undo step. */
|
|
62
60
|
onMoveElements?: (
|
|
63
61
|
edits: Array<{ element: TimelineElement; updates: TimelineAtomicMoveUpdates }>,
|
|
64
62
|
coalesceKey?: string,
|
|
65
63
|
operation?: TimelineMoveOperation,
|
|
66
|
-
coalesceMs?: number,
|
|
67
64
|
) => Promise<void> | void;
|
|
68
65
|
onResizeElement?: (
|
|
69
66
|
element: TimelineElement,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { normalizeToZones } from "./timelineZones";
|
|
12
12
|
import { resolveZMirrorLaneMove } from "./timelineZMirror";
|
|
13
13
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
14
|
+
import { isStudioEditSaving, revertNewestStudioPendingEdit } from "../../utils/studioPendingEdits";
|
|
14
15
|
|
|
15
16
|
function el(
|
|
16
17
|
id: string,
|
|
@@ -1534,6 +1535,36 @@ describe("persistMoveEdits convergence", () => {
|
|
|
1534
1535
|
},
|
|
1535
1536
|
);
|
|
1536
1537
|
|
|
1538
|
+
it("registers a revert for the save in flight, and a reverted move is not reasserted", async () => {
|
|
1539
|
+
let current = el("clip", 0, 1, 2);
|
|
1540
|
+
let releaseSave!: () => void;
|
|
1541
|
+
const pendingSave = new Promise<void>((resolve) => {
|
|
1542
|
+
releaseSave = resolve;
|
|
1543
|
+
});
|
|
1544
|
+
const updateElement = (_key: string, updates: Partial<TimelineElement>) => {
|
|
1545
|
+
current = { ...current, ...updates };
|
|
1546
|
+
};
|
|
1547
|
+
const persisted = persistMoveEdits([{ element: current, updates: { start: 4, track: 0 } }], {
|
|
1548
|
+
elements: [current],
|
|
1549
|
+
trackOrder: [0],
|
|
1550
|
+
updateElement,
|
|
1551
|
+
onMoveElements: () => pendingSave,
|
|
1552
|
+
});
|
|
1553
|
+
expect(current.start).toBe(4);
|
|
1554
|
+
expect(isStudioEditSaving()).toBe(true);
|
|
1555
|
+
|
|
1556
|
+
const reapply = revertNewestStudioPendingEdit();
|
|
1557
|
+
expect(reapply).not.toBeNull();
|
|
1558
|
+
expect(current.start).toBe(1);
|
|
1559
|
+
|
|
1560
|
+
releaseSave();
|
|
1561
|
+
await expect(persisted).resolves.toBe(true);
|
|
1562
|
+
expect(current.start).toBe(1);
|
|
1563
|
+
expect(isStudioEditSaving()).toBe(false);
|
|
1564
|
+
reapply?.();
|
|
1565
|
+
expect(current.start).toBe(4);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1537
1568
|
it("reasserts a saved lane after a stale runtime sync", async () => {
|
|
1538
1569
|
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
|
1539
1570
|
let releaseSave: (() => void) | undefined;
|
|
@@ -25,6 +25,8 @@ import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
|
|
25
25
|
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
|
26
26
|
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
|
27
27
|
import { resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
28
|
+
import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
|
|
29
|
+
import { batchElementUpdates } from "../store/batchElementUpdates";
|
|
28
30
|
|
|
29
31
|
type StartTrack = TimelineAtomicMoveUpdates;
|
|
30
32
|
export interface TimelineMoveEdit extends TimelineAtomicMoveEdit {
|
|
@@ -47,14 +49,11 @@ export interface DragCommitDeps {
|
|
|
47
49
|
/** Atomic multi-clip persist (single undo) for lane changes + track inserts.
|
|
48
50
|
* `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
|
|
49
51
|
* history entry so it merges with the lane change's z-reorder entry (see the
|
|
50
|
-
* lane-change branch below).
|
|
51
|
-
* (per-gesture-unique keys make an unbounded window safe) — required when a
|
|
52
|
-
* server round-trip sits between the gesture's two records. */
|
|
52
|
+
* lane-change branch below). */
|
|
53
53
|
onMoveElements?: (
|
|
54
54
|
edits: TimelineMoveEdit[],
|
|
55
55
|
coalesceKey?: string,
|
|
56
56
|
operation?: TimelineMoveOperation,
|
|
57
|
-
coalesceMs?: number,
|
|
58
57
|
) => Promise<void> | void;
|
|
59
58
|
/**
|
|
60
59
|
* The current multi-selection (store.selectedElementIds). When the dragged
|
|
@@ -121,7 +120,6 @@ export function persistMoveEdits(
|
|
|
121
120
|
deps: DragCommitDeps,
|
|
122
121
|
coalesceKey?: string,
|
|
123
122
|
operation: TimelineMoveOperation = "timing",
|
|
124
|
-
coalesceMs?: number,
|
|
125
123
|
): Promise<boolean> {
|
|
126
124
|
if (edits.length === 0) return Promise.resolve(true);
|
|
127
125
|
edits = edits.map((e) => {
|
|
@@ -175,7 +173,9 @@ export function persistMoveEdits(
|
|
|
175
173
|
}
|
|
176
174
|
if (Object.keys(updates).length) updateElement(key, updates);
|
|
177
175
|
};
|
|
178
|
-
|
|
176
|
+
const applyEdits = (reassert = false) =>
|
|
177
|
+
batchElementUpdates(() => edits.forEach((e) => applyEdit(e, reassert)));
|
|
178
|
+
applyEdits();
|
|
179
179
|
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
|
180
180
|
// track when one was resolved (see TimelineMoveEdit.persistTrack).
|
|
181
181
|
const persistEdits = edits.map((e) =>
|
|
@@ -183,19 +183,8 @@ export function persistMoveEdits(
|
|
|
183
183
|
? e
|
|
184
184
|
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
|
|
185
185
|
);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
|
189
|
-
return Promise.resolve(persisted).then(
|
|
190
|
-
() => {
|
|
191
|
-
// Runtime timeline messages can arrive while the save is in flight and
|
|
192
|
-
// restore the preview manifest's pre-gesture lane. Reassert the durable
|
|
193
|
-
// result after persistence, but only while this remains the latest
|
|
194
|
-
// optimistic gesture so an older save can never clobber a newer drag.
|
|
195
|
-
for (const e of edits) applyEdit(e, true);
|
|
196
|
-
return true;
|
|
197
|
-
},
|
|
198
|
-
(error) => {
|
|
186
|
+
const restorePrev = () =>
|
|
187
|
+
batchElementUpdates(() => {
|
|
199
188
|
for (const p of prev) {
|
|
200
189
|
const { audioGroup, ...timing } = p.updates;
|
|
201
190
|
const updates: Partial<TimelineElement> = {};
|
|
@@ -208,10 +197,38 @@ export function persistMoveEdits(
|
|
|
208
197
|
updates.audioGroup = audioGroup;
|
|
209
198
|
if (Object.keys(updates).length) updateElement(p.key, updates);
|
|
210
199
|
}
|
|
200
|
+
});
|
|
201
|
+
// Cmd+Z while the save is in flight shows the old lanes at once; the returned fn re-applies the move.
|
|
202
|
+
const saving = onMoveElements
|
|
203
|
+
? beginStudioPendingEdit(() => {
|
|
204
|
+
restorePrev();
|
|
205
|
+
return () => applyEdits(true);
|
|
206
|
+
})
|
|
207
|
+
: null;
|
|
208
|
+
const start = () =>
|
|
209
|
+
onMoveElements
|
|
210
|
+
? onMoveElements(persistEdits, coalesceKey, operation)
|
|
211
|
+
: Promise.all(
|
|
212
|
+
persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))),
|
|
213
|
+
);
|
|
214
|
+
const persisted = saving ? saving.adopt(start) : start();
|
|
215
|
+
const done = Promise.resolve(persisted).then(
|
|
216
|
+
() => {
|
|
217
|
+
// Runtime timeline messages can arrive while the save is in flight and
|
|
218
|
+
// restore the preview manifest's pre-gesture lane. Reassert the durable
|
|
219
|
+
// result after persistence, but only while this remains the latest
|
|
220
|
+
// optimistic gesture so an older save can never clobber a newer drag.
|
|
221
|
+
if (!saving?.reverted()) applyEdits(true);
|
|
222
|
+
return true;
|
|
223
|
+
},
|
|
224
|
+
(error) => {
|
|
225
|
+
if (!saving?.reverted()) restorePrev();
|
|
211
226
|
console.error("[Timeline] Failed to persist clip edits", error);
|
|
212
227
|
return false;
|
|
213
228
|
},
|
|
214
229
|
);
|
|
230
|
+
saving?.settle(done);
|
|
231
|
+
return done;
|
|
215
232
|
}
|
|
216
233
|
|
|
217
234
|
/**
|
|
@@ -481,12 +498,7 @@ function commitTrackInsert(
|
|
|
481
498
|
* useCanvasZOrderTimelineMirror).
|
|
482
499
|
*
|
|
483
500
|
* `coalesceKey` MUST be the z persist's key (`z-reorder:<action>:<ids>:g<seq>`)
|
|
484
|
-
* so editHistory folds the z write and this track write into ONE undo entry
|
|
485
|
-
* `coalesceMs` MUST widen this record's fold window: the mirror only runs after
|
|
486
|
-
* the z persist's server round-trip resolved, so under real network latency the
|
|
487
|
-
* gap between the two records exceeds the reducer's 300ms default and the fold
|
|
488
|
-
* would never happen live. The key is unique per gesture, so an unbounded
|
|
489
|
-
* window can never merge distinct gestures.
|
|
501
|
+
* so editHistory folds the z write and this track write into ONE undo entry.
|
|
490
502
|
*
|
|
491
503
|
* Resolves `true` once the move persisted, `false` on rollback / refused insert.
|
|
492
504
|
*/
|
|
@@ -495,7 +507,6 @@ export function commitZMirrorLaneMove(
|
|
|
495
507
|
move: NonNullable<ZMirrorLaneMove>,
|
|
496
508
|
deps: DragCommitDeps,
|
|
497
509
|
coalesceKey: string,
|
|
498
|
-
coalesceMs?: number,
|
|
499
510
|
): Promise<boolean> {
|
|
500
511
|
if (move.kind === "move") {
|
|
501
512
|
const edit: TimelineMoveEdit = {
|
|
@@ -504,14 +515,14 @@ export function commitZMirrorLaneMove(
|
|
|
504
515
|
persistTrack: move.persistTrack,
|
|
505
516
|
};
|
|
506
517
|
return refreshAfterDurableLaneMove(
|
|
507
|
-
persistMoveEdits([edit], deps, coalesceKey, "lane-reorder"
|
|
518
|
+
persistMoveEdits([edit], deps, coalesceKey, "lane-reorder"),
|
|
508
519
|
deps,
|
|
509
520
|
);
|
|
510
521
|
}
|
|
511
522
|
const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
|
|
512
523
|
if (!built || built.edits.length === 0) return Promise.resolve(false);
|
|
513
524
|
return refreshAfterDurableLaneMove(
|
|
514
|
-
persistMoveEdits(built.edits, deps, coalesceKey, "track-insert"
|
|
525
|
+
persistMoveEdits(built.edits, deps, coalesceKey, "track-insert"),
|
|
515
526
|
deps,
|
|
516
527
|
);
|
|
517
528
|
}
|
|
@@ -195,7 +195,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
195
195
|
event.clientX - blocked.originClientX,
|
|
196
196
|
event.clientY - blocked.originClientY,
|
|
197
197
|
);
|
|
198
|
-
const threshold = blocked.intent === "move"
|
|
198
|
+
const threshold = blocked.intent === "move" ? 4 : 2;
|
|
199
199
|
if (!blocked.started && distance < threshold) return;
|
|
200
200
|
if (!blocked.started) {
|
|
201
201
|
blocked.started = true;
|
|
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
4
4
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
5
5
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
6
6
|
import { createClipGestureHandlers, type ClipGestureDeps } from "./timelineClipGestureHandlers";
|
|
7
|
-
import { MAX_HAND_EDIT_CLIPS } from "./timelineEditing";
|
|
8
7
|
|
|
9
8
|
afterEach(() => usePlayerStore.getState().reset());
|
|
10
9
|
|
|
@@ -18,11 +17,9 @@ const clips: TimelineElement[] = Array.from({ length: 6 }, (_, i) => ({
|
|
|
18
17
|
}));
|
|
19
18
|
const capabilities = { canMove: true, canTrimStart: true, canTrimEnd: true, readOnly: false };
|
|
20
19
|
|
|
21
|
-
function grabFirstClip(count: number, gesture: "move" | "resize"
|
|
20
|
+
function grabFirstClip(count: number, gesture: "move" | "resize") {
|
|
22
21
|
const store = usePlayerStore.getState();
|
|
23
|
-
store.setElements(
|
|
24
|
-
clips.map((clip, i) => (locked.includes(i) ? { ...clip, timelineLocked: true } : clip)),
|
|
25
|
-
);
|
|
22
|
+
store.setElements(clips);
|
|
26
23
|
store.setSelectedElementIds(new Set(clips.slice(0, count).map((c) => c.id)));
|
|
27
24
|
const setDraggedClip = vi.fn();
|
|
28
25
|
const setResizingClip = vi.fn();
|
|
@@ -55,40 +52,15 @@ function grabFirstClip(count: number, gesture: "move" | "resize", locked: number
|
|
|
55
52
|
}
|
|
56
53
|
|
|
57
54
|
describe("hand-editing a multi-selection", () => {
|
|
58
|
-
it("moves
|
|
59
|
-
const { setDraggedClip, blockedClipRef } = grabFirstClip(
|
|
55
|
+
it.each([1, 3, 4, 6])("moves %i selected clips by hand", (count) => {
|
|
56
|
+
const { setDraggedClip, blockedClipRef } = grabFirstClip(count, "move");
|
|
60
57
|
expect(setDraggedClip).toHaveBeenCalledOnce();
|
|
61
58
|
expect(blockedClipRef.current).toBeNull();
|
|
62
59
|
});
|
|
63
60
|
|
|
64
|
-
it(
|
|
65
|
-
const {
|
|
66
|
-
expect(setDraggedClip).not.toHaveBeenCalled();
|
|
67
|
-
expect(blockedClipRef.current?.intent).toBe("edit-many");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("resizes up to the limit by hand", () => {
|
|
71
|
-
const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS, "resize");
|
|
61
|
+
it.each([1, 3, 4, 6])("resizes %i selected clips by hand", (count) => {
|
|
62
|
+
const { setResizingClip, blockedClipRef } = grabFirstClip(count, "resize");
|
|
72
63
|
expect(setResizingClip).toHaveBeenCalledOnce();
|
|
73
64
|
expect(blockedClipRef.current).toBeNull();
|
|
74
65
|
});
|
|
75
|
-
|
|
76
|
-
it("refuses to start a resize above the limit and asks for the toast", () => {
|
|
77
|
-
const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize");
|
|
78
|
-
expect(setResizingClip).not.toHaveBeenCalled();
|
|
79
|
-
expect(blockedClipRef.current?.intent).toBe("edit-many");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("counts the clips a move would change: locked ones in the selection stay put", () => {
|
|
83
|
-
const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [3, 4]);
|
|
84
|
-
expect(allowed.setDraggedClip).toHaveBeenCalledOnce();
|
|
85
|
-
const refused = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [4]);
|
|
86
|
-
expect(refused.setDraggedClip).not.toHaveBeenCalled();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("counts the clips a trim would change: a locked member leaves a trim of the grabbed clip alone", () => {
|
|
90
|
-
const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize", [3]);
|
|
91
|
-
expect(allowed.setResizingClip).toHaveBeenCalledOnce();
|
|
92
|
-
expect(allowed.blockedClipRef.current).toBeNull();
|
|
93
|
-
});
|
|
94
66
|
});
|
|
@@ -8,11 +8,7 @@ import {
|
|
|
8
8
|
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
9
9
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
10
10
|
import { CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
-
import {
|
|
12
|
-
exceedsHandEditLimit,
|
|
13
|
-
selectClipWithLinks,
|
|
14
|
-
toggleClipWithLinks,
|
|
15
|
-
} from "./timelineLinkSelection";
|
|
11
|
+
import { selectClipWithLinks, toggleClipWithLinks } from "./timelineLinkSelection";
|
|
16
12
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
17
13
|
|
|
18
14
|
export interface ClipGestureDeps {
|
|
@@ -77,7 +73,6 @@ function isIntentBlocked(
|
|
|
77
73
|
*/
|
|
78
74
|
function resolvePointerDownAction(
|
|
79
75
|
e: ReactPointerEvent,
|
|
80
|
-
grabbed: TimelineElement,
|
|
81
76
|
capabilities: TimelineEditCapabilities,
|
|
82
77
|
onResizeElement: ClipGestureDeps["onResizeElement"],
|
|
83
78
|
onMoveElement: ClipGestureDeps["onMoveElement"],
|
|
@@ -98,8 +93,6 @@ function resolvePointerDownAction(
|
|
|
98
93
|
}
|
|
99
94
|
|
|
100
95
|
if (!onMoveElement || !capabilities.canMove) return { kind: "ignore" };
|
|
101
|
-
if (exceedsHandEditLimit(grabbed, e.altKey, null))
|
|
102
|
-
return { kind: "block", intent: "edit-many", rect };
|
|
103
96
|
return { kind: "move", rect };
|
|
104
97
|
}
|
|
105
98
|
|
|
@@ -136,17 +129,6 @@ export function createClipGestureHandlers(
|
|
|
136
129
|
const onResizeStart = (edge: "start" | "end", e: ReactPointerEvent): void => {
|
|
137
130
|
if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
|
|
138
131
|
e.stopPropagation();
|
|
139
|
-
if (exceedsHandEditLimit(el, e.altKey, edge)) {
|
|
140
|
-
blockedClipRef.current = {
|
|
141
|
-
pointerId: e.pointerId,
|
|
142
|
-
element: el,
|
|
143
|
-
intent: "edit-many",
|
|
144
|
-
originClientX: e.clientX,
|
|
145
|
-
originClientY: e.clientY,
|
|
146
|
-
started: false,
|
|
147
|
-
};
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
132
|
blockedClipRef.current = null;
|
|
151
133
|
setShowPopover(false);
|
|
152
134
|
setRangeSelection(null);
|
|
@@ -165,7 +147,7 @@ export function createClipGestureHandlers(
|
|
|
165
147
|
};
|
|
166
148
|
|
|
167
149
|
const onPointerDown = (e: ReactPointerEvent): void => {
|
|
168
|
-
const action = resolvePointerDownAction(e,
|
|
150
|
+
const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
|
|
169
151
|
if (action.kind === "ignore") return;
|
|
170
152
|
|
|
171
153
|
if (action.kind === "block") {
|
|
@@ -248,9 +248,7 @@ export interface TimelinePromptElement {
|
|
|
248
248
|
track: number;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
export type BlockedTimelineEditIntent = "move" | "
|
|
252
|
-
|
|
253
|
-
export const MAX_HAND_EDIT_CLIPS = 3;
|
|
251
|
+
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
|
|
254
252
|
|
|
255
253
|
export interface TimelineRangeSelection {
|
|
256
254
|
start: number;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
3
3
|
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
|
4
|
+
import { batchElementUpdates } from "../store/batchElementUpdates";
|
|
4
5
|
import {
|
|
5
6
|
beginTimelineOptimisticGesture,
|
|
6
7
|
rollbackLatestTimelineOptimisticGesture,
|
|
@@ -17,13 +18,15 @@ export function commitTimelineGroupResize(
|
|
|
17
18
|
updateElement,
|
|
18
19
|
changes.map((change) => change.key),
|
|
19
20
|
);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
batchElementUpdates(() => {
|
|
22
|
+
for (const change of changes) {
|
|
23
|
+
updateElement(change.key, {
|
|
24
|
+
start: change.start,
|
|
25
|
+
duration: change.duration,
|
|
26
|
+
playbackStart: change.playbackStart,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
27
30
|
if (!persist) {
|
|
28
31
|
rollbackLatestTimelineOptimisticGesture(
|
|
29
32
|
updateElement,
|
|
@@ -32,7 +35,6 @@ export function commitTimelineGroupResize(
|
|
|
32
35
|
);
|
|
33
36
|
return;
|
|
34
37
|
}
|
|
35
|
-
const coalesceKey = `clip-group-resize:${changes.map((change) => change.key).join(":")}`;
|
|
36
38
|
Promise.resolve(
|
|
37
39
|
persist(
|
|
38
40
|
changes.map((change) => ({
|
|
@@ -41,7 +43,6 @@ export function commitTimelineGroupResize(
|
|
|
41
43
|
duration: change.duration,
|
|
42
44
|
playbackStart: change.playbackStart,
|
|
43
45
|
})),
|
|
44
|
-
{ coalesceKey },
|
|
45
46
|
),
|
|
46
47
|
).catch((error) => {
|
|
47
48
|
rollbackLatestTimelineOptimisticGesture(
|