@hyperframes/studio 0.7.55 → 0.7.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.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 +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- 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 +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- 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 +126 -125
- 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 +53 -2
- 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 +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- 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/propertyPanelColorGradingSection.tsx +15 -21
- 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/useCanvasContextMenuState.ts +92 -0
- 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/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -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 +260 -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.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -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 +56 -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 +23 -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 +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -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/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -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 +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- 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 +148 -18
- 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/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- 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/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- 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/TimelineRuler.tsx +58 -48
- 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 +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -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 +42 -2
- 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 +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- 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 +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- 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/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- 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,104 +7,172 @@ 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;
|
|
70
|
+
/**
|
|
71
|
+
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
|
|
72
|
+
* duration — onto the viewport, so the composition ends at ~83% of the width
|
|
73
|
+
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
|
|
74
|
+
* drag clips past the current end without first zooming out). Applied ONLY
|
|
75
|
+
* inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
|
|
76
|
+
* lanes, playhead, marquee, and drag math all inherit it consistently. Manual
|
|
77
|
+
* zoom percentages stay defined relative to this fit basis (100% == fit).
|
|
78
|
+
*/
|
|
79
|
+
export const FIT_ZOOM_HEADROOM = 1.2;
|
|
42
80
|
|
|
43
81
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
44
|
-
|
|
45
|
-
|
|
82
|
+
// fallow-ignore-next-line complexity
|
|
83
|
+
function getMajorTickInterval(
|
|
84
|
+
duration: number,
|
|
85
|
+
pixelsPerSecond?: number,
|
|
86
|
+
frameRate?: number,
|
|
87
|
+
): number {
|
|
88
|
+
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
|
|
89
|
+
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
|
|
90
|
+
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
|
|
91
|
+
const zoomIntervals = [
|
|
92
|
+
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
|
|
93
|
+
];
|
|
94
|
+
let interval: number;
|
|
46
95
|
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
47
96
|
const targetMajorPx = 88;
|
|
48
|
-
|
|
49
|
-
zoomIntervals.find((
|
|
50
|
-
|
|
97
|
+
interval =
|
|
98
|
+
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
|
|
99
|
+
3600;
|
|
100
|
+
} else {
|
|
101
|
+
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
102
|
+
const target = duration / 6;
|
|
103
|
+
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
|
|
104
|
+
}
|
|
105
|
+
// Frame display mode: labels are frame numbers, so a major step must be a
|
|
106
|
+
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
|
|
107
|
+
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
|
|
108
|
+
// the label spacing never drops below the readability target.
|
|
109
|
+
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
|
|
110
|
+
const fps = frameRate ?? 0;
|
|
111
|
+
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
|
|
51
112
|
}
|
|
52
|
-
|
|
53
|
-
const target = duration / 6;
|
|
54
|
-
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
113
|
+
return interval;
|
|
55
114
|
}
|
|
56
115
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
116
|
+
// How many equal parts to split each major interval into for minor ticks. Prefer
|
|
117
|
+
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
|
|
118
|
+
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
|
|
119
|
+
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
|
|
120
|
+
// sub-frame time is not a seekable position), so only divisors of the major
|
|
121
|
+
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
|
|
122
|
+
// thirds, halves.
|
|
123
|
+
// fallow-ignore-next-line complexity
|
|
124
|
+
function getMinorSubdivisions(
|
|
125
|
+
majorInterval: number,
|
|
126
|
+
pixelsPerSecond?: number,
|
|
127
|
+
frameRate?: number,
|
|
128
|
+
): number {
|
|
129
|
+
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
|
|
130
|
+
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
|
|
131
|
+
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
|
|
132
|
+
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
|
|
133
|
+
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
|
|
134
|
+
for (const parts of candidates) {
|
|
135
|
+
if (fps > 0 && majorFrames % parts !== 0) continue;
|
|
136
|
+
if ((majorInterval / parts) * pps >= 8) return parts;
|
|
64
137
|
}
|
|
65
|
-
return
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Ticks are exact multiples of the interval (multiplied per index, never
|
|
142
|
+
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
|
|
143
|
+
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
|
|
144
|
+
function roundTickValue(t: number): number {
|
|
145
|
+
return Math.round(t * 1e6) / 1e6;
|
|
66
146
|
}
|
|
67
147
|
|
|
68
148
|
export function generateTicks(
|
|
69
149
|
duration: number,
|
|
70
150
|
pixelsPerSecond?: number,
|
|
151
|
+
frameRate?: number,
|
|
71
152
|
): { major: number[]; minor: number[] } {
|
|
72
|
-
if (duration <= 0 || !Number.isFinite(duration) || duration >
|
|
153
|
+
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
|
|
73
154
|
return { major: [], minor: [] };
|
|
74
|
-
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
75
|
-
const
|
|
155
|
+
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
|
|
156
|
+
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
|
157
|
+
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
|
76
158
|
const major: number[] = [];
|
|
77
159
|
const minor: number[] = [];
|
|
78
160
|
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
79
|
-
for (let
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
161
|
+
for (let i = 0; major.length < maxTicks; i++) {
|
|
162
|
+
const t = i * majorInterval;
|
|
163
|
+
if (t > duration + 0.001) break;
|
|
164
|
+
major.push(roundTickValue(t));
|
|
165
|
+
// Emit the (subdivisions - 1) minor ticks between this major and the next.
|
|
166
|
+
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
|
|
167
|
+
const m = t + k * minorInterval;
|
|
168
|
+
if (m <= duration + 0.001) minor.push(roundTickValue(m));
|
|
85
169
|
}
|
|
86
170
|
}
|
|
87
171
|
return { major, minor };
|
|
88
172
|
}
|
|
89
173
|
|
|
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
174
|
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
107
|
-
if (!Number.isFinite(time)) return "
|
|
175
|
+
if (!Number.isFinite(time)) return "00:00";
|
|
108
176
|
const safeTime = Math.max(0, time);
|
|
109
177
|
if (majorInterval < 0.1) {
|
|
110
178
|
const totalHundredths = Math.round(safeTime * 100);
|
|
@@ -128,6 +196,47 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
|
|
128
196
|
return formatTime(safeTime);
|
|
129
197
|
}
|
|
130
198
|
|
|
199
|
+
/* ── Width / duration derivation ──────────────────────────────────── */
|
|
200
|
+
/**
|
|
201
|
+
* Fit-mode pixels-per-second: fill the viewport with the composition plus
|
|
202
|
+
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
|
|
203
|
+
* into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
|
|
204
|
+
* seconds onto it — a short comp takes a fraction of the width and the
|
|
205
|
+
* remaining ruler runs to 1:00.
|
|
206
|
+
* Manual zoom multiplies this base, so the floor only anchors the default.
|
|
207
|
+
*/
|
|
208
|
+
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
|
209
|
+
const safeDuration =
|
|
210
|
+
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
|
211
|
+
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
|
212
|
+
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
|
|
213
|
+
return (viewportWidth - GUTTER - 2) / span;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The rendered timeline extent in px. Always covers, whichever is largest:
|
|
218
|
+
* the actual clip content, the visible viewport (no dead black after short
|
|
219
|
+
* content — CapCut-style), a live drag or resize ghost plus the auto-scroll
|
|
220
|
+
* margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
|
|
221
|
+
* RENDERED extent grows; clip positions/durations are untouched.
|
|
222
|
+
*/
|
|
223
|
+
export function getTimelineDisplayContentWidth(input: {
|
|
224
|
+
trackContentWidth: number;
|
|
225
|
+
viewportWidth: number;
|
|
226
|
+
pps: number;
|
|
227
|
+
dragGhostEndPx?: number;
|
|
228
|
+
resizeGhostEndPx?: number;
|
|
229
|
+
}): number {
|
|
230
|
+
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
|
231
|
+
return Math.max(
|
|
232
|
+
input.trackContentWidth,
|
|
233
|
+
input.viewportWidth - GUTTER - 2,
|
|
234
|
+
input.dragGhostEndPx ?? 0,
|
|
235
|
+
input.resizeGhostEndPx ?? 0,
|
|
236
|
+
MIN_TIMELINE_EXTENT_S * safePps,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
131
240
|
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
|
|
132
241
|
export function shouldAutoScrollTimeline(
|
|
133
242
|
zoomMode: ZoomMode,
|
|
@@ -174,13 +283,33 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
|
174
283
|
}
|
|
175
284
|
|
|
176
285
|
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
|
286
|
+
/**
|
|
287
|
+
* Width of the playhead wrapper element (== the diamond head chip's layout
|
|
288
|
+
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
|
|
289
|
+
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
|
|
290
|
+
* shifted LEFT by half this width for the line's center to land exactly on
|
|
291
|
+
* `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
|
|
292
|
+
*/
|
|
293
|
+
export const PLAYHEAD_HEAD_W = 9;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
|
|
297
|
+
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
|
|
298
|
+
* on) at every zoom level. Without the half-head offset the line sat
|
|
299
|
+
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
|
|
300
|
+
*/
|
|
177
301
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
178
|
-
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond))
|
|
179
|
-
|
|
302
|
+
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
|
303
|
+
return GUTTER - PLAYHEAD_HEAD_W / 2;
|
|
304
|
+
}
|
|
305
|
+
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
|
|
180
306
|
}
|
|
181
307
|
|
|
182
308
|
export function getTimelineCanvasHeight(trackCount: number): number {
|
|
183
|
-
|
|
309
|
+
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
|
|
310
|
+
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
|
|
311
|
+
// below the last lane is real scrollable surface, not a hidden buffer.
|
|
312
|
+
return RULER_H + TRACKS_TOP_PAD + Math.max(0, trackCount) * TRACK_H + TRACKS_BOTTOM_PAD;
|
|
184
313
|
}
|
|
185
314
|
|
|
186
315
|
/* ── UI helpers ───────────────────────────────────────────────────── */
|
|
@@ -245,12 +374,15 @@ export function resolveTimelineAssetDrop(
|
|
|
245
374
|
clientY: number,
|
|
246
375
|
): { start: number; track: number } {
|
|
247
376
|
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
248
|
-
const
|
|
377
|
+
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
249
378
|
const start = Math.max(
|
|
250
379
|
0,
|
|
251
380
|
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
|
252
381
|
);
|
|
253
|
-
|
|
382
|
+
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
|
383
|
+
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
|
384
|
+
// past the last lane (getDefaultDroppedTrack then appends a new track).
|
|
385
|
+
const rowIndex = Math.floor(getTimelineRowFromY(contentY));
|
|
254
386
|
return {
|
|
255
387
|
start,
|
|
256
388
|
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
|
+
}
|