@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,401 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
3
|
+
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
4
|
+
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
|
5
|
+
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
6
|
+
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
7
|
+
import {
|
|
8
|
+
beginTimelineOptimisticGesture,
|
|
9
|
+
isLatestTimelineOptimisticGesture,
|
|
10
|
+
} from "./timelineOptimisticRevision";
|
|
11
|
+
|
|
12
|
+
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
13
|
+
export interface TimelineMoveEdit {
|
|
14
|
+
element: TimelineElement;
|
|
15
|
+
updates: StartTrack;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DragCommitDeps {
|
|
19
|
+
elements: TimelineElement[];
|
|
20
|
+
trackOrder: number[];
|
|
21
|
+
updateElement: (key: string, updates: Partial<TimelineElement>) => void;
|
|
22
|
+
/** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
|
|
23
|
+
onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise<void> | void;
|
|
24
|
+
/** Atomic multi-clip persist (single undo) for lane changes + track inserts.
|
|
25
|
+
* `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
|
|
26
|
+
* history entry so it merges with the lane change's z-reorder entry (see the
|
|
27
|
+
* lane-change branch below). */
|
|
28
|
+
onMoveElements?: (
|
|
29
|
+
edits: TimelineMoveEdit[],
|
|
30
|
+
coalesceKey?: string,
|
|
31
|
+
operation?: TimelineMoveOperation,
|
|
32
|
+
) => Promise<void> | void;
|
|
33
|
+
/**
|
|
34
|
+
* The current multi-selection (store.selectedElementIds). When the dragged
|
|
35
|
+
* clip is part of a multi-selection (size > 1), the WHOLE selection moves by
|
|
36
|
+
* the dragged clip's time delta — the standard NLE gesture. Track changes
|
|
37
|
+
* apply to the dragged clip only; the others keep their lanes.
|
|
38
|
+
*/
|
|
39
|
+
selectedKeys?: ReadonlySet<string> | null;
|
|
40
|
+
/**
|
|
41
|
+
* Lane ↔ stacking unification. When a DELIBERATE vertical lane change happens,
|
|
42
|
+
* the edited clip(s) get z-index patches so their canvas stacking matches lane
|
|
43
|
+
* order (higher lane = on top) relative to time-overlapping clips — see
|
|
44
|
+
* timelineStackingSync. Both deps must be supplied to engage; if either is
|
|
45
|
+
* absent the z-sync is skipped (pure time-moves and horizontal collision bumps
|
|
46
|
+
* never restack). `readZIndex` returns the clip's current z-index (from the
|
|
47
|
+
* live DOM inline style / computed; "auto" ⇒ 0).
|
|
48
|
+
*/
|
|
49
|
+
readZIndex?: (element: TimelineElement) => number;
|
|
50
|
+
/**
|
|
51
|
+
* Apply the computed z-index patches. Wiring (in the drag hook, which owns the
|
|
52
|
+
* DOM/persist plumbing) forwards these to the SAME atomic style-patch persist
|
|
53
|
+
* the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
|
|
54
|
+
* research/STAGE3-NEEDED-WIRING.md.
|
|
55
|
+
*/
|
|
56
|
+
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
60
|
+
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
61
|
+
|
|
62
|
+
// One coalesce key per lane-change gesture, shared by the move-persist history
|
|
63
|
+
// entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
|
|
64
|
+
// layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
|
|
65
|
+
// records into a single undo step. A monotonic counter — NOT Date.now() /
|
|
66
|
+
// Math.random(), which the determinism rules forbid — suffices: the key only has
|
|
67
|
+
// to be unique per gesture and identical across the gesture's two records.
|
|
68
|
+
let laneChangeGestureSeq = 0;
|
|
69
|
+
|
|
70
|
+
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
|
71
|
+
function canMoveElement(element: TimelineElement): boolean {
|
|
72
|
+
return getTimelineEditCapabilities({
|
|
73
|
+
tag: element.tag,
|
|
74
|
+
duration: element.duration,
|
|
75
|
+
domId: element.domId,
|
|
76
|
+
selector: element.selector,
|
|
77
|
+
compositionSrc: element.compositionSrc,
|
|
78
|
+
playbackStart: element.playbackStart,
|
|
79
|
+
playbackStartAttr: element.playbackStartAttr,
|
|
80
|
+
sourceDuration: element.sourceDuration,
|
|
81
|
+
timingSource: element.timingSource,
|
|
82
|
+
timelineLocked: element.timelineLocked,
|
|
83
|
+
}).canMove;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Optimistically apply + persist a batch of moves with rollback on failure.
|
|
88
|
+
*
|
|
89
|
+
* Returns a promise that resolves `true` once the write lands, or `false` after a
|
|
90
|
+
* rejected write has been rolled back. The caller uses this to SERIALIZE the
|
|
91
|
+
* lane→z stacking patch: the z-sync is a separate server style-patch, and firing
|
|
92
|
+
* it before this full-file write resolves let the move (computed from a pre-z
|
|
93
|
+
* snapshot) land after — and clobber — the z change. A failed move resolves
|
|
94
|
+
* `false` so the caller also skips the z-sync (no orphaned z patch).
|
|
95
|
+
*
|
|
96
|
+
* The DOM is updated synchronously up front; the returned promise never rejects.
|
|
97
|
+
*/
|
|
98
|
+
function persistMoveEdits(
|
|
99
|
+
edits: TimelineMoveEdit[],
|
|
100
|
+
deps: DragCommitDeps,
|
|
101
|
+
coalesceKey?: string,
|
|
102
|
+
operation: TimelineMoveOperation = "timing",
|
|
103
|
+
): Promise<boolean> {
|
|
104
|
+
if (edits.length === 0) return Promise.resolve(true);
|
|
105
|
+
const { updateElement, onMoveElement, onMoveElements } = deps;
|
|
106
|
+
if (!onMoveElements) {
|
|
107
|
+
console.warn(
|
|
108
|
+
onMoveElement
|
|
109
|
+
? `[Timeline] persistMoveEdits: only single-clip onMoveElement wired — this ${edits.length}-clip move degrades to a per-clip persist race (no atomic single-undo)`
|
|
110
|
+
: `[Timeline] persistMoveEdits: no move persist handler wired — ${edits.length} edit(s) applied to the store only, not saved`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const prev = edits.map((e) => ({
|
|
114
|
+
key: keyOf(e.element),
|
|
115
|
+
start: e.element.start,
|
|
116
|
+
track: e.element.track,
|
|
117
|
+
}));
|
|
118
|
+
const revision = beginTimelineOptimisticGesture(
|
|
119
|
+
updateElement,
|
|
120
|
+
edits.map((edit) => keyOf(edit.element)),
|
|
121
|
+
);
|
|
122
|
+
for (const e of edits) updateElement(keyOf(e.element), e.updates);
|
|
123
|
+
const persisted = onMoveElements
|
|
124
|
+
? onMoveElements(edits, coalesceKey, operation)
|
|
125
|
+
: Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
|
126
|
+
return Promise.resolve(persisted).then(
|
|
127
|
+
() => true,
|
|
128
|
+
(error) => {
|
|
129
|
+
for (const p of prev) {
|
|
130
|
+
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
|
131
|
+
updateElement(p.key, { start: p.start, track: p.track });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
console.error("[Timeline] Failed to persist clip edits", error);
|
|
135
|
+
return false;
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A fractional track value for a NEW lane inserted at boundary `insertRow` in
|
|
142
|
+
* `trackOrder` (0 = above the top, `length` = below the bottom). normalizeToZones
|
|
143
|
+
* then compacts it to a distinct integer lane between its neighbours, and the
|
|
144
|
+
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
|
145
|
+
*/
|
|
146
|
+
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
147
|
+
if (trackOrder.length === 0) return 0;
|
|
148
|
+
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
149
|
+
if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5;
|
|
150
|
+
return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build the time-shift resolver for a multi-selection drag: every member of the
|
|
155
|
+
* selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
|
|
156
|
+
* untouched. Returns null when this is not a multi-selection drag. A locked /
|
|
157
|
+
* implicit member is dropped from the moving set (a marquee can sweep one in).
|
|
158
|
+
*/
|
|
159
|
+
function resolveMultiSelection(
|
|
160
|
+
drag: DraggedClipState,
|
|
161
|
+
deps: DragCommitDeps,
|
|
162
|
+
): {
|
|
163
|
+
keys: ReadonlySet<string>;
|
|
164
|
+
movedStart: (e: TimelineElement) => number;
|
|
165
|
+
} | null {
|
|
166
|
+
const { elements, selectedKeys } = deps;
|
|
167
|
+
const dragKey = keyOf(drag.element);
|
|
168
|
+
if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
|
|
169
|
+
const keys = new Set(
|
|
170
|
+
[...selectedKeys].filter((k) => {
|
|
171
|
+
const el = elements.find((e) => keyOf(e) === k);
|
|
172
|
+
return el ? canMoveElement(el) : false;
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
const delta = drag.previewStart - drag.element.start;
|
|
176
|
+
const movedStart = (e: TimelineElement): number =>
|
|
177
|
+
keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
|
|
178
|
+
return { keys, movedStart };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Commit a finished clip drag.
|
|
183
|
+
*
|
|
184
|
+
* The lane model is CapCut-stable: a clip's display lane is its track, and editing
|
|
185
|
+
* ONE clip must never re-lane or rewrite OTHER clips. Three outcomes:
|
|
186
|
+
*
|
|
187
|
+
* - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the
|
|
188
|
+
* dragged clip's start (multi-selection shifts every selected clip in time).
|
|
189
|
+
* - **Lane change / collision relocation** (the dragged clip's OWN lane changes,
|
|
190
|
+
* no new track): persist ONLY the dragged clip's start + lane. No other clip is
|
|
191
|
+
* touched. z is synced only when the gesture is a DELIBERATE vertical move
|
|
192
|
+
* (the pointer aimed at another lane) — a horizontal drag merely bumped to a
|
|
193
|
+
* free lane never restacks.
|
|
194
|
+
* - **Track insert** (a new lane at a gap boundary): the dragged clip lands on
|
|
195
|
+
* the new lane and the clips at/below the insert are renumbered by +1 (the ONLY
|
|
196
|
+
* permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
|
|
197
|
+
*/
|
|
198
|
+
// fallow-ignore-next-line complexity
|
|
199
|
+
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
|
|
200
|
+
const { elements, updateElement, onMoveElement } = deps;
|
|
201
|
+
const dragKey = keyOf(drag.element);
|
|
202
|
+
const isInsert = drag.insertRow != null;
|
|
203
|
+
const laneChanged = drag.previewTrack !== drag.element.track;
|
|
204
|
+
// Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at a
|
|
205
|
+
// gap boundary (insert). A plain HORIZONTAL drag whose target span is occupied
|
|
206
|
+
// gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the
|
|
207
|
+
// pointer never left its lane (desiredTrack === element.track) — that is NOT a
|
|
208
|
+
// vertical move: it must neither rewrite other clips nor touch z.
|
|
209
|
+
const aimTrack = drag.desiredTrack ?? drag.previewTrack;
|
|
210
|
+
const isVertical = isInsert || aimTrack !== drag.element.track;
|
|
211
|
+
const multi = resolveMultiSelection(drag, deps);
|
|
212
|
+
|
|
213
|
+
// ── Pure time-move (dragged clip keeps its lane, no insert) ─────────────────
|
|
214
|
+
if (!isInsert && !laneChanged) {
|
|
215
|
+
const delta = drag.previewStart - drag.element.start;
|
|
216
|
+
if (delta === 0) return;
|
|
217
|
+
if (multi) {
|
|
218
|
+
const edits: TimelineMoveEdit[] = elements
|
|
219
|
+
.filter((e) => multi.keys.has(keyOf(e)))
|
|
220
|
+
.map((e) => ({
|
|
221
|
+
element: e,
|
|
222
|
+
updates: { start: multi.movedStart(e), track: e.track },
|
|
223
|
+
}))
|
|
224
|
+
.filter((e) => e.updates.start !== e.element.start);
|
|
225
|
+
void persistMoveEdits(edits, deps);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const updates = { start: drag.previewStart, track: drag.element.track };
|
|
229
|
+
const prev = { start: drag.element.start, track: drag.element.track };
|
|
230
|
+
const revision = beginTimelineOptimisticGesture(updateElement, [dragKey]);
|
|
231
|
+
updateElement(dragKey, updates);
|
|
232
|
+
Promise.resolve(onMoveElement?.(drag.element, updates)).catch((error) => {
|
|
233
|
+
if (isLatestTimelineOptimisticGesture(updateElement, revision, dragKey)) {
|
|
234
|
+
updateElement(dragKey, prev);
|
|
235
|
+
}
|
|
236
|
+
console.error("[Timeline] Failed to persist clip edit", error);
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Track insert: renumber the at/below clips by +1 (the one multi-clip write) ─
|
|
242
|
+
if (isInsert) {
|
|
243
|
+
commitTrackInsert(drag, deps, multi);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Lane change / collision relocation: persist ONLY the dragged clip ────────
|
|
248
|
+
// CapCut invariant — one edit never re-lanes another clip. The dragged clip
|
|
249
|
+
// takes its new lane (previewTrack); the rest of any selection shifts in time
|
|
250
|
+
// only. Nothing else is written.
|
|
251
|
+
const dragEdit: TimelineMoveEdit = {
|
|
252
|
+
element: drag.element,
|
|
253
|
+
updates: { start: drag.previewStart, track: drag.previewTrack },
|
|
254
|
+
};
|
|
255
|
+
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
|
|
256
|
+
|
|
257
|
+
const edits: TimelineMoveEdit[] = [dragEdit];
|
|
258
|
+
if (multi) {
|
|
259
|
+
for (const e of elements) {
|
|
260
|
+
if (keyOf(e) === dragKey || !multi.keys.has(keyOf(e))) continue;
|
|
261
|
+
const start = multi.movedStart(e);
|
|
262
|
+
if (start !== e.start) edits.push({ element: e, updates: { start, track: e.track } });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// The drop-intent set for the z-sync: the dragged clip at its new lane, others
|
|
266
|
+
// as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
|
|
267
|
+
// move; computeStackingPatches only compares lanes relatively.
|
|
268
|
+
const candidate = elements.map((e) =>
|
|
269
|
+
keyOf(e) === dragKey ? { ...e, start: drag.previewStart, track: drag.previewTrack } : e,
|
|
270
|
+
);
|
|
271
|
+
const multiKeys = multi ? multi.keys : null;
|
|
272
|
+
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
|
|
273
|
+
if (moved && isVertical) {
|
|
274
|
+
syncStackingForEdit(
|
|
275
|
+
candidate,
|
|
276
|
+
dragKey,
|
|
277
|
+
drag.element.track,
|
|
278
|
+
drag.previewTrack,
|
|
279
|
+
multiKeys,
|
|
280
|
+
deps,
|
|
281
|
+
coalesceKey,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Insert a new track at the drop's gap boundary. The dragged clip lands on the
|
|
289
|
+
* fractional insert lane; normalizeToZones then compacts every lane to a contiguous
|
|
290
|
+
* integer, which shifts the clips at/below the insert down by one. That +1
|
|
291
|
+
* renumber is the ONLY sanctioned multi-clip write; it is index-only (never z).
|
|
292
|
+
* The whole affected set is persisted atomically (single undo), and the deliberate
|
|
293
|
+
* vertical move syncs the dragged clip's stacking afterwards.
|
|
294
|
+
*/
|
|
295
|
+
function commitTrackInsert(
|
|
296
|
+
drag: DraggedClipState,
|
|
297
|
+
deps: DragCommitDeps,
|
|
298
|
+
multi: {
|
|
299
|
+
keys: ReadonlySet<string>;
|
|
300
|
+
movedStart: (e: TimelineElement) => number;
|
|
301
|
+
} | null,
|
|
302
|
+
): void {
|
|
303
|
+
const { elements, trackOrder } = deps;
|
|
304
|
+
const dragKey = keyOf(drag.element);
|
|
305
|
+
const targetTrack = insertTrackValue(trackOrder, drag.insertRow!);
|
|
306
|
+
// Drop-intent set: dragged clip at the fractional insert lane (so it sorts
|
|
307
|
+
// between its neighbours), selection members time-shifted, others as-is.
|
|
308
|
+
const candidate = elements.map((e) => {
|
|
309
|
+
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack };
|
|
310
|
+
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
311
|
+
return e;
|
|
312
|
+
});
|
|
313
|
+
// normalizeToZones compacts the fractional lane to a contiguous integer, which
|
|
314
|
+
// shifts the at/below clips down by one — the sanctioned +1 index renumber.
|
|
315
|
+
const normalized = normalizeToZones(candidate);
|
|
316
|
+
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
|
|
317
|
+
const edits: TimelineMoveEdit[] = [];
|
|
318
|
+
for (const norm of normalized) {
|
|
319
|
+
const src = bySrc.get(keyOf(norm));
|
|
320
|
+
if (!src) continue;
|
|
321
|
+
// Capabilities gate: never write a locked/implicit clip, even one only swept
|
|
322
|
+
// along by the renumber (not just a marquee member).
|
|
323
|
+
if (!canMoveElement(src)) continue;
|
|
324
|
+
const start =
|
|
325
|
+
keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
|
|
326
|
+
? (multi?.movedStart(src) ?? drag.previewStart)
|
|
327
|
+
: src.start;
|
|
328
|
+
edits.push({ element: src, updates: { start, track: norm.track } });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
|
|
332
|
+
void persistMoveEdits(edits, deps, coalesceKey, "track-insert").then((moved) => {
|
|
333
|
+
// Skip the z-sync when the insert produced NO move edits (e.g. every clip in
|
|
334
|
+
// the set is locked/implicit and gets filtered out). persistMoveEdits resolves
|
|
335
|
+
// `true` for an empty batch so the caller's serialization proceeds, but firing
|
|
336
|
+
// the z-sync here would record an orphaned z-only history entry for a move that
|
|
337
|
+
// never persisted.
|
|
338
|
+
if (moved && edits.length > 0) {
|
|
339
|
+
// Reason the z-sync on the drop-intent `candidate` (dragged clip at its
|
|
340
|
+
// fractional insert lane) — NOT the re-normalized lanes — so the sync sees
|
|
341
|
+
// the user's move. The guard lane is the aimed insert row (a boundary in
|
|
342
|
+
// display-lane space, comparable to the clip's contiguous current lane).
|
|
343
|
+
syncStackingForEdit(
|
|
344
|
+
candidate,
|
|
345
|
+
dragKey,
|
|
346
|
+
drag.element.track,
|
|
347
|
+
drag.insertRow!,
|
|
348
|
+
multi ? multi.keys : null,
|
|
349
|
+
deps,
|
|
350
|
+
coalesceKey,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Compute + apply z-index patches for the edited clip(s) after a DELIBERATE
|
|
358
|
+
* vertical lane change. Projects the drop-intent element set (`candidate`: the
|
|
359
|
+
* dragged clip at its new / fractional-insert lane, others at their current tracks)
|
|
360
|
+
* onto StackingElement using the caller-supplied live z-index reader, then
|
|
361
|
+
* delegates the minimal-z resolution to computeStackingPatches — a clip on the
|
|
362
|
+
* upper lane paints above every clip it time-overlaps. No-op unless both z-sync
|
|
363
|
+
* deps are present, and never when the gesture aimed at the clip's OWN current
|
|
364
|
+
* lane (`aimedLane === currentLane` — not a relocation).
|
|
365
|
+
*/
|
|
366
|
+
function syncStackingForEdit(
|
|
367
|
+
candidate: TimelineElement[],
|
|
368
|
+
dragKey: string,
|
|
369
|
+
currentLane: number,
|
|
370
|
+
aimedLane: number,
|
|
371
|
+
multiKeys: ReadonlySet<string> | null,
|
|
372
|
+
deps: DragCommitDeps,
|
|
373
|
+
coalesceKey?: string,
|
|
374
|
+
): void {
|
|
375
|
+
const { readZIndex, onStackingPatches } = deps;
|
|
376
|
+
if (!readZIndex || !onStackingPatches) return;
|
|
377
|
+
|
|
378
|
+
// Aiming at the clip's OWN current display lane is not a relocation — never
|
|
379
|
+
// touch z (guards the pure-time-move invariant even if a spurious topology call
|
|
380
|
+
// slips through). Every real lane-realization drop aims at a DIFFERENT lane.
|
|
381
|
+
if (aimedLane === currentLane) return;
|
|
382
|
+
|
|
383
|
+
// `candidate` is in discovery order, so its array index IS the DOM document
|
|
384
|
+
// position. Equal-z clips paint by DOM order, so the sync needs it to decide
|
|
385
|
+
// "is A above B" (see StackingElement.domIndex).
|
|
386
|
+
const stackingEls = candidate.map((el, domIndex) => ({
|
|
387
|
+
key: keyOf(el),
|
|
388
|
+
start: el.start,
|
|
389
|
+
duration: el.duration,
|
|
390
|
+
track: el.track,
|
|
391
|
+
zIndex: readZIndex(el),
|
|
392
|
+
isAudio: classifyZone(el) === "audio",
|
|
393
|
+
domIndex,
|
|
394
|
+
}));
|
|
395
|
+
|
|
396
|
+
const editedKeys = [dragKey];
|
|
397
|
+
if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
|
|
398
|
+
|
|
399
|
+
const patches = computeStackingPatches(stackingEls, editedKeys);
|
|
400
|
+
if (patches.length > 0) onStackingPatches(patches, coalesceKey);
|
|
401
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview";
|
|
4
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
|
+
import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
|
6
|
+
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
|
|
9
|
+
// on its own top lane armed a phantom new-track insert (the old 0.32 insert band
|
|
10
|
+
// reached deep into the clip body). That insert flipped the commit into the
|
|
11
|
+
// lane-change branch, which nudged the clip's z-index and re-sorted it off its
|
|
12
|
+
// lane. The invariant: a horizontal drag over a clip BODY → insertRow === null,
|
|
13
|
+
// previewTrack unchanged (a pure time move — zero topology change, zero z sync).
|
|
14
|
+
//
|
|
15
|
+
// Elements mirror the user's index.html shapes: a high-z "v-moodboard" alone on
|
|
16
|
+
// the top display lane, over several lower-lane video clips it overlaps in time,
|
|
17
|
+
// plus a caption. Tracks here are already the normalized DISPLAY lanes (the store
|
|
18
|
+
// runs normalizeToZones on discovery), matching what the drag hook passes in.
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const PPS = 40;
|
|
22
|
+
|
|
23
|
+
function clip(
|
|
24
|
+
id: string,
|
|
25
|
+
track: number,
|
|
26
|
+
start: number,
|
|
27
|
+
duration: number,
|
|
28
|
+
zIndex: number,
|
|
29
|
+
tag = "video",
|
|
30
|
+
): TimelineElement {
|
|
31
|
+
return { id, key: id, tag, start, duration, track, zIndex, domId: id };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// v-moodboard: own top lane (0). Lower lane (1) carries overlapping video clips;
|
|
35
|
+
// captions sit on lane 2. trackOrder = [0, 1, 2].
|
|
36
|
+
const moodboard = clip("v-moodboard", 0, 19, 5.5, 37);
|
|
37
|
+
const fixtureElements: TimelineElement[] = [
|
|
38
|
+
moodboard,
|
|
39
|
+
clip("v-dashboard", 1, 19, 4, 16),
|
|
40
|
+
clip("v-globe", 1, 23, 1.5, 17),
|
|
41
|
+
clip("cap", 2, 20.82, 1.78, 0, "text"),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// A scroll container whose content-space y equals clientY (rect top 0, no scroll).
|
|
45
|
+
function fakeScroll(): HTMLDivElement {
|
|
46
|
+
return {
|
|
47
|
+
getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }),
|
|
48
|
+
scrollLeft: 0,
|
|
49
|
+
scrollTop: 0,
|
|
50
|
+
scrollWidth: 100000,
|
|
51
|
+
} as unknown as HTMLDivElement;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ctx(): DragPreviewContext {
|
|
55
|
+
return {
|
|
56
|
+
scroll: fakeScroll(),
|
|
57
|
+
pps: PPS,
|
|
58
|
+
duration: 44.5,
|
|
59
|
+
trackOrder: [0, 1, 2],
|
|
60
|
+
elements: fixtureElements,
|
|
61
|
+
selectedKeys: new Set<string>(),
|
|
62
|
+
buildSnapTargets: () => [],
|
|
63
|
+
audioTracks: new Set<number>(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// content-space y for a fractional row index (inverse of getTimelineRowFromY).
|
|
68
|
+
const yForRow = (rowFloat: number) => RULER_H + TRACKS_TOP_PAD + rowFloat * TRACK_H;
|
|
69
|
+
|
|
70
|
+
// A drag grabbing `element` at vertical position `grabRowFloat` within its lane.
|
|
71
|
+
function horizontalDrag(
|
|
72
|
+
element: TimelineElement,
|
|
73
|
+
grabRowFloat: number,
|
|
74
|
+
deltaSeconds: number,
|
|
75
|
+
): { drag: DraggedClipState; clientX: number; clientY: number } {
|
|
76
|
+
const originClientX = 800;
|
|
77
|
+
const originClientY = yForRow(grabRowFloat);
|
|
78
|
+
const drag: DraggedClipState = {
|
|
79
|
+
element,
|
|
80
|
+
originClientX,
|
|
81
|
+
originClientY,
|
|
82
|
+
originScrollLeft: 0,
|
|
83
|
+
originScrollTop: 0,
|
|
84
|
+
pointerClientX: originClientX,
|
|
85
|
+
pointerClientY: originClientY,
|
|
86
|
+
pointerOffsetX: 0,
|
|
87
|
+
pointerOffsetY: 0,
|
|
88
|
+
previewStart: element.start,
|
|
89
|
+
previewTrack: element.track,
|
|
90
|
+
insertRow: null,
|
|
91
|
+
snapTime: null,
|
|
92
|
+
snapType: null,
|
|
93
|
+
started: true,
|
|
94
|
+
};
|
|
95
|
+
// Horizontal: clientY stays at the grab point; only x advances by the delta.
|
|
96
|
+
return { drag, clientX: originClientX + deltaSeconds * PPS, clientY: originClientY };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
|
|
100
|
+
it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
|
|
101
|
+
const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
|
|
102
|
+
const next = computeDragPreview(drag, clientX, clientY, ctx());
|
|
103
|
+
expect(next.insertRow).toBeNull(); // no phantom new-track insert
|
|
104
|
+
expect(next.previewTrack).toBe(0); // stays on its own lane
|
|
105
|
+
expect(next.desiredTrack).toBe(0); // pointer never left lane 0 → not a vertical aim
|
|
106
|
+
expect(next.previewStart).toBeCloseTo(21, 5); // +2s moved
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("grabbing ANYWHERE across the clip body (not just dead-center) stays a pure time move", () => {
|
|
110
|
+
// Sweep the whole clip body of lane 0; a horizontal drag must never insert.
|
|
111
|
+
for (let grab = 0.1; grab <= 0.9 + 1e-9; grab += 0.1) {
|
|
112
|
+
const { drag, clientX, clientY } = horizontalDrag(moodboard, grab, 2);
|
|
113
|
+
const next = computeDragPreview(drag, clientX, clientY, ctx());
|
|
114
|
+
expect(next.insertRow).toBeNull();
|
|
115
|
+
expect(next.previewTrack).toBe(0);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("aiming the gutter ABOVE the top lane arms a top insert (UX rule 2)", () => {
|
|
120
|
+
// Drag v-moodboard up into the top breathing pad → insert a new top track.
|
|
121
|
+
const originClientX = 800;
|
|
122
|
+
const originClientY = yForRow(0.5);
|
|
123
|
+
const drag: DraggedClipState = {
|
|
124
|
+
element: moodboard,
|
|
125
|
+
originClientX,
|
|
126
|
+
originClientY,
|
|
127
|
+
originScrollLeft: 0,
|
|
128
|
+
originScrollTop: 0,
|
|
129
|
+
pointerClientX: originClientX,
|
|
130
|
+
pointerClientY: originClientY,
|
|
131
|
+
pointerOffsetX: 0,
|
|
132
|
+
pointerOffsetY: 0,
|
|
133
|
+
previewStart: moodboard.start,
|
|
134
|
+
previewTrack: moodboard.track,
|
|
135
|
+
insertRow: null,
|
|
136
|
+
snapTime: null,
|
|
137
|
+
snapType: null,
|
|
138
|
+
started: true,
|
|
139
|
+
};
|
|
140
|
+
// Pointer well above the first lane (into the top pad → rowFloat < 0).
|
|
141
|
+
const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
|
|
142
|
+
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
|
|
143
|
+
});
|
|
144
|
+
});
|