@hyperframes/studio 0.7.55 → 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-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CPetwHFV.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 +40414 -37680
- 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/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 +106 -10
- 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 +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/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 +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 +120 -61
- 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/timelineTheme.ts +6 -1
- 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/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/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.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- 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
|
@@ -1,52 +1,337 @@
|
|
|
1
|
+
import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
|
|
1
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
3
|
+
import { TRACK_H, getTimelineRowFromY, INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
|
4
|
+
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
5
|
+
import {
|
|
6
|
+
TIMELINE_SNAP_PX,
|
|
7
|
+
snapMoveToTargets,
|
|
8
|
+
snapTimelineTime,
|
|
9
|
+
type TimelineSnapTarget,
|
|
10
|
+
} from "./timelineSnapping";
|
|
11
|
+
import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision";
|
|
12
|
+
import {
|
|
13
|
+
applyTimelineGroupResizePreview,
|
|
14
|
+
type TimelineGroupResizeSession,
|
|
15
|
+
} from "./timelineGroupEditing";
|
|
16
|
+
import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
|
|
17
|
+
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
|
4
18
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
/** Snap-target builder closure supplied by the hook (closes over refs + store). */
|
|
20
|
+
type BuildSnapTargets = (
|
|
21
|
+
excludeElementKey: string | null,
|
|
22
|
+
includeBeats: boolean,
|
|
23
|
+
) => TimelineSnapTarget[];
|
|
24
|
+
|
|
25
|
+
export interface DragPreviewContext {
|
|
26
|
+
scroll: HTMLDivElement | null;
|
|
27
|
+
pps: number;
|
|
28
|
+
duration: number;
|
|
29
|
+
trackOrder: number[];
|
|
30
|
+
elements: TimelineElement[];
|
|
31
|
+
selectedKeys: ReadonlySet<string>;
|
|
32
|
+
buildSnapTargets: BuildSnapTargets;
|
|
33
|
+
/**
|
|
34
|
+
* The set of tracks that hold audio clips (drives zone-aware drop placement).
|
|
35
|
+
* Frozen for the whole gesture, so the hook builds it ONCE at drag start and
|
|
36
|
+
* passes it in — see useTimelineClipDrag. Absent (e.g. in unit tests) ⇒ built
|
|
37
|
+
* on demand from `elements`, so the result is identical either way.
|
|
38
|
+
*/
|
|
39
|
+
audioTracks?: ReadonlySet<number>;
|
|
9
40
|
}
|
|
10
41
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Max start a drag may reach. Allow dragging past the current content into the
|
|
44
|
+
* rendered timeline extent (the viewport-fill keeps that ≥ the viewport width).
|
|
45
|
+
* The composition grows to fit on commit (content-driven duration), so don't
|
|
46
|
+
* cap at content length.
|
|
47
|
+
*/
|
|
48
|
+
function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duration: number): number {
|
|
49
|
+
return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
|
|
17
50
|
}
|
|
18
51
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Rigid group move: when the grabbed clip is part of a multi-selection, the
|
|
54
|
+
* WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
|
|
55
|
+
* Clamp that delta here — against every selected member's start — so the
|
|
56
|
+
* grabbed clip can't out-run the group: it STOPS the instant any member would
|
|
57
|
+
* cross 0, exactly as it lands on commit. Lane changes still apply to the
|
|
58
|
+
* grabbed clip only, so only the start (x) is constrained.
|
|
59
|
+
*/
|
|
60
|
+
function resolveGroupClampedStart(
|
|
61
|
+
snapStart: number,
|
|
62
|
+
element: TimelineElement,
|
|
63
|
+
dragKey: string,
|
|
64
|
+
elements: TimelineElement[],
|
|
65
|
+
selectedKeys: ReadonlySet<string>,
|
|
66
|
+
): number {
|
|
67
|
+
if (selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return snapStart;
|
|
68
|
+
const memberStarts = elements.filter((e) => selectedKeys.has(e.key ?? e.id)).map((e) => e.start);
|
|
69
|
+
const clampedDelta = clampGroupMoveDelta(snapStart - element.start, memberStarts);
|
|
70
|
+
return element.start + clampedDelta;
|
|
22
71
|
}
|
|
23
72
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The whole drop decision (no same-track overlap, zone-respecting, relocate or
|
|
75
|
+
* create) — one tested pure function, so what runs here is what's verified.
|
|
76
|
+
*/
|
|
77
|
+
function resolveDropPlacement(
|
|
78
|
+
drag: DraggedClipState,
|
|
79
|
+
clientY: number,
|
|
80
|
+
previewStart: number,
|
|
81
|
+
desiredTrack: number,
|
|
82
|
+
ctx: DragPreviewContext,
|
|
83
|
+
): { track: number; insertRow: number | null } {
|
|
84
|
+
const { scroll, trackOrder, elements } = ctx;
|
|
85
|
+
// rowFloat = the pointer's position in track-heights from the top lane; a
|
|
86
|
+
// near-boundary hover requests a deliberate new-track insert. Uses the
|
|
87
|
+
// shared row→y inverse so the top breathing pad is subtracted consistently.
|
|
88
|
+
const rowFloat = scroll
|
|
89
|
+
? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop)
|
|
90
|
+
: 0;
|
|
91
|
+
// Geometry-exact band (the clip inset) so an insert only arms in the visible
|
|
92
|
+
// gutter BETWEEN clip bodies — dragging over a clip body is a lane move, never a
|
|
93
|
+
// phantom insert (the plain-horizontal-drag misfire). See INSERT_BOUNDARY_BAND.
|
|
94
|
+
const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND);
|
|
95
|
+
// Pointer sub-row half: when a drop must auto-create a track (aimed span
|
|
96
|
+
// occupied, no free lane), open it on the side the pointer is nearer.
|
|
97
|
+
const preferInsertAbove = rowFloat - Math.floor(rowFloat) < 0.5;
|
|
98
|
+
const audioTracks =
|
|
99
|
+
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
|
|
100
|
+
return resolveZoneDropPlacement({
|
|
101
|
+
order: trackOrder,
|
|
102
|
+
audioTracks,
|
|
103
|
+
elements,
|
|
104
|
+
desiredTrack,
|
|
105
|
+
deliberateInsertRow: rawInsertRow,
|
|
106
|
+
start: previewStart,
|
|
107
|
+
duration: drag.element.duration,
|
|
108
|
+
dragKey: drag.element.key ?? drag.element.id,
|
|
109
|
+
isAudio: isAudioTimelineElement(drag.element),
|
|
110
|
+
preferInsertAbove,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
|
|
115
|
+
export function computeDragPreview(
|
|
116
|
+
drag: DraggedClipState,
|
|
117
|
+
clientX: number,
|
|
118
|
+
clientY: number,
|
|
119
|
+
ctx: DragPreviewContext,
|
|
120
|
+
): DraggedClipState {
|
|
121
|
+
const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
|
|
122
|
+
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
|
|
123
|
+
const nextMove = resolveTimelineMove(
|
|
124
|
+
{
|
|
125
|
+
start: drag.element.start,
|
|
126
|
+
track: drag.element.track,
|
|
127
|
+
duration: drag.element.duration,
|
|
128
|
+
originClientX: drag.originClientX,
|
|
129
|
+
originClientY: drag.originClientY,
|
|
130
|
+
originScrollLeft: drag.originScrollLeft,
|
|
131
|
+
originScrollTop: drag.originScrollTop,
|
|
132
|
+
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
|
133
|
+
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
|
|
134
|
+
pixelsPerSecond: pps,
|
|
135
|
+
trackHeight: TRACK_H,
|
|
136
|
+
maxStart: dragMaxStart,
|
|
137
|
+
trackOrder,
|
|
138
|
+
},
|
|
139
|
+
clientX,
|
|
140
|
+
clientY,
|
|
141
|
+
);
|
|
142
|
+
// The music track defines the beats, so it must not snap to them —
|
|
143
|
+
// but it still snaps to the playhead and other clip edges.
|
|
144
|
+
const targets = buildSnapTargets(
|
|
145
|
+
drag.element.key ?? drag.element.id,
|
|
146
|
+
!isMusicTrack(drag.element),
|
|
147
|
+
);
|
|
148
|
+
const snap = snapMoveToTargets(
|
|
149
|
+
nextMove.start,
|
|
150
|
+
drag.element.duration,
|
|
151
|
+
targets,
|
|
152
|
+
pps,
|
|
153
|
+
// Relaxed clamp: allow the snapped start past the content, up to the
|
|
154
|
+
// rendered extent (see dragMaxStart) — the composition grows on commit.
|
|
155
|
+
dragMaxStart + drag.element.duration,
|
|
156
|
+
);
|
|
157
|
+
const dragKey = drag.element.key ?? drag.element.id;
|
|
158
|
+
const previewStart = resolveGroupClampedStart(
|
|
159
|
+
snap.start,
|
|
160
|
+
drag.element,
|
|
161
|
+
dragKey,
|
|
162
|
+
elements,
|
|
163
|
+
selectedKeys,
|
|
164
|
+
);
|
|
165
|
+
const { track: previewTrack, insertRow } = resolveDropPlacement(
|
|
166
|
+
drag,
|
|
167
|
+
clientY,
|
|
168
|
+
previewStart,
|
|
169
|
+
nextMove.track,
|
|
170
|
+
ctx,
|
|
171
|
+
);
|
|
172
|
+
return {
|
|
173
|
+
...drag,
|
|
174
|
+
started: true,
|
|
175
|
+
pointerClientX: clientX,
|
|
176
|
+
pointerClientY: clientY,
|
|
177
|
+
previewStart,
|
|
178
|
+
previewTrack,
|
|
179
|
+
// The lane the POINTER aims at (pre-collision): the commit reads it to tell a
|
|
180
|
+
// deliberate vertical lane change from a horizontal drag merely bumped sideways.
|
|
181
|
+
desiredTrack: nextMove.track,
|
|
182
|
+
insertRow,
|
|
183
|
+
snapTime: snap.snapTime,
|
|
184
|
+
snapType: snap.snapType,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ResizePreviewContext {
|
|
189
|
+
scroll: HTMLDivElement | null;
|
|
190
|
+
pps: number;
|
|
191
|
+
buildSnapTargets: BuildSnapTargets;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface ResizePreviewResult {
|
|
195
|
+
originScrollLeft: number;
|
|
29
196
|
previewStart: number;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
197
|
+
previewDuration: number;
|
|
198
|
+
previewPlaybackStart?: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Compute the trim preview for a pointer x (pure — the hook applies the state). */
|
|
202
|
+
// fallow-ignore-next-line complexity
|
|
203
|
+
export function computeResizePreview(
|
|
204
|
+
resize: ResizingClipState,
|
|
205
|
+
clientX: number,
|
|
206
|
+
ctx: ResizePreviewContext,
|
|
207
|
+
): ResizePreviewResult {
|
|
208
|
+
const { scroll, pps, buildSnapTargets } = ctx;
|
|
209
|
+
// Scroll compensation: auto-scroll moves the content while the pointer stays
|
|
210
|
+
// put, so fold the scroll delta into the pointer x (mirrors
|
|
211
|
+
// resolveTimelineMove's originScrollLeft handling).
|
|
212
|
+
const originScrollLeft = resize.originScrollLeft ?? scroll?.scrollLeft ?? 0;
|
|
213
|
+
const effectiveClientX = clientX + ((scroll?.scrollLeft ?? originScrollLeft) - originScrollLeft);
|
|
214
|
+
|
|
215
|
+
const sourceRemaining =
|
|
216
|
+
resize.element.sourceDuration != null
|
|
217
|
+
? Math.max(
|
|
218
|
+
0,
|
|
219
|
+
(resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
|
|
220
|
+
Math.max(resize.element.playbackRate ?? 1, 0.1),
|
|
221
|
+
)
|
|
222
|
+
: Number.POSITIVE_INFINITY;
|
|
223
|
+
const normalizedTag = resize.element.tag.toLowerCase();
|
|
224
|
+
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
|
225
|
+
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
|
226
|
+
// Trim limit = available source media only — NOT the composition length.
|
|
227
|
+
// Duration is content-driven (the comp grows/shrinks to fit on commit), so
|
|
228
|
+
// capping a trim at the current comp end both blocked extending the last clip
|
|
229
|
+
// rightward and, after a far move, collapsed a clip to the sliver between its
|
|
230
|
+
// start and the comp end (the 8s→0.95s audio incident). Images/text/shapes
|
|
231
|
+
// have no source, so they extend freely.
|
|
232
|
+
const maxEnd = resize.element.start + sourceRemaining;
|
|
233
|
+
let nextResize = resolveTimelineResize(
|
|
234
|
+
{
|
|
235
|
+
start: resize.element.start,
|
|
236
|
+
duration: resize.element.duration,
|
|
237
|
+
originClientX: resize.originClientX,
|
|
238
|
+
pixelsPerSecond: pps,
|
|
239
|
+
minStart: 0,
|
|
240
|
+
maxEnd,
|
|
241
|
+
playbackStart:
|
|
242
|
+
resize.edge === "start" && canSeedPlaybackStart
|
|
243
|
+
? (resize.element.playbackStart ?? 0)
|
|
244
|
+
: resize.element.playbackStart,
|
|
245
|
+
playbackRate: resize.element.playbackRate,
|
|
246
|
+
},
|
|
247
|
+
resize.edge,
|
|
248
|
+
effectiveClientX,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Snap edge to unified targets (beats + clip edges + playhead) when available.
|
|
252
|
+
// The snap must stay inside the same limits resolveTimelineResize enforces, or
|
|
253
|
+
// it would push the edge past the available source media / composition end.
|
|
254
|
+
// The music track defines the beats, so it must not snap to them — but it
|
|
255
|
+
// still snaps to the playhead and other clip edges.
|
|
256
|
+
const trimTargets = buildSnapTargets(
|
|
257
|
+
resize.element.key ?? resize.element.id,
|
|
258
|
+
!isMusicTrack(resize.element),
|
|
259
|
+
);
|
|
260
|
+
if (trimTargets.length > 0) {
|
|
261
|
+
const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
|
|
262
|
+
if (resize.edge === "end") {
|
|
263
|
+
const edgeTime = nextResize.start + nextResize.duration;
|
|
264
|
+
const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs).time;
|
|
265
|
+
// Stay within [start+minDuration, maxEnd] so the snap can't create a
|
|
266
|
+
// degenerate clip or run past the source/composition limit.
|
|
267
|
+
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
|
|
268
|
+
if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
|
|
269
|
+
nextResize = { ...nextResize, duration: snappedDuration };
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs).time;
|
|
273
|
+
const delta = nextResize.start - snapped; // >0 when snapping left
|
|
274
|
+
// Leftward snap reveals more source; cap so playbackStart can't go < 0.
|
|
275
|
+
const maxLeftDelta =
|
|
276
|
+
nextResize.playbackStart != null
|
|
277
|
+
? nextResize.playbackStart / playbackRate
|
|
278
|
+
: Number.POSITIVE_INFINITY;
|
|
279
|
+
// Also require the resulting duration to stay >= minDuration so a rightward
|
|
280
|
+
// snap (delta < 0) can't collapse the clip to zero/negative.
|
|
281
|
+
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
|
|
282
|
+
if (
|
|
283
|
+
snapped !== nextResize.start &&
|
|
284
|
+
snapped >= 0 &&
|
|
285
|
+
delta <= maxLeftDelta + 1e-6 &&
|
|
286
|
+
snappedDuration >= 0.05
|
|
287
|
+
) {
|
|
288
|
+
nextResize = {
|
|
289
|
+
...nextResize,
|
|
290
|
+
start: snapped,
|
|
291
|
+
duration: snappedDuration,
|
|
292
|
+
playbackStart:
|
|
293
|
+
nextResize.playbackStart != null
|
|
294
|
+
? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
|
|
295
|
+
1000
|
|
296
|
+
: undefined,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
43
300
|
}
|
|
44
301
|
|
|
45
302
|
return {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
previewStackingReorder: nextMove.stackingReorder ?? null,
|
|
303
|
+
originScrollLeft,
|
|
304
|
+
previewStart: nextResize.start,
|
|
305
|
+
previewDuration: nextResize.duration,
|
|
306
|
+
previewPlaybackStart: nextResize.playbackStart,
|
|
51
307
|
};
|
|
52
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the
|
|
312
|
+
* session, preview every non-grabbed member through the store (`updateElement`),
|
|
313
|
+
* and set the grabbed clip's preview state (it renders from resizingClip state, so
|
|
314
|
+
* its store value stays pristine until commit — like the single-clip path).
|
|
315
|
+
*/
|
|
316
|
+
export function previewGroupResize(
|
|
317
|
+
session: TimelineGroupResizeSession,
|
|
318
|
+
next: ResizePreviewResult,
|
|
319
|
+
grabbedKey: string,
|
|
320
|
+
updateElement: (
|
|
321
|
+
key: string,
|
|
322
|
+
patch: { start: number; duration: number; playbackStart?: number },
|
|
323
|
+
) => void,
|
|
324
|
+
setResizeState: (v: ResizePreviewResult) => void,
|
|
325
|
+
): void {
|
|
326
|
+
const grabbedChange = applyTimelineGroupResizePreview(session, next);
|
|
327
|
+
for (const c of session.changes) {
|
|
328
|
+
if (c.key === grabbedKey) continue;
|
|
329
|
+
updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart });
|
|
330
|
+
}
|
|
331
|
+
setResizeState({
|
|
332
|
+
originScrollLeft: next.originScrollLeft,
|
|
333
|
+
previewStart: grabbedChange?.start ?? next.previewStart,
|
|
334
|
+
previewDuration: grabbedChange?.duration ?? next.previewDuration,
|
|
335
|
+
previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import type { TimelineSnapType } from "./timelineSnapping";
|
|
3
|
+
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
4
|
+
|
|
5
|
+
/* ── Shared clip-drag state types ───────────────────────────────── */
|
|
6
|
+
export interface DraggedClipState {
|
|
7
|
+
element: TimelineElement;
|
|
8
|
+
originClientX: number;
|
|
9
|
+
originClientY: number;
|
|
10
|
+
originScrollLeft: number;
|
|
11
|
+
originScrollTop: number;
|
|
12
|
+
pointerClientX: number;
|
|
13
|
+
pointerClientY: number;
|
|
14
|
+
pointerOffsetX: number;
|
|
15
|
+
pointerOffsetY: number;
|
|
16
|
+
previewStart: number;
|
|
17
|
+
previewTrack: number;
|
|
18
|
+
/**
|
|
19
|
+
* The lane the POINTER aims at (from the drag's vertical position), before
|
|
20
|
+
* collision resolution. `previewTrack` is where the clip actually LANDS —
|
|
21
|
+
* which may differ from `desiredTrack` when the aimed span is occupied and the
|
|
22
|
+
* collision rules bump the dragged clip to a free lane. The commit reads this
|
|
23
|
+
* to tell a deliberate VERTICAL lane change (pointer aimed at another lane →
|
|
24
|
+
* stacking sync allowed) from a plain HORIZONTAL drag whose clip merely got
|
|
25
|
+
* bumped sideways (never touches z). Optional: when absent the commit falls
|
|
26
|
+
* back to `previewTrack` (pre-existing behaviour).
|
|
27
|
+
*/
|
|
28
|
+
desiredTrack?: number;
|
|
29
|
+
/**
|
|
30
|
+
* When non-null, the drop inserts a NEW track at this visual row boundary
|
|
31
|
+
* (0 = above the top lane, trackOrder.length = below the bottom) instead of
|
|
32
|
+
* landing on previewTrack. Drives the insertion-line indicator.
|
|
33
|
+
*/
|
|
34
|
+
insertRow: number | null;
|
|
35
|
+
/** Snap target the clip will land on, for the guide highlight. */
|
|
36
|
+
snapTime: number | null;
|
|
37
|
+
snapType: TimelineSnapType | null;
|
|
38
|
+
started: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ResizingClipState {
|
|
42
|
+
element: TimelineElement;
|
|
43
|
+
edge: "start" | "end";
|
|
44
|
+
originClientX: number;
|
|
45
|
+
/**
|
|
46
|
+
* scrollLeft at gesture start. Edge auto-scroll moves the content under a
|
|
47
|
+
* stationary pointer, so the trim math folds (current − origin) scrollLeft
|
|
48
|
+
* into the pointer x — mirroring resolveTimelineMove's scroll compensation.
|
|
49
|
+
* Optional so pre-existing constructors/tests stay valid; when absent the
|
|
50
|
+
* first preview update captures the current scrollLeft.
|
|
51
|
+
*/
|
|
52
|
+
originScrollLeft?: number;
|
|
53
|
+
previewStart: number;
|
|
54
|
+
previewDuration: number;
|
|
55
|
+
previewPlaybackStart?: number;
|
|
56
|
+
started: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BlockedClipState {
|
|
60
|
+
element: TimelineElement;
|
|
61
|
+
intent: BlockedTimelineEditIntent;
|
|
62
|
+
originClientX: number;
|
|
63
|
+
originClientY: number;
|
|
64
|
+
started: boolean;
|
|
65
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveZoneDropPlacement,
|
|
10
10
|
timeRangesOverlap,
|
|
11
11
|
} from "./timelineCollision";
|
|
12
|
+
import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
|
12
13
|
|
|
13
14
|
function el(id: string, track: number, start: number, duration: number): TimelineElement {
|
|
14
15
|
return { id, tag: "video", start, duration, track };
|
|
@@ -191,8 +192,47 @@ describe("resolveInsertRow", () => {
|
|
|
191
192
|
});
|
|
192
193
|
});
|
|
193
194
|
|
|
194
|
-
|
|
195
|
-
//
|
|
195
|
+
describe("resolveInsertRow — production band only arms in the inter-clip gutter (UX rule 1)", () => {
|
|
196
|
+
// The production band equals the clip inset (CLIP_Y / TRACK_H): a clip body fills
|
|
197
|
+
// [band, 1 − band] of its row, so an insert must ONLY arm in the thin gutter that
|
|
198
|
+
// straddles a boundary — never while the pointer is over a clip body. This is the
|
|
199
|
+
// regression for the plain-horizontal-drag misfire (the old 0.32 band armed an
|
|
200
|
+
// insert across ~64% of every row).
|
|
201
|
+
const n = 3;
|
|
202
|
+
const b = INSERT_BOUNDARY_BAND;
|
|
203
|
+
|
|
204
|
+
it("the band is the clip inset (3/48), not the old feel-tuned 0.32", () => {
|
|
205
|
+
expect(b).toBeCloseTo(3 / 48, 10);
|
|
206
|
+
expect(b).toBeLessThan(0.1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("returns null across the WHOLE clip body of every lane (no insert = move-to-lane)", () => {
|
|
210
|
+
// Sweep each lane's body [band+ε, 1−band−ε] in fine steps → always a move.
|
|
211
|
+
for (let lane = 0; lane < n; lane++) {
|
|
212
|
+
for (let frac = b + 0.01; frac <= 1 - b - 0.01 + 1e-9; frac += 0.02) {
|
|
213
|
+
expect(resolveInsertRow(lane + frac, n, b)).toBeNull();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("arms an insert in the gutter straddling every internal boundary (dead-zone-free)", () => {
|
|
219
|
+
// Just under a boundary → insert BELOW the upper lane; just over → ABOVE the
|
|
220
|
+
// lower lane. Both resolve to the same boundary row, so the gutter has no dead
|
|
221
|
+
// spot that neither moves nor inserts.
|
|
222
|
+
expect(resolveInsertRow(1 - b / 2, n, b)).toBe(1); // bottom gutter of lane 0
|
|
223
|
+
expect(resolveInsertRow(1 + b / 2, n, b)).toBe(1); // top gutter of lane 1
|
|
224
|
+
expect(resolveInsertRow(2 - b / 2, n, b)).toBe(2);
|
|
225
|
+
expect(resolveInsertRow(2 + b / 2, n, b)).toBe(2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("still arms a top / bottom insert above the first / below the last lane", () => {
|
|
229
|
+
expect(resolveInsertRow(b / 2, n, b)).toBe(0); // top gutter of lane 0 → insert above top
|
|
230
|
+
expect(resolveInsertRow(-0.4, n, b)).toBe(0); // in the top breathing pad
|
|
231
|
+
expect(resolveInsertRow(n - b / 2, n, b)).toBe(n); // bottom gutter of last lane
|
|
232
|
+
expect(resolveInsertRow(n + 0.4, n, b)).toBe(n); // in the bottom breathing pad
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
196
236
|
describe("clampTrackToZone", () => {
|
|
197
237
|
// trackOrder [0,1,2,3]: rows 0,1 = visual; rows 2,3 = audio (audioRow = 2).
|
|
198
238
|
const order = [0, 1, 2, 3];
|
|
@@ -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
|
}
|