@hyperframes/studio 0.7.55 → 0.7.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +126 -125
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +53 -2
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +260 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +56 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +23 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +148 -18
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +42 -2
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
|
|
6
|
-
* — layering is CSS z-index (the renderer ignores track index), so the
|
|
7
|
-
* only job is to keep visual clips grouped above audio clips.
|
|
6
|
+
* — canvas layering is CSS z-index (the renderer ignores track index), so the
|
|
7
|
+
* timeline's only job is to keep visual clips grouped above audio clips.
|
|
8
8
|
*/
|
|
9
9
|
export type TrackZone = "visual" | "audio";
|
|
10
10
|
|
|
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
|
|
|
16
16
|
|
|
17
17
|
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
18
18
|
|
|
19
|
-
/** Stacking order for a clip: missing / "auto" z is treated as 0. */
|
|
20
|
-
const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
|
|
21
|
-
|
|
22
19
|
const EPS = 1e-6;
|
|
23
20
|
|
|
24
21
|
/** Two clips overlap when their half-open [start, end) intervals intersect. */
|
|
@@ -26,173 +23,123 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
|
|
|
26
23
|
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** One display lane: the clips packed onto it, in placement order. */
|
|
37
|
-
interface Lane {
|
|
38
|
-
occupants: IndexedClip[];
|
|
39
|
-
/** The single authored track all occupants share, or null once mixed (never
|
|
40
|
-
* happens — we only ever add same-track clips to an existing lane). */
|
|
41
|
-
track: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Lowest lane index a clip may occupy: strictly above every already-placed lane
|
|
46
|
-
* holding a clip it overlaps in time (all of which out-stack it by the z-desc
|
|
47
|
-
* placement order).
|
|
48
|
-
*/
|
|
49
|
-
function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
|
|
50
|
-
let minLane = 0;
|
|
51
|
-
for (let i = 0; i < lanes.length; i++) {
|
|
52
|
-
if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
|
|
53
|
-
}
|
|
54
|
-
return minLane;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* First lane at index ≥ minLane that holds solely this clip's authored track and
|
|
59
|
-
* nothing overlapping (so sequential same-track clips share a lane); -1 when none
|
|
60
|
-
* qualifies and a fresh lane must open.
|
|
61
|
-
*/
|
|
62
|
-
function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
|
|
63
|
-
for (let i = minLane; i < lanes.length; i++) {
|
|
64
|
-
const lane = lanes[i];
|
|
65
|
-
if (lane.track !== item.el.track) continue;
|
|
66
|
-
if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
|
|
67
|
-
return i;
|
|
68
|
-
}
|
|
69
|
-
return -1;
|
|
26
|
+
/** Deterministic order on the stable clip id (never the mutated lane/track). */
|
|
27
|
+
function byStableId(a: TimelineElement, b: TimelineElement): number {
|
|
28
|
+
const ka = keyOf(a);
|
|
29
|
+
const kb = keyOf(b);
|
|
30
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
70
31
|
}
|
|
71
32
|
|
|
72
33
|
/**
|
|
73
|
-
* Pack
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* whole-track mapping can fix that; the mapping must be per-clip.
|
|
34
|
+
* Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
|
|
35
|
+
* share a lane. Clips are ordered by their STABLE id (a function of the input, not
|
|
36
|
+
* of the lane being computed — the historical oscillation bug tie-broke on the
|
|
37
|
+
* mutated track) and placed first-fit, so sequential (non-overlapping) clips
|
|
38
|
+
* collapse onto a single lane and only genuine time overlaps spill onto adjacent
|
|
39
|
+
* sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
|
|
40
|
+
* number of lanes used (≥ 1 when non-empty).
|
|
81
41
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* must place first / upper); final tie → stable key. NEVER tie-break on the
|
|
86
|
-
* mutated lane/track index (historic oscillation bug — the tie-break must be
|
|
87
|
-
* a stable function of the input, not of the output being computed).
|
|
88
|
-
* 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
|
|
89
|
-
* OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
|
|
90
|
-
* clip out-stacks this one (higher z, or equal z but later in DOM), so this
|
|
91
|
-
* guarantees lane order == stacking order for every overlapping pair.
|
|
92
|
-
* 3. To preserve the "distinct authored tracks stay distinct / sequential
|
|
93
|
-
* same-track clips share a lane" feel, reuse an existing lane at index ≥ that
|
|
94
|
-
* minimum ONLY when the lane's occupants are all from the SAME authored track
|
|
95
|
-
* AND none overlaps this clip in time; otherwise open a fresh lane.
|
|
42
|
+
* The editor enforces no per-track time overlap, so the spill only fires on legacy
|
|
43
|
+
* files. It is DISPLAY-ONLY — a drag commit persists just the dragged clip, never
|
|
44
|
+
* this re-lane — so it never rewrites the source.
|
|
96
45
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
46
|
+
* Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
|
|
47
|
+
* display lane). Because every occupant of a sub-lane shares the base lane's
|
|
48
|
+
* authored track by construction, dropping a clip onto one persists that shared
|
|
49
|
+
* authored track — a legitimate same-track join. On the next normalize the
|
|
50
|
+
* joined track re-packs (stable-id first-fit), so the clip may display on a
|
|
51
|
+
* DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
|
|
52
|
+
* the persisted source value is correct either way.
|
|
99
53
|
*/
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
let
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
lanes.push(
|
|
54
|
+
function packTrackLanes(
|
|
55
|
+
clips: TimelineElement[],
|
|
56
|
+
base: number,
|
|
57
|
+
laneOf: Map<string, number>,
|
|
58
|
+
): number {
|
|
59
|
+
const ordered = [...clips].sort(byStableId);
|
|
60
|
+
const lanes: TimelineElement[][] = [];
|
|
61
|
+
for (const el of ordered) {
|
|
62
|
+
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
|
|
63
|
+
if (sub === -1) {
|
|
64
|
+
sub = lanes.length;
|
|
65
|
+
lanes.push([]);
|
|
112
66
|
}
|
|
113
|
-
lanes[
|
|
114
|
-
laneOf.set(keyOf(
|
|
67
|
+
lanes[sub].push(el);
|
|
68
|
+
laneOf.set(keyOf(el), base + sub);
|
|
115
69
|
}
|
|
116
|
-
return lanes.length;
|
|
70
|
+
return Math.max(1, lanes.length);
|
|
117
71
|
}
|
|
118
72
|
|
|
119
73
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* the number of lanes used
|
|
74
|
+
* Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
|
|
75
|
+
* follow the authored `data-track-index` (ASCENDING; ties by stable id) — NEVER a
|
|
76
|
+
* z-rank. Each distinct authored track owns its own lane (in ascending order);
|
|
77
|
+
* sequential same-track clips share it; time-overlapping same-track clips spill to
|
|
78
|
+
* adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
|
|
79
|
+
*
|
|
80
|
+
* This REPLACES the old global-z-rank interval pack. That pack ordered visual
|
|
81
|
+
* lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
|
|
82
|
+
* whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
|
|
83
|
+
* product decision is the opposite: a clip's lane is its track, period — z is
|
|
84
|
+
* canvas paint order only, and lane assignment must ignore it.
|
|
125
85
|
*/
|
|
126
|
-
function
|
|
127
|
-
clips:
|
|
86
|
+
function packZoneLanes(
|
|
87
|
+
clips: TimelineElement[],
|
|
128
88
|
base: number,
|
|
129
89
|
laneOf: Map<string, number>,
|
|
130
90
|
): number {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
|
|
137
|
-
if (sub === -1) {
|
|
138
|
-
sub = lanes.length;
|
|
139
|
-
lanes.push([]);
|
|
140
|
-
}
|
|
141
|
-
lanes[sub].push(item);
|
|
142
|
-
laneOf.set(keyOf(item.el), base + sub);
|
|
91
|
+
const byTrack = new Map<number, TimelineElement[]>();
|
|
92
|
+
for (const el of clips) {
|
|
93
|
+
const list = byTrack.get(el.track);
|
|
94
|
+
if (list) list.push(el);
|
|
95
|
+
else byTrack.set(el.track, [el]);
|
|
143
96
|
}
|
|
144
|
-
|
|
97
|
+
let used = 0;
|
|
98
|
+
for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
|
|
99
|
+
used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
|
|
100
|
+
}
|
|
101
|
+
return used;
|
|
145
102
|
}
|
|
146
103
|
|
|
147
104
|
/**
|
|
148
105
|
* Assign display lanes for the timeline: visual lanes on top, audio lanes below.
|
|
149
106
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* The AUDIO zone keeps the original behavior — authored-track order, per-track
|
|
158
|
-
* interval packing — because audio has no z / stacking semantics.
|
|
107
|
+
* Both zones are packed the SAME way — by authored track-index, ascending (see
|
|
108
|
+
* packZoneLanes) — so the timeline's vertical order follows each clip's track and
|
|
109
|
+
* nothing else. z-index does not participate in lane assignment (it is canvas
|
|
110
|
+
* paint order only; the lane ↔ z stacking sync in timelineStackingSync runs the
|
|
111
|
+
* other direction, only on a deliberate vertical edit). Time-overlapping same-track
|
|
112
|
+
* clips still split onto separate sub-lanes (legacy files only — the editor forbids
|
|
113
|
+
* per-track overlap), and that split is display-only, never persisted.
|
|
159
114
|
*
|
|
160
115
|
* Pure — returns a new array; unchanged clips keep their identity. Display-only
|
|
161
116
|
* (runs on discovery); it does not rewrite the source. Idempotent (running it on
|
|
162
|
-
* its own output is a fixed point)
|
|
117
|
+
* its own output is a fixed point): the lanes it emits are contiguous integers in
|
|
118
|
+
* ascending track order, and re-running groups by those same integers unchanged.
|
|
163
119
|
*/
|
|
164
120
|
export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
|
|
165
121
|
if (elements.length === 0) return elements;
|
|
166
122
|
|
|
167
123
|
const laneOf = new Map<string, number>();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
(classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
|
|
174
|
-
});
|
|
124
|
+
const visual: TimelineElement[] = [];
|
|
125
|
+
const audio: TimelineElement[] = [];
|
|
126
|
+
for (const el of elements) {
|
|
127
|
+
(classifyZone(el) === "audio" ? audio : visual).push(el);
|
|
128
|
+
}
|
|
175
129
|
|
|
130
|
+
let nextLane = 0;
|
|
176
131
|
nextLane += packZoneLanes(visual, nextLane, laneOf);
|
|
177
|
-
|
|
178
|
-
// Audio: preserve legacy behavior — group by authored track (ascending), pack
|
|
179
|
-
// each track's overlapping clips onto sub-lanes.
|
|
180
|
-
const audioByTrack = new Map<number, IndexedClip[]>();
|
|
181
|
-
for (const item of audio) {
|
|
182
|
-
const list = audioByTrack.get(item.el.track);
|
|
183
|
-
if (list) list.push(item);
|
|
184
|
-
else audioByTrack.set(item.el.track, [item]);
|
|
185
|
-
}
|
|
186
|
-
for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
|
|
187
|
-
nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
|
|
188
|
-
}
|
|
132
|
+
packZoneLanes(audio, nextLane, laneOf);
|
|
189
133
|
|
|
190
134
|
let changed = false;
|
|
191
135
|
const remapped = elements.map((el) => {
|
|
192
136
|
const lane = laneOf.get(keyOf(el));
|
|
193
137
|
if (lane == null || lane === el.track) return el;
|
|
194
138
|
changed = true;
|
|
195
|
-
|
|
139
|
+
// Record the source-file track the first time a clip is remapped so lane
|
|
140
|
+
// edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
|
|
141
|
+
// Re-normalizing already-remapped elements must keep the original value.
|
|
142
|
+
return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
|
|
196
143
|
});
|
|
197
144
|
return changed ? remapped : elements;
|
|
198
145
|
}
|
|
@@ -6,7 +6,12 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
|
6
6
|
// the rest of the callback bag still comes from TimelineEditContext.
|
|
7
7
|
export type TimelineEditOverrides = Pick<
|
|
8
8
|
TimelineEditCallbacks,
|
|
9
|
-
|
|
9
|
+
| "onMoveElement"
|
|
10
|
+
| "onMoveElements"
|
|
11
|
+
| "onResizeElement"
|
|
12
|
+
| "onResizeElements"
|
|
13
|
+
| "onBlockedEditAttempt"
|
|
14
|
+
| "onSplitElement"
|
|
10
15
|
>;
|
|
11
16
|
|
|
12
17
|
// Merge any prop overrides over the context callbacks. Used so NLELayout can
|
|
@@ -16,15 +21,32 @@ export function useResolvedTimelineEditCallbacks(
|
|
|
16
21
|
overrides: TimelineEditOverrides,
|
|
17
22
|
): TimelineEditCallbacks {
|
|
18
23
|
const ctx = useTimelineEditContextOptional();
|
|
19
|
-
const {
|
|
24
|
+
const {
|
|
25
|
+
onMoveElement,
|
|
26
|
+
onMoveElements,
|
|
27
|
+
onResizeElement,
|
|
28
|
+
onResizeElements,
|
|
29
|
+
onBlockedEditAttempt,
|
|
30
|
+
onSplitElement,
|
|
31
|
+
} = overrides;
|
|
20
32
|
return useMemo(
|
|
21
33
|
() => ({
|
|
22
34
|
...ctx,
|
|
23
35
|
onMoveElement: onMoveElement ?? ctx.onMoveElement,
|
|
36
|
+
onMoveElements: onMoveElements ?? ctx.onMoveElements,
|
|
24
37
|
onResizeElement: onResizeElement ?? ctx.onResizeElement,
|
|
38
|
+
onResizeElements: onResizeElements ?? ctx.onResizeElements,
|
|
25
39
|
onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
|
|
26
40
|
onSplitElement: onSplitElement ?? ctx.onSplitElement,
|
|
27
41
|
}),
|
|
28
|
-
[
|
|
42
|
+
[
|
|
43
|
+
ctx,
|
|
44
|
+
onMoveElement,
|
|
45
|
+
onMoveElements,
|
|
46
|
+
onResizeElement,
|
|
47
|
+
onResizeElements,
|
|
48
|
+
onBlockedEditAttempt,
|
|
49
|
+
onSplitElement,
|
|
50
|
+
],
|
|
29
51
|
);
|
|
30
52
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
8
|
+
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
9
|
+
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
key: id,
|
|
17
|
+
tag: "video",
|
|
18
|
+
start: 0,
|
|
19
|
+
duration: 2,
|
|
20
|
+
track: 0,
|
|
21
|
+
domId: id,
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
usePlayerStore.getState().reset();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function renderResizeHarness(
|
|
32
|
+
elements: TimelineElement[],
|
|
33
|
+
selected: string[],
|
|
34
|
+
options: { wireGroupResize?: boolean } = {},
|
|
35
|
+
) {
|
|
36
|
+
usePlayerStore.getState().setElements(elements);
|
|
37
|
+
usePlayerStore.setState({ timelineSnapEnabled: false });
|
|
38
|
+
usePlayerStore.getState().setSelectedElementIds(new Set(selected));
|
|
39
|
+
|
|
40
|
+
const scroll = document.createElement("div");
|
|
41
|
+
document.body.append(scroll);
|
|
42
|
+
const onResizeElement = vi.fn();
|
|
43
|
+
const onResizeElements = vi.fn().mockResolvedValue(undefined);
|
|
44
|
+
let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
|
|
45
|
+
|
|
46
|
+
function Harness() {
|
|
47
|
+
const hook = useTimelineClipDrag({
|
|
48
|
+
scrollRef: { current: scroll },
|
|
49
|
+
ppsRef: { current: 100 },
|
|
50
|
+
durationRef: { current: 100 },
|
|
51
|
+
trackOrderRef: { current: [0, 1] },
|
|
52
|
+
onResizeElement,
|
|
53
|
+
onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
|
|
54
|
+
setShowPopover: vi.fn(),
|
|
55
|
+
setRangeSelectionRef: { current: vi.fn() },
|
|
56
|
+
});
|
|
57
|
+
setResizingClip = hook.setResizingClip;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const root = mountReactHarness(<Harness />);
|
|
62
|
+
const apply = setResizingClip!;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
onResizeElement,
|
|
66
|
+
onResizeElements,
|
|
67
|
+
storeById(id: string) {
|
|
68
|
+
return usePlayerStore.getState().elements.find((e) => e.id === id)!;
|
|
69
|
+
},
|
|
70
|
+
startResize(element: TimelineElement, edge: "start" | "end") {
|
|
71
|
+
act(() => {
|
|
72
|
+
apply({
|
|
73
|
+
element,
|
|
74
|
+
edge,
|
|
75
|
+
originClientX: 0,
|
|
76
|
+
previewStart: element.start,
|
|
77
|
+
previewDuration: element.duration,
|
|
78
|
+
previewPlaybackStart: element.playbackStart,
|
|
79
|
+
started: false,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
movePointer(clientX: number) {
|
|
84
|
+
act(() => {
|
|
85
|
+
window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 }));
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
async dropPointer() {
|
|
89
|
+
await act(async () => {
|
|
90
|
+
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
pressEscape() {
|
|
94
|
+
act(() => {
|
|
95
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
unmount() {
|
|
99
|
+
act(() => root.unmount());
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Two clips a(0,2) + b(5,3) selected as a group, with a's END edge grabbed and
|
|
105
|
+
// dragged +0.5s (50px @ 100pps). `bOver` customizes b (e.g. locking it).
|
|
106
|
+
function startGroupResize(bOver: Partial<TimelineElement> = {}) {
|
|
107
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
108
|
+
const b = el("b", { start: 5, duration: 3, ...bOver });
|
|
109
|
+
const h = renderResizeHarness([a, b], ["a", "b"]);
|
|
110
|
+
h.startResize(a, "end");
|
|
111
|
+
h.movePointer(50);
|
|
112
|
+
return { a, b, h };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Drop the pointer and assert exactly one resize persisted: the grabbed clip `a`
|
|
116
|
+
// grown to 2.5s (the +0.5 gesture), with no other member patched.
|
|
117
|
+
async function expectSingleResizeToA(
|
|
118
|
+
h: ReturnType<typeof renderResizeHarness>,
|
|
119
|
+
a: TimelineElement,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
await h.dropPointer();
|
|
122
|
+
expect(h.onResizeElement).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.5 }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
|
|
127
|
+
it("resizes only the grabbed clip and persists once", async () => {
|
|
128
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
129
|
+
const b = el("b", { start: 5, duration: 2 });
|
|
130
|
+
const h = renderResizeHarness([a, b], []); // no multi-selection
|
|
131
|
+
|
|
132
|
+
h.startResize(a, "end");
|
|
133
|
+
h.movePointer(50); // +0.5s at 100 pps
|
|
134
|
+
await expectSingleResizeToA(h, a);
|
|
135
|
+
expect(h.storeById("a").duration).toBe(2.5);
|
|
136
|
+
expect(h.storeById("b").duration).toBe(2); // untouched
|
|
137
|
+
h.unmount();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not let an older rejected resize roll back a newer optimistic gesture", async () => {
|
|
141
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
142
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
143
|
+
const h = renderResizeHarness([a], []);
|
|
144
|
+
let rejectFirst!: (error: Error) => void;
|
|
145
|
+
h.onResizeElement
|
|
146
|
+
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => (rejectFirst = reject)))
|
|
147
|
+
.mockResolvedValueOnce(undefined);
|
|
148
|
+
|
|
149
|
+
h.startResize(a, "end");
|
|
150
|
+
h.movePointer(50);
|
|
151
|
+
await h.dropPointer();
|
|
152
|
+
h.startResize(a, "end");
|
|
153
|
+
h.movePointer(100);
|
|
154
|
+
await h.dropPointer();
|
|
155
|
+
rejectFirst(new Error("older resize failed"));
|
|
156
|
+
await act(async () => Promise.resolve());
|
|
157
|
+
|
|
158
|
+
expect(h.storeById("a").duration).toBe(3);
|
|
159
|
+
errorSpy.mockRestore();
|
|
160
|
+
h.unmount();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
|
165
|
+
it("previews the non-grabbed member through the store, grabbed stays out until commit", () => {
|
|
166
|
+
const { h } = startGroupResize(); // grabbed asks +0.5
|
|
167
|
+
|
|
168
|
+
// Non-grabbed member is previewed in the store; the grabbed clip renders from
|
|
169
|
+
// resizingClip state, so its store value is still the original until commit.
|
|
170
|
+
expect(h.storeById("b").duration).toBe(3.5);
|
|
171
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
172
|
+
h.unmount();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("commits the whole group — persists every member by the shared delta", async () => {
|
|
176
|
+
const { a, b, h } = startGroupResize();
|
|
177
|
+
await h.dropPointer();
|
|
178
|
+
|
|
179
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
180
|
+
expect(h.onResizeElements).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(h.onResizeElements).toHaveBeenCalledWith(
|
|
182
|
+
[
|
|
183
|
+
expect.objectContaining({ element: a, duration: 2.5 }),
|
|
184
|
+
expect.objectContaining({ element: b, duration: 3.5 }),
|
|
185
|
+
],
|
|
186
|
+
{ coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
|
|
187
|
+
);
|
|
188
|
+
expect(h.storeById("a").duration).toBe(2.5);
|
|
189
|
+
expect(h.storeById("b").duration).toBe(3.5);
|
|
190
|
+
h.unmount();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("rolls the whole group back when the atomic resize batch rejects", async () => {
|
|
194
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
195
|
+
const { h } = startGroupResize();
|
|
196
|
+
h.onResizeElements.mockRejectedValueOnce(new Error("batch failed"));
|
|
197
|
+
await h.dropPointer();
|
|
198
|
+
await act(async () => Promise.resolve());
|
|
199
|
+
expect(h.onResizeElements).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
201
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
202
|
+
expect(h.storeById("b").duration).toBe(3);
|
|
203
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
204
|
+
errorSpy.mockRestore();
|
|
205
|
+
h.unmount();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("rolls the whole group back when no atomic resize callback is wired", async () => {
|
|
209
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
210
|
+
const b = el("b", { start: 5, duration: 3 });
|
|
211
|
+
const h = renderResizeHarness([a, b], ["a", "b"], {
|
|
212
|
+
wireGroupResize: false,
|
|
213
|
+
});
|
|
214
|
+
h.startResize(a, "end");
|
|
215
|
+
h.movePointer(50);
|
|
216
|
+
expect(h.storeById("b").duration).toBe(3.5);
|
|
217
|
+
|
|
218
|
+
await h.dropPointer();
|
|
219
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
220
|
+
expect(h.storeById("b").duration).toBe(3);
|
|
221
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
222
|
+
h.unmount();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("degrades to single-clip when a selected member is locked — locked clip untouched", async () => {
|
|
226
|
+
const { a, h } = startGroupResize({ timelineLocked: true });
|
|
227
|
+
await expectSingleResizeToA(h, a);
|
|
228
|
+
expect(h.storeById("b").duration).toBe(3); // locked member never patched
|
|
229
|
+
h.unmount();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("Escape rolls back the previewed non-grabbed member and persists nothing", () => {
|
|
233
|
+
const { h } = startGroupResize();
|
|
234
|
+
expect(h.storeById("b").duration).toBe(3.5); // previewed
|
|
235
|
+
|
|
236
|
+
h.pressEscape();
|
|
237
|
+
expect(h.storeById("b").duration).toBe(3); // restored
|
|
238
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
239
|
+
h.unmount();
|
|
240
|
+
});
|
|
241
|
+
});
|