@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
buildTimelineGroupResizeMembers,
|
|
5
|
+
resolveTimelineGroupResize,
|
|
6
|
+
resolveTimelineGroupResizeChanges,
|
|
7
|
+
} from "./timelineGroupEditing";
|
|
8
|
+
|
|
9
|
+
function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
|
|
10
|
+
// domId gives a patchable target so getTimelineEditCapabilities().canTrim* is true.
|
|
11
|
+
return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function keys(...ids: string[]): ReadonlySet<string> {
|
|
15
|
+
return new Set(ids);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
|
|
19
|
+
it("returns null for a single-clip selection (no group forms)", () => {
|
|
20
|
+
const a = el("a");
|
|
21
|
+
expect(buildTimelineGroupResizeMembers([a], keys("a"), "a", "end")).toBeNull();
|
|
22
|
+
expect(buildTimelineGroupResizeMembers([a], new Set(), "a", "end")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns null when the grabbed clip is not part of the selection", () => {
|
|
26
|
+
const a = el("a");
|
|
27
|
+
const b = el("b");
|
|
28
|
+
expect(buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "c", "end")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("degrades to single-clip (null) when ANY member is locked — locked clip never patched", () => {
|
|
32
|
+
const a = el("a");
|
|
33
|
+
const locked = el("b", { timelineLocked: true });
|
|
34
|
+
expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("degrades when a member is implicitly timed or has no patch target", () => {
|
|
38
|
+
const a = el("a");
|
|
39
|
+
const implicit = el("b", { timingSource: "implicit" });
|
|
40
|
+
const noTarget = el("c", { domId: undefined, selector: undefined });
|
|
41
|
+
expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
|
|
42
|
+
expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("snapshots members and seeds start-edge media playbackStart to 0", () => {
|
|
46
|
+
const grabbed = el("a", { start: 2, duration: 2 });
|
|
47
|
+
const audio = el("b", { tag: "audio", start: 5, duration: 3 }); // playbackStart undefined
|
|
48
|
+
const members = buildTimelineGroupResizeMembers([grabbed, audio], keys("a", "b"), "a", "start");
|
|
49
|
+
expect(members).not.toBeNull();
|
|
50
|
+
expect(members!.map((m) => [m.key, m.start, m.duration, m.playbackStart])).toEqual([
|
|
51
|
+
["a", 2, 2, 0], // start-edge media seeds playbackStart to 0
|
|
52
|
+
["b", 5, 3, 0],
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not seed playbackStart on the END edge", () => {
|
|
57
|
+
const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
|
|
58
|
+
const b = el("b", { tag: "audio", start: 3, duration: 2 });
|
|
59
|
+
const members = buildTimelineGroupResizeMembers([grabbed, b], keys("a", "b"), "a", "end");
|
|
60
|
+
expect(members!.every((m) => m.playbackStart === undefined)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => {
|
|
65
|
+
it("END edge: extends every member's duration by the shared delta", () => {
|
|
66
|
+
const members = buildTimelineGroupResizeMembers(
|
|
67
|
+
[el("a", { duration: 2 }), el("b", { duration: 3 })],
|
|
68
|
+
keys("a", "b"),
|
|
69
|
+
"a",
|
|
70
|
+
"end",
|
|
71
|
+
)!;
|
|
72
|
+
const changes = resolveTimelineGroupResizeChanges(members, "end", 0.5);
|
|
73
|
+
expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
|
|
74
|
+
["a", 0, 2.5],
|
|
75
|
+
["b", 0, 3.5],
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("END edge: rigid — the most-constrained member clamps the whole group", () => {
|
|
80
|
+
// b (0.2s) can only shrink 0.1 before hitting min duration, so the group as a
|
|
81
|
+
// whole shrinks 0.1 even though the grabbed clip asked for -0.5.
|
|
82
|
+
const members = buildTimelineGroupResizeMembers(
|
|
83
|
+
[el("a", { duration: 2 }), el("b", { duration: 0.2 })],
|
|
84
|
+
keys("a", "b"),
|
|
85
|
+
"a",
|
|
86
|
+
"end",
|
|
87
|
+
)!;
|
|
88
|
+
const changes = resolveTimelineGroupResizeChanges(members, "end", -0.5);
|
|
89
|
+
expect(changes.map((c) => c.duration)).toEqual([1.9, 0.1]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("START edge: shifts start + duration together across the group", () => {
|
|
93
|
+
const members = buildTimelineGroupResizeMembers(
|
|
94
|
+
[
|
|
95
|
+
el("a", { tag: "text", start: 2, duration: 2 }),
|
|
96
|
+
el("b", { tag: "text", start: 5, duration: 3 }),
|
|
97
|
+
],
|
|
98
|
+
keys("a", "b"),
|
|
99
|
+
"a",
|
|
100
|
+
"start",
|
|
101
|
+
)!;
|
|
102
|
+
const changes = resolveTimelineGroupResizeChanges(members, "start", -0.5);
|
|
103
|
+
expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
|
|
104
|
+
["a", 1.5, 2.5],
|
|
105
|
+
["b", 4.5, 3.5],
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("produces exactly the resolveTimelineGroupResize output, keyed per member", () => {
|
|
110
|
+
const members = buildTimelineGroupResizeMembers(
|
|
111
|
+
[el("a", { duration: 2 }), el("b", { duration: 4 })],
|
|
112
|
+
keys("a", "b"),
|
|
113
|
+
"a",
|
|
114
|
+
"end",
|
|
115
|
+
)!;
|
|
116
|
+
const raw = resolveTimelineGroupResize(members, "end", 0.75);
|
|
117
|
+
const changes = resolveTimelineGroupResizeChanges(members, "end", 0.75);
|
|
118
|
+
expect(
|
|
119
|
+
changes.map((c) => ({
|
|
120
|
+
start: c.start,
|
|
121
|
+
duration: c.duration,
|
|
122
|
+
playbackStart: c.playbackStart,
|
|
123
|
+
})),
|
|
124
|
+
).toEqual(raw.members);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { roundToCenti } from "../../utils/rounding";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
2
4
|
|
|
3
5
|
const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
|
|
4
6
|
const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
|
|
@@ -157,3 +159,134 @@ export function resolveTimelineGroupResize(
|
|
|
157
159
|
}),
|
|
158
160
|
};
|
|
159
161
|
}
|
|
162
|
+
|
|
163
|
+
/* ── Multi-select group resize wiring (restored from main 36413da7f) ───────── */
|
|
164
|
+
|
|
165
|
+
/** A selected clip snapshot captured at group-resize gesture start. */
|
|
166
|
+
export interface TimelineGroupResizeMember extends TimelineGroupTimingMember {
|
|
167
|
+
element: TimelineElement;
|
|
168
|
+
key: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Per-member timing patch produced by a group resize (grabbed clip included). */
|
|
172
|
+
export interface TimelineGroupResizeChange {
|
|
173
|
+
element: TimelineElement;
|
|
174
|
+
key: string;
|
|
175
|
+
start: number;
|
|
176
|
+
duration: number;
|
|
177
|
+
playbackStart?: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function elementKey(element: TimelineElement): string {
|
|
181
|
+
return element.key ?? element.id;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isMediaTimelineElement(element: TimelineElement): boolean {
|
|
185
|
+
const tag = element.tag.toLowerCase();
|
|
186
|
+
return tag === "audio" || tag === "video";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
|
|
190
|
+
const caps = getTimelineEditCapabilities({
|
|
191
|
+
tag: element.tag,
|
|
192
|
+
duration: element.duration,
|
|
193
|
+
domId: element.domId,
|
|
194
|
+
selector: element.selector,
|
|
195
|
+
compositionSrc: element.compositionSrc,
|
|
196
|
+
playbackStart: element.playbackStart,
|
|
197
|
+
playbackStartAttr: element.playbackStartAttr,
|
|
198
|
+
sourceDuration: element.sourceDuration,
|
|
199
|
+
timingSource: element.timingSource,
|
|
200
|
+
timelineLocked: element.timelineLocked,
|
|
201
|
+
});
|
|
202
|
+
return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Snapshot the members for a group resize, or `null` when the gesture must stay a
|
|
207
|
+
* single-clip resize. Legacy semantics (main 36413da7f): a group forms only when
|
|
208
|
+
* the grabbed clip is part of a multi-selection (> 1) AND EVERY selected member
|
|
209
|
+
* can take THIS edge's trim. It is all-or-nothing — if any member is locked or
|
|
210
|
+
* implicitly-timed the group does not form, so a locked member is never patched
|
|
211
|
+
* (the grabbed clip then resizes on its own). Start-edge media members seed
|
|
212
|
+
* `playbackStart` to 0 so the in-point trim math has a base, mirroring the legacy
|
|
213
|
+
* `resizeMember`.
|
|
214
|
+
*/
|
|
215
|
+
export function buildTimelineGroupResizeMembers(
|
|
216
|
+
elements: readonly TimelineElement[],
|
|
217
|
+
selectedKeys: ReadonlySet<string>,
|
|
218
|
+
grabbedKey: string,
|
|
219
|
+
edge: TimelineGroupResizeEdge,
|
|
220
|
+
): TimelineGroupResizeMember[] | null {
|
|
221
|
+
if (selectedKeys.size <= 1 || !selectedKeys.has(grabbedKey)) return null;
|
|
222
|
+
const selected = elements.filter((element) => selectedKeys.has(elementKey(element)));
|
|
223
|
+
if (selected.length <= 1) return null;
|
|
224
|
+
if (!selected.every((element) => canTrimEdge(element, edge))) return null;
|
|
225
|
+
return selected.map((element) => ({
|
|
226
|
+
element,
|
|
227
|
+
key: elementKey(element),
|
|
228
|
+
start: element.start,
|
|
229
|
+
duration: element.duration,
|
|
230
|
+
playbackStart:
|
|
231
|
+
edge === "start" && isMediaTimelineElement(element)
|
|
232
|
+
? (element.playbackStart ?? 0)
|
|
233
|
+
: element.playbackStart,
|
|
234
|
+
playbackRate: element.playbackRate,
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve the per-member timing patches for a group resize from the grabbed
|
|
240
|
+
* clip's raw edge delta. Rigid group: one shared delta, clamped by the most
|
|
241
|
+
* constrained member (see resolveTimelineGroupResize), applied to every member.
|
|
242
|
+
*/
|
|
243
|
+
export function resolveTimelineGroupResizeChanges(
|
|
244
|
+
members: readonly TimelineGroupResizeMember[],
|
|
245
|
+
edge: TimelineGroupResizeEdge,
|
|
246
|
+
rawDelta: number,
|
|
247
|
+
minDuration = resolveTimelineMinDuration(),
|
|
248
|
+
): TimelineGroupResizeChange[] {
|
|
249
|
+
const result = resolveTimelineGroupResize(members, edge, rawDelta, minDuration);
|
|
250
|
+
return result.members.map((member, index) => ({
|
|
251
|
+
element: members[index]!.element,
|
|
252
|
+
key: members[index]!.key,
|
|
253
|
+
start: member.start,
|
|
254
|
+
duration: member.duration,
|
|
255
|
+
playbackStart: member.playbackStart,
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** In-flight multi-select group-resize gesture (owned by the drag hook). */
|
|
260
|
+
export interface TimelineGroupResizeSession {
|
|
261
|
+
grabbedKey: string;
|
|
262
|
+
edge: TimelineGroupResizeEdge;
|
|
263
|
+
members: TimelineGroupResizeMember[];
|
|
264
|
+
changes: TimelineGroupResizeChange[];
|
|
265
|
+
hasChanged: boolean;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fold the grabbed clip's single-clip preview into the group: derive the raw
|
|
270
|
+
* edge delta from it, resolve the rigid per-member changes onto the session
|
|
271
|
+
* (updating `changes` + `hasChanged`), and return the grabbed clip's own change
|
|
272
|
+
* so the caller can render it from the resize state.
|
|
273
|
+
*/
|
|
274
|
+
export function applyTimelineGroupResizePreview(
|
|
275
|
+
session: TimelineGroupResizeSession,
|
|
276
|
+
grabbedPreview: { previewStart: number; previewDuration: number },
|
|
277
|
+
): TimelineGroupResizeChange | undefined {
|
|
278
|
+
const grabbed = session.members.find((m) => m.key === session.grabbedKey);
|
|
279
|
+
const rawDelta =
|
|
280
|
+
session.edge === "start"
|
|
281
|
+
? grabbedPreview.previewStart - (grabbed?.start ?? grabbedPreview.previewStart)
|
|
282
|
+
: grabbedPreview.previewDuration - (grabbed?.duration ?? grabbedPreview.previewDuration);
|
|
283
|
+
const changes = resolveTimelineGroupResizeChanges(session.members, session.edge, rawDelta);
|
|
284
|
+
session.changes = changes;
|
|
285
|
+
session.hasChanged = changes.some(
|
|
286
|
+
(c, i) =>
|
|
287
|
+
c.start !== session.members[i]!.start ||
|
|
288
|
+
c.duration !== session.members[i]!.duration ||
|
|
289
|
+
c.playbackStart !== session.members[i]!.playbackStart,
|
|
290
|
+
);
|
|
291
|
+
return changes.find((c) => c.key === session.grabbedKey);
|
|
292
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
3
|
+
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
|
4
|
+
import {
|
|
5
|
+
beginTimelineOptimisticGesture,
|
|
6
|
+
rollbackLatestTimelineOptimisticGesture,
|
|
7
|
+
} from "./timelineOptimisticRevision";
|
|
8
|
+
|
|
9
|
+
export function commitTimelineGroupResize(
|
|
10
|
+
session: TimelineGroupResizeSession,
|
|
11
|
+
updateElement: (key: string, updates: Partial<TimelineElement>) => void,
|
|
12
|
+
persist: TimelineEditCallbacks["onResizeElements"],
|
|
13
|
+
): void {
|
|
14
|
+
if (!session.hasChanged) return;
|
|
15
|
+
const changes = session.changes;
|
|
16
|
+
const revision = beginTimelineOptimisticGesture(
|
|
17
|
+
updateElement,
|
|
18
|
+
changes.map((change) => change.key),
|
|
19
|
+
);
|
|
20
|
+
for (const change of changes) {
|
|
21
|
+
updateElement(change.key, {
|
|
22
|
+
start: change.start,
|
|
23
|
+
duration: change.duration,
|
|
24
|
+
playbackStart: change.playbackStart,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (!persist) {
|
|
28
|
+
rollbackLatestTimelineOptimisticGesture(
|
|
29
|
+
updateElement,
|
|
30
|
+
revision,
|
|
31
|
+
session.members.map((member) => ({ key: member.key, updates: member })),
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const coalesceKey = `clip-group-resize:${changes.map((change) => change.key).join(":")}`;
|
|
36
|
+
Promise.resolve(
|
|
37
|
+
persist(
|
|
38
|
+
changes.map((change) => ({
|
|
39
|
+
element: change.element,
|
|
40
|
+
start: change.start,
|
|
41
|
+
duration: change.duration,
|
|
42
|
+
playbackStart: change.playbackStart,
|
|
43
|
+
})),
|
|
44
|
+
{ coalesceKey },
|
|
45
|
+
),
|
|
46
|
+
).catch((error) => {
|
|
47
|
+
rollbackLatestTimelineOptimisticGesture(
|
|
48
|
+
updateElement,
|
|
49
|
+
revision,
|
|
50
|
+
session.members.map((member) => ({ key: member.key, updates: member })),
|
|
51
|
+
);
|
|
52
|
+
console.error("[Timeline] Failed to persist group clip resize", error);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -1,34 +1,106 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
RULER_H,
|
|
4
|
+
TRACK_H,
|
|
5
|
+
TRACKS_TOP_PAD,
|
|
6
|
+
TRACKS_BOTTOM_PAD,
|
|
7
|
+
GUTTER,
|
|
8
|
+
getTimelineRowTop,
|
|
9
|
+
getTimelineRowFromY,
|
|
10
|
+
getTimelineCanvasHeight,
|
|
11
|
+
resolveTimelineAssetDrop,
|
|
12
|
+
} from "./timelineLayout";
|
|
3
13
|
|
|
4
|
-
describe("
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
14
|
+
describe("track-area breathing pad y-math", () => {
|
|
15
|
+
describe("getTimelineRowTop", () => {
|
|
16
|
+
it("offsets the first lane below the ruler by the top pad", () => {
|
|
17
|
+
expect(getTimelineRowTop(0)).toBe(RULER_H + TRACKS_TOP_PAD);
|
|
18
|
+
});
|
|
8
19
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
it("advances by one track height per row, keeping the pad", () => {
|
|
21
|
+
expect(getTimelineRowTop(1)).toBe(RULER_H + TRACKS_TOP_PAD + TRACK_H);
|
|
22
|
+
expect(getTimelineRowTop(3)).toBe(RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H);
|
|
23
|
+
});
|
|
12
24
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
it("is a strict positive shift from the pre-pad formula (pad is non-zero)", () => {
|
|
26
|
+
expect(TRACKS_TOP_PAD).toBeGreaterThan(0);
|
|
27
|
+
expect(getTimelineRowTop(2)).toBe(RULER_H + 2 * TRACK_H + TRACKS_TOP_PAD);
|
|
28
|
+
});
|
|
16
29
|
});
|
|
17
|
-
});
|
|
18
30
|
|
|
19
|
-
describe("
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
describe("getTimelineRowFromY", () => {
|
|
32
|
+
it("is the exact inverse of getTimelineRowTop at lane boundaries", () => {
|
|
33
|
+
for (const row of [0, 1, 2, 7]) {
|
|
34
|
+
expect(getTimelineRowFromY(getTimelineRowTop(row))).toBeCloseTo(row, 10);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("floors a y inside the top pad (above lane 0) to a negative fraction", () => {
|
|
39
|
+
// A drop in the pad between the ruler and lane 0 sits at row < 0, so a
|
|
40
|
+
// floor lands it on row -1 → getDefaultDroppedTrack floors to the top lane.
|
|
41
|
+
const yInPad = RULER_H + TRACKS_TOP_PAD / 2;
|
|
42
|
+
expect(getTimelineRowFromY(yInPad)).toBeLessThan(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("maps a y in the middle of lane 1 into [1,2)", () => {
|
|
46
|
+
const yMidLane1 = getTimelineRowTop(1) + TRACK_H / 2;
|
|
47
|
+
const row = getTimelineRowFromY(yMidLane1);
|
|
48
|
+
expect(row).toBeGreaterThanOrEqual(1);
|
|
49
|
+
expect(row).toBeLessThan(2);
|
|
50
|
+
});
|
|
22
51
|
});
|
|
23
52
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
53
|
+
describe("getTimelineCanvasHeight", () => {
|
|
54
|
+
it("reserves ruler + top pad + lanes + bottom pad", () => {
|
|
55
|
+
expect(getTimelineCanvasHeight(0)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
|
|
56
|
+
expect(getTimelineCanvasHeight(3)).toBe(
|
|
57
|
+
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("clamps a negative track count to zero lanes", () => {
|
|
62
|
+
expect(getTimelineCanvasHeight(-4)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves room below the last lane for a drag-into-void new track", () => {
|
|
66
|
+
// The gap below the final lane must be at least a full track height so a
|
|
67
|
+
// clip can be dropped there to create a new bottom track.
|
|
68
|
+
const oneLane = getTimelineCanvasHeight(1);
|
|
69
|
+
const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
|
|
70
|
+
expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
|
|
71
|
+
});
|
|
27
72
|
});
|
|
28
73
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
74
|
+
describe("resolveTimelineAssetDrop honours the top pad", () => {
|
|
75
|
+
const base = {
|
|
76
|
+
rectLeft: 0,
|
|
77
|
+
rectTop: 0,
|
|
78
|
+
scrollLeft: 0,
|
|
79
|
+
scrollTop: 0,
|
|
80
|
+
pixelsPerSecond: 100,
|
|
81
|
+
duration: 60,
|
|
82
|
+
trackHeight: TRACK_H,
|
|
83
|
+
trackOrder: [0, 1, 2],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
|
|
87
|
+
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
|
|
88
|
+
const clientX = GUTTER + 100; // t = 1s
|
|
89
|
+
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
|
|
90
|
+
expect(track).toBe(0);
|
|
91
|
+
expect(start).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("drops into the top pad → floors to the first lane (row < 0)", () => {
|
|
95
|
+
const clientY = RULER_H + TRACKS_TOP_PAD / 2; // inside the pad, above lane 0
|
|
96
|
+
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
|
|
97
|
+
expect(track).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("drops below the last lane → appends a new track", () => {
|
|
101
|
+
const clientY = getTimelineRowTop(2) + TRACK_H + 4; // in the bottom pad
|
|
102
|
+
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
|
|
103
|
+
expect(track).toBe(3); // max(trackOrder)+1
|
|
104
|
+
});
|
|
33
105
|
});
|
|
34
106
|
});
|