@hyperframes/studio 0.7.56 → 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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.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 +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- 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 +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- 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/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- 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/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- 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 +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- 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 +19 -1
- 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 +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -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/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -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
|
|
@@ -67,51 +78,105 @@ export const DRAG_EXTEND_MARGIN_PX = 160;
|
|
|
67
78
|
* with 60s of ruler after it.
|
|
68
79
|
*/
|
|
69
80
|
export const MIN_TIMELINE_EXTENT_S = 60;
|
|
81
|
+
/**
|
|
82
|
+
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
|
|
83
|
+
* duration — onto the viewport, so the composition ends at ~83% of the width
|
|
84
|
+
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
|
|
85
|
+
* drag clips past the current end without first zooming out). Applied ONLY
|
|
86
|
+
* inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
|
|
87
|
+
* lanes, playhead, marquee, and drag math all inherit it consistently. Manual
|
|
88
|
+
* zoom percentages stay defined relative to this fit basis (100% == fit).
|
|
89
|
+
*/
|
|
90
|
+
export const FIT_ZOOM_HEADROOM = 1.2;
|
|
70
91
|
|
|
71
92
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
// fallow-ignore-next-line complexity
|
|
94
|
+
function getMajorTickInterval(
|
|
95
|
+
duration: number,
|
|
96
|
+
pixelsPerSecond?: number,
|
|
97
|
+
frameRate?: number,
|
|
98
|
+
): number {
|
|
99
|
+
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
|
|
100
|
+
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
|
|
101
|
+
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
|
|
102
|
+
const zoomIntervals = [
|
|
103
|
+
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
|
|
104
|
+
];
|
|
105
|
+
let interval: number;
|
|
74
106
|
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
75
107
|
const targetMajorPx = 88;
|
|
76
|
-
|
|
77
|
-
zoomIntervals.find((
|
|
78
|
-
|
|
108
|
+
interval =
|
|
109
|
+
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
|
|
110
|
+
3600;
|
|
111
|
+
} else {
|
|
112
|
+
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
113
|
+
const target = duration / 6;
|
|
114
|
+
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
|
|
115
|
+
}
|
|
116
|
+
// Frame display mode: labels are frame numbers, so a major step must be a
|
|
117
|
+
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
|
|
118
|
+
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
|
|
119
|
+
// the label spacing never drops below the readability target.
|
|
120
|
+
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
|
|
121
|
+
const fps = frameRate ?? 0;
|
|
122
|
+
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
|
|
79
123
|
}
|
|
80
|
-
|
|
81
|
-
const target = duration / 6;
|
|
82
|
-
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
124
|
+
return interval;
|
|
83
125
|
}
|
|
84
126
|
|
|
85
127
|
// How many equal parts to split each major interval into for minor ticks. Prefer
|
|
86
128
|
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
|
|
87
|
-
// none (0) as ticks get too dense to read (< ~8px apart).
|
|
88
|
-
|
|
129
|
+
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
|
|
130
|
+
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
|
|
131
|
+
// sub-frame time is not a seekable position), so only divisors of the major
|
|
132
|
+
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
|
|
133
|
+
// thirds, halves.
|
|
134
|
+
// fallow-ignore-next-line complexity
|
|
135
|
+
function getMinorSubdivisions(
|
|
136
|
+
majorInterval: number,
|
|
137
|
+
pixelsPerSecond?: number,
|
|
138
|
+
frameRate?: number,
|
|
139
|
+
): number {
|
|
89
140
|
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
|
|
90
141
|
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
|
|
91
|
-
|
|
92
|
-
|
|
142
|
+
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
|
|
143
|
+
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
|
|
144
|
+
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
|
|
145
|
+
for (const parts of candidates) {
|
|
146
|
+
if (fps > 0 && majorFrames % parts !== 0) continue;
|
|
147
|
+
if ((majorInterval / parts) * pps >= 8) return parts;
|
|
148
|
+
}
|
|
93
149
|
return 0;
|
|
94
150
|
}
|
|
95
151
|
|
|
152
|
+
// Ticks are exact multiples of the interval (multiplied per index, never
|
|
153
|
+
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
|
|
154
|
+
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
|
|
155
|
+
function roundTickValue(t: number): number {
|
|
156
|
+
return Math.round(t * 1e6) / 1e6;
|
|
157
|
+
}
|
|
158
|
+
|
|
96
159
|
export function generateTicks(
|
|
97
160
|
duration: number,
|
|
98
161
|
pixelsPerSecond?: number,
|
|
162
|
+
frameRate?: number,
|
|
99
163
|
): { major: number[]; minor: number[] } {
|
|
100
|
-
if (duration <= 0 || !Number.isFinite(duration) || duration >
|
|
164
|
+
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
|
|
101
165
|
return { major: [], minor: [] };
|
|
102
|
-
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
103
|
-
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
|
|
166
|
+
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
|
|
167
|
+
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
|
104
168
|
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
|
105
169
|
const major: number[] = [];
|
|
106
170
|
const minor: number[] = [];
|
|
107
171
|
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
108
|
-
for (let
|
|
109
|
-
const
|
|
110
|
-
|
|
172
|
+
for (let i = 0; major.length < maxTicks; i++) {
|
|
173
|
+
const t = i * majorInterval;
|
|
174
|
+
if (t > duration + 0.001) break;
|
|
175
|
+
major.push(roundTickValue(t));
|
|
111
176
|
// Emit the (subdivisions - 1) minor ticks between this major and the next.
|
|
112
177
|
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
|
|
113
|
-
const m =
|
|
114
|
-
if (m <= duration + 0.001) minor.push(m);
|
|
178
|
+
const m = t + k * minorInterval;
|
|
179
|
+
if (m <= duration + 0.001) minor.push(roundTickValue(m));
|
|
115
180
|
}
|
|
116
181
|
}
|
|
117
182
|
return { major, minor };
|
|
@@ -144,17 +209,19 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
|
|
144
209
|
|
|
145
210
|
/* ── Width / duration derivation ──────────────────────────────────── */
|
|
146
211
|
/**
|
|
147
|
-
* Fit-mode pixels-per-second: fill the viewport with the composition
|
|
148
|
-
*
|
|
149
|
-
*
|
|
212
|
+
* Fit-mode pixels-per-second: fill the viewport with the composition plus
|
|
213
|
+
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
|
|
214
|
+
* into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
|
|
215
|
+
* seconds onto it — a short comp takes a fraction of the width and the
|
|
216
|
+
* remaining ruler runs to 1:00.
|
|
150
217
|
* Manual zoom multiplies this base, so the floor only anchors the default.
|
|
151
218
|
*/
|
|
152
219
|
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
|
153
220
|
const safeDuration =
|
|
154
221
|
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
|
155
|
-
const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
|
|
156
|
-
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
|
|
157
|
-
return (viewportWidth - GUTTER - 2) / span;
|
|
222
|
+
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
|
223
|
+
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
|
|
224
|
+
return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
|
|
158
225
|
}
|
|
159
226
|
|
|
160
227
|
/**
|
|
@@ -174,7 +241,7 @@ export function getTimelineDisplayContentWidth(input: {
|
|
|
174
241
|
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
|
175
242
|
return Math.max(
|
|
176
243
|
input.trackContentWidth,
|
|
177
|
-
input.viewportWidth - GUTTER - 2,
|
|
244
|
+
input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
|
|
178
245
|
input.dragGhostEndPx ?? 0,
|
|
179
246
|
input.resizeGhostEndPx ?? 0,
|
|
180
247
|
MIN_TIMELINE_EXTENT_S * safePps,
|
|
@@ -227,9 +294,31 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
|
227
294
|
}
|
|
228
295
|
|
|
229
296
|
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
|
297
|
+
/**
|
|
298
|
+
* Width of the playhead wrapper element (== the diamond head chip's layout
|
|
299
|
+
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
|
|
300
|
+
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
|
|
301
|
+
* shifted LEFT by half this width for the line's center to land exactly on
|
|
302
|
+
* `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
|
|
303
|
+
*/
|
|
304
|
+
export const PLAYHEAD_HEAD_W = 9;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
|
|
308
|
+
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
|
|
309
|
+
* on) at every zoom level. Without the half-head offset the line sat
|
|
310
|
+
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
|
|
311
|
+
*/
|
|
230
312
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
231
|
-
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond))
|
|
232
|
-
|
|
313
|
+
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
|
314
|
+
return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
|
|
315
|
+
}
|
|
316
|
+
return (
|
|
317
|
+
GUTTER +
|
|
318
|
+
TRACKS_LEFT_PAD +
|
|
319
|
+
Math.max(0, time) * Math.max(0, pixelsPerSecond) -
|
|
320
|
+
PLAYHEAD_HEAD_W / 2
|
|
321
|
+
);
|
|
233
322
|
}
|
|
234
323
|
|
|
235
324
|
export function getTimelineCanvasHeight(trackCount: number): number {
|
|
@@ -300,7 +389,7 @@ export function resolveTimelineAssetDrop(
|
|
|
300
389
|
clientX: number,
|
|
301
390
|
clientY: number,
|
|
302
391
|
): { start: number; track: number } {
|
|
303
|
-
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
392
|
+
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
|
304
393
|
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
305
394
|
const start = Math.max(
|
|
306
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,
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -19,6 +24,102 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
|
|
|
19
24
|
return out;
|
|
20
25
|
}
|
|
21
26
|
|
|
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
|
+
|
|
64
|
+
it("never compares or patches across stacking contexts", () => {
|
|
65
|
+
// X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
|
|
66
|
+
// with a lower leaf z, overlapping in time. Their leaf z values are NOT
|
|
67
|
+
// comparable (the ancestors' z decides paint order), so moving X's lane above
|
|
68
|
+
// Y must not reason on Y or patch either based on the 10-vs-5 comparison.
|
|
69
|
+
const x: StackingElement = {
|
|
70
|
+
key: "x",
|
|
71
|
+
track: 0,
|
|
72
|
+
start: 0,
|
|
73
|
+
duration: 5,
|
|
74
|
+
zIndex: 10,
|
|
75
|
+
isAudio: false,
|
|
76
|
+
stackingContextId: "scene-1",
|
|
77
|
+
};
|
|
78
|
+
const y: StackingElement = {
|
|
79
|
+
key: "y",
|
|
80
|
+
track: 1,
|
|
81
|
+
start: 0,
|
|
82
|
+
duration: 5,
|
|
83
|
+
zIndex: 5,
|
|
84
|
+
isAudio: false,
|
|
85
|
+
stackingContextId: null,
|
|
86
|
+
};
|
|
87
|
+
// X edited: only same-context neighbours participate — none here, so X keeps
|
|
88
|
+
// its z (nothing to fix WITHIN its context) and Y is never touched.
|
|
89
|
+
expect(patchMap([x, y], ["x"])).toEqual({});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("still resolves within the edited clip's own context", () => {
|
|
93
|
+
const a: StackingElement = {
|
|
94
|
+
key: "a",
|
|
95
|
+
track: 1,
|
|
96
|
+
start: 0,
|
|
97
|
+
duration: 5,
|
|
98
|
+
zIndex: 1,
|
|
99
|
+
isAudio: false,
|
|
100
|
+
stackingContextId: "scene-1",
|
|
101
|
+
};
|
|
102
|
+
const b: StackingElement = {
|
|
103
|
+
key: "b",
|
|
104
|
+
track: 0,
|
|
105
|
+
start: 0,
|
|
106
|
+
duration: 5,
|
|
107
|
+
zIndex: 5,
|
|
108
|
+
isAudio: false,
|
|
109
|
+
stackingContextId: "scene-1",
|
|
110
|
+
};
|
|
111
|
+
// 'a' moved BELOW b's lane... a (track 1) is under b (track 0): a's z (1)
|
|
112
|
+
// is already below b's (5) — consistent, no patch. Move a ABOVE (track -1
|
|
113
|
+
// relative ordering) is covered by existing suites; here we just prove the
|
|
114
|
+
// same-context pair still participates (no patch ≠ no participation: verify
|
|
115
|
+
// by flipping z so a MUST be lifted).
|
|
116
|
+
const aWrong = { ...a, track: 0, zIndex: 1 };
|
|
117
|
+
const bLow = { ...b, track: 1, zIndex: 5 };
|
|
118
|
+
const patches = patchMap([aWrong, bLow], ["a"]);
|
|
119
|
+
expect(patches.a).toBeGreaterThan(5);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
22
123
|
describe("laneIsAbove", () => {
|
|
23
124
|
it("lower track renders above (top of timeline wins)", () => {
|
|
24
125
|
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
|