@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,477 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
clampTrackToZone,
|
|
5
|
+
isInsertAllowedForZone,
|
|
6
|
+
isLaneFree,
|
|
7
|
+
resolveInsertRow,
|
|
8
|
+
resolvePlacement,
|
|
9
|
+
resolveZoneDropPlacement,
|
|
10
|
+
timeRangesOverlap,
|
|
11
|
+
} from "./timelineCollision";
|
|
12
|
+
import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
|
13
|
+
|
|
14
|
+
function el(id: string, track: number, start: number, duration: number): TimelineElement {
|
|
15
|
+
return { id, tag: "video", start, duration, track };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("timeRangesOverlap", () => {
|
|
19
|
+
it("detects overlap and treats touching edges as free (half-open)", () => {
|
|
20
|
+
expect(timeRangesOverlap(0, 2, 1, 3)).toBe(true);
|
|
21
|
+
expect(timeRangesOverlap(0, 2, 2, 4)).toBe(false); // touching at 2
|
|
22
|
+
expect(timeRangesOverlap(2, 4, 0, 2)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("isLaneFree", () => {
|
|
27
|
+
const els = [el("a", 0, 0, 5), el("b", 1, 2, 3)];
|
|
28
|
+
|
|
29
|
+
it("is free when nothing overlaps on the track", () => {
|
|
30
|
+
expect(isLaneFree(els, 2, 0, 5, null)).toBe(true);
|
|
31
|
+
expect(isLaneFree(els, 0, 6, 8, null)).toBe(true); // same track, no time overlap
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("is occupied when a clip overlaps on the same track", () => {
|
|
35
|
+
expect(isLaneFree(els, 0, 1, 3, null)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("ignores the excluded (dragged) clip", () => {
|
|
39
|
+
expect(isLaneFree(els, 0, 1, 3, "a")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("resolvePlacement", () => {
|
|
44
|
+
const trackOrder = [0, 1, 2, 3];
|
|
45
|
+
|
|
46
|
+
it("keeps the desired lane when it is free", () => {
|
|
47
|
+
const els = [el("a", 2, 0, 4)];
|
|
48
|
+
expect(
|
|
49
|
+
resolvePlacement({
|
|
50
|
+
elements: els,
|
|
51
|
+
desiredTrack: 1,
|
|
52
|
+
start: 0,
|
|
53
|
+
duration: 4,
|
|
54
|
+
trackOrder,
|
|
55
|
+
excludeKey: null,
|
|
56
|
+
}),
|
|
57
|
+
).toEqual({
|
|
58
|
+
track: 1,
|
|
59
|
+
needsInsert: false,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("pushes up to the nearest free lane above when the target is occupied", () => {
|
|
64
|
+
// desired = 2 occupied; 1 free above → land on 1
|
|
65
|
+
const els = [el("blocker", 2, 0, 4)];
|
|
66
|
+
expect(
|
|
67
|
+
resolvePlacement({
|
|
68
|
+
elements: els,
|
|
69
|
+
desiredTrack: 2,
|
|
70
|
+
start: 1,
|
|
71
|
+
duration: 2,
|
|
72
|
+
trackOrder,
|
|
73
|
+
excludeKey: null,
|
|
74
|
+
}),
|
|
75
|
+
).toEqual({ track: 1, needsInsert: false });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("prefers up even when a lane below is also free", () => {
|
|
79
|
+
// desired 2 occupied; both 1 (up) and 3 (down) free → up wins
|
|
80
|
+
const els = [el("blocker", 2, 0, 5)];
|
|
81
|
+
expect(
|
|
82
|
+
resolvePlacement({
|
|
83
|
+
elements: els,
|
|
84
|
+
desiredTrack: 2,
|
|
85
|
+
start: 0,
|
|
86
|
+
duration: 3,
|
|
87
|
+
trackOrder,
|
|
88
|
+
excludeKey: null,
|
|
89
|
+
}),
|
|
90
|
+
).toEqual({ track: 1, needsInsert: false });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("falls to a lane below when every lane above is occupied", () => {
|
|
94
|
+
// desired 1 occupied; 0 occupied above; 2 free below → land on 2
|
|
95
|
+
const els = [el("x", 0, 0, 5), el("y", 1, 0, 5)];
|
|
96
|
+
expect(
|
|
97
|
+
resolvePlacement({
|
|
98
|
+
elements: els,
|
|
99
|
+
desiredTrack: 1,
|
|
100
|
+
start: 1,
|
|
101
|
+
duration: 2,
|
|
102
|
+
trackOrder,
|
|
103
|
+
excludeKey: null,
|
|
104
|
+
}),
|
|
105
|
+
).toEqual({ track: 2, needsInsert: false });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("signals needsInsert when no lane is free", () => {
|
|
109
|
+
const els = [el("a", 0, 0, 9), el("b", 1, 0, 9), el("c", 2, 0, 9), el("d", 3, 0, 9)];
|
|
110
|
+
expect(
|
|
111
|
+
resolvePlacement({
|
|
112
|
+
elements: els,
|
|
113
|
+
desiredTrack: 2,
|
|
114
|
+
start: 1,
|
|
115
|
+
duration: 2,
|
|
116
|
+
trackOrder,
|
|
117
|
+
excludeKey: null,
|
|
118
|
+
}),
|
|
119
|
+
).toEqual({ track: 2, needsInsert: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("signals needsInsert when the desired track is occupied but absent from the zone's lanes (#2195)", () => {
|
|
123
|
+
// desiredTrack 5 is occupied yet not in trackOrder (its kind-zone has no lane
|
|
124
|
+
// here). Landing on it would overlap, so the empty-zone branch must insert —
|
|
125
|
+
// not silently land on the occupied track (the placement hole).
|
|
126
|
+
const els = [el("blocker", 5, 0, 5)];
|
|
127
|
+
expect(
|
|
128
|
+
resolvePlacement({
|
|
129
|
+
elements: els,
|
|
130
|
+
desiredTrack: 5,
|
|
131
|
+
start: 1,
|
|
132
|
+
duration: 2,
|
|
133
|
+
trackOrder: [],
|
|
134
|
+
excludeKey: null,
|
|
135
|
+
}),
|
|
136
|
+
).toEqual({ track: 5, needsInsert: true });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("signals needsInsert when the desired track is FREE but absent from the zone's lanes (#2195 free-span hole)", () => {
|
|
140
|
+
// desiredTrack 5 is FREE (no overlap) yet not in trackOrder — its kind-zone has
|
|
141
|
+
// no lane here. The old code short-circuited on isLaneFree and landed on the
|
|
142
|
+
// foreign-zone lane; the zone check must win and signal an insert.
|
|
143
|
+
const els = [el("elsewhere", 9, 0, 5)];
|
|
144
|
+
expect(
|
|
145
|
+
resolvePlacement({
|
|
146
|
+
elements: els,
|
|
147
|
+
desiredTrack: 5,
|
|
148
|
+
start: 1,
|
|
149
|
+
duration: 2,
|
|
150
|
+
trackOrder: [],
|
|
151
|
+
excludeKey: null,
|
|
152
|
+
}),
|
|
153
|
+
).toEqual({ track: 5, needsInsert: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("placeholder-scenario excludes the dragged clip so it does not collide with itself", () => {
|
|
157
|
+
const els = [el("self", 1, 0, 5)];
|
|
158
|
+
expect(
|
|
159
|
+
resolvePlacement({
|
|
160
|
+
elements: els,
|
|
161
|
+
desiredTrack: 1,
|
|
162
|
+
start: 0,
|
|
163
|
+
duration: 5,
|
|
164
|
+
trackOrder,
|
|
165
|
+
excludeKey: "self",
|
|
166
|
+
}),
|
|
167
|
+
).toEqual({ track: 1, needsInsert: false });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("resolveInsertRow", () => {
|
|
172
|
+
const n = 3; // three lanes: rows 0,1,2
|
|
173
|
+
|
|
174
|
+
it("targets the lane (null) when over its middle band", () => {
|
|
175
|
+
expect(resolveInsertRow(1.5, n, 0.22)).toBe(null); // dead center of lane 1
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("inserts at the top boundary of a lane when near its top edge", () => {
|
|
179
|
+
expect(resolveInsertRow(1.1, n, 0.22)).toBe(1); // just into lane 1 → boundary above it
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("inserts at the bottom boundary of a lane when near its bottom edge", () => {
|
|
183
|
+
expect(resolveInsertRow(1.9, n, 0.22)).toBe(2); // near bottom of lane 1 → boundary below
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("inserts above the top lane when the pointer is above everything", () => {
|
|
187
|
+
expect(resolveInsertRow(-0.5, n, 0.22)).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("inserts below the bottom lane when the pointer is past the last lane", () => {
|
|
191
|
+
expect(resolveInsertRow(3.4, n, 0.22)).toBe(3);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
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
|
+
|
|
236
|
+
describe("clampTrackToZone", () => {
|
|
237
|
+
// trackOrder [0,1,2,3]: rows 0,1 = visual; rows 2,3 = audio (audioRow = 2).
|
|
238
|
+
const order = [0, 1, 2, 3];
|
|
239
|
+
|
|
240
|
+
it("is a no-op when there is no audio zone", () => {
|
|
241
|
+
expect(clampTrackToZone(3, order, -1, false)).toBe(3);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("keeps a visual clip in the visual zone", () => {
|
|
245
|
+
expect(clampTrackToZone(1, order, 2, false)).toBe(1); // already visual
|
|
246
|
+
expect(clampTrackToZone(3, order, 2, false)).toBe(1); // in audio → last visual lane
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("keeps an audio clip in the audio zone", () => {
|
|
250
|
+
expect(clampTrackToZone(2, order, 2, true)).toBe(2); // already audio
|
|
251
|
+
expect(clampTrackToZone(0, order, 2, true)).toBe(2); // in visual → first audio lane
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("isInsertAllowedForZone", () => {
|
|
256
|
+
// audioRow = 2
|
|
257
|
+
it("allows any insert when there is no audio zone", () => {
|
|
258
|
+
expect(isInsertAllowedForZone(0, -1, false)).toBe(true);
|
|
259
|
+
expect(isInsertAllowedForZone(3, -1, true)).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("allows a visual insert only at/above the audio zone top", () => {
|
|
263
|
+
expect(isInsertAllowedForZone(0, 2, false)).toBe(true);
|
|
264
|
+
expect(isInsertAllowedForZone(2, 2, false)).toBe(true); // bottom of the visual zone
|
|
265
|
+
expect(isInsertAllowedForZone(3, 2, false)).toBe(false); // inside the audio zone
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("allows an audio insert only at/below the audio zone top (audio clips make audio tracks)", () => {
|
|
269
|
+
expect(isInsertAllowedForZone(2, 2, true)).toBe(true);
|
|
270
|
+
expect(isInsertAllowedForZone(4, 2, true)).toBe(true); // below the bottom
|
|
271
|
+
expect(isInsertAllowedForZone(1, 2, true)).toBe(false); // inside the visual zone
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("resolveZoneDropPlacement (the whole drop decision, no same-track overlap)", () => {
|
|
276
|
+
// order [0,1,2] visual + [3] audio. audioRow = 3.
|
|
277
|
+
const order = [0, 1, 2, 3];
|
|
278
|
+
const audioTracks = new Set([3]);
|
|
279
|
+
const base = {
|
|
280
|
+
order,
|
|
281
|
+
audioTracks,
|
|
282
|
+
deliberateInsertRow: null as number | null,
|
|
283
|
+
start: 2,
|
|
284
|
+
duration: 2,
|
|
285
|
+
dragKey: "x",
|
|
286
|
+
isAudio: false,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
it("lands on the aimed track when it is free at that time", () => {
|
|
290
|
+
expect(
|
|
291
|
+
resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 10, 3)], desiredTrack: 1 }),
|
|
292
|
+
).toEqual({ track: 1, insertRow: null });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("relocates UP to the nearest free track when the aimed spot overlaps a clip", () => {
|
|
296
|
+
expect(
|
|
297
|
+
resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 0, 5)], desiredTrack: 1 }),
|
|
298
|
+
).toEqual({ track: 0, insertRow: null });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("relocates DOWN when the tracks above are also occupied", () => {
|
|
302
|
+
expect(
|
|
303
|
+
resolveZoneDropPlacement({
|
|
304
|
+
...base,
|
|
305
|
+
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5)],
|
|
306
|
+
desiredTrack: 1,
|
|
307
|
+
}),
|
|
308
|
+
).toEqual({ track: 2, insertRow: null });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
|
|
312
|
+
expect(
|
|
313
|
+
resolveZoneDropPlacement({
|
|
314
|
+
...base,
|
|
315
|
+
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)],
|
|
316
|
+
desiredTrack: 1,
|
|
317
|
+
}),
|
|
318
|
+
).toEqual({ track: 1, insertRow: 2 });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Every visual lane (0,1,2) occupied across the drop span → no free lane exists.
|
|
322
|
+
const allVisualOccupied = () => [el("a", 0, 0, 10), el("b", 1, 0, 10), el("c", 2, 0, 10)];
|
|
323
|
+
|
|
324
|
+
it("occupied aim with NO free lane creates an adjacent track — never snaps back (UX rule 3)", () => {
|
|
325
|
+
// The clip must NOT return to its origin; it gets a fresh track adjacent to the
|
|
326
|
+
// aim. Default bias = below the aimed row.
|
|
327
|
+
const result = resolveZoneDropPlacement({
|
|
328
|
+
...base,
|
|
329
|
+
elements: allVisualOccupied(),
|
|
330
|
+
desiredTrack: 1,
|
|
331
|
+
});
|
|
332
|
+
expect(result.insertRow).not.toBeNull(); // a track is created, not an origin snap-back
|
|
333
|
+
expect(result).toEqual({ track: 1, insertRow: 2 }); // adjacent, below the aimed row
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("opens the new adjacent track ABOVE the aimed row when the pointer is in its upper half (UX rule 3)", () => {
|
|
337
|
+
expect(
|
|
338
|
+
resolveZoneDropPlacement({
|
|
339
|
+
...base,
|
|
340
|
+
elements: allVisualOccupied(),
|
|
341
|
+
desiredTrack: 1,
|
|
342
|
+
preferInsertAbove: true,
|
|
343
|
+
}),
|
|
344
|
+
).toEqual({ track: 1, insertRow: 1 }); // boundary ABOVE lane 1
|
|
345
|
+
// Same aim, pointer in the lower half → the track opens BELOW the aimed row.
|
|
346
|
+
expect(
|
|
347
|
+
resolveZoneDropPlacement({
|
|
348
|
+
...base,
|
|
349
|
+
elements: allVisualOccupied(),
|
|
350
|
+
desiredTrack: 1,
|
|
351
|
+
preferInsertAbove: false,
|
|
352
|
+
}),
|
|
353
|
+
).toEqual({ track: 1, insertRow: 2 });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("shares a track for sequential (non-overlapping) clips", () => {
|
|
357
|
+
expect(
|
|
358
|
+
resolveZoneDropPlacement({
|
|
359
|
+
...base,
|
|
360
|
+
elements: [el("a", 1, 0, 2)],
|
|
361
|
+
desiredTrack: 1,
|
|
362
|
+
start: 2,
|
|
363
|
+
}),
|
|
364
|
+
).toEqual({ track: 1, insertRow: null });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("clamps a visual clip OUT of the audio zone before placing", () => {
|
|
368
|
+
expect(resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 3 })).toEqual({
|
|
369
|
+
track: 2,
|
|
370
|
+
insertRow: null,
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("clamps an audio clip INTO the audio zone before placing", () => {
|
|
375
|
+
expect(
|
|
376
|
+
resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 0, isAudio: true }),
|
|
377
|
+
).toEqual({ track: 3, insertRow: null });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("upward create-drag off the top lane inserts at the TOP of the visual zone, never below audio (reviewer repro)", () => {
|
|
381
|
+
// 3-visual + 1-audio timeline. Dragging a top-lane visual clip up past the
|
|
382
|
+
// create threshold emits the sentinel desiredTrack = minTrack-1 = -1. The
|
|
383
|
+
// old hoist did order.indexOf(-1) = -1 and fell to below = order.length = 4,
|
|
384
|
+
// creating the new track BELOW the audio zone — repro {track:-1, insertRow:4}.
|
|
385
|
+
// It must now anchor to the top boundary of the visual zone.
|
|
386
|
+
expect(
|
|
387
|
+
resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: -1 }),
|
|
388
|
+
).toEqual({ track: -1, insertRow: 0 });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("downward create-drag off the bottom visual lane inserts at the audio boundary, never below it", () => {
|
|
392
|
+
// Sentinel desiredTrack = maxTrack+1 = 4 for a downward create-drag. A visual
|
|
393
|
+
// clip must land at the bottom of the visual zone (row 3 = just above audio),
|
|
394
|
+
// not past the audio lanes.
|
|
395
|
+
expect(
|
|
396
|
+
resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: 4 }),
|
|
397
|
+
).toEqual({ track: 4, insertRow: 3 });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("audio create-drag with an out-of-range aim inserts inside the audio zone", () => {
|
|
401
|
+
// An audio clip aimed above its zone (sentinel below its own min lane) anchors
|
|
402
|
+
// to the audio zone's top boundary (row 3), not into the visual zone.
|
|
403
|
+
expect(
|
|
404
|
+
resolveZoneDropPlacement({
|
|
405
|
+
...base,
|
|
406
|
+
elements: [el("a", 3, 0, 10)],
|
|
407
|
+
desiredTrack: -1,
|
|
408
|
+
isAudio: true,
|
|
409
|
+
}),
|
|
410
|
+
).toEqual({ track: -1, insertRow: 3 });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("honors a deliberate boundary insert in the clip's own zone", () => {
|
|
414
|
+
expect(
|
|
415
|
+
resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 1 }),
|
|
416
|
+
).toEqual({ track: 1, insertRow: 1 });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("ignores a deliberate insert that lands in the WRONG zone (visual into audio)", () => {
|
|
420
|
+
expect(
|
|
421
|
+
resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 4 }),
|
|
422
|
+
).toEqual({ track: 1, insertRow: null });
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("lets an AUDIO clip create a new audio track via a boundary insert", () => {
|
|
426
|
+
expect(
|
|
427
|
+
resolveZoneDropPlacement({
|
|
428
|
+
...base,
|
|
429
|
+
elements: [],
|
|
430
|
+
desiredTrack: 3,
|
|
431
|
+
isAudio: true,
|
|
432
|
+
deliberateInsertRow: 4,
|
|
433
|
+
}),
|
|
434
|
+
).toEqual({ track: 3, insertRow: 4 });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("Abhai repro: audio dropped on a visual-only timeline over an occupied span → insert, no overlap (#2195)", () => {
|
|
438
|
+
// No audio zone exists yet (audioTracks empty). The audio clip is aimed at the
|
|
439
|
+
// sole visual lane, which is occupied at the drop time. The zone-drop must
|
|
440
|
+
// create the audio zone's first lane (insertRow set) rather than stacking the
|
|
441
|
+
// audio clip onto the occupied visual track — the no-overlap invariant.
|
|
442
|
+
const result = resolveZoneDropPlacement({
|
|
443
|
+
order: [0],
|
|
444
|
+
audioTracks: new Set<number>(),
|
|
445
|
+
elements: [el("v", 0, 0, 5)],
|
|
446
|
+
desiredTrack: 0,
|
|
447
|
+
deliberateInsertRow: null,
|
|
448
|
+
start: 1,
|
|
449
|
+
duration: 2,
|
|
450
|
+
dragKey: "audio",
|
|
451
|
+
isAudio: true,
|
|
452
|
+
});
|
|
453
|
+
expect(result.insertRow).not.toBeNull();
|
|
454
|
+
expect(result).toEqual({ track: 0, insertRow: 1 });
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("free-span repro: audio dropped on a FREE stretch of a visual-only timeline → insert, not a visual lane (#2195)", () => {
|
|
458
|
+
// Same visual-only timeline, but the audio clip is aimed at a stretch the sole
|
|
459
|
+
// visual lane is FREE at. The zone check must still fire an insert (create the
|
|
460
|
+
// audio zone) rather than short-circuiting on isLaneFree and dropping the audio
|
|
461
|
+
// clip onto the visual lane — the kind-zone hole where the free-lane fast path
|
|
462
|
+
// beat the wrong-zone check.
|
|
463
|
+
const result = resolveZoneDropPlacement({
|
|
464
|
+
order: [0],
|
|
465
|
+
audioTracks: new Set<number>(),
|
|
466
|
+
elements: [el("v", 0, 0, 5)],
|
|
467
|
+
desiredTrack: 0,
|
|
468
|
+
deliberateInsertRow: null,
|
|
469
|
+
start: 10, // past the visual clip's end → the visual lane is FREE here
|
|
470
|
+
duration: 2,
|
|
471
|
+
dragKey: "audio",
|
|
472
|
+
isAudio: true,
|
|
473
|
+
});
|
|
474
|
+
expect(result.insertRow).not.toBeNull();
|
|
475
|
+
expect(result).toEqual({ track: 0, insertRow: 1 });
|
|
476
|
+
});
|
|
477
|
+
});
|