@hyperframes/studio 0.7.54 → 0.7.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry for the LIVE multi-selection drag preview.
|
|
3
|
+
*
|
|
4
|
+
* Visual model (matches main): while a selected clip is dragged, ALL selected
|
|
5
|
+
* clips move together LIVE as one rigid formation following the cursor. The
|
|
6
|
+
* GRABBED clip is drawn as the free-floating ghost; every OTHER selected member
|
|
7
|
+
* ("passenger") slides by the SAME time delta via a cheap compositor
|
|
8
|
+
* `translateX` (no re-layout). Passengers do NOT stay still, and they do NOT lag
|
|
9
|
+
* behind individually — the whole formation moves by one delta, spacing locked.
|
|
10
|
+
*
|
|
11
|
+
* That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`.
|
|
12
|
+
* The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview
|
|
13
|
+
* runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
|
|
14
|
+
* the instant any member would cross 0 the grabbed clip stops and every passenger
|
|
15
|
+
* stops with it — the formation never deforms. On DROP the commit shifts every
|
|
16
|
+
* selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
|
|
17
|
+
*
|
|
18
|
+
* Track changes apply to the grabbed clip only (mirroring the commit); passengers
|
|
19
|
+
* keep their lanes, so only their x moves.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface MultiDragPreviewInput {
|
|
23
|
+
/** The drag is live (past the movement threshold). */
|
|
24
|
+
dragStarted: boolean;
|
|
25
|
+
/** Key of the clip under the pointer. */
|
|
26
|
+
draggedKey: string;
|
|
27
|
+
/** The dragged clip's committed start (pre-drag). */
|
|
28
|
+
draggedOriginStart: number;
|
|
29
|
+
/** The dragged clip's live preview start (already group-clamped upstream). */
|
|
30
|
+
draggedPreviewStart: number;
|
|
31
|
+
/** The current multi-selection (store.selectedElementIds). */
|
|
32
|
+
selectedKeys: ReadonlySet<string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a live multi-selection drag is in effect: the drag started, and the
|
|
37
|
+
* dragged clip is itself part of a 2+ multi-selection. Below this, single-drag
|
|
38
|
+
* behavior is unchanged and there are no passengers.
|
|
39
|
+
*/
|
|
40
|
+
export function isMultiDragActive(input: MultiDragPreviewInput): boolean {
|
|
41
|
+
return (
|
|
42
|
+
input.dragStarted && input.selectedKeys.size > 1 && input.selectedKeys.has(input.draggedKey)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The single time delta the WHOLE formation shifts by — the grabbed clip's
|
|
48
|
+
* preview start minus its origin start. Because the preview start is already
|
|
49
|
+
* group-clamped, this is the clamped delta every member (ghost + passengers)
|
|
50
|
+
* moves by. Zero when the clip hasn't moved (or no multi-drag).
|
|
51
|
+
*/
|
|
52
|
+
export function multiDragDeltaSeconds(input: MultiDragPreviewInput): number {
|
|
53
|
+
if (!isMultiDragActive(input)) return 0;
|
|
54
|
+
return input.draggedPreviewStart - input.draggedOriginStart;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a specific rendered clip is a passenger — a selected clip that is NOT
|
|
59
|
+
* the dragged clip and NOT the same clip key. Passengers get the translateX
|
|
60
|
+
* treatment; the dragged clip is drawn as the free-floating ghost instead.
|
|
61
|
+
*/
|
|
62
|
+
export function isMultiDragPassenger(clipKey: string, input: MultiDragPreviewInput): boolean {
|
|
63
|
+
return (
|
|
64
|
+
isMultiDragActive(input) && clipKey !== input.draggedKey && input.selectedKeys.has(clipKey)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The passenger's rendered x offset in PIXELS (delta seconds × pixels/second),
|
|
70
|
+
* to apply as `transform: translateX(...px)`. Every passenger uses the SAME
|
|
71
|
+
* formation delta, so the group moves rigidly. Returns 0 for non-passengers so
|
|
72
|
+
* callers can compute unconditionally and only branch on the elevated styling.
|
|
73
|
+
*/
|
|
74
|
+
export function multiDragPassengerOffsetPx(
|
|
75
|
+
clipKey: string,
|
|
76
|
+
pixelsPerSecond: number,
|
|
77
|
+
input: MultiDragPreviewInput,
|
|
78
|
+
): number {
|
|
79
|
+
if (!isMultiDragPassenger(clipKey, input)) return 0;
|
|
80
|
+
const pps = Number.isFinite(pixelsPerSecond) ? pixelsPerSecond : 0;
|
|
81
|
+
return multiDragDeltaSeconds(input) * pps;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clamp a group move so the WHOLE selection moves as ONE rigid formation.
|
|
86
|
+
*
|
|
87
|
+
* The grabbed clip proposes a raw delta (its desired preview start minus its
|
|
88
|
+
* origin start, after its own snapping). Applied naively, a passenger could be
|
|
89
|
+
* pushed below 0 (or past any other member bound), and the commit's per-clip
|
|
90
|
+
* `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
|
|
91
|
+
* the group while a passenger sticks at the wall. This ports main's model
|
|
92
|
+
* (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
|
|
93
|
+
* bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
|
|
94
|
+
* any member hits 0 and the formation never deforms.
|
|
95
|
+
*
|
|
96
|
+
* `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
|
|
97
|
+
* included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
|
|
98
|
+
* no fixed right wall (the composition grows on commit).
|
|
99
|
+
*/
|
|
100
|
+
export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number {
|
|
101
|
+
if (memberStarts.length === 0) return rawDelta;
|
|
102
|
+
// Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0.
|
|
103
|
+
const minStart = Math.min(...memberStarts);
|
|
104
|
+
const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
|
|
105
|
+
return rawDelta < minDelta ? minDelta : rawDelta;
|
|
106
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
type UpdateElement = (key: string, updates: Partial<TimelineElement>) => void;
|
|
4
|
+
const revisionsByUpdater = new WeakMap<UpdateElement, Map<string, number>>();
|
|
5
|
+
|
|
6
|
+
export function beginTimelineOptimisticGesture(
|
|
7
|
+
updateElement: UpdateElement,
|
|
8
|
+
keys: readonly string[],
|
|
9
|
+
): Map<string, number> {
|
|
10
|
+
let revisions = revisionsByUpdater.get(updateElement);
|
|
11
|
+
if (!revisions) {
|
|
12
|
+
revisions = new Map();
|
|
13
|
+
revisionsByUpdater.set(updateElement, revisions);
|
|
14
|
+
}
|
|
15
|
+
const gesture = new Map<string, number>();
|
|
16
|
+
for (const key of keys) {
|
|
17
|
+
const revision = (revisions.get(key) ?? 0) + 1;
|
|
18
|
+
revisions.set(key, revision);
|
|
19
|
+
gesture.set(key, revision);
|
|
20
|
+
}
|
|
21
|
+
return gesture;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isLatestTimelineOptimisticGesture(
|
|
25
|
+
updateElement: UpdateElement,
|
|
26
|
+
gesture: ReadonlyMap<string, number>,
|
|
27
|
+
key: string,
|
|
28
|
+
): boolean {
|
|
29
|
+
return revisionsByUpdater.get(updateElement)?.get(key) === gesture.get(key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rollbackLatestTimelineOptimisticGesture(
|
|
33
|
+
updateElement: UpdateElement,
|
|
34
|
+
gesture: ReadonlyMap<string, number>,
|
|
35
|
+
rollbacks: ReadonlyArray<{ key: string; updates: Partial<TimelineElement> }>,
|
|
36
|
+
): void {
|
|
37
|
+
for (const rollback of rollbacks) {
|
|
38
|
+
if (isLatestTimelineOptimisticGesture(updateElement, gesture, rollback.key)) {
|
|
39
|
+
updateElement(rollback.key, rollback.updates);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
TIMELINE_SNAP_PX,
|
|
4
|
+
collectTimelineSnapTargets,
|
|
5
|
+
snapMoveToTargets,
|
|
6
|
+
snapTimelineTime,
|
|
7
|
+
} from "./timelineSnapping";
|
|
8
|
+
|
|
9
|
+
describe("collectTimelineSnapTargets", () => {
|
|
10
|
+
const elements = [
|
|
11
|
+
{ start: 2, duration: 3, key: "a", id: "a" },
|
|
12
|
+
{ start: 10, duration: 1.5, key: "b", id: "b" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
it("collects clip starts and ends, playhead, and beats with types", () => {
|
|
16
|
+
const targets = collectTimelineSnapTargets({
|
|
17
|
+
elements,
|
|
18
|
+
playheadTime: 7.25,
|
|
19
|
+
beatTimes: [0.5, 1.0],
|
|
20
|
+
});
|
|
21
|
+
expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
|
|
22
|
+
expect(targets).toContainEqual({ time: 5, type: "clip-edge" });
|
|
23
|
+
expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
|
|
24
|
+
expect(targets).toContainEqual({ time: 11.5, type: "clip-edge" });
|
|
25
|
+
expect(targets).toContainEqual({ time: 7.25, type: "playhead" });
|
|
26
|
+
expect(targets).toContainEqual({ time: 0.5, type: "beat" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("excludes the dragged element's own edges", () => {
|
|
30
|
+
const targets = collectTimelineSnapTargets({
|
|
31
|
+
elements,
|
|
32
|
+
playheadTime: null,
|
|
33
|
+
beatTimes: [],
|
|
34
|
+
excludeElementKey: "a",
|
|
35
|
+
});
|
|
36
|
+
expect(targets.some((t) => t.time === 2)).toBe(false);
|
|
37
|
+
expect(targets.some((t) => t.time === 5)).toBe(false);
|
|
38
|
+
expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("omits playhead when null and dedupes identical times preferring playhead > clip-edge > beat", () => {
|
|
42
|
+
const targets = collectTimelineSnapTargets({
|
|
43
|
+
elements: [{ start: 1, duration: 1, key: "x", id: "x" }],
|
|
44
|
+
playheadTime: 2,
|
|
45
|
+
beatTimes: [2],
|
|
46
|
+
});
|
|
47
|
+
const atTwo = targets.filter((t) => t.time === 2);
|
|
48
|
+
expect(atTwo).toEqual([{ time: 2, type: "playhead" }]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("dedupes a coincident clip-edge and beat preferring clip-edge (no playhead)", () => {
|
|
52
|
+
// A beat and a clip edge land on the same time (2). clip-edge has higher
|
|
53
|
+
// priority than beat, so the deduped target is clip-edge — not beat.
|
|
54
|
+
const targets = collectTimelineSnapTargets({
|
|
55
|
+
elements: [{ start: 2, duration: 3, key: "x", id: "x" }],
|
|
56
|
+
playheadTime: null,
|
|
57
|
+
beatTimes: [2],
|
|
58
|
+
});
|
|
59
|
+
const atTwo = targets.filter((t) => t.time === 2);
|
|
60
|
+
expect(atTwo).toEqual([{ time: 2, type: "clip-edge" }]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("snapTimelineTime", () => {
|
|
65
|
+
const targets = [
|
|
66
|
+
{ time: 5, type: "clip-edge" as const },
|
|
67
|
+
{ time: 5.3, type: "playhead" as const },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
it("snaps to the nearest target within threshold", () => {
|
|
71
|
+
expect(snapTimelineTime(5.05, targets, 0.1)).toEqual({
|
|
72
|
+
time: 5,
|
|
73
|
+
target: { time: 5, type: "clip-edge" },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns input unchanged when nothing is within threshold", () => {
|
|
78
|
+
expect(snapTimelineTime(6, targets, 0.1)).toEqual({ time: 6, target: null });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("snapMoveToTargets", () => {
|
|
83
|
+
// pps=100 → threshold = TIMELINE_SNAP_PX/100 = 0.08s
|
|
84
|
+
const targets = [{ time: 5, type: "playhead" as const }];
|
|
85
|
+
|
|
86
|
+
it("snaps the start edge when it is the closer edge", () => {
|
|
87
|
+
const r = snapMoveToTargets(5.05, 2, targets, 100, 60);
|
|
88
|
+
expect(r).toEqual({ start: 5, snapTime: 5, snapType: "playhead" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("snaps the end edge, shifting start so the end lands on the target", () => {
|
|
92
|
+
const r = snapMoveToTargets(3.03, 2, targets, 100, 60);
|
|
93
|
+
expect(r.start).toBeCloseTo(3, 5);
|
|
94
|
+
expect(r.snapTime).toBe(5);
|
|
95
|
+
expect(r.snapType).toBe("playhead");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("drops the snap when clamping to timeline bounds pulls it off target", () => {
|
|
99
|
+
// duration 2, timeline 6 → maxStart 4; target at 5.05 wants start 5.05 → clamped to 4
|
|
100
|
+
const r = snapMoveToTargets(5.0, 2, [{ time: 5.05, type: "beat" }], 100, 6);
|
|
101
|
+
expect(r.snapTime).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("threshold scales with pixels-per-second", () => {
|
|
105
|
+
// pps=10 → threshold 0.8s: 5.5 snaps; pps=1000 → threshold 0.008s: it does not
|
|
106
|
+
expect(snapMoveToTargets(5.5, 2, targets, 10, 60).snapTime).toBe(5);
|
|
107
|
+
expect(snapMoveToTargets(5.5, 2, targets, 1000, 60).snapTime).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("TIMELINE_SNAP_PX matches the historical beat-snap threshold", () => {
|
|
111
|
+
expect(TIMELINE_SNAP_PX).toBe(8);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps the snap indicator for a frame-quantized duration (ms-rounding residue, no clamp)", () => {
|
|
115
|
+
// duration 10/3 ≈ 3.3333…; end-snap onto a clip-edge at 5 gives a candidate
|
|
116
|
+
// start of 5 - 10/3 = 1.6666…, whose ms-rounding residue (~3.3e-4) exceeds the
|
|
117
|
+
// old 1e-6 tolerance. With a huge timeline (no bounds clamp), the snap must
|
|
118
|
+
// survive — the clip snaps AND the indicator shows.
|
|
119
|
+
const duration = 10 / 3;
|
|
120
|
+
const edge = [{ time: 5, type: "clip-edge" as const }];
|
|
121
|
+
const r = snapMoveToTargets(5 - duration + 0.001, duration, edge, 100, 1000);
|
|
122
|
+
expect(r.snapTime).toBe(5);
|
|
123
|
+
expect(r.snapType).toBe("clip-edge");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("still drops the snap when the bounds clamp genuinely moves a frame-quantized clip off target", () => {
|
|
127
|
+
// Same 10/3 duration, but the timeline is short enough that clamping to maxStart
|
|
128
|
+
// pulls the clip off the target — the indicator must still vanish (the residue
|
|
129
|
+
// widening must not mask a real clamp).
|
|
130
|
+
const duration = 10 / 3;
|
|
131
|
+
const r = snapMoveToTargets(5.0, duration, [{ time: 5.05, type: "beat" }], 100, 6);
|
|
132
|
+
expect(r.snapTime).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
export type TimelineSnapType = "beat" | "playhead" | "clip-edge";
|
|
4
|
+
|
|
5
|
+
export interface TimelineSnapTarget {
|
|
6
|
+
time: number;
|
|
7
|
+
type: TimelineSnapType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pixel radius within which a time snaps to a target (matches historical beat snap). */
|
|
11
|
+
export const TIMELINE_SNAP_PX = 8;
|
|
12
|
+
|
|
13
|
+
const TYPE_PRIORITY: Record<TimelineSnapType, number> = {
|
|
14
|
+
playhead: 0,
|
|
15
|
+
"clip-edge": 1,
|
|
16
|
+
beat: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function collectTimelineSnapTargets(input: {
|
|
20
|
+
elements: ReadonlyArray<Pick<TimelineElement, "start" | "duration" | "key" | "id">>;
|
|
21
|
+
playheadTime: number | null;
|
|
22
|
+
beatTimes: readonly number[];
|
|
23
|
+
excludeElementKey?: string | null;
|
|
24
|
+
}): TimelineSnapTarget[] {
|
|
25
|
+
const byTime = new Map<number, TimelineSnapTarget>();
|
|
26
|
+
const add = (time: number, type: TimelineSnapType) => {
|
|
27
|
+
if (!Number.isFinite(time) || time < 0) return;
|
|
28
|
+
const rounded = Math.round(time * 1000) / 1000;
|
|
29
|
+
const existing = byTime.get(rounded);
|
|
30
|
+
if (!existing || TYPE_PRIORITY[type] < TYPE_PRIORITY[existing.type]) {
|
|
31
|
+
byTime.set(rounded, { time: rounded, type });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
for (const beat of input.beatTimes) add(beat, "beat");
|
|
36
|
+
for (const el of input.elements) {
|
|
37
|
+
if (input.excludeElementKey != null && (el.key ?? el.id) === input.excludeElementKey) continue;
|
|
38
|
+
add(el.start, "clip-edge");
|
|
39
|
+
add(el.start + el.duration, "clip-edge");
|
|
40
|
+
}
|
|
41
|
+
if (input.playheadTime != null) add(input.playheadTime, "playhead");
|
|
42
|
+
|
|
43
|
+
return Array.from(byTime.values()).sort((a, b) => a.time - b.time);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function snapTimelineTime(
|
|
47
|
+
time: number,
|
|
48
|
+
targets: readonly TimelineSnapTarget[],
|
|
49
|
+
thresholdSecs: number,
|
|
50
|
+
): { time: number; target: TimelineSnapTarget | null } {
|
|
51
|
+
let best: TimelineSnapTarget | null = null;
|
|
52
|
+
let bestDist = thresholdSecs;
|
|
53
|
+
for (const target of targets) {
|
|
54
|
+
const d = Math.abs(target.time - time);
|
|
55
|
+
if (
|
|
56
|
+
d < bestDist ||
|
|
57
|
+
(d === bestDist && best && TYPE_PRIORITY[target.type] < TYPE_PRIORITY[best.type])
|
|
58
|
+
) {
|
|
59
|
+
bestDist = d;
|
|
60
|
+
best = target;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return best ? { time: best.time, target: best } : { time, target: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Snap a moved clip so whichever edge (start or end) is nearest a target lands
|
|
68
|
+
* on it, keeping duration fixed. Mirrors the historical beat-snap semantics:
|
|
69
|
+
* clamp to [0, timelineDuration - duration]; if clamping pulls the clip off the
|
|
70
|
+
* target, drop the highlight.
|
|
71
|
+
*/
|
|
72
|
+
export function snapMoveToTargets(
|
|
73
|
+
start: number,
|
|
74
|
+
duration: number,
|
|
75
|
+
targets: readonly TimelineSnapTarget[],
|
|
76
|
+
pixelsPerSecond: number,
|
|
77
|
+
timelineDuration: number,
|
|
78
|
+
): { start: number; snapTime: number | null; snapType: TimelineSnapType | null } {
|
|
79
|
+
if (targets.length === 0) return { start, snapTime: null, snapType: null };
|
|
80
|
+
const thresholdSecs = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
|
|
81
|
+
const startSnap = snapTimelineTime(start, targets, thresholdSecs);
|
|
82
|
+
const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs);
|
|
83
|
+
const startMoved = startSnap.target !== null;
|
|
84
|
+
const endMoved = endSnap.target !== null;
|
|
85
|
+
|
|
86
|
+
let candidate = start;
|
|
87
|
+
let target: TimelineSnapTarget | null = null;
|
|
88
|
+
if (
|
|
89
|
+
startMoved &&
|
|
90
|
+
(!endMoved || Math.abs(startSnap.time - start) <= Math.abs(endSnap.time - (start + duration)))
|
|
91
|
+
) {
|
|
92
|
+
candidate = startSnap.time;
|
|
93
|
+
target = startSnap.target;
|
|
94
|
+
} else if (endMoved) {
|
|
95
|
+
candidate = endSnap.time - duration;
|
|
96
|
+
target = endSnap.target;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const maxStart = Math.max(0, timelineDuration - duration);
|
|
100
|
+
// Round the candidate to ms FIRST, then compare the clamp against that rounded
|
|
101
|
+
// value — not the raw candidate. A frame-quantized duration (e.g. 1/30s, 10/3s)
|
|
102
|
+
// leaves sub-ms residue after rounding that exceeds a 1e-6 tolerance, so comparing
|
|
103
|
+
// the clamp to the raw candidate dropped the snap-line indicator on every snap
|
|
104
|
+
// even though no clamping happened. Comparing against the rounded candidate makes
|
|
105
|
+
// the residue exactly 0 unless the timeline-bounds clamp actually moved the clip.
|
|
106
|
+
const roundedCandidate = Math.round(candidate * 1000) / 1000;
|
|
107
|
+
const clamped = Math.max(0, Math.min(maxStart, roundedCandidate));
|
|
108
|
+
if (target && Math.abs(clamped - roundedCandidate) > 1e-6) target = null;
|
|
109
|
+
return { start: clamped, snapTime: target?.time ?? null, snapType: target?.type ?? null };
|
|
110
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync";
|
|
3
|
+
|
|
4
|
+
function el(
|
|
5
|
+
key: string,
|
|
6
|
+
track: number,
|
|
7
|
+
start: number,
|
|
8
|
+
duration: number,
|
|
9
|
+
zIndex: number,
|
|
10
|
+
isAudio = false,
|
|
11
|
+
domIndex?: number,
|
|
12
|
+
): StackingElement {
|
|
13
|
+
return { key, track, start, duration, zIndex, isAudio, domIndex };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function patchMap(elements: StackingElement[], edited: string[]): Record<string, number> {
|
|
17
|
+
const out: Record<string, number> = {};
|
|
18
|
+
for (const p of computeStackingPatches(elements, edited)) out[p.key] = p.zIndex;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("laneIsAbove", () => {
|
|
23
|
+
it("lower track renders above (top of timeline wins)", () => {
|
|
24
|
+
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
|
|
25
|
+
expect(laneIsAbove({ track: 2 }, { track: 1 })).toBe(false);
|
|
26
|
+
expect(laneIsAbove({ track: 1 }, { track: 1 })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("computeStackingPatches", () => {
|
|
31
|
+
it("no overlapping clips → no patch", () => {
|
|
32
|
+
// a (0..5 on track 0) and b (10..15 on track 1) never overlap in time.
|
|
33
|
+
const elements = [el("a", 0, 0, 5, 10), el("b", 1, 10, 5, 5)];
|
|
34
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("edited clip moved to a HIGHER lane (top) but z too low → raised above the below-neighbour", () => {
|
|
38
|
+
// a on top lane (0) overlaps b on lane 1; a.z=1 is below b.z=5 → wrong.
|
|
39
|
+
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
|
|
40
|
+
// Only a is edited → only a gets a patch, lifting it above b (5) → 6.
|
|
41
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("edited clip moved to a LOWER lane (bottom) but z too high → lowered below the above-neighbour", () => {
|
|
45
|
+
// a on lane 2 (bottom) overlaps b on lane 0 (top); a.z=9 above b.z=5 → wrong.
|
|
46
|
+
const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 5)];
|
|
47
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("edited clip already correctly ordered → no patch (authored z preserved)", () => {
|
|
51
|
+
// a on top lane already has higher z than the lower-lane b it overlaps.
|
|
52
|
+
const elements = [el("a", 0, 0, 10, 8), el("b", 1, 0, 10, 3)];
|
|
53
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("untouched clips never get a patch even when they overlap the edit", () => {
|
|
57
|
+
// b is out of order relative to a, but a is the only edited clip.
|
|
58
|
+
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5), el("c", 2, 0, 10, 9)];
|
|
59
|
+
const patches = computeStackingPatches(elements, ["a"]);
|
|
60
|
+
expect(patches.map((p) => p.key)).toEqual(["a"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("sits strictly between neighbours when there is integer room", () => {
|
|
64
|
+
// edited a on middle lane 1 between below-lane-2 (z=2) and above-lane-0 (z=10).
|
|
65
|
+
const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 2), el("above", 0, 0, 10, 10)];
|
|
66
|
+
// Between 2 and 10 → floor((2+10)/2)=6.
|
|
67
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("adjacent neighbours (no integer gap) → edited lands above lower, upper is bumped", () => {
|
|
71
|
+
// below z=4, above z=5 (adjacent). There is no integer strictly between 4 and
|
|
72
|
+
// 5, so the old single-patch a=5 left `a` TIED with `above` — and with no DOM
|
|
73
|
+
// order to break the tie, `above` no longer paints strictly above `a` (the
|
|
74
|
+
// under-patch bug). Tie-aware cascade: a→5 (above below's 4) AND above→6 so it
|
|
75
|
+
// stays strictly on top. Minimal: only the two overlapping neighbours move.
|
|
76
|
+
const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 4), el("above", 0, 0, 10, 5)];
|
|
77
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 5, above: 6 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("audio clips are excluded — an audio edit yields no patch", () => {
|
|
81
|
+
const elements = [el("music", 3, 0, 10, 0, true), el("v", 0, 0, 10, 5)];
|
|
82
|
+
expect(patchMap(elements, ["music"])).toEqual({});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("audio clips are excluded as neighbours — a visual edit ignores overlapping audio", () => {
|
|
86
|
+
// The only overlapping clip is audio → treated as no visual overlap → no patch.
|
|
87
|
+
const elements = [el("v", 0, 0, 10, 3), el("music", 3, 0, 10, 99, true)];
|
|
88
|
+
expect(patchMap(elements, ["v"])).toEqual({});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("only-below neighbours → maxBelow + 1", () => {
|
|
92
|
+
const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 3), el("c", 2, 0, 10, 7)];
|
|
93
|
+
// a on top overlaps b(3) and c(7), both below → 7+1=8.
|
|
94
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 8 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("only-above neighbours → minAbove - 1 (clamped ≥ 0)", () => {
|
|
98
|
+
const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 1), el("c", 1, 0, 10, 4)];
|
|
99
|
+
// a on bottom overlaps b(1) and c(4), both above → min(1)-1=0.
|
|
100
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 0 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("partial time overlap still counts", () => {
|
|
104
|
+
// a: 0..6, b: 5..15 overlap in [5,6).
|
|
105
|
+
const elements = [el("a", 0, 0, 6, 1), el("b", 1, 5, 10, 5)];
|
|
106
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("touching-but-not-overlapping intervals do NOT count", () => {
|
|
110
|
+
// a ends exactly where b starts (t=5) → half-open, no overlap.
|
|
111
|
+
const elements = [el("a", 0, 0, 5, 1), el("b", 1, 5, 5, 5)];
|
|
112
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("multi-clip edit: two dragged clips resolve consistently against the region", () => {
|
|
116
|
+
// Drag a (lane 0) and b (lane 1) onto a region already holding c (lane 2, z=5).
|
|
117
|
+
// Both overlap c. Lower-lane b resolves first (above c → 6), then a (above b → 7).
|
|
118
|
+
const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
|
|
119
|
+
expect(patchMap(elements, ["a", "b"])).toEqual({ a: 7, b: 6 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("multi-clip edit skips a member that is already correctly ordered", () => {
|
|
123
|
+
const elements = [el("a", 0, 0, 10, 20), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
|
|
124
|
+
// a(20) already above everything → no patch. b (lane 1) sits between
|
|
125
|
+
// below-neighbour c(5) and above-neighbour a(20) → floor((5+20)/2)=12.
|
|
126
|
+
expect(patchMap(elements, ["a", "b"])).toEqual({ b: 12 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("empty edited set → no patches", () => {
|
|
130
|
+
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
|
|
131
|
+
expect(computeStackingPatches(elements, [])).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("item 13: an unresolved neighbour (non-finite z) is EXCLUDED, not treated as z=0", () => {
|
|
135
|
+
// `ghost` is an overlapping upper-lane clip whose live node could not be
|
|
136
|
+
// resolved, so its z came back NaN. If it were fabricated to 0 it would be a
|
|
137
|
+
// real above-neighbour at the z-floor and drag the edited clip's cascade down.
|
|
138
|
+
// Excluded, the only real overlap is below-neighbour b(3) → a rises to 4.
|
|
139
|
+
const elements = [
|
|
140
|
+
el("a", 1, 0, 10, 0),
|
|
141
|
+
el("b", 2, 0, 10, 3),
|
|
142
|
+
el("ghost", 0, 0, 10, Number.NaN),
|
|
143
|
+
];
|
|
144
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("item 13: an edited clip whose own z is unresolved (non-finite) yields no patch", () => {
|
|
148
|
+
// The edited clip itself couldn't be resolved → it is dropped from the working
|
|
149
|
+
// set and produces nothing (no fabricated-0 self-patch).
|
|
150
|
+
const elements = [el("a", 0, 0, 10, Number.NaN), el("b", 1, 0, 10, 5)];
|
|
151
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("computeStackingPatches — tie-aware cascade (lane move always realisable)", () => {
|
|
156
|
+
it("drag below an overlapping z=0 neighbour → cascade bumps the neighbour, edit→0", () => {
|
|
157
|
+
// edited `v` (z=2) dragged to the BOTTOM lane, overlapping `r` (z=0) which is
|
|
158
|
+
// now on the upper lane. No z ≥ 0 fits strictly below 0, so the old resolver
|
|
159
|
+
// clamped v to 0 (tied with r) and nothing changed on canvas — the reported
|
|
160
|
+
// bug. Tie-aware: v→0 AND r bumped to 1 so r paints strictly above v.
|
|
161
|
+
const elements = [el("v", 1, 0, 10, 2), el("r", 0, 0, 10, 0)];
|
|
162
|
+
expect(patchMap(elements, ["v"])).toEqual({ v: 0, r: 1 });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("equal-z + domIndex: dragging the LATER-in-DOM clip below is realised via a bump", () => {
|
|
166
|
+
// Two equal-z clips; `v` is later in DOM (domIndex 1) so it currently paints
|
|
167
|
+
// ON TOP of `r` (domIndex 0). User drags v to the lower lane (track 1). With
|
|
168
|
+
// domIndex the sync SEES that v is currently above r and must be lowered:
|
|
169
|
+
// v→0 (already 0, stays) then r bumped to 1 so r wins. Without domIndex the
|
|
170
|
+
// equal z would look already-correct and under-patch.
|
|
171
|
+
const elements = [el("r", 0, 0, 10, 0, false, 0), el("v", 1, 0, 10, 0, false, 1)];
|
|
172
|
+
expect(patchMap(elements, ["v"])).toEqual({ r: 1 });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("equal-z without domIndex is ambiguous → conservatively bumps to guarantee order", () => {
|
|
176
|
+
// Same shape but NO domIndex: equal z is ambiguous, so the resolver cannot
|
|
177
|
+
// prove v is already below r and patches to make the order explicit (r above).
|
|
178
|
+
const elements = [el("r", 0, 0, 10, 0), el("v", 1, 0, 10, 0)];
|
|
179
|
+
const out = patchMap(elements, ["v"]);
|
|
180
|
+
// r must end up strictly above v (higher z) regardless of the exact numbers.
|
|
181
|
+
const vz = out.v ?? 0;
|
|
182
|
+
const rz = out.r ?? 0;
|
|
183
|
+
expect(rz).toBeGreaterThan(vz);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("#2198 (Abhai repro): a lift cascades transitively so an UNTOUCHED pair never inverts", () => {
|
|
187
|
+
// m (z1, lane0, dom0), n (z0, lane1, dom1), e (z2, lane2, dom2, edited).
|
|
188
|
+
// e overlaps n [5,10); n overlaps m [12,15); e does NOT overlap m.
|
|
189
|
+
// Dragging e to the bottom lane forces n up to paint above e. A naive lift sets
|
|
190
|
+
// n→1, which TIES m (z1) and — n being later in the DOM — paints n above m,
|
|
191
|
+
// inverting the untouched (m,n) pair (which the next normalize would reshuffle).
|
|
192
|
+
// The transitive cascade lifts m too (→2) so m stays strictly above n.
|
|
193
|
+
const elements = [
|
|
194
|
+
el("m", 0, 12, 8, 1, false, 0),
|
|
195
|
+
el("n", 1, 5, 10, 0, false, 1),
|
|
196
|
+
el("e", 2, 0, 10, 2, false, 2),
|
|
197
|
+
];
|
|
198
|
+
expect(patchMap(elements, ["e"])).toEqual({ e: 0, n: 1, m: 2 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("cascade patches as FEW clips as possible (only the blockers move)", () => {
|
|
202
|
+
// v dragged to bottom under r(z0) and s(z0) both on higher lanes; a distant
|
|
203
|
+
// non-overlapping clip x is never touched.
|
|
204
|
+
const elements = [
|
|
205
|
+
el("v", 2, 0, 10, 5),
|
|
206
|
+
el("r", 0, 0, 10, 0),
|
|
207
|
+
el("s", 1, 0, 10, 0),
|
|
208
|
+
el("x", 3, 50, 10, 0), // no time overlap → untouched
|
|
209
|
+
];
|
|
210
|
+
const out = patchMap(elements, ["v"]);
|
|
211
|
+
expect("x" in out).toBe(false);
|
|
212
|
+
// v below both r and s.
|
|
213
|
+
expect(out.v).toBeLessThan(out.r ?? 0);
|
|
214
|
+
expect(out.v).toBeLessThan(out.s ?? 0);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("computeStackingPatches — DOM tie-break gates the cascade (item 12)", () => {
|
|
219
|
+
it("tie ACCEPTABLE: edited may sit AT minAbove when the above-neighbour is later in DOM → single patch, no neighbour bump", () => {
|
|
220
|
+
// below b (z3, lane2, dom0), edited e (lane1, dom1), above a (z4, lane0, dom2).
|
|
221
|
+
// e must paint above b and below a. There is no integer strictly between 3 and
|
|
222
|
+
// 4, but a is LATER in the DOM, so e=4 ties a and a still paints on top by DOM
|
|
223
|
+
// order — a valid SINGLE patch. The old gap<2 rule cascaded and needlessly
|
|
224
|
+
// bumped a's authored z (the over-patch). Only e changes here.
|
|
225
|
+
const elements = [
|
|
226
|
+
el("b", 2, 0, 10, 3, false, 0),
|
|
227
|
+
el("e", 1, 0, 10, 0, false, 1),
|
|
228
|
+
el("a", 0, 0, 10, 4, false, 2),
|
|
229
|
+
];
|
|
230
|
+
expect(patchMap(elements, ["e"])).toEqual({ e: 4 });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("tie INVERTING: edited tying minAbove would paint ABOVE it (earlier in DOM) → cascade bumps the neighbour", () => {
|
|
234
|
+
// Same z's, but a is EARLIER in the DOM than e (dom0 vs dom2). Now e=4 would
|
|
235
|
+
// tie a AND paint on top (e later in DOM), violating the lane order, so the
|
|
236
|
+
// tie-break can't save it: e→4 and a is bumped to 5.
|
|
237
|
+
const elements = [
|
|
238
|
+
el("a", 0, 0, 10, 4, false, 0),
|
|
239
|
+
el("b", 2, 0, 10, 3, false, 1),
|
|
240
|
+
el("e", 1, 0, 10, 0, false, 2),
|
|
241
|
+
];
|
|
242
|
+
expect(patchMap(elements, ["e"])).toEqual({ e: 4, a: 5 });
|
|
243
|
+
});
|
|
244
|
+
});
|