@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.
Files changed (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /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
- function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
73
- const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
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
- return (
77
- zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
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
- const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
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
- function getMinorSubdivisions(majorInterval: number, pixelsPerSecond?: number): number {
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
- if ((majorInterval / 4) * pps >= 8) return 4;
92
- if ((majorInterval / 2) * pps >= 8) return 2;
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 > 7200)
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 t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
109
- const rounded = Math.round(t * 100) / 100;
110
- major.push(rounded);
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 = Math.round((t + k * minorInterval) * 100) / 100;
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, but
148
- * never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
149
- * takes a fraction of the width and the remaining ruler runs to 1:00.
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)) return GUTTER;
232
- return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
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 { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
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: GUTTER + 200,
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(GUTTER);
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: GUTTER, top: row0Top, width: 50, height: 10 };
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: GUTTER, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
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: GUTTER + 140, top: row0Top, width: 50, height: 10 };
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: GUTTER + 140, top: row0Top, width: 50, height: 10 };
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: GUTTER, top: row1Top, width: 100, height: 10 };
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: GUTTER, top: row0Top, width: 320, height: 10 };
178
- const narrow = { left: GUTTER, top: row0Top, width: 80, height: 10 };
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 { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
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 { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync";
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);