@hyperframes/studio 0.7.54 → 0.7.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
|
5
|
+
* the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
|
|
6
|
+
* clip from appearing to land in the wrong zone mid-drag (which normalizeToZones
|
|
7
|
+
* would then snap back). `audioRow` = index in `trackOrder` of the first audio
|
|
8
|
+
* lane, or -1 when there is no audio zone yet (then it's a no-op).
|
|
9
|
+
*/
|
|
10
|
+
export function clampTrackToZone(
|
|
11
|
+
targetTrack: number,
|
|
12
|
+
trackOrder: number[],
|
|
13
|
+
audioRow: number,
|
|
14
|
+
isAudio: boolean,
|
|
15
|
+
): number {
|
|
16
|
+
if (audioRow < 0) return targetTrack;
|
|
17
|
+
const row = trackOrder.indexOf(targetTrack);
|
|
18
|
+
if (row < 0) return targetTrack;
|
|
19
|
+
if (isAudio) return row >= audioRow ? targetTrack : (trackOrder[audioRow] ?? targetTrack);
|
|
20
|
+
return row < audioRow ? targetTrack : (trackOrder[audioRow - 1] ?? targetTrack);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a new-track insert at boundary `insertRow` is allowed for a clip of the
|
|
25
|
+
* given kind. Visual clips may only insert visual lanes (boundary at/above the top
|
|
26
|
+
* of the audio zone); audio clips may only insert audio lanes (boundary at/below
|
|
27
|
+
* it) — so audio clips CAN create a new audio track, and neither kind inserts into
|
|
28
|
+
* the other's zone. `audioRow` = first audio lane row, or -1 (no audio zone) → any.
|
|
29
|
+
*/
|
|
30
|
+
export function isInsertAllowedForZone(
|
|
31
|
+
insertRow: number,
|
|
32
|
+
audioRow: number,
|
|
33
|
+
isAudio: boolean,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (audioRow < 0) return true;
|
|
36
|
+
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The full drop-placement decision for a dragged clip — one pure, testable unit.
|
|
41
|
+
* Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
|
|
42
|
+
* a new track is created only when needed. Order of resolution:
|
|
43
|
+
* 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
|
|
44
|
+
* clip's own zone → create a new track there.
|
|
45
|
+
* 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
|
|
46
|
+
* if free at [start, start+duration), else the nearest FREE lane in the zone
|
|
47
|
+
* (prefer up), else auto-create a new track right below the aimed lane.
|
|
48
|
+
* `audioTracks` = the set of track indices that currently hold audio (so the fn
|
|
49
|
+
* needs no element-kind import). Returns the landing `track` and, when a new track
|
|
50
|
+
* should be created, the `insertRow` boundary (else null).
|
|
51
|
+
*
|
|
52
|
+
* `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
|
|
53
|
+
* track) toward the boundary ABOVE the aimed row instead of below it, so the new
|
|
54
|
+
* lane opens on whichever side of the aimed clip the pointer is nearer (the drag
|
|
55
|
+
* preview passes the pointer's sub-row half). A clip whose aimed span is occupied
|
|
56
|
+
* never snaps back to its origin — it relocates to a free lane, or (none free)
|
|
57
|
+
* gets a fresh track next to the aim. Default (below) preserves prior behaviour.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
|
|
61
|
+
* real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
|
|
62
|
+
* repro) or a beyond-the-bottom index a downward one does. Anchors the new track
|
|
63
|
+
* to a boundary of the clip's OWN kind-zone so a visual insert can never land
|
|
64
|
+
* past the audio zone (the old below = order.length fallback dropped it BELOW the
|
|
65
|
+
* audio lanes). Above the zone (`desired` < the zone's min lane) → the zone's TOP
|
|
66
|
+
* boundary; otherwise → its BOTTOM boundary (for a visual clip, the top of the
|
|
67
|
+
* audio zone). `zoneTracks` = this kind's lanes, in `order` sequence.
|
|
68
|
+
*/
|
|
69
|
+
function outOfRangeZoneInsertRow(
|
|
70
|
+
order: number[],
|
|
71
|
+
zoneTracks: number[],
|
|
72
|
+
audioRow: number,
|
|
73
|
+
desired: number,
|
|
74
|
+
): number {
|
|
75
|
+
// No lane of this kind yet: fall to the split (audioRow) or the very top.
|
|
76
|
+
// A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
|
|
77
|
+
if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
|
|
78
|
+
// zoneTracks preserves `order` sequence, so its ends map to the zone boundary
|
|
79
|
+
// rows: above the zone's min lane → its top boundary, else its bottom.
|
|
80
|
+
const zoneTop = order.indexOf(zoneTracks[0]);
|
|
81
|
+
const zoneBottom = order.indexOf(zoneTracks[zoneTracks.length - 1]) + 1;
|
|
82
|
+
return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveZoneDropPlacement(input: {
|
|
86
|
+
order: number[];
|
|
87
|
+
audioTracks: ReadonlySet<number>;
|
|
88
|
+
elements: TimelineElement[];
|
|
89
|
+
desiredTrack: number;
|
|
90
|
+
deliberateInsertRow: number | null;
|
|
91
|
+
start: number;
|
|
92
|
+
duration: number;
|
|
93
|
+
dragKey: string;
|
|
94
|
+
isAudio: boolean;
|
|
95
|
+
preferInsertAbove?: boolean;
|
|
96
|
+
}): { track: number; insertRow: number | null } {
|
|
97
|
+
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
|
|
98
|
+
const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
|
|
99
|
+
const audioRow = order.findIndex((t) => audioTracks.has(t));
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
deliberateInsertRow !== null &&
|
|
103
|
+
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
|
|
104
|
+
) {
|
|
105
|
+
return { track: desiredTrack, insertRow: deliberateInsertRow };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
|
|
109
|
+
const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
|
|
110
|
+
const placement = resolvePlacement({
|
|
111
|
+
elements,
|
|
112
|
+
desiredTrack: desired,
|
|
113
|
+
start,
|
|
114
|
+
duration,
|
|
115
|
+
trackOrder: zoneTracks,
|
|
116
|
+
excludeKey: dragKey,
|
|
117
|
+
});
|
|
118
|
+
if (placement.needsInsert) {
|
|
119
|
+
const desiredRow = order.indexOf(desired);
|
|
120
|
+
if (desiredRow < 0) {
|
|
121
|
+
return {
|
|
122
|
+
track: desired,
|
|
123
|
+
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Prefer the gap NEAREST the pointer: insert above the aimed row when the
|
|
127
|
+
// pointer sits in its upper half AND that boundary is in the clip's own zone
|
|
128
|
+
// (else the visual/audio split would be crossed) — otherwise fall to below.
|
|
129
|
+
// `desired` is clamped into the zone, so both boundaries stay in-zone.
|
|
130
|
+
const insertRow =
|
|
131
|
+
preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
|
|
132
|
+
? desiredRow
|
|
133
|
+
: desiredRow + 1;
|
|
134
|
+
return { track: desired, insertRow };
|
|
135
|
+
}
|
|
136
|
+
return { track: placement.track, insertRow: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fallback half-width (fraction of a track height) of the insert band straddling
|
|
141
|
+
* a lane boundary — used only when the caller passes no explicit band. Production
|
|
142
|
+
* threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
|
|
143
|
+
* inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
|
|
144
|
+
* NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
|
|
145
|
+
* back toward the old 0.32 (which armed an insert across ~64% of every row — the
|
|
146
|
+
* misfire that turned a plain horizontal drag into a phantom track insert).
|
|
147
|
+
*/
|
|
148
|
+
const INSERT_BAND = 3 / 48;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decide whether a vertical drag is inserting a new track at a lane boundary.
|
|
152
|
+
* `rowFloat` is the pointer's position in track-height units from the top of the
|
|
153
|
+
* first lane (0 = top of lane 0). Returns the boundary row to insert at
|
|
154
|
+
* (0 = above the top lane, `trackCount` = below the bottom), or null when the
|
|
155
|
+
* pointer is over a lane's middle band (a normal move/target).
|
|
156
|
+
*/
|
|
157
|
+
export function resolveInsertRow(
|
|
158
|
+
rowFloat: number,
|
|
159
|
+
trackCount: number,
|
|
160
|
+
band: number = INSERT_BAND,
|
|
161
|
+
): number | null {
|
|
162
|
+
if (trackCount === 0) return 0;
|
|
163
|
+
if (rowFloat <= 0) return 0;
|
|
164
|
+
if (rowFloat >= trackCount) return trackCount;
|
|
165
|
+
const lane = Math.floor(rowFloat);
|
|
166
|
+
const frac = rowFloat - lane;
|
|
167
|
+
if (frac < band) return lane;
|
|
168
|
+
if (frac > 1 - band) return lane + 1;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Half-open overlap test: [aStart, aEnd) intersects [bStart, bEnd). */
|
|
173
|
+
export function timeRangesOverlap(
|
|
174
|
+
aStart: number,
|
|
175
|
+
aEnd: number,
|
|
176
|
+
bStart: number,
|
|
177
|
+
bEnd: number,
|
|
178
|
+
): boolean {
|
|
179
|
+
return aStart < bEnd && bStart < aEnd;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True when no clip on `track` overlaps [start, end) — excluding the clip
|
|
184
|
+
* identified by `excludeKey` (the one being dragged).
|
|
185
|
+
*/
|
|
186
|
+
export function isLaneFree(
|
|
187
|
+
elements: TimelineElement[],
|
|
188
|
+
track: number,
|
|
189
|
+
start: number,
|
|
190
|
+
end: number,
|
|
191
|
+
excludeKey: string | null,
|
|
192
|
+
): boolean {
|
|
193
|
+
return !elements.some(
|
|
194
|
+
(el) =>
|
|
195
|
+
(el.key ?? el.id) !== excludeKey &&
|
|
196
|
+
el.track === track &&
|
|
197
|
+
timeRangesOverlap(start, end, el.start, el.start + el.duration),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface PlacementInput {
|
|
202
|
+
elements: TimelineElement[];
|
|
203
|
+
desiredTrack: number;
|
|
204
|
+
start: number;
|
|
205
|
+
duration: number;
|
|
206
|
+
trackOrder: number[];
|
|
207
|
+
excludeKey: string | null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface PlacementResult {
|
|
211
|
+
/** The lane the clip should land on. */
|
|
212
|
+
track: number;
|
|
213
|
+
/**
|
|
214
|
+
* True when no existing lane was free and the caller should insert a new
|
|
215
|
+
* track instead of landing on `track` (which is then the desired lane as a
|
|
216
|
+
* last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
|
|
217
|
+
*/
|
|
218
|
+
needsInsert: boolean;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve where a dragged clip should land, avoiding overlap. If the desired
|
|
223
|
+
* lane is free, keep it. Otherwise search the nearest free lane, **preferring
|
|
224
|
+
* up** (all lanes above, nearest first), then down. If none is free, signal an
|
|
225
|
+
* insert and fall back to the desired lane.
|
|
226
|
+
*/
|
|
227
|
+
export function resolvePlacement({
|
|
228
|
+
elements,
|
|
229
|
+
desiredTrack,
|
|
230
|
+
start,
|
|
231
|
+
duration,
|
|
232
|
+
trackOrder,
|
|
233
|
+
excludeKey,
|
|
234
|
+
}: PlacementInput): PlacementResult {
|
|
235
|
+
const end = start + duration;
|
|
236
|
+
const idx = trackOrder.indexOf(desiredTrack);
|
|
237
|
+
// desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
|
|
238
|
+
// yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
|
|
239
|
+
// BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
|
|
240
|
+
// lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
|
|
241
|
+
// "free" only because that lane belongs to the wrong zone. Landing there would
|
|
242
|
+
// put the clip in the wrong kind-zone, so signal an insert to create the zone's
|
|
243
|
+
// first lane instead — regardless of whether the aimed span is occupied (#2195).
|
|
244
|
+
if (idx === -1) return { track: desiredTrack, needsInsert: true };
|
|
245
|
+
|
|
246
|
+
if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
|
|
247
|
+
return { track: desiredTrack, needsInsert: false };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Prefer up: nearest lane above first, then the rest above.
|
|
251
|
+
for (let up = idx - 1; up >= 0; up--) {
|
|
252
|
+
if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
|
|
253
|
+
return { track: trackOrder[up], needsInsert: false };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Then down: nearest lane below first.
|
|
257
|
+
for (let down = idx + 1; down < trackOrder.length; down++) {
|
|
258
|
+
if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
|
|
259
|
+
return { track: trackOrder[down], needsInsert: false };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { track: desiredTrack, needsInsert: true };
|
|
263
|
+
}
|
|
@@ -1,23 +1,50 @@
|
|
|
1
1
|
import { useCallback, useState, type RefObject } from "react";
|
|
2
2
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
3
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
3
4
|
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
|
4
5
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
5
|
-
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
|
6
6
|
|
|
7
7
|
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
8
8
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
9
9
|
ppsRef: RefObject<number>;
|
|
10
10
|
durationRef: RefObject<number>;
|
|
11
|
-
trackOrderRef: RefObject<
|
|
12
|
-
timelineLayersRef: RefObject<StackingTimelineLayer[]>;
|
|
11
|
+
trackOrderRef: RefObject<number[]>;
|
|
13
12
|
}
|
|
14
13
|
|
|
14
|
+
type TimelinePlacement = { start: number; track: number };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a JSON drag payload and, if it yields a value, forward it to the drop
|
|
18
|
+
* callback. Malformed payloads are ignored. Shared by the asset + block paths so
|
|
19
|
+
* the parse/guard/dispatch shape lives in one place.
|
|
20
|
+
*/
|
|
21
|
+
function applyJsonDropPayload(
|
|
22
|
+
raw: string,
|
|
23
|
+
pick: (parsed: Record<string, string | undefined>) => string | undefined,
|
|
24
|
+
apply: (value: string, placement: TimelinePlacement) => void,
|
|
25
|
+
placement: TimelinePlacement,
|
|
26
|
+
): void {
|
|
27
|
+
try {
|
|
28
|
+
const value = pick(JSON.parse(raw) as Record<string, string | undefined>);
|
|
29
|
+
if (value) apply(value, placement);
|
|
30
|
+
} catch {
|
|
31
|
+
/* ignore malformed drag payloads */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
|
|
37
|
+
* start is the current playhead time, only the track comes from the drop y.
|
|
38
|
+
* Deliberate product choice (user preference, 2026-07-09): every add lands at
|
|
39
|
+
* the playhead regardless of drop x, like CapCut's add-to-timeline. External
|
|
40
|
+
* OS file drops and internal asset drops share this same placement path, so
|
|
41
|
+
* both land identically.
|
|
42
|
+
*/
|
|
15
43
|
export function useTimelineAssetDrop({
|
|
16
44
|
scrollRef,
|
|
17
45
|
ppsRef,
|
|
18
46
|
durationRef,
|
|
19
47
|
trackOrderRef,
|
|
20
|
-
timelineLayersRef,
|
|
21
48
|
onFileDrop,
|
|
22
49
|
onAssetDrop,
|
|
23
50
|
onBlockDrop,
|
|
@@ -25,83 +52,65 @@ export function useTimelineAssetDrop({
|
|
|
25
52
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
26
53
|
|
|
27
54
|
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
|
28
|
-
const hasFiles = e.dataTransfer.files.length > 0;
|
|
29
55
|
const types = Array.from(e.dataTransfer.types);
|
|
56
|
+
const hasFiles = types.includes("Files");
|
|
30
57
|
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
|
31
58
|
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
|
32
59
|
if (!hasFiles && !hasAsset && !hasBlock) return;
|
|
33
60
|
e.preventDefault();
|
|
34
|
-
|
|
61
|
+
e.dataTransfer.dropEffect = "copy";
|
|
35
62
|
setIsDragOver(true);
|
|
36
63
|
}, []);
|
|
37
64
|
|
|
65
|
+
const clearDropPreview = useCallback(() => setIsDragOver(false), []);
|
|
66
|
+
|
|
67
|
+
const resolveDropPlacement = useCallback(
|
|
68
|
+
(clientX: number, clientY: number): TimelinePlacement => {
|
|
69
|
+
const scroll = scrollRef.current;
|
|
70
|
+
const rect = scroll?.getBoundingClientRect();
|
|
71
|
+
// Track comes from the vertical drop position; start is the playhead.
|
|
72
|
+
const { track } = resolveTimelineAssetDrop(
|
|
73
|
+
{
|
|
74
|
+
rectLeft: rect?.left ?? 0,
|
|
75
|
+
rectTop: rect?.top ?? 0,
|
|
76
|
+
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
77
|
+
scrollTop: scroll?.scrollTop ?? 0,
|
|
78
|
+
pixelsPerSecond: ppsRef.current,
|
|
79
|
+
duration: durationRef.current,
|
|
80
|
+
trackHeight: TRACK_H,
|
|
81
|
+
trackOrder: trackOrderRef.current,
|
|
82
|
+
},
|
|
83
|
+
clientX,
|
|
84
|
+
clientY,
|
|
85
|
+
);
|
|
86
|
+
const start = Math.max(0, usePlayerStore.getState().currentTime);
|
|
87
|
+
return { start, track };
|
|
88
|
+
},
|
|
89
|
+
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
|
90
|
+
);
|
|
91
|
+
|
|
38
92
|
const handleAssetDrop = useCallback(
|
|
39
|
-
// fallow-ignore-next-line complexity
|
|
40
93
|
(e: React.DragEvent) => {
|
|
41
94
|
e.preventDefault();
|
|
42
95
|
setIsDragOver(false);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const layerById = new Map(timelineLayersRef.current.map((layer) => [layer.id, layer]));
|
|
46
|
-
const trackOrder = trackOrderRef.current
|
|
47
|
-
.map((id) => layerById.get(id)?.placementTrack)
|
|
48
|
-
.filter((track): track is number => track != null);
|
|
49
|
-
const dropInput = {
|
|
50
|
-
rectLeft: rect?.left ?? 0,
|
|
51
|
-
rectTop: rect?.top ?? 0,
|
|
52
|
-
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
53
|
-
scrollTop: scroll?.scrollTop ?? 0,
|
|
54
|
-
pixelsPerSecond: ppsRef.current,
|
|
55
|
-
duration: durationRef.current,
|
|
56
|
-
trackHeight: TRACK_H,
|
|
57
|
-
trackOrder,
|
|
58
|
-
};
|
|
96
|
+
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
97
|
+
|
|
59
98
|
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
60
|
-
void onFileDrop(
|
|
61
|
-
Array.from(e.dataTransfer.files),
|
|
62
|
-
scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
|
|
63
|
-
);
|
|
99
|
+
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
|
64
100
|
return;
|
|
65
101
|
}
|
|
66
102
|
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
|
67
|
-
if (assetPayload && onAssetDrop
|
|
68
|
-
|
|
69
|
-
const parsed = JSON.parse(assetPayload) as { path?: string };
|
|
70
|
-
if (parsed.path)
|
|
71
|
-
void onAssetDrop(
|
|
72
|
-
parsed.path,
|
|
73
|
-
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
|
74
|
-
);
|
|
75
|
-
} catch {
|
|
76
|
-
/* ignore malformed drag payloads */
|
|
77
|
-
}
|
|
103
|
+
if (assetPayload && onAssetDrop) {
|
|
104
|
+
applyJsonDropPayload(assetPayload, (p) => p.path, onAssetDrop, placement);
|
|
78
105
|
return;
|
|
79
106
|
}
|
|
80
107
|
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
|
81
|
-
if (blockPayload && onBlockDrop
|
|
82
|
-
|
|
83
|
-
const parsed = JSON.parse(blockPayload) as { name?: string };
|
|
84
|
-
if (parsed.name)
|
|
85
|
-
void onBlockDrop(
|
|
86
|
-
parsed.name,
|
|
87
|
-
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
|
88
|
-
);
|
|
89
|
-
} catch {
|
|
90
|
-
/* ignore malformed drag payloads */
|
|
91
|
-
}
|
|
108
|
+
if (blockPayload && onBlockDrop) {
|
|
109
|
+
applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
|
|
92
110
|
}
|
|
93
111
|
},
|
|
94
|
-
[
|
|
95
|
-
onAssetDrop,
|
|
96
|
-
onBlockDrop,
|
|
97
|
-
onFileDrop,
|
|
98
|
-
scrollRef,
|
|
99
|
-
ppsRef,
|
|
100
|
-
durationRef,
|
|
101
|
-
trackOrderRef,
|
|
102
|
-
timelineLayersRef,
|
|
103
|
-
],
|
|
112
|
+
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
|
|
104
113
|
);
|
|
105
114
|
|
|
106
|
-
return { isDragOver,
|
|
115
|
+
return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
|
|
107
116
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface TimelineEditCapabilities {
|
|
2
|
+
canMove: boolean;
|
|
3
|
+
canTrimStart: boolean;
|
|
4
|
+
canTrimEnd: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isDeterministicTimelineWindow(input: {
|
|
8
|
+
tag: string;
|
|
9
|
+
compositionSrc?: string;
|
|
10
|
+
playbackStartAttr?: "media-start" | "playback-start";
|
|
11
|
+
sourceDuration?: number;
|
|
12
|
+
}): boolean {
|
|
13
|
+
if (input.compositionSrc || input.playbackStartAttr != null) return true;
|
|
14
|
+
if (
|
|
15
|
+
input.sourceDuration != null &&
|
|
16
|
+
Number.isFinite(input.sourceDuration) &&
|
|
17
|
+
input.sourceDuration > 0
|
|
18
|
+
) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return ["video", "audio", "img"].includes(input.tag.toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
|
|
25
|
+
return Boolean(input.domId || input.selector);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getTimelineEditCapabilities(input: {
|
|
29
|
+
tag: string;
|
|
30
|
+
duration: number;
|
|
31
|
+
domId?: string;
|
|
32
|
+
selector?: string;
|
|
33
|
+
compositionSrc?: string;
|
|
34
|
+
playbackStart?: number;
|
|
35
|
+
playbackStartAttr?: "media-start" | "playback-start";
|
|
36
|
+
sourceDuration?: number;
|
|
37
|
+
timingSource?: "authored" | "implicit";
|
|
38
|
+
timelineLocked?: boolean;
|
|
39
|
+
}): TimelineEditCapabilities {
|
|
40
|
+
if (input.timingSource === "implicit" || input.timelineLocked) {
|
|
41
|
+
return { canMove: false, canTrimStart: false, canTrimEnd: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const canPatch = hasPatchableTimelineTarget(input);
|
|
45
|
+
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
|
|
46
|
+
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
47
|
+
return {
|
|
48
|
+
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
49
|
+
canTrimEnd: canPatch && hasFiniteDuration,
|
|
50
|
+
canTrimStart: canPatch && hasFiniteDuration,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -693,7 +693,7 @@ describe("buildTimelineAgentPrompt", () => {
|
|
|
693
693
|
prompt: "Move the title later and lower the music",
|
|
694
694
|
});
|
|
695
695
|
|
|
696
|
-
expect(text).toContain("Time range:
|
|
696
|
+
expect(text).toContain("Time range: 00:01 - 00:04");
|
|
697
697
|
expect(text).toContain("#title (div)");
|
|
698
698
|
expect(text).toContain("#music (audio)");
|
|
699
699
|
expect(text).toContain("Move the title later and lower the music");
|
|
@@ -4,6 +4,13 @@ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrde
|
|
|
4
4
|
import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
|
|
5
5
|
import { shouldShowTimelineLayerGroupHeader } from "./TimelineLayerGroupHeader";
|
|
6
6
|
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
|
|
7
|
+
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
getTimelineEditCapabilities,
|
|
11
|
+
hasPatchableTimelineTarget,
|
|
12
|
+
} from "./timelineEditCapabilities";
|
|
13
|
+
export type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
7
14
|
|
|
8
15
|
import {
|
|
9
16
|
applyClipStartTrimDelta,
|
|
@@ -245,12 +252,6 @@ export interface TimelinePromptElement {
|
|
|
245
252
|
track: number;
|
|
246
253
|
}
|
|
247
254
|
|
|
248
|
-
export interface TimelineEditCapabilities {
|
|
249
|
-
canMove: boolean;
|
|
250
|
-
canTrimStart: boolean;
|
|
251
|
-
canTrimEnd: boolean;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
255
|
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
|
|
255
256
|
|
|
256
257
|
export interface TimelineRangeSelection {
|
|
@@ -362,59 +363,6 @@ export function selectTimelineElementsInMarquee({
|
|
|
362
363
|
return selected;
|
|
363
364
|
}
|
|
364
365
|
|
|
365
|
-
function isDeterministicTimelineWindow(input: {
|
|
366
|
-
tag: string;
|
|
367
|
-
compositionSrc?: string;
|
|
368
|
-
playbackStartAttr?: "media-start" | "playback-start";
|
|
369
|
-
sourceDuration?: number;
|
|
370
|
-
}): boolean {
|
|
371
|
-
if (input.compositionSrc) return true;
|
|
372
|
-
if (input.playbackStartAttr != null) return true;
|
|
373
|
-
if (
|
|
374
|
-
input.sourceDuration != null &&
|
|
375
|
-
Number.isFinite(input.sourceDuration) &&
|
|
376
|
-
input.sourceDuration > 0
|
|
377
|
-
) {
|
|
378
|
-
return true;
|
|
379
|
-
}
|
|
380
|
-
const normalizedTag = input.tag.toLowerCase();
|
|
381
|
-
return ["video", "audio", "img"].includes(normalizedTag);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
|
|
385
|
-
return Boolean(input.domId || input.selector);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export function getTimelineEditCapabilities(input: {
|
|
389
|
-
tag: string;
|
|
390
|
-
duration: number;
|
|
391
|
-
domId?: string;
|
|
392
|
-
selector?: string;
|
|
393
|
-
compositionSrc?: string;
|
|
394
|
-
playbackStart?: number;
|
|
395
|
-
playbackStartAttr?: "media-start" | "playback-start";
|
|
396
|
-
sourceDuration?: number;
|
|
397
|
-
timingSource?: "authored" | "implicit";
|
|
398
|
-
timelineLocked?: boolean;
|
|
399
|
-
}): TimelineEditCapabilities {
|
|
400
|
-
if (input.timingSource === "implicit" || input.timelineLocked) {
|
|
401
|
-
return {
|
|
402
|
-
canMove: false,
|
|
403
|
-
canTrimStart: false,
|
|
404
|
-
canTrimEnd: false,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const canPatch = hasPatchableTimelineTarget(input);
|
|
409
|
-
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
|
|
410
|
-
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
411
|
-
return {
|
|
412
|
-
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
413
|
-
canTrimEnd: canPatch && hasFiniteDuration,
|
|
414
|
-
canTrimStart: canPatch && hasFiniteDuration,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
|
|
418
366
|
export function resolveBlockedTimelineEditIntent(input: {
|
|
419
367
|
width: number;
|
|
420
368
|
offsetX: number;
|
|
@@ -525,3 +473,72 @@ export function buildTimelineElementAgentPrompt(element: {
|
|
|
525
473
|
export function formatTimelineAttributeNumber(value: number): string {
|
|
526
474
|
return Number(roundToCentiseconds(value).toFixed(2)).toString();
|
|
527
475
|
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
|
|
479
|
+
* pointer is in, clamped to the scrollable range. Returns true when the
|
|
480
|
+
* container actually moved (the caller keeps its RAF running and re-runs its
|
|
481
|
+
* live preview), false when the pointer is outside the edge zones or the scroll
|
|
482
|
+
* is already clamped (the caller stops).
|
|
483
|
+
*/
|
|
484
|
+
export function applyTimelineAutoScrollStep(
|
|
485
|
+
scroll: HTMLElement,
|
|
486
|
+
clientX: number,
|
|
487
|
+
clientY: number,
|
|
488
|
+
): boolean {
|
|
489
|
+
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
|
|
490
|
+
if (delta.x === 0 && delta.y === 0) return false;
|
|
491
|
+
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
492
|
+
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
|
493
|
+
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
|
|
494
|
+
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
|
|
495
|
+
if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return false;
|
|
496
|
+
scroll.scrollLeft = nextScrollLeft;
|
|
497
|
+
scroll.scrollTop = nextScrollTop;
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is
|
|
503
|
+
* for the current pointer: "start" when the pointer is in an edge zone and no
|
|
504
|
+
* loop is running, "stop" when it left the zones while a loop is running,
|
|
505
|
+
* "none" otherwise.
|
|
506
|
+
*/
|
|
507
|
+
export function resolveTimelineAutoScrollLoopAction(
|
|
508
|
+
scroll: HTMLElement | null,
|
|
509
|
+
clientX: number,
|
|
510
|
+
clientY: number,
|
|
511
|
+
rafActive: boolean,
|
|
512
|
+
): "start" | "stop" | "none" {
|
|
513
|
+
if (!scroll) return "none";
|
|
514
|
+
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
|
|
515
|
+
if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
|
|
516
|
+
return rafActive ? "none" : "start";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Escape cancels an in-progress clip drag / resize / blocked-drag: no commit,
|
|
521
|
+
* no undo entry — the previews live only in the gesture state, so clearing it
|
|
522
|
+
* restores the pre-drag timeline. `suppressClick` arms the click suppressor
|
|
523
|
+
* only when the gesture actually started, so the click generated by the
|
|
524
|
+
* eventual pointerup can't reselect or split the clip.
|
|
525
|
+
*/
|
|
526
|
+
export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
|
|
527
|
+
cancel: boolean;
|
|
528
|
+
suppressClick: boolean;
|
|
529
|
+
} {
|
|
530
|
+
if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
|
|
531
|
+
return { cancel: false, suppressClick: false };
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
cancel: true,
|
|
535
|
+
suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export interface TimelineDragEscapeInput {
|
|
540
|
+
key: string;
|
|
541
|
+
drag: { started: boolean } | null;
|
|
542
|
+
resize: { started: boolean } | null;
|
|
543
|
+
blocked: { started: boolean } | null;
|
|
544
|
+
}
|