@hyperframes/studio 0.7.57 → 0.7.58
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-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- 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 +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- 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/LayersPanel.tsx +110 -99
- 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/layersPanelSort.ts +80 -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/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- 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 +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- 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/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -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/sourceScopedSelectorIndex.ts +29 -0
- 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,221 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
laneGapFloor,
|
|
5
|
+
resolveAllGapIntervals,
|
|
6
|
+
resolveAllTrackGaps,
|
|
7
|
+
resolveCloseGapShifts,
|
|
8
|
+
resolveTrackGapAt,
|
|
9
|
+
trackHasGaps,
|
|
10
|
+
} from "./timelineGaps";
|
|
11
|
+
|
|
12
|
+
function el(id: string, start: number, duration: number): TimelineElement {
|
|
13
|
+
return { id, tag: "video", start, duration, track: 0, domId: id };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("resolveTrackGapAt", () => {
|
|
17
|
+
it("resolves a middle gap between two clips", () => {
|
|
18
|
+
const els = [el("a", 0, 2), el("b", 5, 3)];
|
|
19
|
+
const gap = resolveTrackGapAt(els, 3);
|
|
20
|
+
expect(gap).toEqual({ gapStart: 2, gapEnd: 5, followingKeys: ["b"] });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("resolves the leading gap before the first clip (gapStart = 0)", () => {
|
|
24
|
+
const els = [el("a", 2, 3), el("b", 6, 1)];
|
|
25
|
+
const gap = resolveTrackGapAt(els, 1);
|
|
26
|
+
expect(gap).toEqual({ gapStart: 0, gapEnd: 2, followingKeys: ["a", "b"] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("includes EVERY clip at/after the gap in followingKeys", () => {
|
|
30
|
+
const els = [el("a", 0, 1), el("b", 3, 1), el("c", 5, 1), el("d", 8, 1)];
|
|
31
|
+
const gap = resolveTrackGapAt(els, 2);
|
|
32
|
+
expect(gap).toEqual({ gapStart: 1, gapEnd: 3, followingKeys: ["b", "c", "d"] });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns null when there is no clip to the right of the point", () => {
|
|
36
|
+
const els = [el("a", 0, 2)];
|
|
37
|
+
expect(resolveTrackGapAt(els, 5)).toBeNull();
|
|
38
|
+
expect(resolveTrackGapAt([], 1)).toBeNull(); // empty lane
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null when the point is inside a clip (half-open interval)", () => {
|
|
42
|
+
const els = [el("a", 1, 2), el("b", 5, 1)];
|
|
43
|
+
expect(resolveTrackGapAt(els, 2)).toBeNull(); // strictly inside
|
|
44
|
+
expect(resolveTrackGapAt(els, 1)).toBeNull(); // at clip start (occupied)
|
|
45
|
+
// At clip END (half-open) the point is free — the gap to "b" resolves.
|
|
46
|
+
expect(resolveTrackGapAt(els, 3)).toEqual({ gapStart: 3, gapEnd: 5, followingKeys: ["b"] });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("resolves the leading gap for a single clip", () => {
|
|
50
|
+
const gap = resolveTrackGapAt([el("a", 4, 2)], 1);
|
|
51
|
+
expect(gap).toEqual({ gapStart: 0, gapEnd: 4, followingKeys: ["a"] });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("treats epsilon-adjacent clips as gapless (float drift)", () => {
|
|
55
|
+
// 8.4 + 2.7 = 11.100000000000001 — the classic drift: no point near the
|
|
56
|
+
// seam resolves a gap.
|
|
57
|
+
const drifted = [el("a", 8.4, 2.7), el("b", 11.1, 2)];
|
|
58
|
+
expect(resolveTrackGapAt(drifted, 11.0999)).toBeNull();
|
|
59
|
+
expect(resolveTrackGapAt(drifted, 11.1005)).toBeNull();
|
|
60
|
+
// A sub-epsilon sliver between clips is not a closable gap either.
|
|
61
|
+
const sliver = [el("a", 0, 2.0004), el("b", 2.001, 1)];
|
|
62
|
+
expect(resolveTrackGapAt(sliver, 1.9995)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles overlapping clips sanely (uses the max end left of the point)", () => {
|
|
66
|
+
const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
|
|
67
|
+
const gap = resolveTrackGapAt(els, 5);
|
|
68
|
+
expect(gap).toEqual({ gapStart: 4, gapEnd: 6, followingKeys: ["c"] });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prefers the key over the id when present", () => {
|
|
72
|
+
const withKey = { ...el("a", 3, 1), key: "a-key" };
|
|
73
|
+
const gap = resolveTrackGapAt([withKey], 1);
|
|
74
|
+
expect(gap?.followingKeys).toEqual(["a-key"]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("resolveCloseGapShifts", () => {
|
|
79
|
+
it("shifts the following clips left by exactly the gap width", () => {
|
|
80
|
+
const els = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
81
|
+
const gap = resolveTrackGapAt(els, 3)!;
|
|
82
|
+
expect(resolveCloseGapShifts(els, gap)).toEqual([
|
|
83
|
+
{ key: "b", newStart: 2 },
|
|
84
|
+
{ key: "c", newStart: 7 },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("closing the leading gap lands the first clip at 0", () => {
|
|
89
|
+
const els = [el("a", 2, 3), el("b", 6, 1)];
|
|
90
|
+
const gap = resolveTrackGapAt(els, 1)!;
|
|
91
|
+
expect(resolveCloseGapShifts(els, gap)).toEqual([
|
|
92
|
+
{ key: "a", newStart: 0 },
|
|
93
|
+
{ key: "b", newStart: 4 },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rounds shifted starts to millisecond precision", () => {
|
|
98
|
+
const els = [el("a", 0, 1.1), el("b", 3.3000000000000003, 1)];
|
|
99
|
+
const gap = resolveTrackGapAt(els, 2)!;
|
|
100
|
+
const shifts = resolveCloseGapShifts(els, gap);
|
|
101
|
+
expect(shifts).toEqual([{ key: "b", newStart: 1.1 }]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("resolveAllTrackGaps", () => {
|
|
106
|
+
it("compacts every gap: contiguous from 0, order and durations preserved", () => {
|
|
107
|
+
const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
108
|
+
expect(resolveAllTrackGaps(els)).toEqual([
|
|
109
|
+
{ key: "a", newStart: 0 },
|
|
110
|
+
{ key: "b", newStart: 2 },
|
|
111
|
+
{ key: "c", newStart: 5 },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("includes the leading gap for a single clip", () => {
|
|
116
|
+
expect(resolveAllTrackGaps([el("a", 4, 2)])).toEqual([{ key: "a", newStart: 0 }]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns only the clips whose start actually changes", () => {
|
|
120
|
+
const els = [el("a", 0, 2), el("b", 2, 1), el("c", 5, 1)];
|
|
121
|
+
expect(resolveAllTrackGaps(els)).toEqual([{ key: "c", newStart: 3 }]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns [] for an already-contiguous track and for an empty lane", () => {
|
|
125
|
+
expect(resolveAllTrackGaps([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
|
|
126
|
+
expect(resolveAllTrackGaps([])).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("ignores epsilon-level drift instead of emitting no-op shifts", () => {
|
|
130
|
+
const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
|
|
131
|
+
expect(resolveAllTrackGaps(els)).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("serializes overlapping clips in start order (sum-of-durations rule)", () => {
|
|
135
|
+
const els = [el("a", 0, 4), el("b", 2, 2)];
|
|
136
|
+
expect(resolveAllTrackGaps(els)).toEqual([{ key: "b", newStart: 4 }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("is deterministic for identical starts (key tie-break)", () => {
|
|
140
|
+
const els = [el("b", 3, 1), el("a", 3, 2)];
|
|
141
|
+
expect(resolveAllTrackGaps(els)).toEqual([
|
|
142
|
+
{ key: "a", newStart: 0 },
|
|
143
|
+
{ key: "b", newStart: 2 },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("resolveAllGapIntervals", () => {
|
|
149
|
+
it("reports every current gap, leading gap included, left to right", () => {
|
|
150
|
+
const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
151
|
+
expect(resolveAllGapIntervals(els)).toEqual([
|
|
152
|
+
{ start: 0, end: 1 },
|
|
153
|
+
{ start: 3, end: 5 },
|
|
154
|
+
{ start: 8, end: 10 },
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns [] for contiguous or empty lanes", () => {
|
|
159
|
+
expect(resolveAllGapIntervals([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
|
|
160
|
+
expect(resolveAllGapIntervals([])).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("never fabricates an interval from overlapping clips (cursor = max end)", () => {
|
|
164
|
+
const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
|
|
165
|
+
expect(resolveAllGapIntervals(els)).toEqual([{ start: 4, end: 6 }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("ignores epsilon-level drift seams", () => {
|
|
169
|
+
const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
|
|
170
|
+
expect(resolveAllGapIntervals(els)).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("trackHasGaps", () => {
|
|
175
|
+
it("detects gaps, including the leading gap", () => {
|
|
176
|
+
expect(trackHasGaps([el("a", 1, 2)])).toBe(true);
|
|
177
|
+
expect(trackHasGaps([el("a", 0, 2), el("b", 4, 1)])).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("is false for contiguous or empty tracks", () => {
|
|
181
|
+
expect(trackHasGaps([el("a", 0, 2), el("b", 2, 1)])).toBe(false);
|
|
182
|
+
expect(trackHasGaps([])).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("lane floor (expanded sub-comp children)", () => {
|
|
187
|
+
const child = (id: string, start: number, duration: number): TimelineElement => ({
|
|
188
|
+
...el(id, start, duration),
|
|
189
|
+
expandedParentStart: 16,
|
|
190
|
+
sourceFile: "scene.html",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("laneGapFloor is 0 for ordinary lanes and the host window start for child lanes", () => {
|
|
194
|
+
expect(laneGapFloor([el("a", 0, 2)])).toBe(0);
|
|
195
|
+
expect(laneGapFloor([child("c1", 16.5, 2), child("c2", 20, 2)])).toBe(16);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("compaction lands the first child at the HOST window start, never absolute 0", () => {
|
|
199
|
+
const lane = [child("c1", 18, 2), child("c2", 22, 2)];
|
|
200
|
+
expect(resolveAllTrackGaps(lane, undefined, laneGapFloor(lane))).toEqual([
|
|
201
|
+
{ key: "c1", newStart: 16 },
|
|
202
|
+
{ key: "c2", newStart: 18 },
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("the leading gap starts at the floor for both close-one and the highlight intervals", () => {
|
|
207
|
+
const lane = [child("c1", 18, 2)];
|
|
208
|
+
const floor = laneGapFloor(lane);
|
|
209
|
+
expect(resolveTrackGapAt(lane, 17, undefined, floor)).toEqual({
|
|
210
|
+
gapStart: 16,
|
|
211
|
+
gapEnd: 18,
|
|
212
|
+
followingKeys: ["c1"],
|
|
213
|
+
});
|
|
214
|
+
expect(resolveAllGapIntervals(lane, undefined, floor)).toEqual([{ start: 16, end: 18 }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("a child lane contiguous from its host start has no gaps", () => {
|
|
218
|
+
const lane = [child("c1", 16, 2), child("c2", 18, 2)];
|
|
219
|
+
expect(trackHasGaps(lane, undefined, laneGapFloor(lane))).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure gap math for a single timeline display lane (CapCut/Premiere-style
|
|
5
|
+
* "Close gap" / "Close all gaps"). Operates on the DISPLAY element set the
|
|
6
|
+
* timeline renders for one lane — the caller passes the clips of the
|
|
7
|
+
* right-clicked lane only; cross-lane behavior is out of scope by design.
|
|
8
|
+
*
|
|
9
|
+
* Conventions:
|
|
10
|
+
* - A clip occupies the half-open interval [start, start + duration).
|
|
11
|
+
* - Comparisons are epsilon-tolerant ({@link TRACK_GAP_EPSILON_S}) so float
|
|
12
|
+
* drift (e.g. 8.4 + 2.7 = 11.100000000000001) never fabricates a sliver gap.
|
|
13
|
+
* - Computed starts are rounded to millisecond precision, matching the drag
|
|
14
|
+
* commit's `round3`.
|
|
15
|
+
*/
|
|
16
|
+
const TRACK_GAP_EPSILON_S = 1e-3;
|
|
17
|
+
|
|
18
|
+
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The lane's time ORIGIN — the earliest start a clip on this lane may take.
|
|
22
|
+
* 0 for ordinary lanes; for a lane of expanded sub-comp children (post-
|
|
23
|
+
* collision-fix a lane is always single-origin) it is the children's host
|
|
24
|
+
* window start (`expandedParentStart`): display times are host-absolute, so
|
|
25
|
+
* compacting toward absolute 0 would drag a child BEFORE its host's window
|
|
26
|
+
* and persist a wrong (even negative) local time.
|
|
27
|
+
*/
|
|
28
|
+
export function laneGapFloor(elements: readonly TimelineElement[]): number {
|
|
29
|
+
return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
|
|
30
|
+
}
|
|
31
|
+
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
32
|
+
const endOf = (e: TimelineElement) => e.start + e.duration;
|
|
33
|
+
|
|
34
|
+
/** Lane clips sorted by start (key as a deterministic tie-break). */
|
|
35
|
+
function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
|
|
36
|
+
return [...elements].sort((a, b) => a.start - b.start || keyOf(a).localeCompare(keyOf(b)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TrackGapAt {
|
|
40
|
+
/** Gap left edge: the max end of the clips left of the point (0 for the leading gap). */
|
|
41
|
+
gapStart: number;
|
|
42
|
+
/** Gap right edge: the start of the next clip on the lane. */
|
|
43
|
+
gapEnd: number;
|
|
44
|
+
/** Keys of the next clip and every clip after it on the lane, in start order. */
|
|
45
|
+
followingKeys: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the gap under a right-clicked point on one lane.
|
|
50
|
+
*
|
|
51
|
+
* Returns null when the point sits inside a clip, when there is no clip to the
|
|
52
|
+
* right of the point (nothing to close), or when the neighbouring clips are
|
|
53
|
+
* epsilon-adjacent (no real gap).
|
|
54
|
+
*/
|
|
55
|
+
export function resolveTrackGapAt(
|
|
56
|
+
elements: readonly TimelineElement[],
|
|
57
|
+
time: number,
|
|
58
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
59
|
+
floor: number = 0,
|
|
60
|
+
): TrackGapAt | null {
|
|
61
|
+
const clips = sortedLaneClips(elements);
|
|
62
|
+
// Point inside a clip's half-open [start, end) → not empty space.
|
|
63
|
+
const occupied = clips.some((c) => time >= c.start - epsilon && time < endOf(c) - epsilon);
|
|
64
|
+
if (occupied) return null;
|
|
65
|
+
|
|
66
|
+
const following = clips.filter((c) => c.start > time - epsilon);
|
|
67
|
+
if (following.length === 0) return null; // nothing to the right — nothing to close
|
|
68
|
+
|
|
69
|
+
const gapEnd = following[0].start;
|
|
70
|
+
// Max end among clips left of the point (they all end at/before it since the
|
|
71
|
+
// point is unoccupied); the lane floor for the leading gap before the first
|
|
72
|
+
// clip (0 for ordinary lanes, the host window start for expanded children).
|
|
73
|
+
const gapStart = Math.max(
|
|
74
|
+
floor,
|
|
75
|
+
...clips.filter((c) => c.start <= time - epsilon).map((c) => endOf(c)),
|
|
76
|
+
);
|
|
77
|
+
if (gapEnd - gapStart <= epsilon) return null; // epsilon-adjacent — no gap
|
|
78
|
+
|
|
79
|
+
return { gapStart, gapEnd, followingKeys: following.map(keyOf) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TrackGapShift {
|
|
83
|
+
key: string;
|
|
84
|
+
newStart: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An empty interval on the lane, [start, end) in seconds. */
|
|
88
|
+
export interface TrackGapInterval {
|
|
89
|
+
start: number;
|
|
90
|
+
end: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every CURRENT empty interval on the lane, leading gap included — the regions
|
|
95
|
+
* "Close all gaps" would collapse, in left-to-right order. Purely descriptive
|
|
96
|
+
* (for the hover-highlight overlay): unlike {@link resolveAllTrackGaps} it
|
|
97
|
+
* reports the gaps as they are now, not the post-compaction clip starts.
|
|
98
|
+
* Overlapping clips never fabricate a negative interval (the cursor tracks the
|
|
99
|
+
* max end seen so far).
|
|
100
|
+
*/
|
|
101
|
+
export function resolveAllGapIntervals(
|
|
102
|
+
elements: readonly TimelineElement[],
|
|
103
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
104
|
+
floor: number = 0,
|
|
105
|
+
): TrackGapInterval[] {
|
|
106
|
+
const gaps: TrackGapInterval[] = [];
|
|
107
|
+
let cursor = floor;
|
|
108
|
+
for (const clip of sortedLaneClips(elements)) {
|
|
109
|
+
if (clip.start - cursor > epsilon) gaps.push({ start: cursor, end: clip.start });
|
|
110
|
+
cursor = Math.max(cursor, endOf(clip));
|
|
111
|
+
}
|
|
112
|
+
return gaps;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compact the whole lane: every clip lands at the sum of the durations of the
|
|
117
|
+
* clips before it (contiguous from 0, order and durations preserved).
|
|
118
|
+
* Overlapping clips (spill lanes) are serialized in start order — sane, if
|
|
119
|
+
* lossy for deliberate overlaps; the display lane set should not contain them.
|
|
120
|
+
*
|
|
121
|
+
* Returns ONLY the clips whose start actually changes (beyond epsilon).
|
|
122
|
+
*/
|
|
123
|
+
export function resolveAllTrackGaps(
|
|
124
|
+
elements: readonly TimelineElement[],
|
|
125
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
126
|
+
floor: number = 0,
|
|
127
|
+
): TrackGapShift[] {
|
|
128
|
+
const shifts: TrackGapShift[] = [];
|
|
129
|
+
let cursor = floor;
|
|
130
|
+
for (const clip of sortedLaneClips(elements)) {
|
|
131
|
+
const newStart = round3(cursor);
|
|
132
|
+
if (Math.abs(newStart - clip.start) > epsilon) {
|
|
133
|
+
shifts.push({ key: keyOf(clip), newStart });
|
|
134
|
+
}
|
|
135
|
+
cursor += clip.duration;
|
|
136
|
+
}
|
|
137
|
+
return shifts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every empty interval on the lane across the FULL rendered extent `[0, end)`:
|
|
142
|
+
* the closable gaps plus the open region after the last clip. Powers the
|
|
143
|
+
* click-selected lane highlight ("light the whole track except the clips") —
|
|
144
|
+
* unlike {@link resolveAllGapIntervals} it is not limited to what a gap-close
|
|
145
|
+
* could collapse.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveLaneEmptyIntervals(
|
|
148
|
+
elements: readonly TimelineElement[],
|
|
149
|
+
end: number,
|
|
150
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
151
|
+
floor: number = 0,
|
|
152
|
+
): TrackGapInterval[] {
|
|
153
|
+
const gaps = resolveAllGapIntervals(elements, epsilon, floor);
|
|
154
|
+
const maxEnd = Math.max(floor, ...elements.map(endOf));
|
|
155
|
+
if (end - maxEnd > epsilon) gaps.push({ start: maxEnd, end });
|
|
156
|
+
return gaps;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether the lane has any gap "Close all gaps" would collapse. */
|
|
160
|
+
export function trackHasGaps(
|
|
161
|
+
elements: readonly TimelineElement[],
|
|
162
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
163
|
+
floor: number = 0,
|
|
164
|
+
): boolean {
|
|
165
|
+
return resolveAllTrackGaps(elements, epsilon, floor).length > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Per-clip shifts for closing ONE gap: the next clip and every clip after it
|
|
170
|
+
* on the lane move left by the gap's width. Starts are clamped at 0 (float
|
|
171
|
+
* safety; real shifts never cross the gap's own left edge).
|
|
172
|
+
*/
|
|
173
|
+
export function resolveCloseGapShifts(
|
|
174
|
+
elements: readonly TimelineElement[],
|
|
175
|
+
gap: TrackGapAt,
|
|
176
|
+
): TrackGapShift[] {
|
|
177
|
+
const width = gap.gapEnd - gap.gapStart;
|
|
178
|
+
const followSet = new Set(gap.followingKeys);
|
|
179
|
+
return sortedLaneClips(elements)
|
|
180
|
+
.filter((c) => followSet.has(keyOf(c)))
|
|
181
|
+
.map((c) => ({ key: keyOf(c), newStart: Math.max(gap.gapStart, round3(c.start - width)) }));
|
|
182
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
TRACKS_TOP_PAD,
|
|
6
6
|
TRACKS_BOTTOM_PAD,
|
|
7
7
|
GUTTER,
|
|
8
|
+
TRACKS_LEFT_PAD,
|
|
8
9
|
getTimelineRowTop,
|
|
9
10
|
getTimelineRowFromY,
|
|
10
11
|
getTimelineCanvasHeight,
|
|
@@ -85,7 +86,7 @@ describe("track-area breathing pad y-math", () => {
|
|
|
85
86
|
|
|
86
87
|
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
|
|
87
88
|
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
|
|
88
|
-
const clientX = GUTTER + 100; // t = 1s
|
|
89
|
+
const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
|
|
89
90
|
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
|
|
90
91
|
expect(track).toBe(0);
|
|
91
92
|
expect(start).toBe(1);
|
|
@@ -31,6 +31,17 @@ export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
|
|
|
31
31
|
*/
|
|
32
32
|
export const TRACKS_TOP_PAD = 50;
|
|
33
33
|
export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
|
|
34
|
+
/**
|
|
35
|
+
* Breathing room LEFT of t=0 (CapCut-style), inside the scroll content — the
|
|
36
|
+
* horizontal sibling of TRACKS_TOP_PAD: empty lane surface between the sticky
|
|
37
|
+
* gutter and where the ruler's 00:00 / the clips actually start, scrolling
|
|
38
|
+
* WITH the content. Time↔pixel mapping: content x = GUTTER + TRACKS_LEFT_PAD
|
|
39
|
+
* + t·pps, and every pointer→time inverse subtracts it symmetrically. The
|
|
40
|
+
* lanes and the ruler realize it as a plain flow spacer between the sticky
|
|
41
|
+
* gutter cell and the time-mapped content div, so all content-relative math
|
|
42
|
+
* (clip left = t·pps, beat lines, lane-menu time) is untouched.
|
|
43
|
+
*/
|
|
44
|
+
export const TRACKS_LEFT_PAD = 48;
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
47
|
* The y (content-space) of the top edge of track ROW index `row` (0 = first
|
|
@@ -209,8 +220,8 @@ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: numb
|
|
|
209
220
|
const safeDuration =
|
|
210
221
|
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
|
211
222
|
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
|
212
|
-
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
|
|
213
|
-
return (viewportWidth - GUTTER - 2) / span;
|
|
223
|
+
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
|
|
224
|
+
return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
|
|
214
225
|
}
|
|
215
226
|
|
|
216
227
|
/**
|
|
@@ -230,7 +241,7 @@ export function getTimelineDisplayContentWidth(input: {
|
|
|
230
241
|
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
|
231
242
|
return Math.max(
|
|
232
243
|
input.trackContentWidth,
|
|
233
|
-
input.viewportWidth - GUTTER - 2,
|
|
244
|
+
input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
|
|
234
245
|
input.dragGhostEndPx ?? 0,
|
|
235
246
|
input.resizeGhostEndPx ?? 0,
|
|
236
247
|
MIN_TIMELINE_EXTENT_S * safePps,
|
|
@@ -300,9 +311,14 @@ export const PLAYHEAD_HEAD_W = 9;
|
|
|
300
311
|
*/
|
|
301
312
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
302
313
|
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
|
303
|
-
return GUTTER - PLAYHEAD_HEAD_W / 2;
|
|
314
|
+
return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
|
|
304
315
|
}
|
|
305
|
-
return
|
|
316
|
+
return (
|
|
317
|
+
GUTTER +
|
|
318
|
+
TRACKS_LEFT_PAD +
|
|
319
|
+
Math.max(0, time) * Math.max(0, pixelsPerSecond) -
|
|
320
|
+
PLAYHEAD_HEAD_W / 2
|
|
321
|
+
);
|
|
306
322
|
}
|
|
307
323
|
|
|
308
324
|
export function getTimelineCanvasHeight(trackCount: number): number {
|
|
@@ -373,7 +389,7 @@ export function resolveTimelineAssetDrop(
|
|
|
373
389
|
clientX: number,
|
|
374
390
|
clientY: number,
|
|
375
391
|
): { start: number; track: number } {
|
|
376
|
-
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
392
|
+
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
|
377
393
|
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
378
394
|
const start = Math.max(
|
|
379
395
|
0,
|
|
@@ -7,7 +7,17 @@ import {
|
|
|
7
7
|
getTimelineClipRect,
|
|
8
8
|
computeMarqueeSelection,
|
|
9
9
|
} from "./timelineMarquee";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
GUTTER,
|
|
12
|
+
TRACK_H,
|
|
13
|
+
RULER_H,
|
|
14
|
+
CLIP_Y,
|
|
15
|
+
TRACKS_LEFT_PAD,
|
|
16
|
+
getTimelineRowTop,
|
|
17
|
+
} from "./timelineLayout";
|
|
18
|
+
|
|
19
|
+
// Canvas-space time origin: right edge of the sticky gutter + the left pad.
|
|
20
|
+
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
|
|
11
21
|
|
|
12
22
|
describe("isTimelineRulerPress", () => {
|
|
13
23
|
const rectTop = 500; // scroll container's viewport top
|
|
@@ -86,7 +96,7 @@ describe("getTimelineClipRect", () => {
|
|
|
86
96
|
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
|
|
87
97
|
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
|
|
88
98
|
expect(rect).toEqual({
|
|
89
|
-
left:
|
|
99
|
+
left: ORIGIN + 200,
|
|
90
100
|
top: getTimelineRowTop(1) + CLIP_Y,
|
|
91
101
|
width: 300,
|
|
92
102
|
height: TRACK_H - CLIP_Y * 2,
|
|
@@ -96,7 +106,7 @@ describe("getTimelineClipRect", () => {
|
|
|
96
106
|
it("places the first visible track below the ruler + top breathing pad", () => {
|
|
97
107
|
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
|
|
98
108
|
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
|
|
99
|
-
expect(rect?.left).toBe(
|
|
109
|
+
expect(rect?.left).toBe(ORIGIN);
|
|
100
110
|
});
|
|
101
111
|
|
|
102
112
|
it("uses the row index in trackOrder, not the raw track number", () => {
|
|
@@ -129,26 +139,26 @@ describe("computeMarqueeSelection", () => {
|
|
|
129
139
|
const row1Top = getTimelineRowTop(1) + CLIP_Y;
|
|
130
140
|
|
|
131
141
|
it("selects only the clips the marquee rect intersects", () => {
|
|
132
|
-
const marquee = { left:
|
|
142
|
+
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
|
|
133
143
|
const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
|
134
144
|
expect(ids).toEqual(new Set(["a"]));
|
|
135
145
|
expect(primaryId).toBe("a");
|
|
136
146
|
});
|
|
137
147
|
|
|
138
148
|
it("selects across tracks when the rect spans multiple rows", () => {
|
|
139
|
-
const marquee = { left:
|
|
149
|
+
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
|
140
150
|
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
|
141
151
|
expect(ids).toEqual(new Set(["a", "c"]));
|
|
142
152
|
});
|
|
143
153
|
|
|
144
154
|
it("excludes clips outside the rect horizontally", () => {
|
|
145
|
-
const marquee = { left:
|
|
155
|
+
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
|
146
156
|
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
|
147
157
|
expect(ids).toEqual(new Set());
|
|
148
158
|
});
|
|
149
159
|
|
|
150
160
|
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
|
|
151
|
-
const marquee = { left:
|
|
161
|
+
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
|
152
162
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
153
163
|
clips,
|
|
154
164
|
trackOrder,
|
|
@@ -161,7 +171,7 @@ describe("computeMarqueeSelection", () => {
|
|
|
161
171
|
});
|
|
162
172
|
|
|
163
173
|
it("unions additive base selection with new hits; primary comes from the marquee", () => {
|
|
164
|
-
const marquee = { left:
|
|
174
|
+
const marquee = { left: ORIGIN, top: row1Top, width: 100, height: 10 };
|
|
165
175
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
166
176
|
clips,
|
|
167
177
|
trackOrder,
|
|
@@ -174,8 +184,8 @@ describe("computeMarqueeSelection", () => {
|
|
|
174
184
|
});
|
|
175
185
|
|
|
176
186
|
it("shrinking the rect live drops clips it no longer covers", () => {
|
|
177
|
-
const wide = { left:
|
|
178
|
-
const narrow = { left:
|
|
187
|
+
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
|
|
188
|
+
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
|
|
179
189
|
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
|
|
180
190
|
new Set(["a", "b"]),
|
|
181
191
|
);
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
GUTTER,
|
|
3
|
+
TRACK_H,
|
|
4
|
+
RULER_H,
|
|
5
|
+
CLIP_Y,
|
|
6
|
+
TRACKS_LEFT_PAD,
|
|
7
|
+
getTimelineRowTop,
|
|
8
|
+
} from "./timelineLayout";
|
|
2
9
|
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
|
3
10
|
|
|
4
11
|
/** Pointer must travel at least this far (either axis) before a pointerdown on
|
|
@@ -73,7 +80,7 @@ export function getTimelineClipRect(
|
|
|
73
80
|
const row = trackOrder.indexOf(clip.track);
|
|
74
81
|
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
|
75
82
|
return {
|
|
76
|
-
left: GUTTER + clip.start * pps,
|
|
83
|
+
left: GUTTER + TRACKS_LEFT_PAD + clip.start * pps,
|
|
77
84
|
top: getTimelineRowTop(row) + CLIP_Y,
|
|
78
85
|
width: Math.max(clip.duration * pps, MIN_CLIP_W),
|
|
79
86
|
height: TRACK_H - CLIP_Y * 2,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
computeStackingPatches,
|
|
4
|
+
laneIsAbove,
|
|
5
|
+
samePaintScope,
|
|
6
|
+
type StackingElement,
|
|
7
|
+
} from "./timelineStackingSync";
|
|
3
8
|
|
|
4
9
|
function el(
|
|
5
10
|
key: string,
|
|
@@ -20,6 +25,42 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
describe("stacking-context partitioning", () => {
|
|
28
|
+
it("uses source file and normalized stacking context as the canonical paint scope", () => {
|
|
29
|
+
expect(samePaintScope({}, { stackingContextId: null })).toBe(true);
|
|
30
|
+
expect(samePaintScope({}, { sourceFile: "index.html" })).toBe(false);
|
|
31
|
+
expect(
|
|
32
|
+
samePaintScope(
|
|
33
|
+
{ sourceFile: "scene.html", stackingContextId: "card" },
|
|
34
|
+
{ sourceFile: "scene.html", stackingContextId: "modal" },
|
|
35
|
+
),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("never compares or patches across source files in the root context", () => {
|
|
40
|
+
const root: StackingElement = {
|
|
41
|
+
key: "root",
|
|
42
|
+
track: 0,
|
|
43
|
+
start: 0,
|
|
44
|
+
duration: 5,
|
|
45
|
+
zIndex: 1,
|
|
46
|
+
isAudio: false,
|
|
47
|
+
sourceFile: "index.html",
|
|
48
|
+
stackingContextId: null,
|
|
49
|
+
};
|
|
50
|
+
const scene: StackingElement = {
|
|
51
|
+
key: "scene",
|
|
52
|
+
track: 1,
|
|
53
|
+
start: 0,
|
|
54
|
+
duration: 5,
|
|
55
|
+
zIndex: 10,
|
|
56
|
+
isAudio: false,
|
|
57
|
+
sourceFile: "scenes/scene.html",
|
|
58
|
+
stackingContextId: null,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
expect(patchMap([root, scene], ["root"])).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
|
|
23
64
|
it("never compares or patches across stacking contexts", () => {
|
|
24
65
|
// X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
|
|
25
66
|
// with a lower leaf z, overlapping in time. Their leaf z values are NOT
|