@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
|
@@ -1,126 +1,103 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useMemo } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type BlockedTimelineEditIntent,
|
|
8
|
-
type TimelineStackingReorderIntent,
|
|
4
|
+
applyTimelineAutoScrollStep,
|
|
5
|
+
resolveTimelineAutoScrollLoopAction,
|
|
6
|
+
resolveTimelineDragEscape,
|
|
9
7
|
} from "./timelineEditing";
|
|
10
8
|
import { usePlayerStore } from "../store/playerStore";
|
|
11
9
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
-
import {
|
|
13
|
-
import { isMusicTrack } from "../../utils/timelineInspector";
|
|
10
|
+
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
14
11
|
import { mergeUserBeats } from "../../utils/beatEditing";
|
|
15
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
buildTimelineGroupResizeMembers,
|
|
14
|
+
type TimelineGroupResizeSession,
|
|
15
|
+
} from "./timelineGroupEditing";
|
|
16
|
+
import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
|
|
17
|
+
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
|
18
|
+
import type { StackingPatch } from "./timelineStackingSync";
|
|
19
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
20
|
+
import {
|
|
21
|
+
computeDragPreview,
|
|
22
|
+
computeResizePreview,
|
|
23
|
+
previewGroupResize,
|
|
24
|
+
type ResizePreviewResult,
|
|
25
|
+
} from "./timelineClipDragPreview";
|
|
16
26
|
import type {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
DraggedClipState,
|
|
28
|
+
ResizingClipState,
|
|
29
|
+
BlockedClipState,
|
|
30
|
+
} from "./timelineClipDragTypes";
|
|
20
31
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} from "./timelineSnapTargets";
|
|
26
|
-
import { resolveDragPreviewPlacement } from "./timelineClipDragPreview";
|
|
27
|
-
import { useTimelineClipGroupDrag } from "./useTimelineClipGroupDrag";
|
|
32
|
+
beginTimelineOptimisticGesture,
|
|
33
|
+
rollbackLatestTimelineOptimisticGesture,
|
|
34
|
+
} from "./timelineOptimisticRevision";
|
|
35
|
+
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
originClientX: number;
|
|
35
|
-
originClientY: number;
|
|
36
|
-
originScrollLeft: number;
|
|
37
|
-
originScrollTop: number;
|
|
38
|
-
pointerClientX: number;
|
|
39
|
-
pointerClientY: number;
|
|
40
|
-
pointerOffsetX: number;
|
|
41
|
-
pointerOffsetY: number;
|
|
42
|
-
previewStart: number;
|
|
43
|
-
previewTrack: number;
|
|
44
|
-
previewLayerId: TimelineLayerId;
|
|
45
|
-
previewLayerIndex: number;
|
|
46
|
-
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
|
|
47
|
-
snapBeatTime: number | null;
|
|
48
|
-
snapGuideTime: number | null;
|
|
49
|
-
snapGuideKind: TimelineSnapKind | null;
|
|
50
|
-
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
|
|
51
|
-
previewStackingReorder: TimelineStackingReorderIntent | null;
|
|
52
|
-
started: boolean;
|
|
53
|
-
}
|
|
37
|
+
export type {
|
|
38
|
+
DraggedClipState,
|
|
39
|
+
ResizingClipState,
|
|
40
|
+
BlockedClipState,
|
|
41
|
+
} from "./timelineClipDragTypes";
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
element: TimelineElement;
|
|
57
|
-
edge: "start" | "end";
|
|
58
|
-
originClientX: number;
|
|
59
|
-
previewStart: number;
|
|
60
|
-
previewDuration: number;
|
|
61
|
-
previewPlaybackStart?: number;
|
|
62
|
-
snapGuideTime: number | null;
|
|
63
|
-
snapGuideKind: TimelineSnapKind | null;
|
|
64
|
-
started: boolean;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface BlockedClipState {
|
|
68
|
-
element: TimelineElement;
|
|
69
|
-
intent: BlockedTimelineEditIntent;
|
|
70
|
-
originClientX: number;
|
|
71
|
-
originClientY: number;
|
|
72
|
-
started: boolean;
|
|
73
|
-
}
|
|
43
|
+
const EMPTY_BEAT_TIMES: number[] = [];
|
|
74
44
|
|
|
75
45
|
/* ── Hook ───────────────────────────────────────────────────────── */
|
|
76
46
|
interface UseTimelineClipDragInput {
|
|
77
47
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
78
48
|
ppsRef: React.RefObject<number>;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
timelineElementsRef: React.RefObject<TimelineElement[]>;
|
|
49
|
+
durationRef: React.RefObject<number>;
|
|
50
|
+
trackOrderRef: React.RefObject<number[]>;
|
|
82
51
|
onMoveElement?: (
|
|
83
52
|
element: TimelineElement,
|
|
84
|
-
updates: Pick<TimelineElement, "start" | "track"
|
|
85
|
-
|
|
86
|
-
|
|
53
|
+
updates: Pick<TimelineElement, "start" | "track">,
|
|
54
|
+
) => Promise<void> | void;
|
|
55
|
+
onMoveElements?: (
|
|
56
|
+
edits: Array<{
|
|
57
|
+
element: TimelineElement;
|
|
58
|
+
updates: Pick<TimelineElement, "start" | "track">;
|
|
59
|
+
}>,
|
|
87
60
|
) => Promise<void> | void;
|
|
88
61
|
onResizeElement?: (
|
|
89
62
|
element: TimelineElement,
|
|
90
63
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
91
64
|
) => Promise<void> | void;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
|
|
95
|
-
onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
|
|
96
|
-
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
65
|
+
onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
|
66
|
+
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
|
|
97
67
|
setShowPopover: (show: boolean) => void;
|
|
98
|
-
/** Stable ref to the range selection setter
|
|
68
|
+
/** Stable ref to the range selection setter — wired after mount to break circular dependency. */
|
|
99
69
|
setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Lane ↔ stacking unification (see research/STAGE3-NEEDED-WIRING.md). When both
|
|
72
|
+
* are supplied and a lane-change drag commits, the edited clip(s) get z-index
|
|
73
|
+
* patches so their stacking matches lane order relative to time-overlapping
|
|
74
|
+
* clips. Provisioned by the timeline layer (Timeline.tsx) from the preview
|
|
75
|
+
* iframe + the canvas z-order persist path; forwarded straight to
|
|
76
|
+
* commitDraggedClipMove. Both optional → absent = no-op (backward compatible).
|
|
77
|
+
*/
|
|
78
|
+
readZIndex?: (element: TimelineElement) => number;
|
|
79
|
+
onStackingPatches?: (patches: StackingPatch[]) => void;
|
|
100
80
|
}
|
|
101
81
|
|
|
102
82
|
export function useTimelineClipDrag({
|
|
103
83
|
scrollRef,
|
|
104
84
|
ppsRef,
|
|
85
|
+
durationRef,
|
|
105
86
|
trackOrderRef,
|
|
106
|
-
timelineLayersRef,
|
|
107
|
-
timelineElementsRef,
|
|
108
87
|
onMoveElement,
|
|
109
|
-
onResizeElement,
|
|
110
88
|
onMoveElements,
|
|
89
|
+
onResizeElement,
|
|
111
90
|
onResizeElements,
|
|
112
|
-
onPreviewMoveElements,
|
|
113
|
-
onPreviewResizeElements,
|
|
114
91
|
onBlockedEditAttempt,
|
|
115
92
|
setShowPopover,
|
|
116
93
|
setRangeSelectionRef,
|
|
94
|
+
readZIndex,
|
|
95
|
+
onStackingPatches,
|
|
117
96
|
}: UseTimelineClipDragInput) {
|
|
118
97
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
119
98
|
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
|
120
99
|
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
|
|
121
100
|
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
|
122
|
-
const playhead = usePlayerStore((s) => s.currentTime);
|
|
123
|
-
const compositionDuration = usePlayerStore((s) => s.duration);
|
|
124
101
|
const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
|
|
125
102
|
const musicPlaybackStart = usePlayerStore(
|
|
126
103
|
(s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
|
|
@@ -146,30 +123,45 @@ export function useTimelineClipDrag({
|
|
|
146
123
|
musicDuration,
|
|
147
124
|
]);
|
|
148
125
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
126
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
127
|
+
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
|
|
128
|
+
const snapContextRef = useRef<{ beatTimes: number[]; enabled: boolean }>({
|
|
129
|
+
beatTimes: [],
|
|
130
|
+
enabled: true,
|
|
131
|
+
});
|
|
132
|
+
snapContextRef.current = {
|
|
133
|
+
beatTimes: adjustedBeatTimes,
|
|
134
|
+
enabled: timelineSnapEnabled,
|
|
135
|
+
};
|
|
136
|
+
const elementsRef = useRef(elements);
|
|
137
|
+
elementsRef.current = elements;
|
|
138
|
+
|
|
139
|
+
// Perf (frozen-per-gesture): the snap-target set and the audio-track set are
|
|
140
|
+
// fixed for the duration of one drag/resize (the store is not re-authored mid
|
|
141
|
+
// gesture), so build each ONCE and reuse it across every pointermove and every
|
|
142
|
+
// auto-scroll frame. Both caches are cleared at gesture teardown
|
|
143
|
+
// (stopClipDragAutoScroll), so the next gesture rebuilds against fresh state.
|
|
144
|
+
const snapTargetsCacheRef = useRef<Map<string, TimelineSnapTarget[]>>(new Map());
|
|
145
|
+
const dragAudioTracksRef = useRef<ReadonlySet<number> | null>(null);
|
|
155
146
|
|
|
156
147
|
const buildSnapTargets = useCallback(
|
|
157
|
-
(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
elements:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
beats: isMusicTrack(element) ? EMPTY_BEAT_TIMES : beatTimesRef.current,
|
|
148
|
+
(excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => {
|
|
149
|
+
// Magnet off ⇒ no targets and no scan; do NOT cache so a mid-gesture toggle
|
|
150
|
+
// back on starts scanning immediately (preserves the existing skip).
|
|
151
|
+
if (!snapContextRef.current.enabled) return [];
|
|
152
|
+
const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`;
|
|
153
|
+
const cached = snapTargetsCacheRef.current.get(cacheKey);
|
|
154
|
+
if (cached) return cached;
|
|
155
|
+
const targets = collectTimelineSnapTargets({
|
|
156
|
+
elements: elementsRef.current,
|
|
157
|
+
playheadTime: usePlayerStore.getState().currentTime,
|
|
158
|
+
beatTimes: includeBeats ? snapContextRef.current.beatTimes : [],
|
|
159
|
+
excludeElementKey,
|
|
170
160
|
});
|
|
161
|
+
snapTargetsCacheRef.current.set(cacheKey, targets);
|
|
162
|
+
return targets;
|
|
171
163
|
},
|
|
172
|
-
[
|
|
164
|
+
[],
|
|
173
165
|
);
|
|
174
166
|
|
|
175
167
|
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
|
@@ -183,95 +175,125 @@ export function useTimelineClipDrag({
|
|
|
183
175
|
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
|
184
176
|
const suppressClickRef = useRef(false);
|
|
185
177
|
|
|
178
|
+
// Active multi-select group-resize session (restored from main 36413da7f): set
|
|
179
|
+
// lazily on the first resize pointermove when the grabbed clip is part of a
|
|
180
|
+
// capability-clean multi-selection (null ⇒ single-clip resize). Holds the
|
|
181
|
+
// pre-gesture snapshot so the non-grabbed members (previewed through the store)
|
|
182
|
+
// roll back on escape / cancel / failed persist.
|
|
183
|
+
const groupResizeRef = useRef<TimelineGroupResizeSession | null>(null);
|
|
184
|
+
|
|
185
|
+
// Restore the non-grabbed group members to their pre-gesture timing (the
|
|
186
|
+
// grabbed clip renders from resizingClip state, so it is never written during
|
|
187
|
+
// preview). `all` also restores the grabbed clip after a committed persist fails.
|
|
188
|
+
const restoreGroupResizeMembers = useCallback(
|
|
189
|
+
(session: TimelineGroupResizeSession, all = false) => {
|
|
190
|
+
for (const m of session.members) {
|
|
191
|
+
if (!all && m.key === session.grabbedKey) continue;
|
|
192
|
+
updateElement(m.key, {
|
|
193
|
+
start: m.start,
|
|
194
|
+
duration: m.duration,
|
|
195
|
+
playbackStart: m.playbackStart,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
[updateElement],
|
|
200
|
+
);
|
|
201
|
+
|
|
186
202
|
const onMoveElementRef = useRef(onMoveElement);
|
|
187
203
|
onMoveElementRef.current = onMoveElement;
|
|
188
|
-
const onResizeElementRef = useRef(onResizeElement);
|
|
189
|
-
onResizeElementRef.current = onResizeElement;
|
|
190
204
|
const onMoveElementsRef = useRef(onMoveElements);
|
|
191
205
|
onMoveElementsRef.current = onMoveElements;
|
|
206
|
+
const onBlockedEditAttemptRef = useRef(onBlockedEditAttempt);
|
|
207
|
+
onBlockedEditAttemptRef.current = onBlockedEditAttempt;
|
|
208
|
+
const onResizeElementRef = useRef(onResizeElement);
|
|
209
|
+
onResizeElementRef.current = onResizeElement;
|
|
192
210
|
const onResizeElementsRef = useRef(onResizeElements);
|
|
193
211
|
onResizeElementsRef.current = onResizeElements;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
const selectedElementIdsRef = useRef(usePlayerStore.getState().selectedElementIds);
|
|
199
|
-
selectedElementIdsRef.current = usePlayerStore((s) => s.selectedElementIds);
|
|
212
|
+
const readZIndexRef = useRef(readZIndex);
|
|
213
|
+
readZIndexRef.current = readZIndex;
|
|
214
|
+
const onStackingPatchesRef = useRef(onStackingPatches);
|
|
215
|
+
onStackingPatchesRef.current = onStackingPatches;
|
|
200
216
|
|
|
201
217
|
const clipDragScrollRaf = useRef(0);
|
|
202
|
-
const clipDragPointerRef = useRef<{
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
timelineElementsRef,
|
|
211
|
-
updateElement,
|
|
212
|
-
onMoveElementsRef,
|
|
213
|
-
onResizeElementsRef,
|
|
214
|
-
onPreviewMoveElementsRef,
|
|
215
|
-
onPreviewResizeElementsRef,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// fallow-ignore-next-line complexity
|
|
218
|
+
const clipDragPointerRef = useRef<{
|
|
219
|
+
clientX: number;
|
|
220
|
+
clientY: number;
|
|
221
|
+
} | null>(null);
|
|
222
|
+
|
|
223
|
+
// Recompute the dragged-clip preview for a pointer position. The heavy lifting
|
|
224
|
+
// (move + snap + group clamp + drop placement) is a tested pure function so
|
|
225
|
+
// what runs here is what's verified — see timelineClipDragPreview.
|
|
219
226
|
const updateDraggedClipPreview = useCallback(
|
|
220
227
|
(drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
ppsRef.current,
|
|
250
|
-
|
|
251
|
-
);
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
228
|
+
// Build the audio-track set once per gesture (see snapTargetsCacheRef): it
|
|
229
|
+
// only feeds zone-aware drop placement and is frozen while dragging.
|
|
230
|
+
if (!dragAudioTracksRef.current) {
|
|
231
|
+
dragAudioTracksRef.current = new Set(
|
|
232
|
+
elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return computeDragPreview(drag, clientX, clientY, {
|
|
236
|
+
scroll: scrollRef.current,
|
|
237
|
+
pps: ppsRef.current,
|
|
238
|
+
duration: durationRef.current,
|
|
239
|
+
trackOrder: trackOrderRef.current,
|
|
240
|
+
elements: elementsRef.current,
|
|
241
|
+
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
|
242
|
+
buildSnapTargets,
|
|
243
|
+
audioTracks: dragAudioTracksRef.current,
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
[scrollRef, ppsRef, durationRef, trackOrderRef, buildSnapTargets],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
|
|
250
|
+
// branch and the edge auto-scroll stepper (re-runs as content scrolls under a
|
|
251
|
+
// stationary pointer). computeResizePreview is pure; here we only apply state.
|
|
252
|
+
const applyResizePointer = useCallback(
|
|
253
|
+
(resize: ResizingClipState, clientX: number) => {
|
|
254
|
+
const next = computeResizePreview(resize, clientX, {
|
|
255
|
+
scroll: scrollRef.current,
|
|
256
|
+
pps: ppsRef.current,
|
|
257
|
+
buildSnapTargets,
|
|
258
|
+
});
|
|
259
|
+
const setResizeState = (v: ResizePreviewResult) =>
|
|
260
|
+
setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev));
|
|
261
|
+
|
|
262
|
+
// Group resize: a capability-clean multi-selection resizes rigidly by one
|
|
263
|
+
// shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
|
|
264
|
+
// drives the raw delta and renders from resizingClip state; non-grabbed
|
|
265
|
+
// members preview through the store (their store value stays pristine).
|
|
266
|
+
const grabbedKey = resize.element.key ?? resize.element.id;
|
|
267
|
+
let session = groupResizeRef.current;
|
|
268
|
+
if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) {
|
|
269
|
+
const members = buildTimelineGroupResizeMembers(
|
|
270
|
+
elementsRef.current,
|
|
271
|
+
usePlayerStore.getState().selectedElementIds,
|
|
272
|
+
grabbedKey,
|
|
273
|
+
resize.edge,
|
|
274
|
+
);
|
|
275
|
+
session = members
|
|
276
|
+
? {
|
|
277
|
+
grabbedKey,
|
|
278
|
+
edge: resize.edge,
|
|
279
|
+
members,
|
|
280
|
+
changes: [],
|
|
281
|
+
hasChanged: false,
|
|
282
|
+
}
|
|
283
|
+
: null;
|
|
284
|
+
groupResizeRef.current = session;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!session) {
|
|
288
|
+
setResizeState(next);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
previewGroupResize(session, next, grabbedKey, updateElement, setResizeState);
|
|
264
292
|
},
|
|
265
|
-
[
|
|
266
|
-
scrollRef,
|
|
267
|
-
ppsRef,
|
|
268
|
-
trackOrderRef,
|
|
269
|
-
timelineLayersRef,
|
|
270
|
-
timelineElementsRef,
|
|
271
|
-
buildSnapTargets,
|
|
272
|
-
previewGroupMove,
|
|
273
|
-
],
|
|
293
|
+
[scrollRef, ppsRef, buildSnapTargets, updateElement],
|
|
274
294
|
);
|
|
295
|
+
const applyResizePointerRef = useRef(applyResizePointer);
|
|
296
|
+
applyResizePointerRef.current = applyResizePointer;
|
|
275
297
|
|
|
276
298
|
const stopClipDragAutoScroll = useCallback(() => {
|
|
277
299
|
clipDragPointerRef.current = null;
|
|
@@ -279,48 +301,47 @@ export function useTimelineClipDrag({
|
|
|
279
301
|
cancelAnimationFrame(clipDragScrollRaf.current);
|
|
280
302
|
clipDragScrollRaf.current = 0;
|
|
281
303
|
}
|
|
304
|
+
// Gesture teardown: drop the frozen-per-gesture perf caches so the next drag
|
|
305
|
+
// rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT
|
|
306
|
+
// touch groupResizeRef — commit reads it after this runs.
|
|
307
|
+
snapTargetsCacheRef.current.clear();
|
|
308
|
+
dragAudioTracksRef.current = null;
|
|
282
309
|
}, []);
|
|
283
310
|
|
|
284
311
|
const stepClipDragAutoScroll = useCallback(() => {
|
|
285
312
|
clipDragScrollRaf.current = 0;
|
|
286
313
|
const drag = draggedClipRef.current;
|
|
314
|
+
const resize = resizingClipRef.current;
|
|
287
315
|
const pointer = clipDragPointerRef.current;
|
|
288
316
|
const scroll = scrollRef.current;
|
|
289
|
-
if (!drag || !pointer || !scroll) return;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
scroll.scrollLeft = nextScrollLeft;
|
|
302
|
-
scroll.scrollTop = nextScrollTop;
|
|
303
|
-
setDraggedClip((prev) =>
|
|
304
|
-
prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
|
|
305
|
-
);
|
|
317
|
+
if ((!drag && !resize) || !pointer || !scroll) return;
|
|
318
|
+
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
|
|
319
|
+
|
|
320
|
+
if (drag) {
|
|
321
|
+
setDraggedClip((prev) =>
|
|
322
|
+
prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
|
|
323
|
+
);
|
|
324
|
+
} else if (resize) {
|
|
325
|
+
// Re-run the trim preview so the edge keeps tracking while the content
|
|
326
|
+
// scrolls under the stationary pointer (scroll-compensated pointer x).
|
|
327
|
+
applyResizePointerRef.current(resize, pointer.clientX);
|
|
328
|
+
}
|
|
306
329
|
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
|
307
330
|
}, [scrollRef, updateDraggedClipPreview]);
|
|
308
331
|
|
|
309
332
|
const syncClipDragAutoScroll = useCallback(
|
|
310
333
|
(clientX: number, clientY: number) => {
|
|
311
334
|
clipDragPointerRef.current = { clientX, clientY };
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
if (!clipDragScrollRaf.current) {
|
|
335
|
+
const action = resolveTimelineAutoScrollLoopAction(
|
|
336
|
+
scrollRef.current,
|
|
337
|
+
clientX,
|
|
338
|
+
clientY,
|
|
339
|
+
clipDragScrollRaf.current !== 0,
|
|
340
|
+
);
|
|
341
|
+
if (action === "stop") {
|
|
342
|
+
cancelAnimationFrame(clipDragScrollRaf.current);
|
|
343
|
+
clipDragScrollRaf.current = 0;
|
|
344
|
+
} else if (action === "start") {
|
|
324
345
|
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
|
325
346
|
}
|
|
326
347
|
},
|
|
@@ -329,12 +350,6 @@ export function useTimelineClipDrag({
|
|
|
329
350
|
|
|
330
351
|
const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
|
|
331
352
|
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
|
|
332
|
-
const commitGroupMoveRef = useRef(commitGroupMove);
|
|
333
|
-
commitGroupMoveRef.current = commitGroupMove;
|
|
334
|
-
const commitGroupResizeRef = useRef(commitGroupResize);
|
|
335
|
-
commitGroupResizeRef.current = commitGroupResize;
|
|
336
|
-
const clearGroupDragSessionsRef = useRef(clearGroupDragSessions);
|
|
337
|
-
clearGroupDragSessionsRef.current = clearGroupDragSessions;
|
|
338
353
|
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
|
|
339
354
|
syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
|
|
340
355
|
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
|
|
@@ -347,121 +362,39 @@ export function useTimelineClipDrag({
|
|
|
347
362
|
});
|
|
348
363
|
};
|
|
349
364
|
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
const blocked = blockedClipRef.current;
|
|
365
|
+
/* ── pointermove branch handlers (dispatched by drag/resize/blocked) ── */
|
|
366
|
+
const handleResizePointerMove = (e: PointerEvent, resize: ResizingClipState) => {
|
|
367
|
+
const distance = Math.abs(e.clientX - resize.originClientX);
|
|
368
|
+
if (!resize.started && distance < 2) return;
|
|
355
369
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
370
|
+
setShowPopover(false);
|
|
371
|
+
setRangeSelectionRef.current?.(null);
|
|
372
|
+
|
|
373
|
+
applyResizePointerRef.current(resize, e.clientX);
|
|
374
|
+
// Edge auto-scroll during a trim, exactly like the move branch — lets a
|
|
375
|
+
// right-edge trim keep extending past the current viewport (the stepper
|
|
376
|
+
// re-runs the scroll-compensated preview each frame).
|
|
377
|
+
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
|
378
|
+
};
|
|
359
379
|
|
|
380
|
+
const handleBlockedPointerMove = (e: PointerEvent, blocked: BlockedClipState) => {
|
|
381
|
+
const distance = Math.hypot(
|
|
382
|
+
e.clientX - blocked.originClientX,
|
|
383
|
+
e.clientY - blocked.originClientY,
|
|
384
|
+
);
|
|
385
|
+
const threshold = blocked.intent === "move" ? 4 : 2;
|
|
386
|
+
if (!blocked.started && distance < threshold) return;
|
|
387
|
+
if (!blocked.started) {
|
|
388
|
+
blocked.started = true;
|
|
389
|
+
blockedClipRef.current = blocked;
|
|
390
|
+
suppressClickRef.current = true;
|
|
360
391
|
setShowPopover(false);
|
|
361
392
|
setRangeSelectionRef.current?.(null);
|
|
362
|
-
|
|
363
|
-
const sourceRemaining =
|
|
364
|
-
resize.element.sourceDuration != null
|
|
365
|
-
? Math.max(
|
|
366
|
-
0,
|
|
367
|
-
(resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
|
|
368
|
-
Math.max(resize.element.playbackRate ?? 1, 0.1),
|
|
369
|
-
)
|
|
370
|
-
: Number.POSITIVE_INFINITY;
|
|
371
|
-
const normalizedTag = resize.element.tag.toLowerCase();
|
|
372
|
-
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
|
373
|
-
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
|
374
|
-
const maxEnd = resize.element.start + sourceRemaining;
|
|
375
|
-
let nextResize = resolveTimelineResize(
|
|
376
|
-
{
|
|
377
|
-
start: resize.element.start,
|
|
378
|
-
duration: resize.element.duration,
|
|
379
|
-
originClientX: resize.originClientX,
|
|
380
|
-
pixelsPerSecond: ppsRef.current,
|
|
381
|
-
minStart: 0,
|
|
382
|
-
maxEnd,
|
|
383
|
-
playbackStart:
|
|
384
|
-
resize.edge === "start" && canSeedPlaybackStart
|
|
385
|
-
? (resize.element.playbackStart ?? 0)
|
|
386
|
-
: resize.element.playbackStart,
|
|
387
|
-
playbackRate: resize.element.playbackRate,
|
|
388
|
-
},
|
|
389
|
-
resize.edge,
|
|
390
|
-
e.clientX,
|
|
391
|
-
);
|
|
392
|
-
|
|
393
|
-
const snap = snapResizeEdgeToTargets(
|
|
394
|
-
resize.edge,
|
|
395
|
-
nextResize.start,
|
|
396
|
-
nextResize.duration,
|
|
397
|
-
buildSnapTargets(resize.element),
|
|
398
|
-
ppsRef.current,
|
|
399
|
-
{
|
|
400
|
-
minDuration: 0.05,
|
|
401
|
-
maxEnd,
|
|
402
|
-
maxLeftDelta:
|
|
403
|
-
resize.edge === "start" && nextResize.playbackStart != null
|
|
404
|
-
? nextResize.playbackStart / playbackRate
|
|
405
|
-
: Number.POSITIVE_INFINITY,
|
|
406
|
-
},
|
|
407
|
-
);
|
|
408
|
-
if (snap.snapTime != null) {
|
|
409
|
-
const unsnappedStart = nextResize.start;
|
|
410
|
-
nextResize = { ...nextResize, start: snap.start, duration: snap.duration };
|
|
411
|
-
if (resize.edge === "start" && nextResize.playbackStart != null) {
|
|
412
|
-
const delta = unsnappedStart - snap.start;
|
|
413
|
-
nextResize = {
|
|
414
|
-
...nextResize,
|
|
415
|
-
playbackStart:
|
|
416
|
-
Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
|
|
417
|
-
1000,
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
const groupResize = previewGroupResize(
|
|
422
|
-
resize.element,
|
|
423
|
-
selectedElementIdsRef.current,
|
|
424
|
-
resize.edge,
|
|
425
|
-
nextResize,
|
|
426
|
-
);
|
|
427
|
-
if (groupResize.active) {
|
|
428
|
-
nextResize = groupResize.updates;
|
|
429
|
-
}
|
|
430
|
-
setResizingClip((prev) =>
|
|
431
|
-
prev
|
|
432
|
-
? {
|
|
433
|
-
...prev,
|
|
434
|
-
started: true,
|
|
435
|
-
previewStart: nextResize.start,
|
|
436
|
-
previewDuration: nextResize.duration,
|
|
437
|
-
previewPlaybackStart: nextResize.playbackStart,
|
|
438
|
-
snapGuideTime: snap.snapTime,
|
|
439
|
-
snapGuideKind: snap.snapKind,
|
|
440
|
-
}
|
|
441
|
-
: prev,
|
|
442
|
-
);
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (blocked) {
|
|
447
|
-
const distance = Math.hypot(
|
|
448
|
-
e.clientX - blocked.originClientX,
|
|
449
|
-
e.clientY - blocked.originClientY,
|
|
450
|
-
);
|
|
451
|
-
const threshold = blocked.intent === "move" ? 4 : 2;
|
|
452
|
-
if (!blocked.started && distance < threshold) return;
|
|
453
|
-
if (!blocked.started) {
|
|
454
|
-
blocked.started = true;
|
|
455
|
-
blockedClipRef.current = blocked;
|
|
456
|
-
suppressClickRef.current = true;
|
|
457
|
-
setShowPopover(false);
|
|
458
|
-
setRangeSelectionRef.current?.(null);
|
|
459
|
-
onBlockedEditAttempt?.(blocked.element, blocked.intent);
|
|
460
|
-
}
|
|
461
|
-
return;
|
|
393
|
+
onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
|
|
462
394
|
}
|
|
395
|
+
};
|
|
463
396
|
|
|
464
|
-
|
|
397
|
+
const handleDragPointerMove = (e: PointerEvent, drag: DraggedClipState) => {
|
|
465
398
|
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
|
466
399
|
if (!drag.started && distance < 4) return;
|
|
467
400
|
|
|
@@ -474,60 +407,77 @@ export function useTimelineClipDrag({
|
|
|
474
407
|
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
|
475
408
|
};
|
|
476
409
|
|
|
477
|
-
|
|
478
|
-
const handleWindowPointerUp = () => {
|
|
479
|
-
stopClipDragAutoScrollRef.current();
|
|
480
|
-
|
|
410
|
+
const handleWindowPointerMove = (e: PointerEvent) => {
|
|
481
411
|
const resize = resizingClipRef.current;
|
|
482
|
-
if (resize)
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
412
|
+
if (resize) return handleResizePointerMove(e, resize);
|
|
413
|
+
const blocked = blockedClipRef.current;
|
|
414
|
+
if (blocked) return handleBlockedPointerMove(e, blocked);
|
|
415
|
+
const drag = draggedClipRef.current;
|
|
416
|
+
if (drag) handleDragPointerMove(e, drag);
|
|
417
|
+
};
|
|
486
418
|
|
|
487
|
-
|
|
488
|
-
|
|
419
|
+
/* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */
|
|
420
|
+
const commitResizePointerUp = (resize: ResizingClipState) => {
|
|
421
|
+
resizingClipRef.current = null;
|
|
422
|
+
setResizingClip(null);
|
|
423
|
+
const groupSession = groupResizeRef.current;
|
|
424
|
+
groupResizeRef.current = null;
|
|
425
|
+
if (!resize.started) {
|
|
426
|
+
// No preview ran, so no group store-mutation to undo; guard is defensive.
|
|
427
|
+
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
489
430
|
|
|
490
|
-
|
|
431
|
+
suppressClickRef.current = true;
|
|
432
|
+
clearSuppressedClick();
|
|
491
433
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (!hasChanged) return;
|
|
434
|
+
if (groupSession) {
|
|
435
|
+
commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
497
438
|
|
|
498
|
-
|
|
439
|
+
const hasChanged =
|
|
440
|
+
resize.previewStart !== resize.element.start ||
|
|
441
|
+
resize.previewDuration !== resize.element.duration ||
|
|
442
|
+
resize.previewPlaybackStart !== resize.element.playbackStart;
|
|
443
|
+
if (!hasChanged) return;
|
|
444
|
+
|
|
445
|
+
const resizeKey = resize.element.key ?? resize.element.id;
|
|
446
|
+
const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
|
|
447
|
+
updateElement(resizeKey, {
|
|
448
|
+
start: resize.previewStart,
|
|
449
|
+
duration: resize.previewDuration,
|
|
450
|
+
playbackStart: resize.previewPlaybackStart,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
Promise.resolve(
|
|
454
|
+
onResizeElementRef.current?.(resize.element, {
|
|
499
455
|
start: resize.previewStart,
|
|
500
456
|
duration: resize.previewDuration,
|
|
501
457
|
playbackStart: resize.previewPlaybackStart,
|
|
502
|
-
})
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
});
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
458
|
+
}),
|
|
459
|
+
).catch((error) => {
|
|
460
|
+
rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
|
|
461
|
+
{
|
|
462
|
+
key: resizeKey,
|
|
463
|
+
updates: {
|
|
464
|
+
start: resize.element.start,
|
|
465
|
+
duration: resize.element.duration,
|
|
466
|
+
playbackStart: resize.element.playbackStart,
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
]);
|
|
470
|
+
console.error("[Timeline] Failed to persist clip resize", error);
|
|
471
|
+
});
|
|
472
|
+
};
|
|
520
473
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
474
|
+
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
|
475
|
+
blockedClipRef.current = null;
|
|
476
|
+
if (!blocked.started) return;
|
|
477
|
+
clearSuppressedClick();
|
|
478
|
+
};
|
|
528
479
|
|
|
529
|
-
|
|
530
|
-
if (!drag) return;
|
|
480
|
+
const commitDragPointerUp = (drag: DraggedClipState) => {
|
|
531
481
|
draggedClipRef.current = null;
|
|
532
482
|
setDraggedClip(null);
|
|
533
483
|
if (!drag.started) return;
|
|
@@ -535,40 +485,84 @@ export function useTimelineClipDrag({
|
|
|
535
485
|
suppressClickRef.current = true;
|
|
536
486
|
clearSuppressedClick();
|
|
537
487
|
|
|
538
|
-
|
|
488
|
+
// Commit the drag — insert (new track), main-track ripple (reflow contiguous),
|
|
489
|
+
// a plain single-clip move, or a multi-selection move (every selected clip
|
|
490
|
+
// shifts by the dragged clip's time delta). See timelineClipDragCommit.
|
|
491
|
+
commitDraggedClipMove(drag, {
|
|
492
|
+
elements: elementsRef.current,
|
|
493
|
+
trackOrder: trackOrderRef.current,
|
|
494
|
+
updateElement,
|
|
495
|
+
onMoveElement: onMoveElementRef.current,
|
|
496
|
+
onMoveElements: onMoveElementsRef.current,
|
|
497
|
+
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
|
498
|
+
// Lane ↔ stacking: engages only when the timeline layer provisions both
|
|
499
|
+
// deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
|
|
500
|
+
readZIndex: readZIndexRef.current,
|
|
501
|
+
onStackingPatches: onStackingPatchesRef.current,
|
|
502
|
+
});
|
|
503
|
+
};
|
|
539
504
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
|
|
543
|
-
if (!hasChanged) return;
|
|
505
|
+
const handleWindowPointerUp = () => {
|
|
506
|
+
stopClipDragAutoScrollRef.current();
|
|
544
507
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
});
|
|
508
|
+
const resize = resizingClipRef.current;
|
|
509
|
+
if (resize) return commitResizePointerUp(resize);
|
|
548
510
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
511
|
+
const blocked = blockedClipRef.current;
|
|
512
|
+
if (blocked) return finishBlockedPointerUp(blocked);
|
|
513
|
+
|
|
514
|
+
const drag = draggedClipRef.current;
|
|
515
|
+
if (!drag) {
|
|
516
|
+
// Escape-cancel leaves the click suppressor armed so the click this
|
|
517
|
+
// pointerup generates can't act on the clip; disarm it right after.
|
|
518
|
+
if (suppressClickRef.current) clearSuppressedClick();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
commitDragPointerUp(drag);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// Escape cancels the in-progress gesture: no commit, no undo entry. The
|
|
525
|
+
// previews live only in the drag/resize state (the store is untouched
|
|
526
|
+
// until the pointerup commit), so clearing them restores the pre-drag
|
|
527
|
+
// timeline. Clip drags never take pointer capture (all tracking runs on
|
|
528
|
+
// these window listeners), so there is no capture to release; the null
|
|
529
|
+
// refs make the remaining pointermove/pointerup a no-op.
|
|
530
|
+
const handleWindowKeyDown = (e: KeyboardEvent) => {
|
|
531
|
+
const decision = resolveTimelineDragEscape({
|
|
532
|
+
key: e.key,
|
|
533
|
+
drag: draggedClipRef.current,
|
|
534
|
+
resize: resizingClipRef.current,
|
|
535
|
+
blocked: blockedClipRef.current,
|
|
560
536
|
});
|
|
537
|
+
if (!decision.cancel) return;
|
|
538
|
+
e.preventDefault();
|
|
539
|
+
e.stopPropagation();
|
|
540
|
+
stopClipDragAutoScrollRef.current();
|
|
541
|
+
draggedClipRef.current = null;
|
|
542
|
+
setDraggedClip(null);
|
|
543
|
+
resizingClipRef.current = null;
|
|
544
|
+
setResizingClip(null);
|
|
545
|
+
// Undo any group-resize preview store-mutation (non-grabbed members) so the
|
|
546
|
+
// cancelled gesture restores the pre-drag timeline, like the single-clip path.
|
|
547
|
+
const groupSession = groupResizeRef.current;
|
|
548
|
+
groupResizeRef.current = null;
|
|
549
|
+
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
550
|
+
blockedClipRef.current = null;
|
|
551
|
+
// The pointer is usually still down; keep the suppressor armed until the
|
|
552
|
+
// eventual pointerup (which disarms it) so its click can't reselect.
|
|
553
|
+
if (decision.suppressClick) suppressClickRef.current = true;
|
|
561
554
|
};
|
|
562
555
|
|
|
563
556
|
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
564
557
|
window.addEventListener("pointerup", handleWindowPointerUp);
|
|
565
558
|
window.addEventListener("pointercancel", handleWindowPointerUp);
|
|
559
|
+
window.addEventListener("keydown", handleWindowKeyDown, true);
|
|
566
560
|
return () => {
|
|
567
|
-
clearGroupDragSessionsRef.current();
|
|
568
561
|
stopClipDragAutoScrollRef.current();
|
|
569
562
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
570
563
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
571
564
|
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
|
565
|
+
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
|
572
566
|
};
|
|
573
567
|
});
|
|
574
568
|
|