@hyperframes/studio 0.7.57 → 0.7.59
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 → hyperframes-player-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +18029 -11563
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -6
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +55 -1
- package/src/hooks/timelineTrackVisibility.ts +31 -13
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +81 -162
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +182 -84
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +306 -155
- package/src/hooks/useTimelineEditing.ts +111 -101
- package/src/hooks/useTimelineEditingTypes.ts +4 -1
- package/src/hooks/useTimelineGroupEditing.ts +51 -5
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-DfmYkU44.js +0 -423
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
resolveRepositionLaneMove,
|
|
5
|
+
resolveZMirrorLaneMove,
|
|
6
|
+
type ZMirrorInput,
|
|
7
|
+
} from "./timelineZMirror";
|
|
8
|
+
|
|
9
|
+
function el(
|
|
10
|
+
id: string,
|
|
11
|
+
track: number,
|
|
12
|
+
start: number,
|
|
13
|
+
duration: number,
|
|
14
|
+
extra: Partial<TimelineElement> = {},
|
|
15
|
+
): TimelineElement {
|
|
16
|
+
return { id, key: id, tag: "video", start, duration, track, domId: id, ...extra };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function audio(id: string, track: number, start: number, duration: number): TimelineElement {
|
|
20
|
+
return el(id, track, start, duration, { tag: "audio" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolve(
|
|
24
|
+
action: ZMirrorInput["action"],
|
|
25
|
+
element: TimelineElement,
|
|
26
|
+
elements: TimelineElement[],
|
|
27
|
+
crossedKey?: string | null,
|
|
28
|
+
) {
|
|
29
|
+
return resolveZMirrorLaneMove({ action, element, elements, crossedKey });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Target on TOP lane 0; b/c fully occupy the two lanes below over t's span.
|
|
33
|
+
const stackBelow = () => {
|
|
34
|
+
const t = el("t", 0, 0, 10);
|
|
35
|
+
const b = el("b", 1, 0, 10);
|
|
36
|
+
const c = el("c", 2, 0, 10);
|
|
37
|
+
return { t, elements: [t, b, c] };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Sparse file: authored tracks 3/5/7 displayed as lanes 0/1/2 (a free over t's span).
|
|
41
|
+
const sparseAuthored = () => {
|
|
42
|
+
const a = el("a", 0, 20, 5, { authoredTrack: 3 });
|
|
43
|
+
const b = el("b", 1, 0, 10, { authoredTrack: 5 });
|
|
44
|
+
const t = el("t", 2, 0, 10, { authoredTrack: 7 });
|
|
45
|
+
return { t, elements: [a, b, t] };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("resolveZMirrorLaneMove — bring-forward / send-backward", () => {
|
|
49
|
+
// Stack: a on lane 0, b on lane 1, target on lane 2 — all overlapping in time.
|
|
50
|
+
const stack = () => {
|
|
51
|
+
const a = el("a", 0, 0, 10);
|
|
52
|
+
const b = el("b", 1, 0, 10);
|
|
53
|
+
const t = el("t", 2, 0, 10);
|
|
54
|
+
return { a, b, t, elements: [a, b, t] };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
it("bring-forward with crossedKey lands on the closest free lane above the neighbor", () => {
|
|
58
|
+
// Free lane 0 exists above the crossed neighbor's lane... make lane 0 free by
|
|
59
|
+
// shifting a out of the span.
|
|
60
|
+
const a = el("a", 0, 20, 5); // lane 0 free over t's span
|
|
61
|
+
const b = el("b", 1, 0, 10);
|
|
62
|
+
const t = el("t", 2, 0, 10);
|
|
63
|
+
expect(resolve("bring-forward", t, [a, b, t], "b")).toEqual({
|
|
64
|
+
kind: "move",
|
|
65
|
+
displayTrack: 0,
|
|
66
|
+
persistTrack: 0,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("bring-forward with crossedKey inserts above the neighbor when no lane is free", () => {
|
|
71
|
+
const { t, elements } = stack();
|
|
72
|
+
// Lanes 0 and 1 both occupied over t's span → new lane at the boundary
|
|
73
|
+
// ABOVE the crossed neighbor (row of lane 1 in the ascending order).
|
|
74
|
+
expect(resolve("bring-forward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 1 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("bring-forward without crossedKey uses the closest overlapping neighbor above", () => {
|
|
78
|
+
const { t, elements } = stack();
|
|
79
|
+
// Closest overlapping neighbor above lane 2 is b (lane 1); lanes 0/1 are
|
|
80
|
+
// occupied → insert above b, same as the crossedKey case.
|
|
81
|
+
expect(resolve("bring-forward", t, elements)).toEqual({ kind: "insert", insertRow: 1 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("bring-forward with an unknown crossedKey falls back to the temporal neighbor", () => {
|
|
85
|
+
const { t, elements } = stack();
|
|
86
|
+
expect(resolve("bring-forward", t, elements, "nope")).toEqual({
|
|
87
|
+
kind: "insert",
|
|
88
|
+
insertRow: 1,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("bring-forward returns null when nothing overlaps above and no crossedKey", () => {
|
|
93
|
+
const a = el("a", 0, 20, 5); // above but NOT overlapping in time
|
|
94
|
+
const t = el("t", 1, 0, 10);
|
|
95
|
+
expect(resolve("bring-forward", t, [a, t])).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("send-backward lands on the closest free lane below the neighbor", () => {
|
|
99
|
+
const t = el("t", 0, 0, 10);
|
|
100
|
+
const b = el("b", 1, 0, 10);
|
|
101
|
+
const c = el("c", 2, 20, 5); // lane 2 free over t's span
|
|
102
|
+
expect(resolve("send-backward", t, [t, b, c], "b")).toEqual({
|
|
103
|
+
kind: "move",
|
|
104
|
+
displayTrack: 2,
|
|
105
|
+
persistTrack: 2,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("send-backward inserts below the neighbor when no lane below is free", () => {
|
|
110
|
+
const { t, elements } = stackBelow();
|
|
111
|
+
// Boundary below b's lane (row 1 + 1 = 2).
|
|
112
|
+
expect(resolve("send-backward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("send-backward returns null when nothing overlaps below and no crossedKey", () => {
|
|
116
|
+
const t = el("t", 0, 0, 10);
|
|
117
|
+
const b = el("b", 1, 20, 5);
|
|
118
|
+
expect(resolve("send-backward", t, [t, b])).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("BOUNDED: never steps past the next overlapping element to a farther free lane", () => {
|
|
122
|
+
// Above neighbor b (lane 2): lane 1 holds x — the NEXT temporally-overlapping
|
|
123
|
+
// same-file element in the direction — and lane 0 is free. A single forward
|
|
124
|
+
// step crosses ONE element, so the free lane 0 beyond x is out of reach:
|
|
125
|
+
// insert immediately above b instead (row of lane 2 in the ascending order).
|
|
126
|
+
const a = el("a", 0, 30, 5); // lane 0 free over t's span — but beyond the bound
|
|
127
|
+
const x = el("x", 1, 5, 10); // overlaps t → the exclusive bound
|
|
128
|
+
const b = el("b", 2, 0, 10);
|
|
129
|
+
const t = el("t", 3, 0, 10);
|
|
130
|
+
expect(resolve("bring-forward", t, [a, x, b, t], "b")).toEqual({
|
|
131
|
+
kind: "insert",
|
|
132
|
+
insertRow: 2,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("OPEN SPACE: with no second overlapping element, skips an occupied lane to the next free one", () => {
|
|
137
|
+
// Lane 1's occupant is a FOREIGN-file clip: it occupies the lane (freeness is
|
|
138
|
+
// file-agnostic) but is not in the same stacking context, so it does not
|
|
139
|
+
// bound the step — the search continues to free lane 0, as before.
|
|
140
|
+
const a = el("a", 0, 30, 5); // lane 0 free over t's span
|
|
141
|
+
const x = el("x", 1, 5, 10, { sourceFile: "sub.html" });
|
|
142
|
+
const b = el("b", 2, 0, 10);
|
|
143
|
+
const t = el("t", 3, 0, 10);
|
|
144
|
+
expect(resolve("bring-forward", t, [a, x, b, t], "b")).toEqual({
|
|
145
|
+
kind: "move",
|
|
146
|
+
displayTrack: 0,
|
|
147
|
+
persistTrack: 0,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns null when the closest free lane is the clip's own lane (z/track divergence)", () => {
|
|
152
|
+
// Crossed neighbor sits BELOW the clip in lane space (diverged z): searching
|
|
153
|
+
// up from lane 2 finds lane 1 free — the clip's own lane → already in place.
|
|
154
|
+
const t = el("t", 1, 0, 10);
|
|
155
|
+
const b = el("b", 2, 0, 10);
|
|
156
|
+
expect(resolve("bring-forward", t, [t, b], "b")).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("resolveZMirrorLaneMove — one-element step bound (forward/backward)", () => {
|
|
161
|
+
// Three stacked back-to-back clips (lanes 0/1/2, all overlapping) plus a free
|
|
162
|
+
// lane BEYOND the far element — the lane the old resolver would overshoot to.
|
|
163
|
+
const threeStackedWithFarFree = () => {
|
|
164
|
+
const a = el("a", 0, 0, 10);
|
|
165
|
+
const b = el("b", 1, 0, 10);
|
|
166
|
+
const c = el("c", 2, 0, 10);
|
|
167
|
+
const d = el("d", 3, 20, 5); // lane 3 free over the span — beyond c
|
|
168
|
+
return { a, b, c, d, elements: [a, b, c, d] };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it("send-backward from the top inserts between elements 1 and 2 — not past element 2", () => {
|
|
172
|
+
const { a, elements } = threeStackedWithFarFree();
|
|
173
|
+
// Reference = b (lane 1); next overlap below = c (lane 2) bounds the search;
|
|
174
|
+
// no free lane strictly between → insert at the b/c boundary (row 2), NOT
|
|
175
|
+
// the farther free lane 3.
|
|
176
|
+
expect(resolve("send-backward", a, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("bring-forward from the bottom inserts between elements 1 and 2 (symmetric)", () => {
|
|
180
|
+
const d = el("d", 0, 20, 5); // lane 0 free over the span — beyond a
|
|
181
|
+
const a = el("a", 1, 0, 10);
|
|
182
|
+
const b = el("b", 2, 0, 10);
|
|
183
|
+
const t = el("t", 3, 0, 10);
|
|
184
|
+
// Reference = b (lane 2); next overlap above = a (lane 1) bounds the search;
|
|
185
|
+
// no free lane strictly between → insert at the a/b boundary (row 2), NOT
|
|
186
|
+
// the farther free lane 0.
|
|
187
|
+
expect(resolve("bring-forward", t, [d, a, b, t], "b")).toEqual({
|
|
188
|
+
kind: "insert",
|
|
189
|
+
insertRow: 2,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("takes a free lane strictly between the reference and the next overlap", () => {
|
|
194
|
+
const a = el("a", 0, 0, 10); // second element — the exclusive bound
|
|
195
|
+
const gap = el("gap", 1, 20, 5); // lane 1 free over the span, inside the interval
|
|
196
|
+
const b = el("b", 2, 0, 10); // crossed reference
|
|
197
|
+
const t = el("t", 3, 0, 10);
|
|
198
|
+
expect(resolve("bring-forward", t, [a, gap, b, t], "b")).toEqual({
|
|
199
|
+
kind: "move",
|
|
200
|
+
displayTrack: 1,
|
|
201
|
+
persistTrack: 1,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("of several free lanes in the interval, takes the one closest to the reference", () => {
|
|
206
|
+
const a = el("a", 0, 0, 10); // bound
|
|
207
|
+
const g1 = el("g1", 1, 20, 5); // free, farther from reference
|
|
208
|
+
const g2 = el("g2", 2, 20, 5); // free, closest to reference
|
|
209
|
+
const b = el("b", 3, 0, 10); // crossed reference
|
|
210
|
+
const t = el("t", 4, 0, 10);
|
|
211
|
+
expect(resolve("bring-forward", t, [a, g1, g2, b, t], "b")).toEqual({
|
|
212
|
+
kind: "move",
|
|
213
|
+
displayTrack: 2,
|
|
214
|
+
persistTrack: 2,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("no second overlapping element beyond the reference → the zone edge bounds (as today)", () => {
|
|
219
|
+
const { t, elements } = stackBelow();
|
|
220
|
+
// Only c overlaps below the reference b... remove c's overlap: reference is
|
|
221
|
+
// then the ONLY overlap below; the search runs to the zone edge and takes
|
|
222
|
+
// the free lane beyond the neighbor.
|
|
223
|
+
const spread = elements.map((e) => (e.id === "c" ? { ...e, start: 20 } : e));
|
|
224
|
+
expect(resolve("send-backward", t, spread, "b")).toEqual({
|
|
225
|
+
kind: "move",
|
|
226
|
+
displayTrack: 2,
|
|
227
|
+
persistTrack: 2,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("bring-to-front is NOT bounded: still moves past the whole overlap set", () => {
|
|
232
|
+
const { t, elements } = (() => {
|
|
233
|
+
const free = el("free", 0, 20, 5); // free lane beyond the topmost overlap
|
|
234
|
+
const a = el("a", 1, 0, 10);
|
|
235
|
+
const b = el("b", 2, 0, 10);
|
|
236
|
+
const t = el("t", 3, 0, 10);
|
|
237
|
+
return { t, elements: [free, a, b, t] };
|
|
238
|
+
})();
|
|
239
|
+
expect(resolve("bring-to-front", t, elements)).toEqual({
|
|
240
|
+
kind: "move",
|
|
241
|
+
displayTrack: 0,
|
|
242
|
+
persistTrack: 0,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("resolveZMirrorLaneMove — bring-to-front / send-to-back", () => {
|
|
248
|
+
it("bring-to-front moves above the topmost temporally-overlapping clip", () => {
|
|
249
|
+
const a = el("a", 0, 20, 5); // lane 0 free over t's span
|
|
250
|
+
const b = el("b", 1, 0, 10); // topmost overlap
|
|
251
|
+
const c = el("c", 2, 0, 10);
|
|
252
|
+
const t = el("t", 3, 0, 10);
|
|
253
|
+
expect(resolve("bring-to-front", t, [a, b, c, t])).toEqual({
|
|
254
|
+
kind: "move",
|
|
255
|
+
displayTrack: 0,
|
|
256
|
+
persistTrack: 0,
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("bring-to-front inserts above the topmost overlap when no lane is free", () => {
|
|
261
|
+
const b = el("b", 0, 0, 10);
|
|
262
|
+
const c = el("c", 1, 0, 10);
|
|
263
|
+
const t = el("t", 2, 0, 10);
|
|
264
|
+
expect(resolve("bring-to-front", t, [b, c, t])).toEqual({ kind: "insert", insertRow: 0 });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("bring-to-front is null when already topmost among overlaps (temporal scope)", () => {
|
|
268
|
+
// A clip exists on a higher lane but does NOT overlap in time — with the
|
|
269
|
+
// default temporal-overlap scope the target is already at the front.
|
|
270
|
+
const a = el("a", 0, 20, 5);
|
|
271
|
+
const t = el("t", 1, 0, 10);
|
|
272
|
+
const c = el("c", 2, 0, 10);
|
|
273
|
+
expect(resolve("bring-to-front", t, [a, t, c])).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("send-to-back moves below the bottommost temporally-overlapping clip", () => {
|
|
277
|
+
const t = el("t", 0, 0, 10);
|
|
278
|
+
const b = el("b", 1, 0, 10); // bottommost overlap
|
|
279
|
+
const c = el("c", 2, 20, 5); // lane 2 free over t's span
|
|
280
|
+
expect(resolve("send-to-back", t, [t, b, c])).toEqual({
|
|
281
|
+
kind: "move",
|
|
282
|
+
displayTrack: 2,
|
|
283
|
+
persistTrack: 2,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("send-to-back inserts below the bottommost overlap when no lane is free", () => {
|
|
288
|
+
const { t, elements } = stackBelow();
|
|
289
|
+
expect(resolve("send-to-back", t, elements)).toEqual({ kind: "insert", insertRow: 3 });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("send-to-back is null when already bottommost among overlaps", () => {
|
|
293
|
+
const t = el("t", 1, 0, 10);
|
|
294
|
+
const a = el("a", 0, 0, 10);
|
|
295
|
+
expect(resolve("send-to-back", t, [a, t])).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns null when nothing overlaps at all", () => {
|
|
299
|
+
const t = el("t", 0, 0, 10);
|
|
300
|
+
const a = el("a", 1, 20, 5);
|
|
301
|
+
for (const action of ["bring-to-front", "send-to-back"] as const) {
|
|
302
|
+
expect(resolve(action, t, [t, a])).toBeNull();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("resolveZMirrorLaneMove — span freeness", () => {
|
|
308
|
+
it("a lane free at the clip's start but occupied later in the span is NOT free", () => {
|
|
309
|
+
const t = el("t", 2, 0, 10);
|
|
310
|
+
const b = el("b", 1, 0, 10); // crossed neighbor
|
|
311
|
+
// Lane 0: nothing at t=0, but occupied over [6, 9) — inside t's span.
|
|
312
|
+
const late = el("late", 0, 6, 3);
|
|
313
|
+
expect(resolve("bring-forward", t, [late, b, t], "b")).toEqual({
|
|
314
|
+
kind: "insert",
|
|
315
|
+
insertRow: 1,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("half-open spans: a clip starting exactly at the moved clip's end does not occupy", () => {
|
|
320
|
+
const t = el("t", 2, 0, 10);
|
|
321
|
+
const b = el("b", 1, 0, 10);
|
|
322
|
+
const adjacent = el("adj", 0, 10, 5); // [10, 15) touches [0, 10) but no overlap
|
|
323
|
+
expect(resolve("bring-forward", t, [adjacent, b, t], "b")).toEqual({
|
|
324
|
+
kind: "move",
|
|
325
|
+
displayTrack: 0,
|
|
326
|
+
persistTrack: 0,
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("freeness is file-agnostic: an other-file clip occupies the lane", () => {
|
|
331
|
+
const t = el("t", 2, 0, 10);
|
|
332
|
+
const b = el("b", 1, 0, 10);
|
|
333
|
+
const foreign = el("f", 0, 0, 10, { sourceFile: "sub.html" });
|
|
334
|
+
expect(resolve("bring-forward", t, [foreign, b, t], "b")).toEqual({
|
|
335
|
+
kind: "insert",
|
|
336
|
+
insertRow: 1,
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("resolveZMirrorLaneMove — zone boundary (audio untouched)", () => {
|
|
342
|
+
// Visual lanes 0-1, audio lanes 2-3.
|
|
343
|
+
const zoned = () => {
|
|
344
|
+
const t = el("t", 0, 0, 10);
|
|
345
|
+
const b = el("b", 1, 0, 10);
|
|
346
|
+
const m = audio("music", 2, 0, 30);
|
|
347
|
+
const vo = audio("vo", 3, 0, 30);
|
|
348
|
+
return { t, b, m, vo, elements: [t, b, m, vo] };
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
it("send-backward never lands on an audio lane — inserts at the zone boundary", () => {
|
|
352
|
+
const { t, elements } = zoned();
|
|
353
|
+
// Lane 2 (audio) is out of bounds even though "below"; boundary row 2 sits
|
|
354
|
+
// between the bottom visual lane and the first audio lane — a visual insert.
|
|
355
|
+
expect(resolve("send-backward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("send-to-back stops at the visual zone edge", () => {
|
|
359
|
+
const { t, elements } = zoned();
|
|
360
|
+
expect(resolve("send-to-back", t, elements)).toEqual({ kind: "insert", insertRow: 2 });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("audio clips never mirror (returns null)", () => {
|
|
364
|
+
const { m, elements } = zoned();
|
|
365
|
+
for (const action of [
|
|
366
|
+
"bring-to-front",
|
|
367
|
+
"bring-forward",
|
|
368
|
+
"send-backward",
|
|
369
|
+
"send-to-back",
|
|
370
|
+
] as const) {
|
|
371
|
+
expect(resolve(action, m, elements)).toBeNull();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("audio clips do not count as overlap references for visual clips", () => {
|
|
376
|
+
// Only audio below the target → send-backward has no visual neighbor → null.
|
|
377
|
+
const t = el("t", 0, 0, 10);
|
|
378
|
+
const m = audio("music", 1, 0, 30);
|
|
379
|
+
expect(resolve("send-backward", t, [t, m])).toBeNull();
|
|
380
|
+
expect(resolve("send-to-back", t, [t, m])).toBeNull();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe("resolveZMirrorLaneMove — authored (persist) space", () => {
|
|
385
|
+
it("persistTrack takes the target lane occupant's authoredTrack, not the display lane", () => {
|
|
386
|
+
// Occupant of the free-over-span target lane 0 (authored 3) anchors the persist value.
|
|
387
|
+
const { t, elements } = sparseAuthored();
|
|
388
|
+
expect(resolve("bring-forward", t, elements, "b")).toEqual({
|
|
389
|
+
kind: "move",
|
|
390
|
+
displayTrack: 0,
|
|
391
|
+
persistTrack: 3,
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("falls back to nearest-same-file lane offset when the target lane has no same-file occupant", () => {
|
|
396
|
+
// The moved clip is an expanded sub-comp child; the target lane's only
|
|
397
|
+
// occupant belongs to the host file, so the persist value offsets from the
|
|
398
|
+
// nearest same-file lane instead (authored 4 at lane 1 → lane 0 = 3).
|
|
399
|
+
const host = el("h", 0, 20, 5); // host-file clip on the target lane (not overlapping)
|
|
400
|
+
const sib = el("s", 1, 0, 10, { sourceFile: "sub.html", authoredTrack: 4 });
|
|
401
|
+
const t = el("t", 2, 0, 10, { sourceFile: "sub.html", authoredTrack: 5 });
|
|
402
|
+
expect(resolve("bring-forward", t, [host, sib, t], "s")).toEqual({
|
|
403
|
+
kind: "move",
|
|
404
|
+
displayTrack: 0,
|
|
405
|
+
persistTrack: 3,
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe("resolveZMirrorLaneMove — stacking-context (source file) scoping", () => {
|
|
411
|
+
it("other-file clips are not overlap references (extremes computed per file)", () => {
|
|
412
|
+
// A host clip overlaps above the sub-comp child, but the child's own file
|
|
413
|
+
// has nothing above it → bring-to-front is null (already at ITS front).
|
|
414
|
+
const host = el("h", 0, 0, 10);
|
|
415
|
+
const t = el("t", 1, 0, 10, { sourceFile: "sub.html" });
|
|
416
|
+
expect(resolve("bring-to-front", t, [host, t])).toBeNull();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("same-file overlaps in an expanded sub-comp resolve within the child's lanes", () => {
|
|
420
|
+
const host = el("h", 0, 0, 10);
|
|
421
|
+
const sib = el("s", 1, 0, 10, { sourceFile: "sub.html", authoredTrack: 0 });
|
|
422
|
+
const t = el("t", 2, 0, 10, { sourceFile: "sub.html", authoredTrack: 1 });
|
|
423
|
+
// Topmost same-file overlap is sib (lane 1); lane 0 is occupied by the host
|
|
424
|
+
// over the span (freeness is file-agnostic) → insert above sib's lane.
|
|
425
|
+
expect(resolve("bring-to-front", t, [host, sib, t])).toEqual({
|
|
426
|
+
kind: "insert",
|
|
427
|
+
insertRow: 1,
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("resolveZMirrorLaneMove — degenerate inputs and determinism", () => {
|
|
433
|
+
it("zero-duration element returns null", () => {
|
|
434
|
+
const t = el("t", 1, 0, 0);
|
|
435
|
+
const a = el("a", 0, 0, 10);
|
|
436
|
+
expect(resolve("bring-to-front", t, [a, t])).toBeNull();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("single-clip timeline returns null for every action", () => {
|
|
440
|
+
const t = el("t", 0, 0, 10);
|
|
441
|
+
for (const action of [
|
|
442
|
+
"bring-to-front",
|
|
443
|
+
"bring-forward",
|
|
444
|
+
"send-backward",
|
|
445
|
+
"send-to-back",
|
|
446
|
+
] as const) {
|
|
447
|
+
expect(resolve(action, t, [t])).toBeNull();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("identical inputs produce identical outputs (deterministic, input untouched)", () => {
|
|
452
|
+
const first = sparseAuthored();
|
|
453
|
+
const snapshot = structuredClone(first.elements);
|
|
454
|
+
const r1 = resolve("bring-forward", first.t, first.elements, "b");
|
|
455
|
+
const r2 = resolve("bring-forward", first.t, first.elements, "b");
|
|
456
|
+
const fresh = sparseAuthored();
|
|
457
|
+
const r3 = resolve("bring-forward", fresh.t, fresh.elements, "b");
|
|
458
|
+
expect(r1).toEqual(r2);
|
|
459
|
+
expect(r1).toEqual(r3);
|
|
460
|
+
expect(first.elements).toEqual(snapshot); // pure — never mutates its input
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("resolveRepositionLaneMove (Layers-panel equal jump)", () => {
|
|
465
|
+
// Bottom→top render order helper: keys as the panel's reversed order.
|
|
466
|
+
const reposition = (
|
|
467
|
+
element: TimelineElement,
|
|
468
|
+
elements: TimelineElement[],
|
|
469
|
+
desiredOrderKeys: (string | null)[],
|
|
470
|
+
) => resolveRepositionLaneMove({ element, elements, desiredOrderKeys });
|
|
471
|
+
|
|
472
|
+
// Three stacked clips on lanes 0/1/2, all overlapping. Render order matches
|
|
473
|
+
// lanes today: bottom→top = c(2), b(1), t(0)... target starts on lane 2.
|
|
474
|
+
const stack3 = () => {
|
|
475
|
+
const a = el("a", 0, 0, 10);
|
|
476
|
+
const b = el("b", 1, 0, 10);
|
|
477
|
+
const t = el("t", 2, 0, 10);
|
|
478
|
+
return { a, b, t, elements: [a, b, t] };
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
it("multi-step jump to the top inserts a new lane above the new below-neighbor", () => {
|
|
482
|
+
const { t, elements } = stack3();
|
|
483
|
+
// t dragged to the TOP of the panel: bottom→top = [b, a, t].
|
|
484
|
+
// New below-neighbor is a (lane 0); lanes above are occupied/none free →
|
|
485
|
+
// insert at a's boundary (order.indexOf(0) = 0).
|
|
486
|
+
expect(reposition(t, elements, ["b", "a", "t"])).toEqual({ kind: "insert", insertRow: 0 });
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("multi-step jump lands on a free lane between the new neighbors", () => {
|
|
490
|
+
// Lanes 0,1,2,3: a(0), b(1) short-lived, c(2), t(3). Lane 1 is free over
|
|
491
|
+
// t's span. t dragged between a and c in paint order: above-neighbor a
|
|
492
|
+
// (lane 0), below-neighbor c (lane 2) → free lane 1, strictly between.
|
|
493
|
+
const a = el("a", 0, 0, 10);
|
|
494
|
+
const b = el("b", 1, 20, 5);
|
|
495
|
+
const c = el("c", 2, 0, 10);
|
|
496
|
+
const t = el("t", 3, 0, 10);
|
|
497
|
+
// bottom→top: c (bottom), t (middle), a (top); b not in the sibling set.
|
|
498
|
+
expect(reposition(t, [a, b, c, t], ["c", "t", "a"])).toEqual({
|
|
499
|
+
kind: "move",
|
|
500
|
+
displayTrack: 1,
|
|
501
|
+
persistTrack: 1,
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("drop toward the bottom lands on a free lane below the new above-neighbor", () => {
|
|
506
|
+
// Lanes: t(0), b(1), c(2) short-lived → lane 2 free over t's span.
|
|
507
|
+
const t = el("t", 0, 0, 10);
|
|
508
|
+
const b = el("b", 1, 0, 10);
|
|
509
|
+
const c = el("c", 2, 20, 5);
|
|
510
|
+
// t dragged below b: bottom→top = [t, b]. Above-neighbor b (lane 1) →
|
|
511
|
+
// closest free lane below it is lane 2.
|
|
512
|
+
expect(reposition(t, [t, b, c], ["t", "b"])).toEqual({
|
|
513
|
+
kind: "move",
|
|
514
|
+
displayTrack: 2,
|
|
515
|
+
persistTrack: 2,
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("drop below the bottom clip inserts a new bottom lane when none is free", () => {
|
|
520
|
+
const x = el("x", 0, 0, 10);
|
|
521
|
+
const y = el("y", 1, 0, 10);
|
|
522
|
+
const z = el("z", 2, 0, 10);
|
|
523
|
+
// x (top lane 0) dragged to the bottom of the panel: bottom→top = [x, z, y].
|
|
524
|
+
// Above-neighbor = z (lane 2); no free lane below it → insert below z
|
|
525
|
+
// (order.indexOf(2) + 1 = 3).
|
|
526
|
+
expect(reposition(x, [x, y, z], ["x", "z", "y"])).toEqual({ kind: "insert", insertRow: 3 });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("skips non-clip siblings (null keys) when resolving neighbors", () => {
|
|
530
|
+
// t on TOP lane 0, a below on lane 1. t dragged below a in paint order,
|
|
531
|
+
// with two decorations (null keys) interleaved: the resolver must skip the
|
|
532
|
+
// nulls and find a (lane 1) as the above-neighbor → no free lane below it
|
|
533
|
+
// → insert below a (order.indexOf(1) + 1 = 2).
|
|
534
|
+
const t = el("t", 0, 0, 10);
|
|
535
|
+
const a = el("a", 1, 0, 10);
|
|
536
|
+
expect(reposition(t, [t, a], [null, "t", null, "a"])).toEqual({
|
|
537
|
+
kind: "insert",
|
|
538
|
+
insertRow: 2,
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("returns null when the drop leaves the clip where it already sits", () => {
|
|
543
|
+
const a = el("a", 0, 20, 5); // lane 0 free over t's span but t stays put
|
|
544
|
+
const b = el("b", 1, 0, 10);
|
|
545
|
+
const t = el("t", 2, 0, 10);
|
|
546
|
+
// bottom→top = [t, b] — t stays below b; nearest above-neighbor b (lane 1),
|
|
547
|
+
// scanning down from lane 1 finds t's own lane 2 first.
|
|
548
|
+
expect(reposition(t, [a, b, t], ["t", "b"])).toBeNull();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("returns null for audio, zero-duration, decoration-only sets, and unknown self", () => {
|
|
552
|
+
const t = el("t", 1, 0, 10);
|
|
553
|
+
const a = el("a", 0, 0, 10);
|
|
554
|
+
expect(reposition(audio("s", 3, 0, 10), [t, a], ["s", "t"])).toBeNull();
|
|
555
|
+
expect(reposition(el("z0", 1, 0, 0), [t, a], ["z0", "t"])).toBeNull();
|
|
556
|
+
expect(reposition(t, [t, a], [null, "t", null])).toBeNull(); // no clip neighbor
|
|
557
|
+
expect(reposition(t, [t, a], ["a"])).toBeNull(); // self missing from order
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("audio lanes are never targeted (insert stays within the visual zone)", () => {
|
|
561
|
+
const a = el("a", 0, 0, 10);
|
|
562
|
+
const t = el("t", 1, 0, 10);
|
|
563
|
+
const music = audio("m", 2, 0, 30);
|
|
564
|
+
// t dropped below a... wait, t already below a. Drag a below t instead:
|
|
565
|
+
// bottom→top = [a, t]. Above-neighbor t (lane 1); no free visual lane below
|
|
566
|
+
// → insert below t (order.indexOf(1) + 1 = 2), never onto the audio lane.
|
|
567
|
+
expect(reposition(a, [a, t, music], ["a", "t"])).toEqual({ kind: "insert", insertRow: 2 });
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("is pure: identical inputs, identical outputs, input untouched", () => {
|
|
571
|
+
const build = () => {
|
|
572
|
+
const a = el("a", 0, 0, 10);
|
|
573
|
+
const b = el("b", 1, 0, 10);
|
|
574
|
+
const t = el("t", 2, 0, 10);
|
|
575
|
+
return { t, elements: [a, b, t] };
|
|
576
|
+
};
|
|
577
|
+
const first = build();
|
|
578
|
+
const snapshot = structuredClone(first.elements);
|
|
579
|
+
const r1 = reposition(first.t, first.elements, ["b", "a", "t"]);
|
|
580
|
+
const fresh = build();
|
|
581
|
+
const r2 = reposition(fresh.t, fresh.elements, ["b", "a", "t"]);
|
|
582
|
+
expect(r1).toEqual(r2);
|
|
583
|
+
expect(first.elements).toEqual(snapshot);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe("paint scope (source file + stacking context)", () => {
|
|
588
|
+
it("forward never references a same-file clip in a DIFFERENT stacking context", () => {
|
|
589
|
+
// b overlaps t and sits above, but lives in a nested stacking context —
|
|
590
|
+
// leaf z is not comparable, so the mirror must not treat it as a neighbor.
|
|
591
|
+
const b = el("b", 0, 0, 10, { stackingContextId: "ctx-nested" });
|
|
592
|
+
const t = el("t", 1, 0, 10);
|
|
593
|
+
expect(resolve("bring-forward", t, [b, t])).toBeNull();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("cross-file clips with matching null root contexts never compare (expanded view)", () => {
|
|
597
|
+
const foreign = el("f", 0, 0, 10, { sourceFile: "scenes/intro.html" });
|
|
598
|
+
const t = el("t", 1, 0, 10);
|
|
599
|
+
expect(resolve("bring-forward", t, [foreign, t])).toBeNull();
|
|
600
|
+
expect(resolve("bring-to-front", t, [foreign, t])).toBeNull();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("reposition skips neighbors outside the paint scope when resolving lanes", () => {
|
|
604
|
+
// t (lane 0) dragged below a in paint order; o sits between them in the
|
|
605
|
+
// DESIRED order but lives in a nested stacking context, so it must be
|
|
606
|
+
// skipped: the above-neighbor is a (lane 1). Lane 2 is occupied by o over
|
|
607
|
+
// the span (freeness is scope-agnostic) → insert below a (row 2).
|
|
608
|
+
const t = el("t", 0, 0, 10);
|
|
609
|
+
const a = el("a", 1, 0, 10);
|
|
610
|
+
const other = el("o", 2, 0, 10, { stackingContextId: "ctx-a" });
|
|
611
|
+
expect(
|
|
612
|
+
resolveRepositionLaneMove({
|
|
613
|
+
element: t,
|
|
614
|
+
elements: [t, a, other],
|
|
615
|
+
desiredOrderKeys: ["t", "o", "a"],
|
|
616
|
+
}),
|
|
617
|
+
).toEqual({ kind: "insert", insertRow: 2 });
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
describe("expanded sub-comp children — lane scoping", () => {
|
|
622
|
+
const child = (id: string, track: number, start: number, duration: number) =>
|
|
623
|
+
el(id, track, start, duration, {
|
|
624
|
+
sourceFile: "scene.html",
|
|
625
|
+
expandedParentStart: 5,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("a child mirrors onto a SIBLING's lane and persists the sibling's AUTHORED track", () => {
|
|
629
|
+
// Sibling c1 owns fractional display lane 0.25 (authored track 2 in its own
|
|
630
|
+
// file) but sits elsewhere in time → its lane is free over t's span.
|
|
631
|
+
const c1 = el("c1", 0.25, 20, 3, {
|
|
632
|
+
sourceFile: "scene.html",
|
|
633
|
+
expandedParentStart: 5,
|
|
634
|
+
authoredTrack: 2,
|
|
635
|
+
});
|
|
636
|
+
const c2 = child("c2", 0.5, 5, 5);
|
|
637
|
+
const t = child("t", 0.75, 5, 5);
|
|
638
|
+
expect(resolve("bring-forward", t, [c1, c2, t], "c2")).toEqual({
|
|
639
|
+
kind: "move",
|
|
640
|
+
displayTrack: 0.25,
|
|
641
|
+
persistTrack: 2,
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("a child NEVER lands on a host-space lane with no same-file occupant", () => {
|
|
646
|
+
// Free integer host lane 0 above the crossed sibling — out of scope for a
|
|
647
|
+
// child; with no sibling lane free either, the mirror refuses (null), it
|
|
648
|
+
// does not insert.
|
|
649
|
+
const host = el("h", 0, 20, 3); // host-space lane, free over the span
|
|
650
|
+
const c2 = child("c2", 0.5, 5, 5);
|
|
651
|
+
const t = child("t", 0.75, 5, 5);
|
|
652
|
+
expect(resolve("bring-forward", t, [host, c2, t], "c2")).toBeNull();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("reposition of a child refuses instead of inserting a host lane", () => {
|
|
656
|
+
const c2 = child("c2", 0.5, 5, 5);
|
|
657
|
+
const t = child("t", 0.75, 5, 5);
|
|
658
|
+
// t dragged above c2 in the layers order; no free sibling lane → null.
|
|
659
|
+
expect(
|
|
660
|
+
resolveRepositionLaneMove({
|
|
661
|
+
element: t,
|
|
662
|
+
elements: [c2, t],
|
|
663
|
+
desiredOrderKeys: ["c2", "t"],
|
|
664
|
+
}),
|
|
665
|
+
).toBeNull();
|
|
666
|
+
});
|
|
667
|
+
});
|