@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
|
@@ -7,38 +7,66 @@ export const TRACK_H = 48;
|
|
|
7
7
|
export const RULER_H = 24;
|
|
8
8
|
export const CLIP_Y = 3;
|
|
9
9
|
export const CLIP_HANDLE_W = 18;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Half-width (as a fraction of TRACK_H) of the new-track INSERT band that
|
|
12
|
+
* straddles each lane boundary. Deliberately equals the clip's vertical inset
|
|
13
|
+
* (`CLIP_Y / TRACK_H`): a clip body fills [CLIP_Y, TRACK_H − CLIP_Y] of its row,
|
|
14
|
+
* so the ONLY region this band covers is the visible empty gutter between two
|
|
15
|
+
* clip bodies (plus the top/bottom breathing pads, handled separately by the
|
|
16
|
+
* rowFloat ≤ 0 / ≥ trackCount extremes). Aiming at a clip body is therefore a
|
|
17
|
+
* move-to-that-lane; only the inter-clip gap arms an insert — see resolveInsertRow.
|
|
18
|
+
* Threaded into resolveInsertRow by the drag preview so the hit band can never
|
|
19
|
+
* drift from the rendered clip geometry.
|
|
20
|
+
*/
|
|
21
|
+
export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
|
|
22
|
+
/**
|
|
23
|
+
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
|
|
24
|
+
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
|
|
25
|
+
* offset; a track row's top is always `RULER_H + TRACKS_TOP_PAD + row*TRACK_H`.
|
|
26
|
+
*
|
|
27
|
+
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
|
|
28
|
+
* (~half a track height) so the first clip isn't jammed under the ruler.
|
|
29
|
+
* - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
|
|
30
|
+
* enough to comfortably drag a clip into the void to create a new bottom lane.
|
|
31
|
+
*/
|
|
32
|
+
export const TRACKS_TOP_PAD = 50;
|
|
33
|
+
export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
|
|
13
34
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const safeDur = Number.isFinite(rootDuration) ? rootDuration : 0;
|
|
24
|
-
if (clipEnds.length === 0) return safeDur;
|
|
25
|
-
const maxEnd = Math.max(safeDur, ...clipEnds);
|
|
26
|
-
return Number.isFinite(maxEnd) ? maxEnd : safeDur;
|
|
35
|
+
/**
|
|
36
|
+
* The y (content-space) of the top edge of track ROW index `row` (0 = first
|
|
37
|
+
* displayed lane). The single source of truth for row→y — the ruler height plus
|
|
38
|
+
* the top breathing pad plus whole track lanes above it. Every clip/ghost/
|
|
39
|
+
* placeholder/insertion top and every pointer-y→row inversion goes through this
|
|
40
|
+
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
|
|
41
|
+
*/
|
|
42
|
+
export function getTimelineRowTop(row: number): number {
|
|
43
|
+
return RULER_H + TRACKS_TOP_PAD + row * TRACK_H;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
let maxEnd = basisDuration;
|
|
37
|
-
for (const end of previewEnds) {
|
|
38
|
-
if (end != null && Number.isFinite(end)) maxEnd = Math.max(maxEnd, end);
|
|
39
|
-
}
|
|
40
|
-
return maxEnd;
|
|
46
|
+
/**
|
|
47
|
+
* Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
|
|
48
|
+
* space y (used for insert-row / drop-lane decisions). Subtracts the ruler and
|
|
49
|
+
* top pad before dividing by the track height.
|
|
50
|
+
*/
|
|
51
|
+
export function getTimelineRowFromY(contentY: number): number {
|
|
52
|
+
return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H;
|
|
41
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* While a clip drag is live, the rendered timeline extends this far past the
|
|
56
|
+
* ghost's end so the right-edge auto-scroll zone always has room to keep
|
|
57
|
+
* stepping — that's what lets a drag extend the timeline past its current
|
|
58
|
+
* rendered width (see Timeline.tsx displayContentWidth).
|
|
59
|
+
*/
|
|
60
|
+
export const DRAG_EXTEND_MARGIN_PX = 160;
|
|
61
|
+
/**
|
|
62
|
+
* The rendered timeline always spans at least this many seconds of ruler +
|
|
63
|
+
* track lanes, even when the composition is shorter — the empty space on the
|
|
64
|
+
* right is a real, drag/drop-enabled surface (clips can be moved into it; the
|
|
65
|
+
* composition grows on commit, content-driven). In fit mode the fit pps is
|
|
66
|
+
* derived against this floor, so a 10s comp renders as ~1/6 of the viewport
|
|
67
|
+
* with 60s of ruler after it.
|
|
68
|
+
*/
|
|
69
|
+
export const MIN_TIMELINE_EXTENT_S = 60;
|
|
42
70
|
|
|
43
71
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
44
72
|
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
@@ -54,15 +82,15 @@ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): numbe
|
|
|
54
82
|
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
55
83
|
}
|
|
56
84
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
85
|
+
// How many equal parts to split each major interval into for minor ticks. Prefer
|
|
86
|
+
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
|
|
87
|
+
// none (0) as ticks get too dense to read (< ~8px apart).
|
|
88
|
+
function getMinorSubdivisions(majorInterval: number, pixelsPerSecond?: number): number {
|
|
89
|
+
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
|
|
90
|
+
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
|
|
91
|
+
if ((majorInterval / 4) * pps >= 8) return 4;
|
|
92
|
+
if ((majorInterval / 2) * pps >= 8) return 2;
|
|
93
|
+
return 0;
|
|
66
94
|
}
|
|
67
95
|
|
|
68
96
|
export function generateTicks(
|
|
@@ -72,39 +100,25 @@ export function generateTicks(
|
|
|
72
100
|
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
|
73
101
|
return { major: [], minor: [] };
|
|
74
102
|
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
75
|
-
const
|
|
103
|
+
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
|
|
104
|
+
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
|
76
105
|
const major: number[] = [];
|
|
77
106
|
const minor: number[] = [];
|
|
78
107
|
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
79
108
|
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
|
|
80
109
|
const rounded = Math.round(t * 100) / 100;
|
|
81
110
|
major.push(rounded);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
111
|
+
// Emit the (subdivisions - 1) minor ticks between this major and the next.
|
|
112
|
+
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
|
|
113
|
+
const m = Math.round((t + k * minorInterval) * 100) / 100;
|
|
114
|
+
if (m <= duration + 0.001) minor.push(m);
|
|
85
115
|
}
|
|
86
116
|
}
|
|
87
117
|
return { major, minor };
|
|
88
118
|
}
|
|
89
119
|
|
|
90
|
-
/**
|
|
91
|
-
* Ticks spanning the full visible ruler width, not just the composition, so a
|
|
92
|
-
* zoomed-out ruler stays filled with labels instead of ending mid-panel. The
|
|
93
|
-
* major/minor interval is driven by pixelsPerSecond (pixel spacing), so widening
|
|
94
|
-
* the range keeps spacing identical — it only adds ticks past the content end.
|
|
95
|
-
*/
|
|
96
|
-
export function generateVisibleTicks(
|
|
97
|
-
effectiveDuration: number,
|
|
98
|
-
pixelsPerSecond: number,
|
|
99
|
-
viewportWidth: number,
|
|
100
|
-
gutter: number,
|
|
101
|
-
): { major: number[]; minor: number[] } {
|
|
102
|
-
const visible = viewportWidth > gutter ? (viewportWidth - gutter) / pixelsPerSecond : 0;
|
|
103
|
-
return generateTicks(Math.max(effectiveDuration, visible), pixelsPerSecond);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
120
|
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
107
|
-
if (!Number.isFinite(time)) return "
|
|
121
|
+
if (!Number.isFinite(time)) return "00:00";
|
|
108
122
|
const safeTime = Math.max(0, time);
|
|
109
123
|
if (majorInterval < 0.1) {
|
|
110
124
|
const totalHundredths = Math.round(safeTime * 100);
|
|
@@ -128,6 +142,45 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
|
|
128
142
|
return formatTime(safeTime);
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
/* ── Width / duration derivation ──────────────────────────────────── */
|
|
146
|
+
/**
|
|
147
|
+
* Fit-mode pixels-per-second: fill the viewport with the composition, but
|
|
148
|
+
* never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
|
|
149
|
+
* takes a fraction of the width and the remaining ruler runs to 1:00.
|
|
150
|
+
* Manual zoom multiplies this base, so the floor only anchors the default.
|
|
151
|
+
*/
|
|
152
|
+
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
|
153
|
+
const safeDuration =
|
|
154
|
+
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
|
155
|
+
const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
|
|
156
|
+
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
|
|
157
|
+
return (viewportWidth - GUTTER - 2) / span;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The rendered timeline extent in px. Always covers, whichever is largest:
|
|
162
|
+
* the actual clip content, the visible viewport (no dead black after short
|
|
163
|
+
* content — CapCut-style), a live drag or resize ghost plus the auto-scroll
|
|
164
|
+
* margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
|
|
165
|
+
* RENDERED extent grows; clip positions/durations are untouched.
|
|
166
|
+
*/
|
|
167
|
+
export function getTimelineDisplayContentWidth(input: {
|
|
168
|
+
trackContentWidth: number;
|
|
169
|
+
viewportWidth: number;
|
|
170
|
+
pps: number;
|
|
171
|
+
dragGhostEndPx?: number;
|
|
172
|
+
resizeGhostEndPx?: number;
|
|
173
|
+
}): number {
|
|
174
|
+
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
|
175
|
+
return Math.max(
|
|
176
|
+
input.trackContentWidth,
|
|
177
|
+
input.viewportWidth - GUTTER - 2,
|
|
178
|
+
input.dragGhostEndPx ?? 0,
|
|
179
|
+
input.resizeGhostEndPx ?? 0,
|
|
180
|
+
MIN_TIMELINE_EXTENT_S * safePps,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
131
184
|
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
|
|
132
185
|
export function shouldAutoScrollTimeline(
|
|
133
186
|
zoomMode: ZoomMode,
|
|
@@ -180,7 +233,10 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
|
|
|
180
233
|
}
|
|
181
234
|
|
|
182
235
|
export function getTimelineCanvasHeight(trackCount: number): number {
|
|
183
|
-
|
|
236
|
+
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
|
|
237
|
+
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
|
|
238
|
+
// below the last lane is real scrollable surface, not a hidden buffer.
|
|
239
|
+
return RULER_H + TRACKS_TOP_PAD + Math.max(0, trackCount) * TRACK_H + TRACKS_BOTTOM_PAD;
|
|
184
240
|
}
|
|
185
241
|
|
|
186
242
|
/* ── UI helpers ───────────────────────────────────────────────────── */
|
|
@@ -245,12 +301,15 @@ export function resolveTimelineAssetDrop(
|
|
|
245
301
|
clientY: number,
|
|
246
302
|
): { start: number; track: number } {
|
|
247
303
|
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
248
|
-
const
|
|
304
|
+
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
249
305
|
const start = Math.max(
|
|
250
306
|
0,
|
|
251
307
|
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
|
252
308
|
);
|
|
253
|
-
|
|
309
|
+
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
|
310
|
+
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
|
311
|
+
// past the last lane (getDefaultDroppedTrack then appends a new track).
|
|
312
|
+
const rowIndex = Math.floor(getTimelineRowFromY(contentY));
|
|
254
313
|
return {
|
|
255
314
|
start,
|
|
256
315
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
MARQUEE_DRAG_THRESHOLD_PX,
|
|
4
|
+
isMarqueeDrag,
|
|
5
|
+
isTimelineRulerPress,
|
|
6
|
+
getMarqueeRect,
|
|
7
|
+
getTimelineClipRect,
|
|
8
|
+
computeMarqueeSelection,
|
|
9
|
+
} from "./timelineMarquee";
|
|
10
|
+
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
|
|
11
|
+
|
|
12
|
+
describe("isTimelineRulerPress", () => {
|
|
13
|
+
const rectTop = 500; // scroll container's viewport top
|
|
14
|
+
|
|
15
|
+
it("treats a press inside the ruler band as a ruler press (unscrolled)", () => {
|
|
16
|
+
expect(isTimelineRulerPress(rectTop, rectTop)).toBe(true);
|
|
17
|
+
expect(isTimelineRulerPress(rectTop + RULER_H - 1, rectTop)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("treats a press below the ruler band as a body press", () => {
|
|
21
|
+
expect(isTimelineRulerPress(rectTop + RULER_H, rectTop)).toBe(false);
|
|
22
|
+
expect(isTimelineRulerPress(rectTop + RULER_H + 100, rectTop)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("stays a ruler press when the body is scrolled down (sticky ruler)", () => {
|
|
26
|
+
// The ruler is position:sticky — scrolled down, its VISUAL band is still
|
|
27
|
+
// the top RULER_H px of the container. Content-space math
|
|
28
|
+
// (clientY - rectTop + scrollTop) would report y = 10 + 300 = 310 ≥ RULER_H
|
|
29
|
+
// and misclassify this as a body/marquee press; viewport-space math must
|
|
30
|
+
// still classify it as a ruler press regardless of scrollTop.
|
|
31
|
+
const scrollTop = 300;
|
|
32
|
+
const clientY = rectTop + 10; // visually on the stuck ruler
|
|
33
|
+
const contentSpaceY = clientY - rectTop + scrollTop;
|
|
34
|
+
expect(contentSpaceY).toBeGreaterThanOrEqual(RULER_H); // the old, broken signal
|
|
35
|
+
expect(isTimelineRulerPress(clientY, rectTop)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("honors a custom ruler height", () => {
|
|
39
|
+
expect(isTimelineRulerPress(rectTop + 30, rectTop, 32)).toBe(true);
|
|
40
|
+
expect(isTimelineRulerPress(rectTop + 33, rectTop, 32)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("isMarqueeDrag", () => {
|
|
45
|
+
it("treats sub-threshold movement as a click, not a drag", () => {
|
|
46
|
+
expect(isMarqueeDrag(100, 50, 100, 50)).toBe(false);
|
|
47
|
+
expect(
|
|
48
|
+
isMarqueeDrag(
|
|
49
|
+
100,
|
|
50
|
+
50,
|
|
51
|
+
100 + MARQUEE_DRAG_THRESHOLD_PX - 1,
|
|
52
|
+
50 + MARQUEE_DRAG_THRESHOLD_PX - 1,
|
|
53
|
+
),
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("becomes a drag once either axis passes the threshold", () => {
|
|
58
|
+
expect(isMarqueeDrag(100, 50, 100 + MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
|
|
59
|
+
expect(isMarqueeDrag(100, 50, 100, 50 + MARQUEE_DRAG_THRESHOLD_PX)).toBe(true);
|
|
60
|
+
expect(isMarqueeDrag(100, 50, 100 - MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("getMarqueeRect", () => {
|
|
65
|
+
it("builds a rect from origin to current for a down-right drag", () => {
|
|
66
|
+
expect(getMarqueeRect(10, 20, 110, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("normalizes negative drags (up-left) into a positive rect", () => {
|
|
70
|
+
expect(getMarqueeRect(110, 70, 10, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("normalizes mixed-direction drags (down-left / up-right)", () => {
|
|
74
|
+
expect(getMarqueeRect(110, 20, 10, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
75
|
+
expect(getMarqueeRect(10, 70, 110, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("yields a zero-size rect when the pointer has not moved", () => {
|
|
79
|
+
expect(getMarqueeRect(42, 42, 42, 42)).toEqual({ left: 42, top: 42, width: 0, height: 0 });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("getTimelineClipRect", () => {
|
|
84
|
+
const trackOrder = [0, 2, 5];
|
|
85
|
+
|
|
86
|
+
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
|
|
87
|
+
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
|
|
88
|
+
expect(rect).toEqual({
|
|
89
|
+
left: GUTTER + 200,
|
|
90
|
+
top: getTimelineRowTop(1) + CLIP_Y,
|
|
91
|
+
width: 300,
|
|
92
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("places the first visible track below the ruler + top breathing pad", () => {
|
|
97
|
+
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
|
|
98
|
+
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
|
|
99
|
+
expect(rect?.left).toBe(GUTTER);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("uses the row index in trackOrder, not the raw track number", () => {
|
|
103
|
+
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50);
|
|
104
|
+
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("enforces the 4px minimum rendered width", () => {
|
|
108
|
+
const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, trackOrder, 10);
|
|
109
|
+
expect(rect?.width).toBe(4);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns null for a track that is not displayed or an invalid pps", () => {
|
|
113
|
+
expect(getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100)).toBeNull();
|
|
114
|
+
expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull();
|
|
115
|
+
expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("computeMarqueeSelection", () => {
|
|
120
|
+
// Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
|
|
121
|
+
const trackOrder = [0, 1];
|
|
122
|
+
const pps = 100;
|
|
123
|
+
const clips = [
|
|
124
|
+
{ id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0
|
|
125
|
+
{ id: "b", start: 2, duration: 1, track: 0 }, // x [232,332], row 0
|
|
126
|
+
{ id: "c", start: 0.5, duration: 1, track: 1 }, // x [82,182], row 1
|
|
127
|
+
];
|
|
128
|
+
const row0Top = getTimelineRowTop(0) + CLIP_Y;
|
|
129
|
+
const row1Top = getTimelineRowTop(1) + CLIP_Y;
|
|
130
|
+
|
|
131
|
+
it("selects only the clips the marquee rect intersects", () => {
|
|
132
|
+
const marquee = { left: GUTTER, top: row0Top, width: 50, height: 10 };
|
|
133
|
+
const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
|
134
|
+
expect(ids).toEqual(new Set(["a"]));
|
|
135
|
+
expect(primaryId).toBe("a");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("selects across tracks when the rect spans multiple rows", () => {
|
|
139
|
+
const marquee = { left: GUTTER, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
|
140
|
+
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
|
141
|
+
expect(ids).toEqual(new Set(["a", "c"]));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("excludes clips outside the rect horizontally", () => {
|
|
145
|
+
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
|
|
146
|
+
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
|
147
|
+
expect(ids).toEqual(new Set());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
|
|
151
|
+
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
|
|
152
|
+
const { ids, primaryId } = computeMarqueeSelection({
|
|
153
|
+
clips,
|
|
154
|
+
trackOrder,
|
|
155
|
+
pps,
|
|
156
|
+
marquee,
|
|
157
|
+
baseSelection: ["b"],
|
|
158
|
+
});
|
|
159
|
+
expect(ids).toEqual(new Set(["b"]));
|
|
160
|
+
expect(primaryId).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("unions additive base selection with new hits; primary comes from the marquee", () => {
|
|
164
|
+
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
|
|
165
|
+
const { ids, primaryId } = computeMarqueeSelection({
|
|
166
|
+
clips,
|
|
167
|
+
trackOrder,
|
|
168
|
+
pps,
|
|
169
|
+
marquee,
|
|
170
|
+
baseSelection: ["b"],
|
|
171
|
+
});
|
|
172
|
+
expect(ids).toEqual(new Set(["b", "c"]));
|
|
173
|
+
expect(primaryId).toBe("c");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("shrinking the rect live drops clips it no longer covers", () => {
|
|
177
|
+
const wide = { left: GUTTER, top: row0Top, width: 320, height: 10 };
|
|
178
|
+
const narrow = { left: GUTTER, top: row0Top, width: 80, height: 10 };
|
|
179
|
+
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
|
|
180
|
+
new Set(["a", "b"]),
|
|
181
|
+
);
|
|
182
|
+
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual(
|
|
183
|
+
new Set(["a"]),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("ignores clips on hidden/undisplayed tracks", () => {
|
|
188
|
+
const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
|
|
189
|
+
const { ids } = computeMarqueeSelection({
|
|
190
|
+
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
|
|
191
|
+
trackOrder,
|
|
192
|
+
pps,
|
|
193
|
+
marquee,
|
|
194
|
+
});
|
|
195
|
+
expect(ids).toEqual(new Set());
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
|
|
2
|
+
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
|
3
|
+
|
|
4
|
+
/** Pointer must travel at least this far (either axis) before a pointerdown on
|
|
5
|
+
* the empty timeline body becomes a marquee drag instead of a plain click. */
|
|
6
|
+
export const MARQUEE_DRAG_THRESHOLD_PX = 4;
|
|
7
|
+
|
|
8
|
+
/** Minimum rendered clip width, mirrors TimelineClip's `Math.max(w, 4)`. */
|
|
9
|
+
const MIN_CLIP_W = 4;
|
|
10
|
+
|
|
11
|
+
export interface MarqueeClipInput {
|
|
12
|
+
id: string;
|
|
13
|
+
start: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
track: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ruler-vs-body decision for a pointerdown on the timeline scroll container.
|
|
20
|
+
*
|
|
21
|
+
* The ruler is `position: sticky; top: 0` — once the body is scrolled down its
|
|
22
|
+
* VISUAL position stays pinned to the container top while its LAYOUT position
|
|
23
|
+
* scrolls away. The hit test must therefore use VIEWPORT-space y (clientY
|
|
24
|
+
* relative to the scroll container's bounding rect), NOT content-space y
|
|
25
|
+
* (clientY - rect.top + scrollTop), which misclassifies a press on the stuck
|
|
26
|
+
* ruler as a body/marquee press whenever scrollTop > 0.
|
|
27
|
+
*/
|
|
28
|
+
export function isTimelineRulerPress(
|
|
29
|
+
clientY: number,
|
|
30
|
+
scrollRectTop: number,
|
|
31
|
+
rulerHeight: number = RULER_H,
|
|
32
|
+
): boolean {
|
|
33
|
+
return clientY - scrollRectTop < rulerHeight;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isMarqueeDrag(
|
|
37
|
+
originX: number,
|
|
38
|
+
originY: number,
|
|
39
|
+
currentX: number,
|
|
40
|
+
currentY: number,
|
|
41
|
+
threshold: number = MARQUEE_DRAG_THRESHOLD_PX,
|
|
42
|
+
): boolean {
|
|
43
|
+
return Math.abs(currentX - originX) >= threshold || Math.abs(currentY - originY) >= threshold;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Normalized marquee rect (canvas/content coordinates) from the drag origin and
|
|
47
|
+
* the current pointer — handles drags in any direction (negative deltas). */
|
|
48
|
+
export function getMarqueeRect(
|
|
49
|
+
originX: number,
|
|
50
|
+
originY: number,
|
|
51
|
+
currentX: number,
|
|
52
|
+
currentY: number,
|
|
53
|
+
): Rect {
|
|
54
|
+
return {
|
|
55
|
+
left: Math.min(originX, currentX),
|
|
56
|
+
top: Math.min(originY, currentY),
|
|
57
|
+
width: Math.abs(currentX - originX),
|
|
58
|
+
height: Math.abs(currentY - originY),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A clip's rendered rect in canvas/content coordinates (the same space the
|
|
64
|
+
* marquee rect lives in): x from GUTTER + start * pps, y from the clip's row
|
|
65
|
+
* index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y).
|
|
66
|
+
* Returns null when the clip's track is not currently displayed.
|
|
67
|
+
*/
|
|
68
|
+
export function getTimelineClipRect(
|
|
69
|
+
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
|
70
|
+
trackOrder: number[],
|
|
71
|
+
pps: number,
|
|
72
|
+
): Rect | null {
|
|
73
|
+
const row = trackOrder.indexOf(clip.track);
|
|
74
|
+
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
|
75
|
+
return {
|
|
76
|
+
left: GUTTER + clip.start * pps,
|
|
77
|
+
top: getTimelineRowTop(row) + CLIP_Y,
|
|
78
|
+
width: Math.max(clip.duration * pps, MIN_CLIP_W),
|
|
79
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface MarqueeSelectionResult {
|
|
84
|
+
/** Every clip id the marquee currently covers (plus the additive base). */
|
|
85
|
+
ids: Set<string>;
|
|
86
|
+
/** The last marquee-hit clip in element order — the primary selection.
|
|
87
|
+
* Null when the marquee covers nothing new (caller keeps its current primary). */
|
|
88
|
+
primaryId: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Live marquee selection: every clip whose rendered rect intersects the marquee.
|
|
93
|
+
* `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId.
|
|
94
|
+
*/
|
|
95
|
+
export function computeMarqueeSelection(input: {
|
|
96
|
+
clips: MarqueeClipInput[];
|
|
97
|
+
trackOrder: number[];
|
|
98
|
+
pps: number;
|
|
99
|
+
marquee: Rect;
|
|
100
|
+
baseSelection?: Iterable<string>;
|
|
101
|
+
}): MarqueeSelectionResult {
|
|
102
|
+
const ids = new Set<string>(input.baseSelection ?? []);
|
|
103
|
+
let primaryId: string | null = null;
|
|
104
|
+
for (const clip of input.clips) {
|
|
105
|
+
const rect = getTimelineClipRect(clip, input.trackOrder, input.pps);
|
|
106
|
+
if (rect && rectsOverlap(rect, input.marquee)) {
|
|
107
|
+
ids.add(clip.id);
|
|
108
|
+
primaryId = clip.id;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { ids, primaryId };
|
|
112
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clampGroupMoveDelta,
|
|
4
|
+
isMultiDragActive,
|
|
5
|
+
isMultiDragPassenger,
|
|
6
|
+
multiDragDeltaSeconds,
|
|
7
|
+
multiDragPassengerOffsetPx,
|
|
8
|
+
type MultiDragPreviewInput,
|
|
9
|
+
} from "./timelineMultiDragPreview";
|
|
10
|
+
|
|
11
|
+
const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput => ({
|
|
12
|
+
dragStarted: true,
|
|
13
|
+
draggedKey: "a",
|
|
14
|
+
draggedOriginStart: 2,
|
|
15
|
+
draggedPreviewStart: 5,
|
|
16
|
+
selectedKeys: new Set(["a", "b", "c"]),
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("isMultiDragActive", () => {
|
|
21
|
+
it("is active when a started drag's clip is part of a 2+ selection", () => {
|
|
22
|
+
expect(isMultiDragActive(base())).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is inactive before the drag starts", () => {
|
|
26
|
+
expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is inactive for a single-clip selection (single-drag behavior)", () => {
|
|
30
|
+
expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("is inactive when the dragged clip is not itself selected", () => {
|
|
34
|
+
expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("multiDragDeltaSeconds (the one formation delta)", () => {
|
|
39
|
+
it("is the grabbed clip's preview − origin start when active", () => {
|
|
40
|
+
// The preview start is already group-clamped upstream, so this delta is the
|
|
41
|
+
// clamped delta every member (ghost + passengers) moves by.
|
|
42
|
+
expect(multiDragDeltaSeconds(base())).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("supports a leftward (negative) delta", () => {
|
|
46
|
+
expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("is zero when no multi-drag is active", () => {
|
|
50
|
+
expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("isMultiDragPassenger", () => {
|
|
55
|
+
it("marks a selected non-dragged clip as a passenger", () => {
|
|
56
|
+
expect(isMultiDragPassenger("b", base())).toBe(true);
|
|
57
|
+
expect(isMultiDragPassenger("c", base())).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("never marks the dragged clip itself (it is the free ghost)", () => {
|
|
61
|
+
expect(isMultiDragPassenger("a", base())).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("never marks an unselected clip", () => {
|
|
65
|
+
expect(isMultiDragPassenger("d", base())).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("marks nothing when the drag is a single-drag", () => {
|
|
69
|
+
const single = base({ selectedKeys: new Set(["a"]) });
|
|
70
|
+
expect(isMultiDragPassenger("b", single)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
|
|
75
|
+
it("converts the one formation delta to pixels for every passenger", () => {
|
|
76
|
+
// Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
|
|
77
|
+
expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
|
|
78
|
+
expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is zero for the dragged clip and for non-passengers", () => {
|
|
82
|
+
expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
|
|
83
|
+
expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("is zero for a non-finite pps", () => {
|
|
87
|
+
expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("follows a leftward delta", () => {
|
|
91
|
+
expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("clampGroupMoveDelta (rigid group move)", () => {
|
|
96
|
+
it("passes a rightward delta through unchanged (no right wall)", () => {
|
|
97
|
+
expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
|
|
98
|
+
expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("passes a leftward delta through when no member would cross 0", () => {
|
|
102
|
+
// Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
|
|
103
|
+
expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
|
|
107
|
+
// Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
|
|
108
|
+
// A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
|
|
109
|
+
// formation instead of out-running it.
|
|
110
|
+
expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
|
|
114
|
+
// Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
|
|
115
|
+
expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("already-at-0 member forbids any leftward move", () => {
|
|
119
|
+
expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
|
|
120
|
+
// rightward still allowed
|
|
121
|
+
expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns the raw delta for an empty formation", () => {
|
|
125
|
+
expect(clampGroupMoveDelta(-9, [])).toBe(-9);
|
|
126
|
+
});
|
|
127
|
+
});
|