@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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
type UpdateElement = (key: string, updates: Partial<TimelineElement>) => void;
|
|
4
|
+
const revisionsByUpdater = new WeakMap<UpdateElement, Map<string, number>>();
|
|
5
|
+
|
|
6
|
+
export function beginTimelineOptimisticGesture(
|
|
7
|
+
updateElement: UpdateElement,
|
|
8
|
+
keys: readonly string[],
|
|
9
|
+
): Map<string, number> {
|
|
10
|
+
let revisions = revisionsByUpdater.get(updateElement);
|
|
11
|
+
if (!revisions) {
|
|
12
|
+
revisions = new Map();
|
|
13
|
+
revisionsByUpdater.set(updateElement, revisions);
|
|
14
|
+
}
|
|
15
|
+
const gesture = new Map<string, number>();
|
|
16
|
+
for (const key of keys) {
|
|
17
|
+
const revision = (revisions.get(key) ?? 0) + 1;
|
|
18
|
+
revisions.set(key, revision);
|
|
19
|
+
gesture.set(key, revision);
|
|
20
|
+
}
|
|
21
|
+
return gesture;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isLatestTimelineOptimisticGesture(
|
|
25
|
+
updateElement: UpdateElement,
|
|
26
|
+
gesture: ReadonlyMap<string, number>,
|
|
27
|
+
key: string,
|
|
28
|
+
): boolean {
|
|
29
|
+
return revisionsByUpdater.get(updateElement)?.get(key) === gesture.get(key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rollbackLatestTimelineOptimisticGesture(
|
|
33
|
+
updateElement: UpdateElement,
|
|
34
|
+
gesture: ReadonlyMap<string, number>,
|
|
35
|
+
rollbacks: ReadonlyArray<{ key: string; updates: Partial<TimelineElement> }>,
|
|
36
|
+
): void {
|
|
37
|
+
for (const rollback of rollbacks) {
|
|
38
|
+
if (isLatestTimelineOptimisticGesture(updateElement, gesture, rollback.key)) {
|
|
39
|
+
updateElement(rollback.key, rollback.updates);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
computeRevealScroll,
|
|
4
|
+
REVEAL_SCROLL_PADDING_PX,
|
|
5
|
+
type RevealScrollInput,
|
|
6
|
+
} from "./timelineRevealScroll";
|
|
7
|
+
|
|
8
|
+
/** A 1000×400 viewport with a 32px sticky gutter and 24px sticky ruler. */
|
|
9
|
+
function makeInput(overrides: Partial<RevealScrollInput> = {}): RevealScrollInput {
|
|
10
|
+
return {
|
|
11
|
+
scrollLeft: 0,
|
|
12
|
+
scrollTop: 0,
|
|
13
|
+
viewportWidth: 1000,
|
|
14
|
+
viewportHeight: 400,
|
|
15
|
+
clipLeft: 100,
|
|
16
|
+
clipRight: 200,
|
|
17
|
+
clipTop: 100,
|
|
18
|
+
clipBottom: 148,
|
|
19
|
+
stickyLeft: 32,
|
|
20
|
+
stickyTop: 24,
|
|
21
|
+
allowHorizontal: true,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("computeRevealScroll", () => {
|
|
27
|
+
it("returns null on both axes when the clip is fully visible", () => {
|
|
28
|
+
expect(computeRevealScroll(makeInput())).toEqual({ left: null, top: null });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("scrolls right minimally when the clip end is past the right edge", () => {
|
|
32
|
+
const result = computeRevealScroll(makeInput({ clipLeft: 1500, clipRight: 1600 }));
|
|
33
|
+
// Clip end lands padding px inside the right edge.
|
|
34
|
+
expect(result.left).toBe(1600 - 1000 + REVEAL_SCROLL_PADDING_PX);
|
|
35
|
+
expect(result.top).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("scrolls left when the clip start is hidden (including under the sticky gutter)", () => {
|
|
39
|
+
const result = computeRevealScroll(
|
|
40
|
+
makeInput({ scrollLeft: 500, clipLeft: 510, clipRight: 610 }),
|
|
41
|
+
);
|
|
42
|
+
// clipLeft 510 sits under the 32px sticky gutter (window starts at 500+32+pad).
|
|
43
|
+
expect(result.left).toBe(510 - 32 - REVEAL_SCROLL_PADDING_PX);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("aligns the start edge when the clip is wider than the viewport", () => {
|
|
47
|
+
const result = computeRevealScroll(makeInput({ clipLeft: 2000, clipRight: 4000 }));
|
|
48
|
+
expect(result.left).toBe(2000 - 32 - REVEAL_SCROLL_PADDING_PX);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("never returns a negative scroll target", () => {
|
|
52
|
+
const result = computeRevealScroll(
|
|
53
|
+
makeInput({
|
|
54
|
+
scrollLeft: 300,
|
|
55
|
+
clipLeft: 10,
|
|
56
|
+
clipRight: 60,
|
|
57
|
+
scrollTop: 200,
|
|
58
|
+
clipTop: 4,
|
|
59
|
+
clipBottom: 20,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
expect(result.left).toBe(0);
|
|
63
|
+
expect(result.top).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("suppresses horizontal scroll when allowHorizontal is false (fit zoom)", () => {
|
|
67
|
+
const result = computeRevealScroll(
|
|
68
|
+
makeInput({
|
|
69
|
+
allowHorizontal: false,
|
|
70
|
+
clipLeft: 1500,
|
|
71
|
+
clipRight: 1600,
|
|
72
|
+
clipTop: 700,
|
|
73
|
+
clipBottom: 748,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
expect(result.left).toBeNull();
|
|
77
|
+
// Vertical reveal still happens.
|
|
78
|
+
expect(result.top).toBe(748 - 400 + REVEAL_SCROLL_PADDING_PX);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("scrolls up when the clip lane is hidden under the sticky ruler", () => {
|
|
82
|
+
const result = computeRevealScroll(
|
|
83
|
+
makeInput({ scrollTop: 200, clipTop: 210, clipBottom: 258 }),
|
|
84
|
+
);
|
|
85
|
+
// clipTop 210 sits under the 24px sticky ruler (window starts at 200+24+pad).
|
|
86
|
+
expect(result.top).toBe(210 - 24 - REVEAL_SCROLL_PADDING_PX);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("scrolls down minimally when the clip lane is below the viewport", () => {
|
|
90
|
+
const result = computeRevealScroll(makeInput({ clipTop: 500, clipBottom: 548 }));
|
|
91
|
+
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
|
|
92
|
+
expect(result.left).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("never scrolls on an axis whose visible window is degenerate", () => {
|
|
96
|
+
// Horizontal window collapses: 40px viewport minus the 32px gutter and
|
|
97
|
+
// 2x12px padding is negative; vertical is intact and still reveals.
|
|
98
|
+
const result = computeRevealScroll(
|
|
99
|
+
makeInput({
|
|
100
|
+
viewportWidth: 40,
|
|
101
|
+
clipLeft: 1500,
|
|
102
|
+
clipRight: 1600,
|
|
103
|
+
clipTop: 500,
|
|
104
|
+
clipBottom: 548,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
expect(result.left).toBeNull();
|
|
108
|
+
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
|
|
109
|
+
|
|
110
|
+
// Exactly-zero window (viewport == sticky + 2x padding) is degenerate too.
|
|
111
|
+
const zero = computeRevealScroll(
|
|
112
|
+
makeInput({
|
|
113
|
+
viewportWidth: 32 + 2 * REVEAL_SCROLL_PADDING_PX,
|
|
114
|
+
clipLeft: 1500,
|
|
115
|
+
clipRight: 1600,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(zero.left).toBeNull();
|
|
119
|
+
|
|
120
|
+
// Both axes degenerate: no scroll at all.
|
|
121
|
+
const both = computeRevealScroll(
|
|
122
|
+
makeInput({
|
|
123
|
+
viewportWidth: 10,
|
|
124
|
+
viewportHeight: 10,
|
|
125
|
+
clipLeft: 1500,
|
|
126
|
+
clipRight: 1600,
|
|
127
|
+
clipTop: 500,
|
|
128
|
+
clipBottom: 548,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
expect(both).toEqual({ left: null, top: null });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scroll-target math for revealing a timeline clip inside the timeline's
|
|
3
|
+
* scroll container (the overflow div in Timeline.tsx).
|
|
4
|
+
*
|
|
5
|
+
* Coordinates are content-space: a clip edge measured from the scroll
|
|
6
|
+
* container's content origin (rect delta + current scroll offset). The visible
|
|
7
|
+
* window on each axis is reduced by the sticky chrome that occludes it — the
|
|
8
|
+
* track gutter on the left (GUTTER) and the ruler on top (RULER_H) — so a clip
|
|
9
|
+
* "hidden" under the sticky gutter still counts as off-screen.
|
|
10
|
+
*
|
|
11
|
+
* Scrolls minimally: an axis already fully visible returns null for that axis;
|
|
12
|
+
* otherwise the nearest edge is brought just inside the window (plus padding).
|
|
13
|
+
* A clip larger than the window aligns its start edge. Pure — unit-tested.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface RevealScrollInput {
|
|
17
|
+
scrollLeft: number;
|
|
18
|
+
scrollTop: number;
|
|
19
|
+
/** Scroll container clientWidth / clientHeight. */
|
|
20
|
+
viewportWidth: number;
|
|
21
|
+
viewportHeight: number;
|
|
22
|
+
/** Clip bounds in content-space (relative to the scroll content origin). */
|
|
23
|
+
clipLeft: number;
|
|
24
|
+
clipRight: number;
|
|
25
|
+
clipTop: number;
|
|
26
|
+
clipBottom: number;
|
|
27
|
+
/** Width of the sticky left gutter occluding the viewport's left edge. */
|
|
28
|
+
stickyLeft: number;
|
|
29
|
+
/** Height of the sticky ruler occluding the viewport's top edge. */
|
|
30
|
+
stickyTop: number;
|
|
31
|
+
/** False in "fit" zoom mode, where horizontal scrolling is disabled. */
|
|
32
|
+
allowHorizontal: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RevealScrollTarget {
|
|
36
|
+
/** Target scrollLeft, or null when the horizontal axis needs no scroll. */
|
|
37
|
+
left: number | null;
|
|
38
|
+
/** Target scrollTop, or null when the vertical axis needs no scroll. */
|
|
39
|
+
top: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Breathing room between the revealed clip edge and the window edge. */
|
|
43
|
+
export const REVEAL_SCROLL_PADDING_PX = 12;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Minimal scroll on one axis to bring [start, end] inside the visible window
|
|
47
|
+
* [scroll + stickyStart, scroll + viewport], with padding. Returns null when
|
|
48
|
+
* the range is already fully visible.
|
|
49
|
+
*/
|
|
50
|
+
function revealAxis(
|
|
51
|
+
scroll: number,
|
|
52
|
+
viewport: number,
|
|
53
|
+
stickyStart: number,
|
|
54
|
+
start: number,
|
|
55
|
+
end: number,
|
|
56
|
+
): number | null {
|
|
57
|
+
const windowStart = scroll + stickyStart + REVEAL_SCROLL_PADDING_PX;
|
|
58
|
+
const windowEnd = scroll + viewport - REVEAL_SCROLL_PADDING_PX;
|
|
59
|
+
const windowSize = windowEnd - windowStart;
|
|
60
|
+
// Degenerate viewport (container smaller than the sticky chrome + padding):
|
|
61
|
+
// there is no visible window to reveal into, so never scroll on this axis.
|
|
62
|
+
if (windowSize <= 0) return null;
|
|
63
|
+
if (start >= windowStart && end <= windowEnd) return null;
|
|
64
|
+
// Oversized range (or start hidden): align the start edge to the window start.
|
|
65
|
+
if (end - start > windowSize || start < windowStart) {
|
|
66
|
+
return Math.max(0, start - stickyStart - REVEAL_SCROLL_PADDING_PX);
|
|
67
|
+
}
|
|
68
|
+
// Only the end is clipped: pull it just inside the window's far edge.
|
|
69
|
+
return Math.max(0, end - viewport + REVEAL_SCROLL_PADDING_PX);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function computeRevealScroll(input: RevealScrollInput): RevealScrollTarget {
|
|
73
|
+
return {
|
|
74
|
+
left: input.allowHorizontal
|
|
75
|
+
? revealAxis(
|
|
76
|
+
input.scrollLeft,
|
|
77
|
+
input.viewportWidth,
|
|
78
|
+
input.stickyLeft,
|
|
79
|
+
input.clipLeft,
|
|
80
|
+
input.clipRight,
|
|
81
|
+
)
|
|
82
|
+
: null,
|
|
83
|
+
top: revealAxis(
|
|
84
|
+
input.scrollTop,
|
|
85
|
+
input.viewportHeight,
|
|
86
|
+
input.stickyTop,
|
|
87
|
+
input.clipTop,
|
|
88
|
+
input.clipBottom,
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -19,6 +19,66 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
|
|
|
19
19
|
return out;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
describe("stacking-context partitioning", () => {
|
|
23
|
+
it("never compares or patches across stacking contexts", () => {
|
|
24
|
+
// X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
|
|
25
|
+
// with a lower leaf z, overlapping in time. Their leaf z values are NOT
|
|
26
|
+
// comparable (the ancestors' z decides paint order), so moving X's lane above
|
|
27
|
+
// Y must not reason on Y or patch either based on the 10-vs-5 comparison.
|
|
28
|
+
const x: StackingElement = {
|
|
29
|
+
key: "x",
|
|
30
|
+
track: 0,
|
|
31
|
+
start: 0,
|
|
32
|
+
duration: 5,
|
|
33
|
+
zIndex: 10,
|
|
34
|
+
isAudio: false,
|
|
35
|
+
stackingContextId: "scene-1",
|
|
36
|
+
};
|
|
37
|
+
const y: StackingElement = {
|
|
38
|
+
key: "y",
|
|
39
|
+
track: 1,
|
|
40
|
+
start: 0,
|
|
41
|
+
duration: 5,
|
|
42
|
+
zIndex: 5,
|
|
43
|
+
isAudio: false,
|
|
44
|
+
stackingContextId: null,
|
|
45
|
+
};
|
|
46
|
+
// X edited: only same-context neighbours participate — none here, so X keeps
|
|
47
|
+
// its z (nothing to fix WITHIN its context) and Y is never touched.
|
|
48
|
+
expect(patchMap([x, y], ["x"])).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("still resolves within the edited clip's own context", () => {
|
|
52
|
+
const a: StackingElement = {
|
|
53
|
+
key: "a",
|
|
54
|
+
track: 1,
|
|
55
|
+
start: 0,
|
|
56
|
+
duration: 5,
|
|
57
|
+
zIndex: 1,
|
|
58
|
+
isAudio: false,
|
|
59
|
+
stackingContextId: "scene-1",
|
|
60
|
+
};
|
|
61
|
+
const b: StackingElement = {
|
|
62
|
+
key: "b",
|
|
63
|
+
track: 0,
|
|
64
|
+
start: 0,
|
|
65
|
+
duration: 5,
|
|
66
|
+
zIndex: 5,
|
|
67
|
+
isAudio: false,
|
|
68
|
+
stackingContextId: "scene-1",
|
|
69
|
+
};
|
|
70
|
+
// 'a' moved BELOW b's lane... a (track 1) is under b (track 0): a's z (1)
|
|
71
|
+
// is already below b's (5) — consistent, no patch. Move a ABOVE (track -1
|
|
72
|
+
// relative ordering) is covered by existing suites; here we just prove the
|
|
73
|
+
// same-context pair still participates (no patch ≠ no participation: verify
|
|
74
|
+
// by flipping z so a MUST be lifted).
|
|
75
|
+
const aWrong = { ...a, track: 0, zIndex: 1 };
|
|
76
|
+
const bLow = { ...b, track: 1, zIndex: 5 };
|
|
77
|
+
const patches = patchMap([aWrong, bLow], ["a"]);
|
|
78
|
+
expect(patches.a).toBeGreaterThan(5);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
22
82
|
describe("laneIsAbove", () => {
|
|
23
83
|
it("lower track renders above (top of timeline wins)", () => {
|
|
24
84
|
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
|
|
@@ -43,6 +43,13 @@ export interface StackingElement {
|
|
|
43
43
|
zIndex: number;
|
|
44
44
|
/** Audio clips have no visual stacking and are excluded from the computation. */
|
|
45
45
|
isAudio: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* CSS stacking context the clip's node lives in (TimelineElement.stackingContextId).
|
|
48
|
+
* Leaf z-indexes are only comparable WITHIN one context — across contexts the
|
|
49
|
+
* ancestors' z decides paint order — so the sync partitions by this key and
|
|
50
|
+
* never patches across contexts. Null/undefined ⇒ the root context.
|
|
51
|
+
*/
|
|
52
|
+
stackingContextId?: string | null;
|
|
46
53
|
/**
|
|
47
54
|
* Discovery / DOM document position (optional). Two clips with EQUAL z paint by
|
|
48
55
|
* DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
|
|
@@ -62,6 +69,14 @@ export interface StackingPatch {
|
|
|
62
69
|
|
|
63
70
|
const EPS = 1e-6;
|
|
64
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Canonical stacking-context key: null/undefined both mean the root context.
|
|
74
|
+
* The ONLY place the normalization lives — context partitioning, membership
|
|
75
|
+
* checks, and pairwise equality must all go through it.
|
|
76
|
+
*/
|
|
77
|
+
const contextKey = (el: { stackingContextId?: string | null }): string | null =>
|
|
78
|
+
el.stackingContextId ?? null;
|
|
79
|
+
|
|
65
80
|
/**
|
|
66
81
|
* Two clips overlap in time when their half-open [start, end) intervals intersect.
|
|
67
82
|
*
|
|
@@ -284,7 +299,14 @@ export function computeStackingPatches(
|
|
|
284
299
|
// z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
|
|
285
300
|
// unresolved clip is neither a neighbour nor resolvable as an edit, so it is
|
|
286
301
|
// excluded outright (item 13).
|
|
287
|
-
const
|
|
302
|
+
const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
|
|
303
|
+
|
|
304
|
+
// Leaf z is only meaningful within ONE stacking context: across contexts the
|
|
305
|
+
// ancestor contexts' z decides paint order, so comparing (or patching) leaf
|
|
306
|
+
// values across contexts is nonsense. Restrict the computation to the edited
|
|
307
|
+
// clips' own context(s); cross-context lane relations are out of scope.
|
|
308
|
+
const editedContexts = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(contextKey));
|
|
309
|
+
const resolved = allResolved.filter((e) => editedContexts.has(contextKey(e)));
|
|
288
310
|
|
|
289
311
|
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
|
|
290
312
|
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
|
|
@@ -304,8 +326,11 @@ export function computeStackingPatches(
|
|
|
304
326
|
// The full live set, so the transitive cascade can reach clips that overlap a
|
|
305
327
|
// LIFTED neighbour without overlapping the edited clip itself (#2198).
|
|
306
328
|
const all = [...byKey.values()];
|
|
329
|
+
const sameContext = (a: MutZ, b: MutZ) => contextKey(a) === contextKey(b);
|
|
307
330
|
const overlappersOf = (clip: MutZ): MutZ[] =>
|
|
308
|
-
all.filter(
|
|
331
|
+
all.filter(
|
|
332
|
+
(o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
|
|
333
|
+
);
|
|
309
334
|
|
|
310
335
|
for (const clip of edited) {
|
|
311
336
|
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
|
|
@@ -43,10 +43,15 @@ const TRACK_STYLE: TimelineTrackStyle = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
export const defaultTimelineTheme: TimelineTheme = {
|
|
46
|
+
// Near-black card surfaces: the panels sit dark while the shell canvas
|
|
47
|
+
// between them is a step LIGHTER (#18181B), so the gaps read as visible
|
|
48
|
+
// seams around dark cards (CapCut-style).
|
|
46
49
|
shellBackground: "#0A0A0B",
|
|
47
50
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
48
51
|
rulerBorder: "rgba(255,255,255,0.16)",
|
|
49
|
-
|
|
52
|
+
// All track lanes use a single uniform color — one step lighter than the panel
|
|
53
|
+
// surface (#0A0A0B) so lanes are visibly distinct from the ruler/shell.
|
|
54
|
+
rowBackground: "#101014",
|
|
50
55
|
rowBorder: "rgba(255,255,255,0.06)",
|
|
51
56
|
gutterBackground: "#0E0F12",
|
|
52
57
|
gutterBorder: "rgba(255,255,255,0.10)",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
|
+
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
|
5
|
+
import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
|
|
6
|
+
import { normalizeToZones } from "./timelineZones";
|
|
7
|
+
import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
|
|
8
|
+
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pipeline test across the REAL manifest→element boundary: no hand-injected
|
|
12
|
+
* `authoredTrack` / `stackingContextId`. A runtime-manifest-shaped payload with
|
|
13
|
+
* SPARSE authored tracks flows through createTimelineElementFromManifestClip →
|
|
14
|
+
* (normalizeToZones | buildChildElements) → commitDraggedClipMove, and the
|
|
15
|
+
* persisted data-track-index must be the AUTHORED target, never the display lane.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const manifestClip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
|
19
|
+
id: "x",
|
|
20
|
+
label: "x",
|
|
21
|
+
start: 0,
|
|
22
|
+
duration: 2,
|
|
23
|
+
track: 0,
|
|
24
|
+
stackingContextId: "root",
|
|
25
|
+
kind: "element",
|
|
26
|
+
tagName: "div",
|
|
27
|
+
compositionId: null,
|
|
28
|
+
parentCompositionId: null,
|
|
29
|
+
compositionSrc: null,
|
|
30
|
+
assetUrl: null,
|
|
31
|
+
...over,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function fromManifest(clips: ClipManifestClip[]): TimelineElement[] {
|
|
35
|
+
return clips.map((clip, index) =>
|
|
36
|
+
createTimelineElementFromManifestClip({ clip, fallbackIndex: index }),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function drag(
|
|
41
|
+
element: TimelineElement,
|
|
42
|
+
opts: { previewStart: number; previewTrack: number },
|
|
43
|
+
): DraggedClipState {
|
|
44
|
+
return {
|
|
45
|
+
element,
|
|
46
|
+
originClientX: 0,
|
|
47
|
+
originClientY: 0,
|
|
48
|
+
originScrollLeft: 0,
|
|
49
|
+
originScrollTop: 0,
|
|
50
|
+
pointerClientX: 0,
|
|
51
|
+
pointerClientY: 0,
|
|
52
|
+
pointerOffsetX: 0,
|
|
53
|
+
pointerOffsetY: 0,
|
|
54
|
+
previewStart: opts.previewStart,
|
|
55
|
+
previewTrack: opts.previewTrack,
|
|
56
|
+
desiredTrack: opts.previewTrack,
|
|
57
|
+
insertRow: null,
|
|
58
|
+
snapTime: null,
|
|
59
|
+
snapType: null,
|
|
60
|
+
started: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Commit a lane-change drag and return the single persisted edit batch. */
|
|
65
|
+
function commitLaneChange(
|
|
66
|
+
element: TimelineElement,
|
|
67
|
+
previewTrack: number,
|
|
68
|
+
elements: TimelineElement[],
|
|
69
|
+
trackOrder: number[],
|
|
70
|
+
): TimelineMoveEdit[] {
|
|
71
|
+
const onMoveElements = vi.fn();
|
|
72
|
+
commitDraggedClipMove(drag(element, { previewStart: element.start, previewTrack }), {
|
|
73
|
+
elements,
|
|
74
|
+
trackOrder,
|
|
75
|
+
updateElement: vi.fn(),
|
|
76
|
+
onMoveElements,
|
|
77
|
+
});
|
|
78
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
79
|
+
return onMoveElements.mock.calls[0][0] as TimelineMoveEdit[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("track persist pipeline (manifest → factory → lanes → drag commit)", () => {
|
|
83
|
+
// Sparse authored tracks 3 and 7 (mixed kinds), plus audio on 5, exactly as a
|
|
84
|
+
// runtime manifest would ship them (clip.track is the verbatim data-track-index).
|
|
85
|
+
const sparseManifest = [
|
|
86
|
+
manifestClip({ id: "v", kind: "video", tagName: "video", track: 3, start: 0 }),
|
|
87
|
+
manifestClip({ id: "g", kind: "element", tagName: "div", track: 7, start: 10 }),
|
|
88
|
+
manifestClip({ id: "m", kind: "audio", tagName: "audio", track: 5, start: 0 }),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
it("factory records the authored track and stacking context from the manifest clip", () => {
|
|
92
|
+
const [v, g, m] = fromManifest(sparseManifest);
|
|
93
|
+
expect([v.authoredTrack, g.authoredTrack, m.authoredTrack]).toEqual([3, 7, 5]);
|
|
94
|
+
expect(v.stackingContextId).toBe("root");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a lane change on a sparse file persists the AUTHORED target track, not the display lane", () => {
|
|
98
|
+
// normalizeToZones packs visual tracks {3, 7} onto display lanes {0, 1} and
|
|
99
|
+
// the audio track 5 onto lane 2, preserving the factory-set authoredTrack.
|
|
100
|
+
const elements = normalizeToZones(fromManifest(sparseManifest));
|
|
101
|
+
const byId = new Map(elements.map((e) => [e.id, e]));
|
|
102
|
+
expect(byId.get("v")).toMatchObject({ track: 0, authoredTrack: 3 });
|
|
103
|
+
expect(byId.get("g")).toMatchObject({ track: 1, authoredTrack: 7 });
|
|
104
|
+
expect(byId.get("m")).toMatchObject({ track: 2, authoredTrack: 5 });
|
|
105
|
+
|
|
106
|
+
// Drag the video (lane 0) onto the div's lane (display 1, authored 7).
|
|
107
|
+
const down = commitLaneChange(byId.get("v")!, 1, elements, [0, 1, 2]);
|
|
108
|
+
expect(down).toHaveLength(1);
|
|
109
|
+
expect(down[0].updates.track).toBe(7); // authored, NOT display lane 1
|
|
110
|
+
expect(down[0].updates.track).not.toBe(1);
|
|
111
|
+
|
|
112
|
+
// And the reverse: the div (lane 1) onto the video's lane (display 0, authored 3).
|
|
113
|
+
const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
|
|
114
|
+
expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
|
|
118
|
+
// Host timeline: the sub-comp host plus a root clip, discovered through the
|
|
119
|
+
// factory and lane-normalized like the store does.
|
|
120
|
+
const hostManifest = [
|
|
121
|
+
manifestClip({
|
|
122
|
+
id: "scene",
|
|
123
|
+
kind: "composition",
|
|
124
|
+
tagName: "div",
|
|
125
|
+
track: 0,
|
|
126
|
+
start: 0,
|
|
127
|
+
duration: 10,
|
|
128
|
+
compositionId: "scene",
|
|
129
|
+
compositionSrc: "scene.html",
|
|
130
|
+
}),
|
|
131
|
+
manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
|
|
132
|
+
];
|
|
133
|
+
// scene.html has SPARSE authored tracks 3 and 7.
|
|
134
|
+
const childClips = [
|
|
135
|
+
manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
|
|
136
|
+
manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
|
|
137
|
+
];
|
|
138
|
+
const storeElements = normalizeToZones(fromManifest(hostManifest));
|
|
139
|
+
const parentMap = new Map([
|
|
140
|
+
["c3", "scene"],
|
|
141
|
+
["c7", "scene"],
|
|
142
|
+
]);
|
|
143
|
+
const expanded = buildExpandedElements(
|
|
144
|
+
storeElements,
|
|
145
|
+
[...hostManifest, ...childClips],
|
|
146
|
+
parentMap,
|
|
147
|
+
"scene",
|
|
148
|
+
"scene",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// The children replaced the host row: synthetic display lanes, but the
|
|
152
|
+
// authored track (in scene.html's coordinate space) survived the expansion.
|
|
153
|
+
const c3 = expanded.find((e) => e.domId === "c3")!;
|
|
154
|
+
const c7 = expanded.find((e) => e.domId === "c7")!;
|
|
155
|
+
expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
|
|
156
|
+
expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
|
|
157
|
+
expect(c3.stackingContextId).toBe("root");
|
|
158
|
+
expect(c3.track).not.toBe(3); // display row is synthetic
|
|
159
|
+
|
|
160
|
+
// Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
|
|
161
|
+
// the written track must be c7's authored 7 — not the display-lane integer.
|
|
162
|
+
const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
|
|
163
|
+
const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
|
|
164
|
+
expect(edits).toHaveLength(1);
|
|
165
|
+
expect(edits[0].updates.track).toBe(7);
|
|
166
|
+
expect(edits[0].updates.track).not.toBe(c7.track);
|
|
167
|
+
});
|
|
168
|
+
});
|