feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)

* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides

Track order = default paint order; authored z = advanced override.

- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
  timeline lane move — closest track in the action's direction that is free
  over the clip's whole span, else a new lane adjacent to the crossed
  neighbor; temporal-overlap scope (default pending product sign-off, see
  module doc); visual zone only; same-file reference scoping; persistTrack
  via the shared authored-space rules. null for non-clips (menu stays
  z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
  persists the lane move through the same machinery as a timeline lane drag
  (optimistic store update, authoredTrack refresh, rollback); inserts reuse
  commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
  writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
  entry (test proves it over the real history reducer). The mirror never
  triggers the lane->z stacking sync, so it cannot fight the z values the
  action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
  contradicts lane order among temporally-overlapping same-context visual
  neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
  a 'z' badge — authored z overrides are surfaced instead of silently
  disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
  (600-line cap).

* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists

Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).

zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.

* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu

- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
  skip the GSAP fallback round-trip and the preview reload entirely — the
  renderer never reads data-track-index, and the live DOM patch + optimistic
  store update cover the UI. Mixed batches keep current behavior. Kills the
  canvas blink on mirrored Bring/Send actions (live-verified: an
  iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
  directional arrow for Forward/Backward; pierced two-layer stack for
  Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
  clip and every clip after it on that lane left by the clicked gap's width;
  leading gaps count, so a single clip with empty space before it compacts
  to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
  in timelineGaps.ts; persists through the drag path's atomic batch move
  (one undo per action); refuses when a clip that must shift is locked;
  items disable when there is nothing to close.

* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order

Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.

The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.

Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).

Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.

* fix(studio): bound forward/backward mirror to a one-element step

User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.

* feat(studio): permanent gap-menu rows with hover and click-select gap highlights

- TrackGapContextMenu always renders both rows; an inapplicable action dims
  with a tooltip ("No gap here" / lock reason / "No gaps on this track")
  instead of vanishing into a one-item menu. Width badge only when a gap
  exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
  timeline: the single gap for Close gap, every current gap (leading included)
  for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
  present-state intervals (epsilon-tolerant, overlap-safe), distinct from
  resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
  (suppressed for marquee multi-selection and during drags; the gap-menu hover
  wins on its own lane). Derivation lives in useTimelineGapHighlights with the
  pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
  + clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
  plumbing (ResizeObserver width + shortcut-hint sync) into
  useTimelineScrollViewport, behavior unchanged.

* feat(studio): stronger capcut-style timeline zoom steps

One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.

* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits

Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.

- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
  the canvas menu's realization core via resolveZOrderReposition (one
  between-z write when a strict gap exists, band-safe scoped renumber
  otherwise) instead of computeReorderZValues' all-sibling stamp — that
  helper is deleted, completing the #2347 unification follow-up. The drop
  then mirrors into a timeline lane move through the same machinery as the
  canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
  strictly between its NEW paint neighbors' lanes — nearest clip siblings in
  the desired render order, decorations skipped — else a track insert at
  that boundary; audio zone never crossed). Both writes share one
  per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
  is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
  factored into useMirrorLaneMoveCommit and reused by the new
  useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
  order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
  bump no refreshKey, so the panel's z-sorted order went stale while paused.
  handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
  rollback; the panel re-collects on it. Verified live: the panel re-sorts
  the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
  hidden at the current frame (animation-parked opacity, non-clip
  display/visibility hides, hidden ancestors) temporarily forces the chain
  visible with live inline styles — exact priors restored on deselect, on
  another reveal, on play, and on unmount; never persisted (file diff == 0
  verified live). Clips keep the existing seek-into-window behavior; the
  override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.

Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.

* feat(studio): full-track selection highlight, borderless gap hover strips

- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
  gap, inter-clip gaps, and the open space after the last clip to the rendered
  end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
  derivation). Still click-only: any drag/resize suppresses the strips, and a
  marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
  nudged to 0.18 alpha to keep the same visual weight.

* feat(studio): selected layer paints on top via a reader-transparent z lift

Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:

- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
  writes a far-top inline z; a static element gets a layout-preserving
  position:relative with its prior parked in data-hf-reveal-prior-pos. Only
  the RENDERER sees the lift: all three studio z readers
  (readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
  the parked prior while the attribute is present, so the canvas z-menu, the
  zOverride badge, the lane mirror, the stacking sync, and the panel sort
  keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
  play / unmount, each property only while it still holds the value the
  override wrote (a later real edit is never clobbered). File diff == 0
  verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
  reads the parked position for its persist-position:relative static check,
  then drops the attributes) — the committed z becomes the truth and the
  later restore is a guarded no-op.

* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path

Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:

1. Master-view path gate: activeCompPath is NULL at the master view, so the
   'paths[0] === activeCompPath' eligibility check could never match the
   index.html restore and every default-view undo full-reloaded at the first
   gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
   element's innerHTML, but the composition root wraps every clip — any child
   change re-detected at the root rejected the restore. Change detection now
   compares only each element's OWN attribute surface; structure/text
   integrity is still guaranteed by the normalize-residual whole-doc pass
   (text nodes, added/removed elements, and un-identified attrs all remain
   after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
   fell outside the diff entirely. Identity is now id OR data-hf-id, with the
   live sync resolving either.

Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.

Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.

* feat(studio): left breathing pad before t=0, double zoom sensitivity again

TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.

- The lanes and the ruler realize it as a plain flow spacer between the
  sticky gutter cell and the time-mapped content div, so every
  content-relative computation (clip left = t*pps, beat lines, lane-menu
  time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
  gap strips, drop placeholder, snap guide, range highlight, marquee clip
  rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
  range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
  pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
  all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
  center exactly under the pointer.

Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.

* fix(studio): left pad renders as true empty space, not lane surface

The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.

* fix(studio): no vertical line in the ruler band before 00:00

The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.

* refactor(studio): remove the timeline z-override badge

User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.

* fix(studio): collision-free expanded child lanes and host-window gap floors

Review findings D1 (blocker) and 4.

- D1: buildChildElements assigned expanded children synthetic display rows as
  `host.track + index` — integers that can EQUAL a real clip's lane in another
  file (host on 0 with two children puts child #2 on 1). Lane grouping merges
  purely by track number, so the collision fused clips from different source
  files into one display lane, and lane-scoped actions (the gap menu) then
  batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
  between the host's lane and the next integer — structurally unable to
  collide with any normalized lane, while still rendering as ordered rows
  under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
  display time is host-anchored — close/compact could drag it before its host
  window and persist a wrong (even negative) local time. All gap functions
  now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
  expandedParentStart for child lanes — single-origin per lane post-D1),
  threaded through the menu model, hover highlights, selected-lane strips,
  and both commits. Close-gap shifts clamp at the gap's own left edge.

* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity

Review findings 1, 2, and 3.

- Finding 1: buildTrackInsertEdits normalized the FULL display set and
  persisted every shifted clip — writing host-lane numbers into OTHER
  composition files when expanded children were showing. The renumber write
  set is now the edited element's own source file (the sanctioned multi-write
  converges one FILE to lane space, never neighbors' files); foreign clips
  keep their authored tracks and re-derive display lanes. The locked-clip
  refusal scopes the same way. Expanded-origin elements refuse the insert
  outright (a new lane is a host-space renumber, meaningless in the child's
  file), and the mirrors restrict an expanded child's lane candidates to its
  own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
  (persisting the sibling's authored track) but can never land on a host lane
  with no same-file occupant. authoredTrackForLane's offset fallback rounds:
  fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
  file can contain several CSS stacking contexts and leaf z is only
  comparable within one. Both resolvers now scope by samePaintScope — same
  source file AND same stackingContextId (the file check also stops null root
  contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
  duplicate class selectors (.sub) resolved to occurrence 0 — a different
  clip. The key now carries getSelectorIndex, matching how z-reorder entries
  derive theirs.

* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists

Review findings 5 and 7.

- Finding 5: commitDomEditPatchBatches resolved successfully even when the
  server matched NO patch target — the z write never reached disk (the
  preview reloads to reconverge) yet the lane mirror still ran, desyncing
  track order from what actually paints. The commit now resolves a durability
  report ({allMatched, changed}; the save queue and commit types are generic
  over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
  rides the timeline/SDK path — two queues, so a second rapid gesture's z
  write could land BETWEEN the first gesture's z and lane phases. Every
  z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
  through runZLaneGesture: a single module-level tail that serializes the
  COMPLETE two-phase transaction, with unit tests for ordering, the
  durability gate, and queue resilience to failed gestures. The timeline
  lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
  already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).

* fix(studio): multi-clip GSAP batch mutations roll back on late failure

Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.

* fix(studio): scope mirror inserts to their lane zone

* fix(studio): unify source-scoped clip identity

* fix(studio): isolate track insert topology

* fix(studio): harden timeline paint synchronization

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
This commit is contained in:
Ular Kimsanov
2026-07-14 14:31:58 -04:00
committed by GitHub
co-authored by Miguel Angel Simon Sierra
parent d7204ac47f
commit 89db718899
101 changed files with 9489 additions and 1517 deletions
@@ -25,6 +25,7 @@ import {
PLAYHEAD_HEAD_W,
RULER_H,
TRACK_H,
TRACKS_LEFT_PAD,
getTimelineDisplayContentWidth,
getTimelineFitPps,
} from "./timelineLayout";
@@ -152,7 +153,8 @@ describe("Timeline provider boundary", () => {
});
const row = button.parentElement?.parentElement;
const trackContent = row?.children.item(1);
// Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content].
const trackContent = row?.children.item(2);
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
expect(trackContent).toBeInstanceOf(HTMLElement);
if (!(trackContent instanceof HTMLElement)) {
@@ -454,27 +456,27 @@ describe("shouldAutoScrollTimeline", () => {
});
describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
const viewport = 632; // usable width = 632 - GUTTER - TRACKS_LEFT_PAD - 2
it("computes fit pps against the 60s floor for short compositions", () => {
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
// (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
const pps = getTimelineFitPps(viewport, 10);
expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S);
expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6);
});
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
(viewport - GUTTER - 2) / (60 * FIT_ZOOM_HEADROOM),
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM),
);
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
(viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM),
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM),
);
});
it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
const usable = viewport - GUTTER - 2;
const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2;
const pps = getTimelineFitPps(viewport, 120);
// Composition content occupies usable/1.2 px; the remaining ~17% is empty
// droppable ruler/lane surface past the end.
@@ -484,16 +486,16 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
it("falls back to 100 pps before the viewport is measured", () => {
expect(getTimelineFitPps(0, 10)).toBe(100);
expect(getTimelineFitPps(GUTTER, 10)).toBe(100);
expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100);
expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
});
it("uses the floor for zero/invalid durations", () => {
expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
(viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
);
expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
(viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
);
});
});
@@ -509,7 +511,7 @@ describe("getTimelineDisplayContentWidth", () => {
it("still fills the viewport when that is larger than the 60s floor", () => {
expect(
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
).toBe(2000 - GUTTER - 2);
).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2);
});
it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
@@ -599,20 +601,28 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
});
describe("getTimelinePlayheadLeft", () => {
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => {
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => {
// Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
// equal GUTTER + t*pps at any zoom.
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
// equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom.
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(
GUTTER + TRACKS_LEFT_PAD + 4 * 20,
);
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(
GUTTER + TRACKS_LEFT_PAD + 75,
);
});
it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => {
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD);
});
it("guards invalid input", () => {
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(
GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
);
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
);
});
});
@@ -681,7 +691,7 @@ describe("resolveTimelineAssetDrop", () => {
trackHeight: 72,
trackOrder: [0, 3, 7],
},
432,
480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps
// clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
// TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
368,
@@ -702,7 +712,7 @@ describe("resolveTimelineAssetDrop", () => {
trackHeight: 72,
trackOrder: [0, 3, 7],
},
250,
250 + TRACKS_LEFT_PAD,
600,
),
).toEqual({ start: 1.18, track: 8 });
@@ -9,7 +9,7 @@ import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
import { getTrackStyle } from "./timelineIcons";
import { useTimelineZoom } from "./useTimelineZoom";
import { useTimelineAssetDrop } from "./timelineDragDrop";
import { TimelineEmptyState } from "./TimelineEmptyState";
@@ -20,15 +20,14 @@ import { TimelineOverlays } from "./TimelineOverlays";
import { useTimelineEditPinning } from "./useTimelineEditPinning";
import { useTimelineStackingSync } from "./useTimelineStackingSync";
import { useTimelineGeometry } from "./useTimelineGeometry";
import {
GUTTER,
generateTicks,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
} from "./timelineLayout";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout";
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
import { useTrackGapMenu } from "./useTrackGapMenu";
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
@@ -114,106 +113,41 @@ export const Timeline = memo(function Timeline({
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
useMountEffect(() => {
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
const blur = () => setShiftHeld(false);
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("keydown", key);
window.addEventListener("keyup", key);
window.addEventListener("blur", blur);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("keydown", key);
window.removeEventListener("keyup", key);
window.removeEventListener("blur", blur);
};
});
const [showPopover, setShowPopover] = useState(false);
const [showShortcutHint, setShowShortcutHint] = useState(true);
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
const [clipContextMenu, setClipContextMenu] = useState<{
x: number;
y: number;
element: TimelineElement;
} | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const shortcutHintRafRef = useRef(0);
const syncShortcutHintVisibility = useCallback(() => {
const scroll = scrollRef.current;
setShowShortcutHint(
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
);
}, []);
const scheduleShortcutHintVisibilitySync = useCallback(() => {
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
shortcutHintRafRef.current = requestAnimationFrame(() => {
shortcutHintRafRef.current = 0;
syncShortcutHintVisibility();
});
}, [syncShortcutHintVisibility]);
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el;
}, []);
// Last horizontal scroll offset, RESTORED across the post-edit iframe reload (which clamps into
// a scroll jump); with the pinned zoom this keeps the user parked at the same spot after edits.
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
const lastScrollLeftRef = useRef(0);
const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
scrollRef.current = el;
if (!el) return;
const syncScrollViewport = () => {
setViewportWidth(el.clientWidth);
scheduleShortcutHintVisibilitySync();
};
syncScrollViewport();
roRef.current = new ResizeObserver(syncScrollViewport);
roRef.current.observe(el);
},
[scheduleShortcutHintVisibilitySync],
);
useMountEffect(() => () => {
roRef.current?.disconnect();
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
});
const effectiveDuration = useMemo(() => {
const safeDur = Number.isFinite(duration) ? duration : 0;
if (rawElements.length === 0) return safeDur;
const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
const result = Math.max(safeDur, maxEnd);
const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
return Number.isFinite(result) ? result : safeDur;
}, [rawElements, duration]);
const tracks = useMemo(() => {
const map = new Map<number, typeof expandedElements>();
for (const el of expandedElements) {
const list = map.get(el.track) ?? [];
list.push(el);
map.set(el.track, list);
}
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>();
for (const [trackNum, els] of tracks) {
map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
}
return map;
}, [tracks]);
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const expandedElementsRef = useRef(expandedElements);
@@ -222,8 +156,7 @@ export const Timeline = memo(function Timeline({
const ppsRef = useRef(100);
const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration;
// Declared here (used before the fitPps derivation below) so the edit-pin
// wrappers can close over it; `fitPpsRef.current` is refreshed each render.
// Declared before the fitPps derivation so the edit-pin wrappers can close over it.
const fitPpsRef = useRef(100);
const {
@@ -252,6 +185,22 @@ export const Timeline = memo(function Timeline({
expandedElementsRef,
});
const {
gapMenuModel,
gapHighlight,
setHoveredGapAction,
openGapMenu,
dismissGapMenu,
closeTrackGap,
closeAllTrackGaps,
} = useTrackGapMenu({
tracks,
expandedElementsRef,
trackOrderRef,
onMoveElement: pinnedOnMoveElement,
onMoveElements: pinnedOnMoveElements,
});
const {
draggedClip,
setDraggedClip,
@@ -293,6 +242,11 @@ export const Timeline = memo(function Timeline({
}, [draggedClip, trackOrder]);
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
timelineReady,
expandedElements.length,
totalH,
]);
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
@@ -328,6 +282,16 @@ export const Timeline = memo(function Timeline({
lastScrollLeftRef,
});
const laneGapStrips = useTimelineGapHighlights({
gapHighlight,
tracks,
selectedElementId,
selectedElementIds,
expandedElements,
dragActive: draggedClip?.started === true || resizingClip != null,
displayDuration,
});
const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({
playheadRef,
scrollRef,
@@ -379,8 +343,7 @@ export const Timeline = memo(function Timeline({
trackOrderRef,
onSelectElement,
});
// Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag
setRangeSelectionRef.current = setRangeSelection;
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
const prevSelectedRef = useRef(selectedElementRef.current);
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
@@ -402,10 +365,6 @@ export const Timeline = memo(function Timeline({
);
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
useEffect(() => {
syncShortcutHintVisibility();
}, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]);
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (
@@ -459,8 +418,7 @@ export const Timeline = memo(function Timeline({
tabIndex={-1}
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
onScroll={(e) => {
// Remember the live offset so it can be restored across a post-edit reload.
lastScrollLeftRef.current = e.currentTarget.scrollLeft;
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
}}
onDragOver={handleAssetDragOver}
onDragLeave={() => clearDropPreview()}
@@ -468,7 +426,8 @@ export const Timeline = memo(function Timeline({
onPointerDown={(e) => {
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER;
const x =
e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
const splitTime = Math.max(0, x / pps);
onRazorSplitAll?.(splitTime);
return;
@@ -489,6 +448,7 @@ export const Timeline = memo(function Timeline({
majorTickInterval={majorTickInterval}
rangeSelection={rangeSelection}
marqueeRect={marqueeRect}
laneGapStrips={laneGapStrips}
theme={theme}
displayTrackOrder={displayTrackOrder}
trackOrder={trackOrder}
@@ -560,8 +520,14 @@ export const Timeline = memo(function Timeline({
e.preventDefault();
setSelectedElementId(el.key ?? el.id);
onSelectElement?.(el);
dismissGapMenu();
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
}}
onContextMenuLane={(e, track, time) => {
if (draggedClip?.started || resizingClip) return;
setClipContextMenu(null);
openGapMenu({ x: e.clientX, y: e.clientY, track, time });
}}
/>
{activeTool === "razor" && razorGuideX !== null && (
<div
@@ -594,6 +560,11 @@ export const Timeline = memo(function Timeline({
onSplitElement={onSplitElement}
pinZoomBeforeEdit={pinZoomBeforeEdit}
onDeleteElement={_onDeleteElement}
gapContextMenu={gapMenuModel}
onDismissGapContextMenu={dismissGapMenu}
onCloseTrackGap={closeTrackGap}
onCloseAllTrackGaps={closeAllTrackGaps}
onHoverGapAction={setHoveredGapAction}
/>
</div>
);
@@ -10,6 +10,7 @@ import {
CLIP_Y,
TRACKS_TOP_PAD,
TRACKS_BOTTOM_PAD,
TRACKS_LEFT_PAD,
PLAYHEAD_HEAD_W,
getTimelinePlayheadLeft,
getTimelineRowTop,
@@ -23,6 +24,7 @@ import { TimelineClip } from "./TimelineClip";
import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
import { renderClipChildren } from "./timelineClipChildren";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
@@ -37,6 +39,8 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
/** Playhead is being actively scrubbed — fills the grab-handle head. */
isScrubbing: boolean;
playheadRef: React.RefObject<HTMLDivElement | null>;
/** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
laneGapStrips: TimelineLaneGapStrips[];
}
export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
@@ -97,7 +101,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
return (
<div
className="relative"
style={{ height: props.totalH, width: GUTTER + props.trackContentWidth }}
style={{ height: props.totalH, width: GUTTER + TRACKS_LEFT_PAD + props.trackContentWidth }}
>
<TimelineRuler
major={props.major}
@@ -131,6 +135,31 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
<div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
menu row would collapse; a quiet tint for every gap on the selected
clip's lane. Geometry mirrors the drop placeholder (row top + clip
inset) so strips sit exactly where a clip body would. */}
{props.laneGapStrips.map((strip) => {
const rowIndex = displayTrackOrder.indexOf(strip.track);
if (rowIndex < 0) return null;
const loud = strip.kind === "hover";
return strip.intervals.map((gap) => (
<div
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
className="pointer-events-none absolute"
style={{
top: getTimelineRowTop(rowIndex) + CLIP_Y,
left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps,
width: Math.max((gap.end - gap.start) * props.pps, 2),
height: TRACK_H - CLIP_Y * 2,
background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
borderRadius: 4,
zIndex: 25,
}}
/>
));
})}
{/* Drop placeholder — a clip-sized slot at the exact landing spot (target
lane + snapped start), parallel to the ghost. Hidden in insert mode. */}
{draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
@@ -138,7 +167,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
className="absolute pointer-events-none"
style={{
top: getTimelineRowTop(draggedRowIndex) + CLIP_Y,
left: GUTTER + draggedClip.previewStart * props.pps,
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.previewStart * props.pps,
width: Math.max(draggedClip.element.duration * props.pps, 4),
height: TRACK_H - CLIP_Y * 2,
border: "1px solid rgba(60,230,172,0.55)",
@@ -156,7 +185,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
className="absolute pointer-events-none"
style={{
top: getTimelineRowTop(draggedClip.insertRow) - 0.5,
left: GUTTER,
left: GUTTER + TRACKS_LEFT_PAD,
width: props.trackContentWidth,
height: 1,
background: "#3CE6AC",
@@ -171,7 +200,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
<div
className="absolute pointer-events-none"
style={{
left: GUTTER + draggedClip.snapTime * props.pps,
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.snapTime * props.pps,
top: RULER_H,
bottom: 0,
width: 1,
@@ -251,7 +280,9 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
className="absolute pointer-events-none"
style={{
left:
GUTTER + Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
GUTTER +
TRACKS_LEFT_PAD +
Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
top: RULER_H,
bottom: 0,
@@ -6,7 +6,7 @@ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
import {
usePlayerStore,
type TimelineElement,
@@ -80,6 +80,12 @@ export interface TimelineLaneBaseProps {
toClipPercentage: number,
) => void;
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
/**
* Right-click on EMPTY lane space (not on a clip — those preventDefault
* before this fires — not the gutter/ruler, not below the lanes). `time` is
* the timeline time (seconds) under the pointer on that lane.
*/
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
beatAnalysis?: MusicBeatAnalysis | null;
}
@@ -133,6 +139,7 @@ export function TimelineLanes({
onContextMenuKeyframe,
onMoveKeyframe,
onContextMenuClip,
onContextMenuLane,
beatAnalysis,
onToggleTrackHidden,
onResizeElement,
@@ -167,21 +174,14 @@ export function TimelineLanes({
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
return (
<div
key={trackNum}
className="relative flex"
style={{
height: TRACK_H,
background: rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
<div key={trackNum} className="relative flex" style={{ height: TRACK_H }}>
<div
className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
style={{
width: GUTTER,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
{isAudioTrack && (
@@ -211,13 +211,35 @@ export function TimelineLanes({
)}
</button>
</div>
{/* Left breathing pad — empty lane surface before t=0, scrolling
with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE
the time-mapped content div so clip/beat/menu math stays
content-relative (clip left = t·pps). */}
<div
aria-hidden="true"
className="flex-shrink-0"
style={{ width: TRACKS_LEFT_PAD }}
/>
<div
style={{
width: trackContentWidth,
background: rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
opacity: isTrackHidden ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
className="relative"
onContextMenu={(e: React.MouseEvent) => {
// Clip / keyframe-diamond context menus preventDefault at the
// target before this bubble handler runs — respect them so a
// right-click on a clip never also opens the gap menu.
if (e.defaultPrevented || !onContextMenuLane) return;
const rect = e.currentTarget.getBoundingClientRect();
const time = (e.clientX - rect.left) / pps;
if (time < 0) return;
e.preventDefault();
onContextMenuLane(e, trackNum, time);
}}
>
{/* Faint beat lines in every track's background (behind the clips);
the active move-snap target is highlighted. */}
@@ -8,6 +8,7 @@ import {
type KeyframeDiamondContextMenuState,
} from "./KeyframeDiamondContextMenu";
import { ClipContextMenu } from "./ClipContextMenu";
import { TrackGapContextMenu } from "./TrackGapContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint";
interface ClipContextMenuState {
@@ -16,6 +17,16 @@ interface ClipContextMenuState {
element: TimelineElement;
}
/** Resolved model for the empty-lane-space (track gap) context menu. */
interface TrackGapContextMenuState {
x: number;
y: number;
gapWidth: number | null;
canCloseGap: boolean;
canCloseAllGaps: boolean;
hasAnyGaps: boolean;
}
interface TimelineOverlaysProps {
theme: TimelineTheme;
showShortcutHint: boolean;
@@ -36,6 +47,11 @@ interface TimelineOverlaysProps {
onSplitElement: TimelineEditCallbacks["onSplitElement"];
pinZoomBeforeEdit: () => void;
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
gapContextMenu: TrackGapContextMenuState | null;
onDismissGapContextMenu: () => void;
onCloseTrackGap: () => void;
onCloseAllTrackGaps: () => void;
onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
}
// The timeline's floating overlays, rendered as siblings above the scroll area:
@@ -61,6 +77,11 @@ export function TimelineOverlays({
onSplitElement,
pinZoomBeforeEdit,
onDeleteElement,
gapContextMenu,
onDismissGapContextMenu,
onCloseTrackGap,
onCloseAllTrackGaps,
onHoverGapAction,
}: TimelineOverlaysProps) {
return (
<>
@@ -117,6 +138,21 @@ export function TimelineOverlays({
}}
/>
)}
{gapContextMenu && (
<TrackGapContextMenu
x={gapContextMenu.x}
y={gapContextMenu.y}
gapWidth={gapContextMenu.gapWidth}
canCloseGap={gapContextMenu.canCloseGap}
canCloseAllGaps={gapContextMenu.canCloseAllGaps}
hasAnyGaps={gapContextMenu.hasAnyGaps}
onClose={onDismissGapContextMenu}
onCloseGap={onCloseTrackGap}
onCloseAllGaps={onCloseAllTrackGaps}
onHoverAction={onHoverGapAction}
/>
)}
</>
);
}
@@ -1,6 +1,6 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import { GUTTER, RULER_H, TRACKS_LEFT_PAD, formatTimelineTickLabel } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { secondsToFrame } from "../lib/time";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
@@ -45,7 +45,7 @@ export const TimelineRuler = memo(function TimelineRuler({
the ruler's own small ticks mark intervals (no full-height lines). */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
height={totalH}
>
{showBeats &&
@@ -74,17 +74,30 @@ export const TimelineRuler = memo(function TimelineRuler({
rows and drag overlays but below the playhead (z 100). */}
<div
className="sticky top-0 flex"
style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
style={{
height: RULER_H,
width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
zIndex: 70,
}}
>
<div
className="sticky left-0 z-[12] flex-shrink-0"
style={{
width: GUTTER,
// Ruler corner uses the panel surface — same as the ruler strip itself.
// Ruler corner uses the panel surface — same as the ruler strip
// itself, and NO right border: the ruler band stays completely
// clean until 00:00 (the header-boundary line belongs to the track
// rows below, not the ruler).
background: theme.shellBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
/>
{/* Left breathing pad scrolls with the content, so 00:00 starts a
beat right of the gutter (see TRACKS_LEFT_PAD). */}
<div
aria-hidden="true"
className="flex-shrink-0"
style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
/>
<div
className="relative overflow-hidden"
style={{
@@ -0,0 +1,116 @@
import { memo } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
interface TrackGapContextMenuProps {
x: number;
y: number;
/** Width (seconds) of the gap under the pointer, or null when no clip exists to the right. */
gapWidth: number | null;
/** "Close gap" actionable: a gap exists AND every clip that must shift is movable. */
canCloseGap: boolean;
/** "Close all gaps" actionable: the lane has gaps AND every shifting clip is movable. */
canCloseAllGaps: boolean;
/** The lane has at least one gap (distinguishes the two disabled reasons). */
hasAnyGaps: boolean;
onClose: () => void;
onCloseGap: () => void;
onCloseAllGaps: () => void;
/** Hover state for the gap-strip highlight overlay (null = nothing hovered).
* Only reported for ACTIONABLE rows a disabled row closes nothing, so
* highlighting from it would promise an action that can't happen. */
onHoverAction: (action: "close-gap" | "close-all" | null) => void;
}
/**
* Context menu for right-clicking EMPTY space on a timeline lane
* (CapCut/Premiere-style). Offers "Close gap" (collapse the clicked gap by
* shifting the following clips on that lane left) and "Close all gaps"
* (compact the whole lane contiguous from 0). Both rows are ALWAYS present
* an inapplicable action dims with a tooltip explaining why, rather than
* vanishing into a one-item menu. Hovering an actionable row highlights the
* gap strip(s) it would close (via onHoverAction TimelineCanvas overlay).
* Styling mirrors ClipContextMenu.
*/
export const TrackGapContextMenu = memo(function TrackGapContextMenu({
x,
y,
gapWidth,
canCloseGap,
canCloseAllGaps,
hasAnyGaps,
onClose,
onCloseGap,
onCloseAllGaps,
onHoverAction,
}: TrackGapContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
const menuWidth = 200;
const menuHeight = 68;
const overflowY = y + menuHeight - window.innerHeight;
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
const itemClass = (enabled: boolean) =>
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
enabled
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`;
// Disabled reasons: no gap under the pointer beats the lock reason — a
// pointer not on a gap has nothing to close regardless of movability.
const closeGapTitle = canCloseGap
? undefined
: gapWidth == null
? "No gap here"
: "A clip on this track can't be moved";
const closeAllTitle = canCloseAllGaps
? undefined
: hasAnyGaps
? "A clip on this track can't be moved"
: "No gaps on this track";
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
onPointerLeave={() => onHoverAction(null)}
>
<button
type="button"
className={itemClass(canCloseGap)}
disabled={!canCloseGap}
title={closeGapTitle}
onPointerEnter={() => onHoverAction(canCloseGap ? "close-gap" : null)}
onClick={() => {
if (!canCloseGap) return;
onCloseGap();
onClose();
}}
>
<span>Close gap</span>
{gapWidth != null && (
<span className="text-neutral-500 text-[10px] ml-3">{gapWidth.toFixed(2)}s</span>
)}
</button>
<button
type="button"
className={itemClass(canCloseAllGaps)}
disabled={!canCloseAllGaps}
title={closeAllTitle}
onPointerEnter={() => onHoverAction(canCloseAllGaps ? "close-all" : null)}
onClick={() => {
if (!canCloseAllGaps) return;
onCloseAllGaps();
onClose();
}}
>
<span>Close all gaps</span>
</button>
</div>,
document.body,
);
});
@@ -31,11 +31,14 @@ export interface TimelineEditCallbacks {
) => Promise<void> | void;
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
* lane change's follow-up z-reorder entry into one undo step. */
* lane change's follow-up z-reorder entry into one undo step; `coalesceMs`
* widens that entry's fold window when a server round-trip separates the
* gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
onMoveElements?: (
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
coalesceKey?: string,
operation?: TimelineMoveOperation,
coalesceMs?: number,
) => Promise<void> | void;
onResizeElement?: (
element: TimelineElement,
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./useTimelineClipDrag";
import {
commitDraggedClipMove,
commitZMirrorLaneMove,
type DragCommitDeps,
type TimelineMoveEdit,
} from "./timelineClipDragCommit";
@@ -12,6 +13,7 @@ import {
pushEditHistoryEntry,
} from "../../utils/editHistory";
import { normalizeToZones } from "./timelineZones";
import { resolveZMirrorLaneMove } from "./timelineZMirror";
import type { StackingPatch } from "./timelineStackingSync";
function el(
@@ -29,9 +31,7 @@ function el(
/** Flush the microtask chain: the z-sync now fires only after the move persist
* promise resolves (serialized), so tests asserting on it must await. */
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
for (let step = 0; step < 8; step += 1) await Promise.resolve();
}
function drag(
@@ -370,6 +370,73 @@ describe("commitDraggedClipMove", () => {
expect(map.b.track).toBe(2); // at/below the insert → +1 shift
});
it("a selected audio passenger moves in time during a visual insert without being renumbered", () => {
const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
const audio = {
...el("audio", 2, 4, 20, "audio"),
sourceFile: "scene.html",
authoredTrack: 7,
};
const { onMoveElements } = runClipMove(
drag(t, { previewStart: 5, previewTrack: 1, insertRow: 1 }),
{
elements: [a, b, t, audio],
trackOrder: [0, 1, 2],
selectedKeys: new Set(["t", "audio"]),
},
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.audio).toEqual({ start: 9, track: 7 });
});
it("an audio insert persists zone-local tracks without rewriting visual lanes", () => {
const v0 = el("v0", 0, 0, 5);
const v1 = el("v1", 1, 0, 5);
const a = { ...el("a", 2, 0, 5, "audio"), authoredTrack: 0 };
const b = { ...el("b", 3, 10, 5, "audio"), authoredTrack: 1 };
const t = { ...el("t", 3, 0, 5, "audio"), authoredTrack: 1 };
const { onMoveElements } = runClipMove(
drag(t, { previewStart: 0, previewTrack: 3, insertRow: 3 }),
{ elements: [v0, v1, a, b, t], trackOrder: [0, 1, 2, 3] },
);
expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
a: { start: 0, track: 0 },
t: { start: 0, track: 1 },
b: { start: 10, track: 2 },
});
});
it("refuses an audio insert when it would renumber a locked authored row", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const visual = el("visual", 0, 0, 5);
const locked = {
...el("locked", 1, 0, 5, "audio"),
authoredTrack: 0,
timelineLocked: true,
};
const target = { ...el("target", 2, 0, 5, "audio"), authoredTrack: 1 };
try {
const { onMoveElements } = runClipMove(
drag(target, { previewStart: 0, previewTrack: 2, insertRow: 1 }),
{ elements: [visual, locked, target], trackOrder: [0, 1, 2] },
);
expect(onMoveElements).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("locked clip locked would need renumbering"),
);
} finally {
warnSpy.mockRestore();
}
});
describe("lane ↔ stacking sync", () => {
it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
// a & b overlap in time. Elements carry their authored z (as real discovery
@@ -1024,3 +1091,212 @@ describe("commitDraggedClipMove", () => {
});
});
});
describe("commitZMirrorLaneMove", () => {
const mirrorDeps = (elements: TimelineElement[], trackOrder: number[]) => {
const updateElement = vi.fn();
const onMoveElements = vi.fn();
return {
updateElement,
onMoveElements,
deps: { elements, trackOrder, updateElement, onMoveElements } as DragCommitDeps,
};
};
it("kind:move persists start + display lane with the persistTrack override (same shape as a lane drag)", async () => {
// t sits at lane 2 in a sparse file (authored 7); the mirror lands it on
// lane 0 whose authored track is 3.
const t = { ...el("t", 2, 0, 10), authoredTrack: 7 };
const elements = [{ ...el("a", 0, 20, 5), authoredTrack: 3 }, el("b", 1, 0, 10), t];
const { updateElement, onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
const moved = await commitZMirrorLaneMove(
t,
{ kind: "move", displayTrack: 0, persistTrack: 3 },
deps,
"z-reorder:bring-forward:t",
);
expect(moved).toBe(true);
// Optimistic store update: DISPLAY lane + the written authoredTrack mirror.
expect(updateElement).toHaveBeenCalledWith("t", {
start: 0,
track: 0,
authoredTrack: 3,
});
// Persist: authored-space track, the z persist's coalesce key, lane-reorder op.
expect(onMoveElements).toHaveBeenCalledTimes(1);
const [persistEdits, coalesceKey, operation] = onMoveElements.mock.calls[0];
expect(editMap(persistEdits)).toEqual({ t: { start: 0, track: 3 } });
expect(coalesceKey).toBe("z-reorder:bring-forward:t");
expect(operation).toBe("lane-reorder");
});
it("kind:insert reuses the track-insert renumber core (+1 shift below the new lane)", async () => {
// a,b,c mutually overlapping on lanes 0/1/2. Mirror-insert c at row 1: c
// lands on the new lane, b (at/below) shifts down — identical to the drag
// insert test above, proving the shared core (no duplicated renumber logic).
const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
const moved = await commitZMirrorLaneMove(
elements[2],
{ kind: "insert", insertRow: 1 },
deps,
"z-reorder:bring-forward:c",
);
expect(moved).toBe(true);
expect(onMoveElements).toHaveBeenCalledTimes(1);
expect(onMoveElements.mock.calls[0][1]).toBe("z-reorder:bring-forward:c");
expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.a.track).toBe(0);
expect(map.c.track).toBe(1);
expect(map.b.track).toBe(2);
});
it("visual mirror inserts never persist or renumber same-file audio", async () => {
// b and t share a visual lane because they do not overlap. Inserting t between
// a and b creates one extra visual lane, so whole-timeline normalization moves
// audio from display lane 2 to 3. That display-only shift must not be written.
const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
const audio = { ...el("audio", 2, 0, 20, "audio"), sourceFile: "scene.html" };
const { onMoveElements, deps } = mirrorDeps([a, b, t, audio], [0, 1, 2]);
const moved = await commitZMirrorLaneMove(
t,
{ kind: "insert", insertRow: 1 },
deps,
"z-reorder:bring-forward:t",
);
expect(moved).toBe(true);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.audio).toBeUndefined();
expect(map).toEqual({
a: { start: 0, track: 0 },
b: { start: 10, track: 2 },
t: { start: 0, track: 1 },
});
});
it("foreign expanded rows never distort the root-file insert topology", async () => {
const host = { ...el("host", 0, 0, 5), sourceFile: "index.html" };
const child1 = {
...el("child-1", 0.25, 0, 5),
sourceFile: "scene.html",
expandedParentStart: 0,
};
const child2 = {
...el("child-2", 0.5, 0, 5),
sourceFile: "scene.html",
expandedParentStart: 0,
};
const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
const elements = [host, child1, child2, b, t];
const { onMoveElements, deps } = mirrorDeps(elements, [0, 0.25, 0.5, 1, 2]);
const moved = await commitZMirrorLaneMove(
t,
{ kind: "insert", insertRow: 1 },
deps,
"z-reorder:bring-forward:t",
);
expect(moved).toBe(true);
expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
host: { start: 0, track: 0 },
t: { start: 0, track: 1 },
b: { start: 0, track: 2 },
});
});
it("never triggers the lane→z stacking sync (it would fight the just-set z values)", async () => {
// Even with BOTH z-sync deps supplied (the drag paths would engage them for
// a vertical move like this), the mirror commit must not emit stacking
// patches — the z values were just written by the user's menu action.
const t = el("t", 2, 0, 10);
const elements = [el("a", 0, 20, 5), el("b", 1, 0, 10), t];
const { deps } = mirrorDeps(elements, [0, 1, 2]);
const onStackingPatches = vi.fn();
const moved = await commitZMirrorLaneMove(
t,
{ kind: "move", displayTrack: 0, persistTrack: 0 },
{ ...deps, readZIndex: () => 0, onStackingPatches },
"z-reorder:bring-forward:t",
);
await flushMicrotasks();
expect(moved).toBe(true);
expect(onStackingPatches).not.toHaveBeenCalled();
});
it("resolves false and rolls the store back when the persist rejects", async () => {
const t = el("t", 1, 0, 10);
const elements = [el("a", 0, 20, 5), t];
const updateElement = vi.fn();
const onMoveElements = vi.fn().mockRejectedValue(new Error("boom"));
const moved = await commitZMirrorLaneMove(
t,
{ kind: "move", displayTrack: 0, persistTrack: 0 },
{ elements, trackOrder: [0, 1], updateElement, onMoveElements },
"z-reorder:send-backward:t",
);
expect(moved).toBe(false);
// Optimistic write then rollback to the original lane.
expect(updateElement).toHaveBeenLastCalledWith("t", {
start: 0,
track: 1,
authoredTrack: undefined,
});
});
it("END-TO-END one-element step: resolver insertRow renumbers the clip strictly between the two neighbors", async () => {
// 3 stacked back-to-back clips + a free lane beyond the far one. Send t
// (top) backward past b: the resolver must bound at c and produce the
// insert row IMMEDIATELY below b, and commitZMirrorLaneMove's renumber must
// land t strictly between b and c — never on the farther free lane 3.
const t = el("t", 0, 0, 10);
const b = el("b", 1, 0, 10);
const c = el("c", 2, 0, 10);
const far = el("far", 3, 20, 5); // free over t's span, beyond c
const elements = [t, b, c, far];
const move = resolveZMirrorLaneMove({
action: "send-backward",
element: t,
elements,
crossedKey: "b",
});
expect(move).toEqual({ kind: "insert", insertRow: 2 });
const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2, 3]);
const moved = await commitZMirrorLaneMove(t, move!, deps, "z-reorder:send-backward:t");
expect(moved).toBe(true);
const map = editMap(onMoveElements.mock.calls[0][0]);
// The renumber compacts t's vacated top lane, so the whole set shifts up by
// one while t lands on the b/c boundary — strictly between the two.
expect(map).toEqual({
b: { start: 0, track: 0 },
t: { start: 0, track: 1 },
c: { start: 0, track: 2 },
far: { start: 20, track: 3 },
});
expect(map.b.track).toBeLessThan(map.t.track);
expect(map.t.track).toBeLessThan(map.c.track);
});
it("resolves false for a refused insert (locked clip would need renumbering)", async () => {
// b is locked and sits at/below the insert row, so the whole-set renumber
// is refused — no persist call.
const a = el("a", 0, 0, 5);
const b: TimelineElement = { ...el("b", 1, 0, 5), timelineLocked: true };
const c = el("c", 2, 0, 5);
const { onMoveElements, deps } = mirrorDeps([a, b, c], [0, 1, 2]);
const moved = await commitZMirrorLaneMove(
c,
{ kind: "insert", insertRow: 1 },
deps,
"z-reorder:bring-forward:c",
);
expect(moved).toBe(false);
expect(onMoveElements).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,8 @@
import type { TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./useTimelineClipDrag";
// Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
// value-import edge stays acyclic.
import type { ZMirrorLaneMove } from "./timelineZMirror";
import { classifyZone, normalizeToZones } from "./timelineZones";
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
import { getTimelineEditCapabilities } from "./timelineEditing";
@@ -8,6 +11,7 @@ import {
beginTimelineOptimisticGesture,
isLatestTimelineOptimisticGesture,
} from "./timelineOptimisticRevision";
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
type StartTrack = Pick<TimelineElement, "start" | "track">;
export interface TimelineMoveEdit {
@@ -31,11 +35,14 @@ export interface DragCommitDeps {
/** Atomic multi-clip persist (single undo) for lane changes + track inserts.
* `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
* history entry so it merges with the lane change's z-reorder entry (see the
* lane-change branch below). */
* lane-change branch below). `coalesceMs` widens that entry's fold window
* (per-gesture-unique keys make an unbounded window safe) required when a
* server round-trip sits between the gesture's two records. */
onMoveElements?: (
edits: TimelineMoveEdit[],
coalesceKey?: string,
operation?: TimelineMoveOperation,
coalesceMs?: number,
) => Promise<void> | void;
/**
* The current multi-selection (store.selectedElementIds). When the dragged
@@ -60,18 +67,13 @@ export interface DragCommitDeps {
* the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
* research/STAGE3-NEEDED-WIRING.md.
*/
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
}
const keyOf = (e: TimelineElement) => e.key ?? e.id;
const round3 = (v: number) => Math.round(v * 1000) / 1000;
// One coalesce key per lane-change gesture, shared by the move-persist history
// entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
// layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
// records into a single undo step. A monotonic counter — NOT Date.now() /
// Math.random(), which the determinism rules forbid — suffices: the key only has
// to be unique per gesture and identical across the gesture's two records.
// One deterministic coalesce key shared by both records in a lane-change gesture.
let laneChangeGestureSeq = 0;
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
@@ -101,12 +103,17 @@ function canMoveElement(element: TimelineElement): boolean {
* `false` so the caller also skips the z-sync (no orphaned z patch).
*
* The DOM is updated synchronously up front; the returned promise never rejects.
*
* Exported for reuse by non-drag batch time-moves (track gap closing see
* timelineGapCommit.ts) so they share the same optimistic-apply, rollback, and
* atomic single-undo persist semantics as a drag commit.
*/
function persistMoveEdits(
export function persistMoveEdits(
edits: TimelineMoveEdit[],
deps: DragCommitDeps,
coalesceKey?: string,
operation: TimelineMoveOperation = "timing",
coalesceMs?: number,
): Promise<boolean> {
if (edits.length === 0) return Promise.resolve(true);
const { updateElement, onMoveElement, onMoveElements } = deps;
@@ -148,7 +155,7 @@ function persistMoveEdits(
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
);
const persisted = onMoveElements
? onMoveElements(persistEdits, coalesceKey, operation)
? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
return Promise.resolve(persisted).then(
() => true,
@@ -173,7 +180,7 @@ function persistMoveEdits(
/** Same-source-file predicate: authored track numbers only compare within ONE
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
* file, not the host timeline's). `undefined` means the active composition. */
const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
(a.sourceFile ?? null) === (b.sourceFile ?? null);
/**
@@ -203,7 +210,7 @@ const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
* never here.
*/
function authoredTrackForLane(
export function authoredTrackForLane(
lane: number,
elements: TimelineElement[],
dragged: TimelineElement,
@@ -217,7 +224,10 @@ function authoredTrackForLane(
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
}
if (!nearest) return lane;
return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
// Rounded: expanded children live on FRACTIONAL synthetic display rows (see
// buildChildElements), so a lane distance measured against one can carry a
// fraction — an authored data-track-index must stay an integer.
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
}
function insertTrackValue(trackOrder: number[], insertRow: number): number {
@@ -351,8 +361,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
return e;
});
const multiKeys = multi ? multi.keys : null;
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
if (moved && isVertical) {
if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
return;
}
void runLaneZGesture({
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
commitZ: () =>
syncStackingForEdit(
candidate,
dragKey,
@@ -361,20 +376,94 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
multiKeys,
deps,
coalesceKey,
);
}
});
),
}).catch(() => undefined);
}
/**
* Insert a new track at the drop's gap boundary. The dragged clip lands on the
* fractional insert lane; normalizeToZones then compacts every lane to a contiguous
* integer, which shifts the clips at/below the insert down by one. That +1
* renumber is the ONLY sanctioned multi-clip write; it is index-only (never z).
* The whole affected set is persisted atomically (single undo), and the deliberate
* vertical move syncs the dragged clip's stacking afterwards.
*/
/** Build the one sanctioned multi-clip write: atomically insert and compact a
* source-file zone, then let the caller sync the deliberate vertical stacking. */
// fallow-ignore-next-line complexity
function buildTrackInsertEdits(
element: TimelineElement,
previewStart: number,
insertRow: number,
multi: {
keys: ReadonlySet<string>;
movedStart: (e: TimelineElement) => number;
} | null,
deps: DragCommitDeps,
): { candidate: TimelineElement[]; edits: TimelineMoveEdit[] } | null {
const { elements, trackOrder } = deps;
const editKey = keyOf(element);
// Expanded-child rows are synthetic host lanes, not source-file topology.
if (element.expandedParentStart != null) return null;
const targetTrack = insertTrackValue(trackOrder, insertRow);
const candidate = elements.map((e) => {
if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
return e;
});
// Foreign display rows and the opposite zone must not affect this topology.
const writableZone = classifyZone(element);
const writable = (src: TimelineElement): boolean =>
sameSourceFile(src, element) &&
classifyZone(src) === writableZone &&
src.expandedParentStart == null;
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
(a, b) => a - b,
);
const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
const normalized = normalizeToZones(
elements.filter(writable).map((e) => {
if (keyOf(e) === editKey) {
return { ...e, start: previewStart, track: topologyTargetTrack };
}
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
return e;
}),
);
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
// A partial zone renumber creates collisions; refuse a shifted locked row.
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (
src &&
writable(src) &&
!canMoveElement(src) &&
norm.track !== (src.authoredTrack ?? src.track)
) {
console.warn(
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
);
return null;
}
}
const edits: TimelineMoveEdit[] = [];
if (multi) {
for (const src of elements) {
const srcKey = keyOf(src);
if (srcKey !== editKey && multi.keys.has(srcKey) && !writable(src) && canMoveElement(src)) {
edits.push({
element: src,
updates: { start: multi.movedStart(src), track: src.track },
persistTrack: src.authoredTrack,
});
}
}
}
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (!src || !canMoveElement(src)) continue;
const start =
keyOf(norm) === editKey || multi?.keys.has(keyOf(norm))
? (multi?.movedStart(src) ?? previewStart)
: src.start;
edits.push({ element: src, updates: { start, track: norm.track } });
}
return { candidate, edits };
}
function commitTrackInsert(
drag: DraggedClipState,
deps: DragCommitDeps,
@@ -383,59 +472,27 @@ function commitTrackInsert(
movedStart: (e: TimelineElement) => number;
} | null,
): void {
const { elements, trackOrder } = deps;
const dragKey = keyOf(drag.element);
const targetTrack = insertTrackValue(trackOrder, drag.insertRow!);
// Drop-intent set: dragged clip at the fractional insert lane (so it sorts
// between its neighbours), selection members time-shifted, others as-is.
const candidate = elements.map((e) => {
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack };
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
return e;
});
// normalizeToZones compacts the fractional lane to a contiguous integer, which
// shifts the at/below clips down by one — the sanctioned +1 index renumber.
const normalized = normalizeToZones(candidate);
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
// The renumber is only correct as a WHOLE-SET write: skipping an unwritable
// clip whose lane shifts leaves its track colliding with a renumbered
// neighbour, and the next normalize merges the two lanes. If any shifted clip
// can't be written, refuse the insert instead of persisting a broken layout.
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (src && !canMoveElement(src) && norm.track !== src.track) {
console.warn(
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
);
return;
}
}
const edits: TimelineMoveEdit[] = [];
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (!src) continue;
// Capabilities gate (unchanged-lane clips only reach here now): never write
// a locked/implicit clip.
if (!canMoveElement(src)) continue;
const start =
keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
? (multi?.movedStart(src) ?? drag.previewStart)
: src.start;
edits.push({ element: src, updates: { start, track: norm.track } });
}
const built = buildTrackInsertEdits(
drag.element,
drag.previewStart,
drag.insertRow!,
multi,
deps,
);
if (!built) return;
const { candidate, edits } = built;
if (edits.length === 0) return;
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
void persistMoveEdits(edits, deps, coalesceKey, "track-insert").then((moved) => {
// Skip the z-sync when the insert produced NO move edits (e.g. every clip in
// the set is locked/implicit and gets filtered out). persistMoveEdits resolves
// `true` for an empty batch so the caller's serialization proceeds, but firing
// the z-sync here would record an orphaned z-only history entry for a move that
// never persisted.
if (moved && edits.length > 0) {
// Reason the z-sync on the drop-intent `candidate` (dragged clip at its
// fractional insert lane) — NOT the re-normalized lanes — so the sync sees
// the user's move. The guard lane is the aimed insert row (a boundary in
// display-lane space, comparable to the clip's contiguous current lane).
if (!deps.readZIndex || !deps.onStackingPatches) {
void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
return;
}
void runLaneZGesture({
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
commitZ: () =>
// Sync from the fractional drop intent, not the normalized persisted lanes.
syncStackingForEdit(
candidate,
dragKey,
@@ -444,9 +501,54 @@ function commitTrackInsert(
multi ? multi.keys : null,
deps,
coalesceKey,
);
}
});
),
}).catch(() => undefined);
}
/**
* Commit the timeline lane move that MIRRORS a canvas z-order menu action
* (resolveZMirrorLaneMove's non-null result). Same machinery as a lane drag:
*
* - kind "move": persistMoveEdits with `{start: element.start, track: displayTrack}`
* + `persistTrack` identical shape to commitDraggedClipMove's lane-change
* branch (optimistic store update, authoredTrack mirror, rollback on failure).
* - kind "insert": buildTrackInsertEdits the SAME renumber core commitTrackInsert
* uses then the same atomic persist.
*
* Deliberately NO syncStackingForEdit here: the z values were just set by the
* user's menu action, and the lanez sync would recompute (and fight) them. The
* mirror caller also omits `readZIndex`/`onStackingPatches` from `deps`, so even
* a future call into the sync would no-op (double protection; see
* useCanvasZOrderTimelineMirror).
*
* `coalesceKey` MUST be the z persist's key (`z-reorder:<action>:<ids>:g<seq>`)
* so editHistory folds the z write and this track write into ONE undo entry, and
* `coalesceMs` MUST widen this record's fold window: the mirror only runs after
* the z persist's server round-trip resolved, so under real network latency the
* gap between the two records exceeds the reducer's 300ms default and the fold
* would never happen live. The key is unique per gesture, so an unbounded
* window can never merge distinct gestures.
*
* Resolves `true` once the move persisted, `false` on rollback / refused insert.
*/
export function commitZMirrorLaneMove(
element: TimelineElement,
move: NonNullable<ZMirrorLaneMove>,
deps: DragCommitDeps,
coalesceKey: string,
coalesceMs?: number,
): Promise<boolean> {
if (move.kind === "move") {
const edit: TimelineMoveEdit = {
element,
updates: { start: element.start, track: move.displayTrack },
persistTrack: move.persistTrack,
};
return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
}
const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
if (!built || built.edits.length === 0) return Promise.resolve(false);
return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
}
/**
@@ -467,18 +569,16 @@ function syncStackingForEdit(
multiKeys: ReadonlySet<string> | null,
deps: DragCommitDeps,
coalesceKey?: string,
): void {
): Promise<void> {
const { readZIndex, onStackingPatches } = deps;
if (!readZIndex || !onStackingPatches) return;
if (!readZIndex || !onStackingPatches) return Promise.resolve();
// Aiming at the clip's OWN current display lane is not a relocation — never
// touch z (guards the pure-time-move invariant even if a spurious topology call
// slips through). Every real lane-realization drop aims at a DIFFERENT lane.
if (aimedLane === currentLane) return;
if (aimedLane === currentLane) return Promise.resolve();
// `candidate` is in discovery order, so its array index IS the DOM document
// position. Equal-z clips paint by DOM order, so the sync needs it to decide
// "is A above B" (see StackingElement.domIndex).
// Discovery order is DOM order, which breaks equal-z ties.
const stackingEls = candidate.map((el, domIndex) => ({
key: keyOf(el),
start: el.start,
@@ -486,6 +586,7 @@ function syncStackingForEdit(
track: el.track,
zIndex: readZIndex(el),
isAudio: classifyZone(el) === "audio",
sourceFile: el.sourceFile,
domIndex,
stackingContextId: el.stackingContextId ?? null,
}));
@@ -494,5 +595,6 @@ function syncStackingForEdit(
if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
const patches = computeStackingPatches(stackingEls, editedKeys);
if (patches.length > 0) onStackingPatches(patches, coalesceKey);
if (patches.length === 0) return Promise.resolve();
return Promise.resolve(onStackingPatches(patches, coalesceKey)).then(() => undefined);
}
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import type { DragCommitDeps } from "./timelineClipDragCommit";
import {
canShiftTrackGapClips,
commitCloseAllTrackGaps,
commitCloseTrackGap,
} from "./timelineGapCommit";
import { resolveAllTrackGaps } from "./timelineGaps";
function el(id: string, start: number, duration: number, track = 0): TimelineElement {
// domId + video tag → getTimelineEditCapabilities(...).canMove === true
return { id, tag: "video", start, duration, track, domId: id };
}
function lockedEl(id: string, start: number, duration: number): TimelineElement {
return { ...el(id, start, duration), timelineLocked: true };
}
function makeDeps(laneElements: TimelineElement[]) {
const onMoveElements = vi.fn(() => Promise.resolve());
const updateElement = vi.fn();
const deps: DragCommitDeps = {
elements: laneElements,
trackOrder: [0],
updateElement,
onMoveElements,
};
return { deps, onMoveElements, updateElement };
}
/** Assert exactly ONE atomic persist batch; return its flattened edits + coalesce key. */
function singleBatch(onMoveElements: ReturnType<typeof vi.fn>) {
expect(onMoveElements).toHaveBeenCalledTimes(1);
const [edits, coalesceKey] = onMoveElements.mock.calls[0] as unknown as [
Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
string,
];
return { coalesceKey, edits: edits.map((e) => ({ id: e.element.id, ...e.updates })) };
}
describe("commitCloseTrackGap", () => {
it("persists ONE atomic batch shifting the next clip and every clip after it", () => {
const lane = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
const { deps, onMoveElements } = makeDeps(lane);
expect(commitCloseTrackGap(lane, 3, deps)).toBe(true);
const { edits, coalesceKey } = singleBatch(onMoveElements);
// Gap is [2, 5) → width 3; b and c shift left by 3, tracks unchanged.
expect(edits).toEqual([
{ id: "b", start: 2, track: 0 },
{ id: "c", start: 7, track: 0 },
]);
expect(typeof coalesceKey).toBe("string");
expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
});
it("optimistically applies the same starts to the store", () => {
const lane = [el("a", 0, 2), el("b", 5, 3)];
const { deps, updateElement } = makeDeps(lane);
commitCloseTrackGap(lane, 3, deps);
expect(updateElement).toHaveBeenCalledWith("b", { start: 2, track: 0 });
});
it("closes the leading gap (first clip lands at 0)", () => {
const lane = [el("a", 2, 3), el("b", 6, 1)];
const { deps, onMoveElements } = makeDeps(lane);
expect(commitCloseTrackGap(lane, 1, deps)).toBe(true);
const [edits] = onMoveElements.mock.calls[0] as unknown as [
Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
];
expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
{ id: "a", start: 0 },
{ id: "b", start: 4 },
]);
});
it("uses a fresh coalesce key per gesture", () => {
const lane = [el("a", 0, 2), el("b", 5, 3)];
const first = makeDeps(lane);
const second = makeDeps(lane);
commitCloseTrackGap(lane, 3, first.deps);
commitCloseTrackGap(lane, 3, second.deps);
const keyA = first.onMoveElements.mock.calls[0][1 as never];
const keyB = second.onMoveElements.mock.calls[0][1 as never];
expect(keyA).not.toEqual(keyB);
});
it("refuses (no write) when there is no clip right of the point", () => {
const lane = [el("a", 0, 2)];
const { deps, onMoveElements, updateElement } = makeDeps(lane);
expect(commitCloseTrackGap(lane, 5, deps)).toBe(false);
expect(onMoveElements).not.toHaveBeenCalled();
expect(updateElement).not.toHaveBeenCalled();
});
it("refuses (no partial compaction) when ANY shifting clip is unmovable", () => {
const lane = [el("a", 0, 2), el("b", 5, 3), lockedEl("c", 10, 1)];
const { deps, onMoveElements, updateElement } = makeDeps(lane);
expect(commitCloseTrackGap(lane, 3, deps)).toBe(false);
expect(onMoveElements).not.toHaveBeenCalled();
expect(updateElement).not.toHaveBeenCalled();
});
});
describe("commitCloseAllTrackGaps", () => {
it("compacts the whole lane in ONE atomic batch (leading gap included)", () => {
const lane = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
const { deps, onMoveElements } = makeDeps(lane);
expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
const { edits, coalesceKey } = singleBatch(onMoveElements);
expect(edits).toEqual([
{ id: "a", start: 0, track: 0 },
{ id: "b", start: 2, track: 0 },
{ id: "c", start: 5, track: 0 },
]);
expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
});
it("refuses when the track is already contiguous (no gaps)", () => {
const lane = [el("a", 0, 2), el("b", 2, 3)];
const { deps, onMoveElements } = makeDeps(lane);
expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
expect(onMoveElements).not.toHaveBeenCalled();
});
it("refuses when any shifting clip is unmovable, even if others could move", () => {
const lane = [lockedEl("a", 1, 2), el("b", 5, 3)];
const { deps, onMoveElements, updateElement } = makeDeps(lane);
expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
expect(onMoveElements).not.toHaveBeenCalled();
expect(updateElement).not.toHaveBeenCalled();
});
it("proceeds when an unmovable clip does NOT need to shift", () => {
// Locked clip already sits flush at 0 — only movable clips shift.
const lane = [lockedEl("a", 0, 2), el("b", 4, 1)];
const { deps, onMoveElements } = makeDeps(lane);
expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
const [edits] = onMoveElements.mock.calls[0] as unknown as [
Array<{ element: TimelineElement; updates: { start: number } }>,
];
expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
{ id: "b", start: 2 },
]);
});
});
describe("canShiftTrackGapClips", () => {
it("is true only when every named clip is movable", () => {
const lane = [el("a", 1, 2), lockedEl("b", 5, 3)];
expect(canShiftTrackGapClips(lane, [{ key: "a", newStart: 0 }])).toBe(true);
expect(canShiftTrackGapClips(lane, resolveAllTrackGaps(lane))).toBe(false);
});
it("is false for unknown keys", () => {
expect(canShiftTrackGapClips([el("a", 0, 1)], [{ key: "ghost", newStart: 0 }])).toBe(false);
});
});
@@ -0,0 +1,103 @@
import type { TimelineElement } from "../store/playerStore";
import { getTimelineEditCapabilities } from "./timelineEditing";
import {
persistMoveEdits,
type DragCommitDeps,
type TimelineMoveEdit,
} from "./timelineClipDragCommit";
import {
laneGapFloor,
resolveAllTrackGaps,
resolveCloseGapShifts,
resolveTrackGapAt,
type TrackGapShift,
} from "./timelineGaps";
/**
* Commit layer for the track-gap context menu ("Close gap" / "Close all gaps").
*
* Each action is ONE atomic {@link persistMoveEdits} batch pure time moves
* (`updates.track === element.track`, no authored-track rewrite) tagged with a
* per-gesture-unique coalesce key, so an action is exactly one undo entry and
* flows through the existing move pipeline (optimistic store apply + rollback,
* SDK fast path, patchIframeDomTiming preview).
*
* Refusal rule: if ANY clip that must shift is unmovable
* ({@link getTimelineEditCapabilities}.canMove === false), the whole action is
* refused never a partial compaction. The menu disables the item via
* {@link canShiftTrackGapClips}; the commit re-checks as defense in depth.
*/
const keyOf = (e: TimelineElement) => e.key ?? e.id;
// Per-gesture-unique coalesce key. A monotonic counter — NOT Date.now() /
// Math.random() (determinism rules) — mirrors laneChangeGestureSeq in
// timelineClipDragCommit.ts.
let gapCloseGestureSeq = 0;
/** True when every clip named in `shifts` may be time-moved. */
export function canShiftTrackGapClips(
laneElements: readonly TimelineElement[],
shifts: readonly TrackGapShift[],
): boolean {
const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
return shifts.every((s) => {
const element = byKey.get(s.key);
return element != null && getTimelineEditCapabilities(element).canMove;
});
}
function buildShiftEdits(
laneElements: readonly TimelineElement[],
shifts: readonly TrackGapShift[],
): TimelineMoveEdit[] | null {
if (shifts.length === 0 || !canShiftTrackGapClips(laneElements, shifts)) return null;
const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
return shifts.map((s) => {
const element = byKey.get(s.key)!;
return { element, updates: { start: s.newStart, track: element.track } };
});
}
function commitShifts(
laneElements: readonly TimelineElement[],
shifts: readonly TrackGapShift[],
deps: DragCommitDeps,
): boolean {
const edits = buildShiftEdits(laneElements, shifts);
if (!edits) return false;
void persistMoveEdits(edits, deps, `track-gap-close:${gapCloseGestureSeq++}`);
return true;
}
/**
* Close the ONE gap under `time` on the lane: the next clip and every clip
* after it on that lane shift left by the gap's width. Returns false (and
* writes nothing) when there is no gap at the point or a shifting clip is
* unmovable.
*/
export function commitCloseTrackGap(
laneElements: readonly TimelineElement[],
time: number,
deps: DragCommitDeps,
): boolean {
const gap = resolveTrackGapAt(laneElements, time, undefined, laneGapFloor(laneElements));
if (!gap) return false;
return commitShifts(laneElements, resolveCloseGapShifts(laneElements, gap), deps);
}
/**
* Compact the whole lane (leading gap included): clips become contiguous from
* 0, order and durations preserved. Returns false (and writes nothing) when
* the lane has no gaps or a shifting clip is unmovable.
*/
export function commitCloseAllTrackGaps(
laneElements: readonly TimelineElement[],
deps: DragCommitDeps,
): boolean {
return commitShifts(
laneElements,
resolveAllTrackGaps(laneElements, undefined, laneGapFloor(laneElements)),
deps,
);
}
@@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import {
laneGapFloor,
resolveAllGapIntervals,
resolveAllTrackGaps,
resolveCloseGapShifts,
resolveTrackGapAt,
trackHasGaps,
} from "./timelineGaps";
function el(id: string, start: number, duration: number): TimelineElement {
return { id, tag: "video", start, duration, track: 0, domId: id };
}
describe("resolveTrackGapAt", () => {
it("resolves a middle gap between two clips", () => {
const els = [el("a", 0, 2), el("b", 5, 3)];
const gap = resolveTrackGapAt(els, 3);
expect(gap).toEqual({ gapStart: 2, gapEnd: 5, followingKeys: ["b"] });
});
it("resolves the leading gap before the first clip (gapStart = 0)", () => {
const els = [el("a", 2, 3), el("b", 6, 1)];
const gap = resolveTrackGapAt(els, 1);
expect(gap).toEqual({ gapStart: 0, gapEnd: 2, followingKeys: ["a", "b"] });
});
it("includes EVERY clip at/after the gap in followingKeys", () => {
const els = [el("a", 0, 1), el("b", 3, 1), el("c", 5, 1), el("d", 8, 1)];
const gap = resolveTrackGapAt(els, 2);
expect(gap).toEqual({ gapStart: 1, gapEnd: 3, followingKeys: ["b", "c", "d"] });
});
it("returns null when there is no clip to the right of the point", () => {
const els = [el("a", 0, 2)];
expect(resolveTrackGapAt(els, 5)).toBeNull();
expect(resolveTrackGapAt([], 1)).toBeNull(); // empty lane
});
it("returns null when the point is inside a clip (half-open interval)", () => {
const els = [el("a", 1, 2), el("b", 5, 1)];
expect(resolveTrackGapAt(els, 2)).toBeNull(); // strictly inside
expect(resolveTrackGapAt(els, 1)).toBeNull(); // at clip start (occupied)
// At clip END (half-open) the point is free — the gap to "b" resolves.
expect(resolveTrackGapAt(els, 3)).toEqual({ gapStart: 3, gapEnd: 5, followingKeys: ["b"] });
});
it("resolves the leading gap for a single clip", () => {
const gap = resolveTrackGapAt([el("a", 4, 2)], 1);
expect(gap).toEqual({ gapStart: 0, gapEnd: 4, followingKeys: ["a"] });
});
it("treats epsilon-adjacent clips as gapless (float drift)", () => {
// 8.4 + 2.7 = 11.100000000000001 — the classic drift: no point near the
// seam resolves a gap.
const drifted = [el("a", 8.4, 2.7), el("b", 11.1, 2)];
expect(resolveTrackGapAt(drifted, 11.0999)).toBeNull();
expect(resolveTrackGapAt(drifted, 11.1005)).toBeNull();
// A sub-epsilon sliver between clips is not a closable gap either.
const sliver = [el("a", 0, 2.0004), el("b", 2.001, 1)];
expect(resolveTrackGapAt(sliver, 1.9995)).toBeNull();
});
it("handles overlapping clips sanely (uses the max end left of the point)", () => {
const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
const gap = resolveTrackGapAt(els, 5);
expect(gap).toEqual({ gapStart: 4, gapEnd: 6, followingKeys: ["c"] });
});
it("prefers the key over the id when present", () => {
const withKey = { ...el("a", 3, 1), key: "a-key" };
const gap = resolveTrackGapAt([withKey], 1);
expect(gap?.followingKeys).toEqual(["a-key"]);
});
});
describe("resolveCloseGapShifts", () => {
it("shifts the following clips left by exactly the gap width", () => {
const els = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
const gap = resolveTrackGapAt(els, 3)!;
expect(resolveCloseGapShifts(els, gap)).toEqual([
{ key: "b", newStart: 2 },
{ key: "c", newStart: 7 },
]);
});
it("closing the leading gap lands the first clip at 0", () => {
const els = [el("a", 2, 3), el("b", 6, 1)];
const gap = resolveTrackGapAt(els, 1)!;
expect(resolveCloseGapShifts(els, gap)).toEqual([
{ key: "a", newStart: 0 },
{ key: "b", newStart: 4 },
]);
});
it("rounds shifted starts to millisecond precision", () => {
const els = [el("a", 0, 1.1), el("b", 3.3000000000000003, 1)];
const gap = resolveTrackGapAt(els, 2)!;
const shifts = resolveCloseGapShifts(els, gap);
expect(shifts).toEqual([{ key: "b", newStart: 1.1 }]);
});
});
describe("resolveAllTrackGaps", () => {
it("compacts every gap: contiguous from 0, order and durations preserved", () => {
const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
expect(resolveAllTrackGaps(els)).toEqual([
{ key: "a", newStart: 0 },
{ key: "b", newStart: 2 },
{ key: "c", newStart: 5 },
]);
});
it("includes the leading gap for a single clip", () => {
expect(resolveAllTrackGaps([el("a", 4, 2)])).toEqual([{ key: "a", newStart: 0 }]);
});
it("returns only the clips whose start actually changes", () => {
const els = [el("a", 0, 2), el("b", 2, 1), el("c", 5, 1)];
expect(resolveAllTrackGaps(els)).toEqual([{ key: "c", newStart: 3 }]);
});
it("returns [] for an already-contiguous track and for an empty lane", () => {
expect(resolveAllTrackGaps([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
expect(resolveAllTrackGaps([])).toEqual([]);
});
it("ignores epsilon-level drift instead of emitting no-op shifts", () => {
const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
expect(resolveAllTrackGaps(els)).toEqual([]);
});
it("serializes overlapping clips in start order (sum-of-durations rule)", () => {
const els = [el("a", 0, 4), el("b", 2, 2)];
expect(resolveAllTrackGaps(els)).toEqual([{ key: "b", newStart: 4 }]);
});
it("is deterministic for identical starts (key tie-break)", () => {
const els = [el("b", 3, 1), el("a", 3, 2)];
expect(resolveAllTrackGaps(els)).toEqual([
{ key: "a", newStart: 0 },
{ key: "b", newStart: 2 },
]);
});
});
describe("resolveAllGapIntervals", () => {
it("reports every current gap, leading gap included, left to right", () => {
const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
expect(resolveAllGapIntervals(els)).toEqual([
{ start: 0, end: 1 },
{ start: 3, end: 5 },
{ start: 8, end: 10 },
]);
});
it("returns [] for contiguous or empty lanes", () => {
expect(resolveAllGapIntervals([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
expect(resolveAllGapIntervals([])).toEqual([]);
});
it("never fabricates an interval from overlapping clips (cursor = max end)", () => {
const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
expect(resolveAllGapIntervals(els)).toEqual([{ start: 4, end: 6 }]);
});
it("ignores epsilon-level drift seams", () => {
const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
expect(resolveAllGapIntervals(els)).toEqual([]);
});
});
describe("trackHasGaps", () => {
it("detects gaps, including the leading gap", () => {
expect(trackHasGaps([el("a", 1, 2)])).toBe(true);
expect(trackHasGaps([el("a", 0, 2), el("b", 4, 1)])).toBe(true);
});
it("is false for contiguous or empty tracks", () => {
expect(trackHasGaps([el("a", 0, 2), el("b", 2, 1)])).toBe(false);
expect(trackHasGaps([])).toBe(false);
});
});
describe("lane floor (expanded sub-comp children)", () => {
const child = (id: string, start: number, duration: number): TimelineElement => ({
...el(id, start, duration),
expandedParentStart: 16,
sourceFile: "scene.html",
});
it("laneGapFloor is 0 for ordinary lanes and the host window start for child lanes", () => {
expect(laneGapFloor([el("a", 0, 2)])).toBe(0);
expect(laneGapFloor([child("c1", 16.5, 2), child("c2", 20, 2)])).toBe(16);
});
it("compaction lands the first child at the HOST window start, never absolute 0", () => {
const lane = [child("c1", 18, 2), child("c2", 22, 2)];
expect(resolveAllTrackGaps(lane, undefined, laneGapFloor(lane))).toEqual([
{ key: "c1", newStart: 16 },
{ key: "c2", newStart: 18 },
]);
});
it("the leading gap starts at the floor for both close-one and the highlight intervals", () => {
const lane = [child("c1", 18, 2)];
const floor = laneGapFloor(lane);
expect(resolveTrackGapAt(lane, 17, undefined, floor)).toEqual({
gapStart: 16,
gapEnd: 18,
followingKeys: ["c1"],
});
expect(resolveAllGapIntervals(lane, undefined, floor)).toEqual([{ start: 16, end: 18 }]);
});
it("a child lane contiguous from its host start has no gaps", () => {
const lane = [child("c1", 16, 2), child("c2", 18, 2)];
expect(trackHasGaps(lane, undefined, laneGapFloor(lane))).toBe(false);
});
});
@@ -0,0 +1,182 @@
import type { TimelineElement } from "../store/playerStore";
/**
* Pure gap math for a single timeline display lane (CapCut/Premiere-style
* "Close gap" / "Close all gaps"). Operates on the DISPLAY element set the
* timeline renders for one lane the caller passes the clips of the
* right-clicked lane only; cross-lane behavior is out of scope by design.
*
* Conventions:
* - A clip occupies the half-open interval [start, start + duration).
* - Comparisons are epsilon-tolerant ({@link TRACK_GAP_EPSILON_S}) so float
* drift (e.g. 8.4 + 2.7 = 11.100000000000001) never fabricates a sliver gap.
* - Computed starts are rounded to millisecond precision, matching the drag
* commit's `round3`.
*/
const TRACK_GAP_EPSILON_S = 1e-3;
const keyOf = (e: TimelineElement) => e.key ?? e.id;
/**
* The lane's time ORIGIN the earliest start a clip on this lane may take.
* 0 for ordinary lanes; for a lane of expanded sub-comp children (post-
* collision-fix a lane is always single-origin) it is the children's host
* window start (`expandedParentStart`): display times are host-absolute, so
* compacting toward absolute 0 would drag a child BEFORE its host's window
* and persist a wrong (even negative) local time.
*/
export function laneGapFloor(elements: readonly TimelineElement[]): number {
return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
}
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const endOf = (e: TimelineElement) => e.start + e.duration;
/** Lane clips sorted by start (key as a deterministic tie-break). */
function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
return [...elements].sort((a, b) => a.start - b.start || keyOf(a).localeCompare(keyOf(b)));
}
export interface TrackGapAt {
/** Gap left edge: the max end of the clips left of the point (0 for the leading gap). */
gapStart: number;
/** Gap right edge: the start of the next clip on the lane. */
gapEnd: number;
/** Keys of the next clip and every clip after it on the lane, in start order. */
followingKeys: string[];
}
/**
* Resolve the gap under a right-clicked point on one lane.
*
* Returns null when the point sits inside a clip, when there is no clip to the
* right of the point (nothing to close), or when the neighbouring clips are
* epsilon-adjacent (no real gap).
*/
export function resolveTrackGapAt(
elements: readonly TimelineElement[],
time: number,
epsilon: number = TRACK_GAP_EPSILON_S,
floor: number = 0,
): TrackGapAt | null {
const clips = sortedLaneClips(elements);
// Point inside a clip's half-open [start, end) → not empty space.
const occupied = clips.some((c) => time >= c.start - epsilon && time < endOf(c) - epsilon);
if (occupied) return null;
const following = clips.filter((c) => c.start > time - epsilon);
if (following.length === 0) return null; // nothing to the right — nothing to close
const gapEnd = following[0].start;
// Max end among clips left of the point (they all end at/before it since the
// point is unoccupied); the lane floor for the leading gap before the first
// clip (0 for ordinary lanes, the host window start for expanded children).
const gapStart = Math.max(
floor,
...clips.filter((c) => c.start <= time - epsilon).map((c) => endOf(c)),
);
if (gapEnd - gapStart <= epsilon) return null; // epsilon-adjacent — no gap
return { gapStart, gapEnd, followingKeys: following.map(keyOf) };
}
export interface TrackGapShift {
key: string;
newStart: number;
}
/** An empty interval on the lane, [start, end) in seconds. */
export interface TrackGapInterval {
start: number;
end: number;
}
/**
* Every CURRENT empty interval on the lane, leading gap included the regions
* "Close all gaps" would collapse, in left-to-right order. Purely descriptive
* (for the hover-highlight overlay): unlike {@link resolveAllTrackGaps} it
* reports the gaps as they are now, not the post-compaction clip starts.
* Overlapping clips never fabricate a negative interval (the cursor tracks the
* max end seen so far).
*/
export function resolveAllGapIntervals(
elements: readonly TimelineElement[],
epsilon: number = TRACK_GAP_EPSILON_S,
floor: number = 0,
): TrackGapInterval[] {
const gaps: TrackGapInterval[] = [];
let cursor = floor;
for (const clip of sortedLaneClips(elements)) {
if (clip.start - cursor > epsilon) gaps.push({ start: cursor, end: clip.start });
cursor = Math.max(cursor, endOf(clip));
}
return gaps;
}
/**
* Compact the whole lane: every clip lands at the sum of the durations of the
* clips before it (contiguous from 0, order and durations preserved).
* Overlapping clips (spill lanes) are serialized in start order sane, if
* lossy for deliberate overlaps; the display lane set should not contain them.
*
* Returns ONLY the clips whose start actually changes (beyond epsilon).
*/
export function resolveAllTrackGaps(
elements: readonly TimelineElement[],
epsilon: number = TRACK_GAP_EPSILON_S,
floor: number = 0,
): TrackGapShift[] {
const shifts: TrackGapShift[] = [];
let cursor = floor;
for (const clip of sortedLaneClips(elements)) {
const newStart = round3(cursor);
if (Math.abs(newStart - clip.start) > epsilon) {
shifts.push({ key: keyOf(clip), newStart });
}
cursor += clip.duration;
}
return shifts;
}
/**
* Every empty interval on the lane across the FULL rendered extent `[0, end)`:
* the closable gaps plus the open region after the last clip. Powers the
* click-selected lane highlight ("light the whole track except the clips")
* unlike {@link resolveAllGapIntervals} it is not limited to what a gap-close
* could collapse.
*/
export function resolveLaneEmptyIntervals(
elements: readonly TimelineElement[],
end: number,
epsilon: number = TRACK_GAP_EPSILON_S,
floor: number = 0,
): TrackGapInterval[] {
const gaps = resolveAllGapIntervals(elements, epsilon, floor);
const maxEnd = Math.max(floor, ...elements.map(endOf));
if (end - maxEnd > epsilon) gaps.push({ start: maxEnd, end });
return gaps;
}
/** Whether the lane has any gap "Close all gaps" would collapse. */
export function trackHasGaps(
elements: readonly TimelineElement[],
epsilon: number = TRACK_GAP_EPSILON_S,
floor: number = 0,
): boolean {
return resolveAllTrackGaps(elements, epsilon, floor).length > 0;
}
/**
* Per-clip shifts for closing ONE gap: the next clip and every clip after it
* on the lane move left by the gap's width. Starts are clamped at 0 (float
* safety; real shifts never cross the gap's own left edge).
*/
export function resolveCloseGapShifts(
elements: readonly TimelineElement[],
gap: TrackGapAt,
): TrackGapShift[] {
const width = gap.gapEnd - gap.gapStart;
const followSet = new Set(gap.followingKeys);
return sortedLaneClips(elements)
.filter((c) => followSet.has(keyOf(c)))
.map((c) => ({ key: keyOf(c), newStart: Math.max(gap.gapStart, round3(c.start - width)) }));
}
@@ -5,6 +5,7 @@ import {
TRACKS_TOP_PAD,
TRACKS_BOTTOM_PAD,
GUTTER,
TRACKS_LEFT_PAD,
getTimelineRowTop,
getTimelineRowFromY,
getTimelineCanvasHeight,
@@ -85,7 +86,7 @@ describe("track-area breathing pad y-math", () => {
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
const clientX = GUTTER + 100; // t = 1s
const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
expect(track).toBe(0);
expect(start).toBe(1);
@@ -31,6 +31,17 @@ export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
*/
export const TRACKS_TOP_PAD = 50;
export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
/**
* Breathing room LEFT of t=0 (CapCut-style), inside the scroll content the
* horizontal sibling of TRACKS_TOP_PAD: empty lane surface between the sticky
* gutter and where the ruler's 00:00 / the clips actually start, scrolling
* WITH the content. Timepixel mapping: content x = GUTTER + TRACKS_LEFT_PAD
* + t·pps, and every pointertime inverse subtracts it symmetrically. The
* lanes and the ruler realize it as a plain flow spacer between the sticky
* gutter cell and the time-mapped content div, so all content-relative math
* (clip left = t·pps, beat lines, lane-menu time) is untouched.
*/
export const TRACKS_LEFT_PAD = 48;
/**
* 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
const safeDuration =
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
return (viewportWidth - GUTTER - 2) / span;
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
}
/**
@@ -230,7 +241,7 @@ export function getTimelineDisplayContentWidth(input: {
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
return Math.max(
input.trackContentWidth,
input.viewportWidth - GUTTER - 2,
input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
input.dragGhostEndPx ?? 0,
input.resizeGhostEndPx ?? 0,
MIN_TIMELINE_EXTENT_S * safePps,
@@ -300,9 +311,14 @@ export const PLAYHEAD_HEAD_W = 9;
*/
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
return GUTTER - PLAYHEAD_HEAD_W / 2;
return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
}
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
return (
GUTTER +
TRACKS_LEFT_PAD +
Math.max(0, time) * Math.max(0, pixelsPerSecond) -
PLAYHEAD_HEAD_W / 2
);
}
export function getTimelineCanvasHeight(trackCount: number): number {
@@ -373,7 +389,7 @@ export function resolveTimelineAssetDrop(
clientX: number,
clientY: number,
): { start: number; track: number } {
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
const contentY = clientY - input.rectTop + input.scrollTop;
const start = Math.max(
0,
@@ -7,7 +7,17 @@ import {
getTimelineClipRect,
computeMarqueeSelection,
} from "./timelineMarquee";
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
import {
GUTTER,
TRACK_H,
RULER_H,
CLIP_Y,
TRACKS_LEFT_PAD,
getTimelineRowTop,
} from "./timelineLayout";
// Canvas-space time origin: right edge of the sticky gutter + the left pad.
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
describe("isTimelineRulerPress", () => {
const rectTop = 500; // scroll container's viewport top
@@ -86,7 +96,7 @@ describe("getTimelineClipRect", () => {
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
expect(rect).toEqual({
left: GUTTER + 200,
left: ORIGIN + 200,
top: getTimelineRowTop(1) + CLIP_Y,
width: 300,
height: TRACK_H - CLIP_Y * 2,
@@ -96,7 +106,7 @@ describe("getTimelineClipRect", () => {
it("places the first visible track below the ruler + top breathing pad", () => {
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
expect(rect?.left).toBe(GUTTER);
expect(rect?.left).toBe(ORIGIN);
});
it("uses the row index in trackOrder, not the raw track number", () => {
@@ -129,26 +139,26 @@ describe("computeMarqueeSelection", () => {
const row1Top = getTimelineRowTop(1) + CLIP_Y;
it("selects only the clips the marquee rect intersects", () => {
const marquee = { left: GUTTER, top: row0Top, width: 50, height: 10 };
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
expect(ids).toEqual(new Set(["a"]));
expect(primaryId).toBe("a");
});
it("selects across tracks when the rect spans multiple rows", () => {
const marquee = { left: GUTTER, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
expect(ids).toEqual(new Set(["a", "c"]));
});
it("excludes clips outside the rect horizontally", () => {
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
expect(ids).toEqual(new Set());
});
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({
clips,
trackOrder,
@@ -161,7 +171,7 @@ describe("computeMarqueeSelection", () => {
});
it("unions additive base selection with new hits; primary comes from the marquee", () => {
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
const marquee = { left: ORIGIN, top: row1Top, width: 100, height: 10 };
const { ids, primaryId } = computeMarqueeSelection({
clips,
trackOrder,
@@ -174,8 +184,8 @@ describe("computeMarqueeSelection", () => {
});
it("shrinking the rect live drops clips it no longer covers", () => {
const wide = { left: GUTTER, top: row0Top, width: 320, height: 10 };
const narrow = { left: GUTTER, top: row0Top, width: 80, height: 10 };
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
new Set(["a", "b"]),
);
@@ -1,4 +1,11 @@
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
import {
GUTTER,
TRACK_H,
RULER_H,
CLIP_Y,
TRACKS_LEFT_PAD,
getTimelineRowTop,
} from "./timelineLayout";
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
/** Pointer must travel at least this far (either axis) before a pointerdown on
@@ -73,7 +80,7 @@ export function getTimelineClipRect(
const row = trackOrder.indexOf(clip.track);
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
return {
left: GUTTER + clip.start * pps,
left: GUTTER + TRACKS_LEFT_PAD + clip.start * pps,
top: getTimelineRowTop(row) + CLIP_Y,
width: Math.max(clip.duration * pps, MIN_CLIP_W),
height: TRACK_H - CLIP_Y * 2,
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync";
import {
computeStackingPatches,
laneIsAbove,
samePaintScope,
type StackingElement,
} from "./timelineStackingSync";
function el(
key: string,
@@ -20,6 +25,42 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
}
describe("stacking-context partitioning", () => {
it("uses source file and normalized stacking context as the canonical paint scope", () => {
expect(samePaintScope({}, { stackingContextId: null })).toBe(true);
expect(samePaintScope({}, { sourceFile: "index.html" })).toBe(false);
expect(
samePaintScope(
{ sourceFile: "scene.html", stackingContextId: "card" },
{ sourceFile: "scene.html", stackingContextId: "modal" },
),
).toBe(false);
});
it("never compares or patches across source files in the root context", () => {
const root: StackingElement = {
key: "root",
track: 0,
start: 0,
duration: 5,
zIndex: 1,
isAudio: false,
sourceFile: "index.html",
stackingContextId: null,
};
const scene: StackingElement = {
key: "scene",
track: 1,
start: 0,
duration: 5,
zIndex: 10,
isAudio: false,
sourceFile: "scenes/scene.html",
stackingContextId: null,
};
expect(patchMap([root, scene], ["root"])).toEqual({});
});
it("never compares or patches across stacking contexts", () => {
// X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
// with a lower leaf z, overlapping in time. Their leaf z values are NOT
@@ -43,6 +43,8 @@ export interface StackingElement {
zIndex: number;
/** Audio clips have no visual stacking and are excluded from the computation. */
isAudio: boolean;
/** Source document. Leaf z-indexes are comparable only inside this file. */
sourceFile?: string;
/**
* CSS stacking context the clip's node lives in (TimelineElement.stackingContextId).
* Leaf z-indexes are only comparable WITHIN one context across contexts the
@@ -70,12 +72,20 @@ export interface StackingPatch {
const EPS = 1e-6;
/**
* Canonical stacking-context key: null/undefined both mean the root context.
* The ONLY place the normalization lives context partitioning, membership
* checks, and pairwise equality must all go through it.
* Canonical paint-scope key: leaf z-indexes are comparable only within the same
* source document and CSS stacking context. The ONLY place this normalization
* lives partitioning, membership checks, and pairwise equality all use it.
*/
const contextKey = (el: { stackingContextId?: string | null }): string | null =>
el.stackingContextId ?? null;
const paintScopeKey = (el: { sourceFile?: string; stackingContextId?: string | null }): string =>
JSON.stringify([el.sourceFile ?? null, el.stackingContextId ?? null]);
/** Canonical paint-scope equality for stacking sync and its inverse mirror. */
export function samePaintScope(
a: { sourceFile?: string; stackingContextId?: string | null },
b: { sourceFile?: string; stackingContextId?: string | null },
): boolean {
return paintScopeKey(a) === paintScopeKey(b);
}
/**
* Two clips overlap in time when their half-open [start, end) intervals intersect.
@@ -86,7 +96,10 @@ const contextKey = (el: { stackingContextId?: string | null }): string | null =>
* epsilon guards against float fuzz (e.g. 5.0000001 vs 5) spuriously overlapping two
* abutting clips and shuffling lanes. The two are intended to differ, not align.
*/
function overlapsInTime(a: StackingElement, b: StackingElement): boolean {
function overlapsInTime(
a: Pick<StackingElement, "start" | "duration">,
b: Pick<StackingElement, "start" | "duration">,
): boolean {
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
}
@@ -114,9 +127,13 @@ interface MutZ extends StackingElement {
* Does `a` currently paint ON TOP of `b`? Higher z wins; equal z breaks by DOM
* order (later in DOM paints on top). When either domIndex is absent, equal z is
* treated as "not strictly above" (ambiguous) callers should supply domIndex to
* disambiguate (see StackingElement.domIndex). Operates on resolved (`MutZ`) clips.
* disambiguate (see StackingElement.domIndex). Exported (like laneIsAbove) as the
* ONE paint-order predicate so every consumer agrees on what "paints above" means.
*/
function paintsAbove(a: MutZ, b: MutZ): boolean {
function paintsAbove(
a: Pick<StackingElement, "zIndex" | "domIndex">,
b: Pick<StackingElement, "zIndex" | "domIndex">,
): boolean {
if (a.zIndex !== b.zIndex) return a.zIndex > b.zIndex;
if (a.domIndex != null && b.domIndex != null) return a.domIndex > b.domIndex;
return false;
@@ -301,12 +318,11 @@ export function computeStackingPatches(
// excluded outright (item 13).
const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
// Leaf z is only meaningful within ONE stacking context: across contexts the
// ancestor contexts' z decides paint order, so comparing (or patching) leaf
// values across contexts is nonsense. Restrict the computation to the edited
// clips' own context(s); cross-context lane relations are out of scope.
const editedContexts = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(contextKey));
const resolved = allResolved.filter((e) => editedContexts.has(contextKey(e)));
// Leaf z is only meaningful within ONE source document and stacking context:
// across either boundary the ancestor composition/context decides paint order.
// Restrict the computation to the edited clips' own paint scope(s).
const editedScopes = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(paintScopeKey));
const resolved = allResolved.filter((e) => editedScopes.has(paintScopeKey(e)));
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
@@ -326,10 +342,9 @@ export function computeStackingPatches(
// The full live set, so the transitive cascade can reach clips that overlap a
// LIFTED neighbour without overlapping the edited clip itself (#2198).
const all = [...byKey.values()];
const sameContext = (a: MutZ, b: MutZ) => contextKey(a) === contextKey(b);
const overlappersOf = (clip: MutZ): MutZ[] =>
all.filter(
(o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
(o) => o.key !== clip.key && !o.isAudio && samePaintScope(clip, o) && overlapsInTime(clip, o),
);
for (const clip of edited) {
@@ -0,0 +1,667 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import {
resolveRepositionLaneMove,
resolveZMirrorLaneMove,
type ZMirrorInput,
} from "./timelineZMirror";
function el(
id: string,
track: number,
start: number,
duration: number,
extra: Partial<TimelineElement> = {},
): TimelineElement {
return { id, key: id, tag: "video", start, duration, track, domId: id, ...extra };
}
function audio(id: string, track: number, start: number, duration: number): TimelineElement {
return el(id, track, start, duration, { tag: "audio" });
}
function resolve(
action: ZMirrorInput["action"],
element: TimelineElement,
elements: TimelineElement[],
crossedKey?: string | null,
) {
return resolveZMirrorLaneMove({ action, element, elements, crossedKey });
}
// Target on TOP lane 0; b/c fully occupy the two lanes below over t's span.
const stackBelow = () => {
const t = el("t", 0, 0, 10);
const b = el("b", 1, 0, 10);
const c = el("c", 2, 0, 10);
return { t, elements: [t, b, c] };
};
// Sparse file: authored tracks 3/5/7 displayed as lanes 0/1/2 (a free over t's span).
const sparseAuthored = () => {
const a = el("a", 0, 20, 5, { authoredTrack: 3 });
const b = el("b", 1, 0, 10, { authoredTrack: 5 });
const t = el("t", 2, 0, 10, { authoredTrack: 7 });
return { t, elements: [a, b, t] };
};
describe("resolveZMirrorLaneMove — bring-forward / send-backward", () => {
// Stack: a on lane 0, b on lane 1, target on lane 2 — all overlapping in time.
const stack = () => {
const a = el("a", 0, 0, 10);
const b = el("b", 1, 0, 10);
const t = el("t", 2, 0, 10);
return { a, b, t, elements: [a, b, t] };
};
it("bring-forward with crossedKey lands on the closest free lane above the neighbor", () => {
// Free lane 0 exists above the crossed neighbor's lane... make lane 0 free by
// shifting a out of the span.
const a = el("a", 0, 20, 5); // lane 0 free over t's span
const b = el("b", 1, 0, 10);
const t = el("t", 2, 0, 10);
expect(resolve("bring-forward", t, [a, b, t], "b")).toEqual({
kind: "move",
displayTrack: 0,
persistTrack: 0,
});
});
it("bring-forward with crossedKey inserts above the neighbor when no lane is free", () => {
const { t, elements } = stack();
// Lanes 0 and 1 both occupied over t's span → new lane at the boundary
// ABOVE the crossed neighbor (row of lane 1 in the ascending order).
expect(resolve("bring-forward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 1 });
});
it("bring-forward without crossedKey uses the closest overlapping neighbor above", () => {
const { t, elements } = stack();
// Closest overlapping neighbor above lane 2 is b (lane 1); lanes 0/1 are
// occupied → insert above b, same as the crossedKey case.
expect(resolve("bring-forward", t, elements)).toEqual({ kind: "insert", insertRow: 1 });
});
it("bring-forward with an unknown crossedKey falls back to the temporal neighbor", () => {
const { t, elements } = stack();
expect(resolve("bring-forward", t, elements, "nope")).toEqual({
kind: "insert",
insertRow: 1,
});
});
it("bring-forward returns null when nothing overlaps above and no crossedKey", () => {
const a = el("a", 0, 20, 5); // above but NOT overlapping in time
const t = el("t", 1, 0, 10);
expect(resolve("bring-forward", t, [a, t])).toBeNull();
});
it("send-backward lands on the closest free lane below the neighbor", () => {
const t = el("t", 0, 0, 10);
const b = el("b", 1, 0, 10);
const c = el("c", 2, 20, 5); // lane 2 free over t's span
expect(resolve("send-backward", t, [t, b, c], "b")).toEqual({
kind: "move",
displayTrack: 2,
persistTrack: 2,
});
});
it("send-backward inserts below the neighbor when no lane below is free", () => {
const { t, elements } = stackBelow();
// Boundary below b's lane (row 1 + 1 = 2).
expect(resolve("send-backward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
});
it("send-backward returns null when nothing overlaps below and no crossedKey", () => {
const t = el("t", 0, 0, 10);
const b = el("b", 1, 20, 5);
expect(resolve("send-backward", t, [t, b])).toBeNull();
});
it("BOUNDED: never steps past the next overlapping element to a farther free lane", () => {
// Above neighbor b (lane 2): lane 1 holds x — the NEXT temporally-overlapping
// same-file element in the direction — and lane 0 is free. A single forward
// step crosses ONE element, so the free lane 0 beyond x is out of reach:
// insert immediately above b instead (row of lane 2 in the ascending order).
const a = el("a", 0, 30, 5); // lane 0 free over t's span — but beyond the bound
const x = el("x", 1, 5, 10); // overlaps t → the exclusive bound
const b = el("b", 2, 0, 10);
const t = el("t", 3, 0, 10);
expect(resolve("bring-forward", t, [a, x, b, t], "b")).toEqual({
kind: "insert",
insertRow: 2,
});
});
it("OPEN SPACE: with no second overlapping element, skips an occupied lane to the next free one", () => {
// Lane 1's occupant is a FOREIGN-file clip: it occupies the lane (freeness is
// file-agnostic) but is not in the same stacking context, so it does not
// bound the step — the search continues to free lane 0, as before.
const a = el("a", 0, 30, 5); // lane 0 free over t's span
const x = el("x", 1, 5, 10, { sourceFile: "sub.html" });
const b = el("b", 2, 0, 10);
const t = el("t", 3, 0, 10);
expect(resolve("bring-forward", t, [a, x, b, t], "b")).toEqual({
kind: "move",
displayTrack: 0,
persistTrack: 0,
});
});
it("returns null when the closest free lane is the clip's own lane (z/track divergence)", () => {
// Crossed neighbor sits BELOW the clip in lane space (diverged z): searching
// up from lane 2 finds lane 1 free — the clip's own lane → already in place.
const t = el("t", 1, 0, 10);
const b = el("b", 2, 0, 10);
expect(resolve("bring-forward", t, [t, b], "b")).toBeNull();
});
});
describe("resolveZMirrorLaneMove — one-element step bound (forward/backward)", () => {
// Three stacked back-to-back clips (lanes 0/1/2, all overlapping) plus a free
// lane BEYOND the far element — the lane the old resolver would overshoot to.
const threeStackedWithFarFree = () => {
const a = el("a", 0, 0, 10);
const b = el("b", 1, 0, 10);
const c = el("c", 2, 0, 10);
const d = el("d", 3, 20, 5); // lane 3 free over the span — beyond c
return { a, b, c, d, elements: [a, b, c, d] };
};
it("send-backward from the top inserts between elements 1 and 2 — not past element 2", () => {
const { a, elements } = threeStackedWithFarFree();
// Reference = b (lane 1); next overlap below = c (lane 2) bounds the search;
// no free lane strictly between → insert at the b/c boundary (row 2), NOT
// the farther free lane 3.
expect(resolve("send-backward", a, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
});
it("bring-forward from the bottom inserts between elements 1 and 2 (symmetric)", () => {
const d = el("d", 0, 20, 5); // lane 0 free over the span — beyond a
const a = el("a", 1, 0, 10);
const b = el("b", 2, 0, 10);
const t = el("t", 3, 0, 10);
// Reference = b (lane 2); next overlap above = a (lane 1) bounds the search;
// no free lane strictly between → insert at the a/b boundary (row 2), NOT
// the farther free lane 0.
expect(resolve("bring-forward", t, [d, a, b, t], "b")).toEqual({
kind: "insert",
insertRow: 2,
});
});
it("takes a free lane strictly between the reference and the next overlap", () => {
const a = el("a", 0, 0, 10); // second element — the exclusive bound
const gap = el("gap", 1, 20, 5); // lane 1 free over the span, inside the interval
const b = el("b", 2, 0, 10); // crossed reference
const t = el("t", 3, 0, 10);
expect(resolve("bring-forward", t, [a, gap, b, t], "b")).toEqual({
kind: "move",
displayTrack: 1,
persistTrack: 1,
});
});
it("of several free lanes in the interval, takes the one closest to the reference", () => {
const a = el("a", 0, 0, 10); // bound
const g1 = el("g1", 1, 20, 5); // free, farther from reference
const g2 = el("g2", 2, 20, 5); // free, closest to reference
const b = el("b", 3, 0, 10); // crossed reference
const t = el("t", 4, 0, 10);
expect(resolve("bring-forward", t, [a, g1, g2, b, t], "b")).toEqual({
kind: "move",
displayTrack: 2,
persistTrack: 2,
});
});
it("no second overlapping element beyond the reference → the zone edge bounds (as today)", () => {
const { t, elements } = stackBelow();
// Only c overlaps below the reference b... remove c's overlap: reference is
// then the ONLY overlap below; the search runs to the zone edge and takes
// the free lane beyond the neighbor.
const spread = elements.map((e) => (e.id === "c" ? { ...e, start: 20 } : e));
expect(resolve("send-backward", t, spread, "b")).toEqual({
kind: "move",
displayTrack: 2,
persistTrack: 2,
});
});
it("bring-to-front is NOT bounded: still moves past the whole overlap set", () => {
const { t, elements } = (() => {
const free = el("free", 0, 20, 5); // free lane beyond the topmost overlap
const a = el("a", 1, 0, 10);
const b = el("b", 2, 0, 10);
const t = el("t", 3, 0, 10);
return { t, elements: [free, a, b, t] };
})();
expect(resolve("bring-to-front", t, elements)).toEqual({
kind: "move",
displayTrack: 0,
persistTrack: 0,
});
});
});
describe("resolveZMirrorLaneMove — bring-to-front / send-to-back", () => {
it("bring-to-front moves above the topmost temporally-overlapping clip", () => {
const a = el("a", 0, 20, 5); // lane 0 free over t's span
const b = el("b", 1, 0, 10); // topmost overlap
const c = el("c", 2, 0, 10);
const t = el("t", 3, 0, 10);
expect(resolve("bring-to-front", t, [a, b, c, t])).toEqual({
kind: "move",
displayTrack: 0,
persistTrack: 0,
});
});
it("bring-to-front inserts above the topmost overlap when no lane is free", () => {
const b = el("b", 0, 0, 10);
const c = el("c", 1, 0, 10);
const t = el("t", 2, 0, 10);
expect(resolve("bring-to-front", t, [b, c, t])).toEqual({ kind: "insert", insertRow: 0 });
});
it("bring-to-front is null when already topmost among overlaps (temporal scope)", () => {
// A clip exists on a higher lane but does NOT overlap in time — with the
// default temporal-overlap scope the target is already at the front.
const a = el("a", 0, 20, 5);
const t = el("t", 1, 0, 10);
const c = el("c", 2, 0, 10);
expect(resolve("bring-to-front", t, [a, t, c])).toBeNull();
});
it("send-to-back moves below the bottommost temporally-overlapping clip", () => {
const t = el("t", 0, 0, 10);
const b = el("b", 1, 0, 10); // bottommost overlap
const c = el("c", 2, 20, 5); // lane 2 free over t's span
expect(resolve("send-to-back", t, [t, b, c])).toEqual({
kind: "move",
displayTrack: 2,
persistTrack: 2,
});
});
it("send-to-back inserts below the bottommost overlap when no lane is free", () => {
const { t, elements } = stackBelow();
expect(resolve("send-to-back", t, elements)).toEqual({ kind: "insert", insertRow: 3 });
});
it("send-to-back is null when already bottommost among overlaps", () => {
const t = el("t", 1, 0, 10);
const a = el("a", 0, 0, 10);
expect(resolve("send-to-back", t, [a, t])).toBeNull();
});
it("returns null when nothing overlaps at all", () => {
const t = el("t", 0, 0, 10);
const a = el("a", 1, 20, 5);
for (const action of ["bring-to-front", "send-to-back"] as const) {
expect(resolve(action, t, [t, a])).toBeNull();
}
});
});
describe("resolveZMirrorLaneMove — span freeness", () => {
it("a lane free at the clip's start but occupied later in the span is NOT free", () => {
const t = el("t", 2, 0, 10);
const b = el("b", 1, 0, 10); // crossed neighbor
// Lane 0: nothing at t=0, but occupied over [6, 9) — inside t's span.
const late = el("late", 0, 6, 3);
expect(resolve("bring-forward", t, [late, b, t], "b")).toEqual({
kind: "insert",
insertRow: 1,
});
});
it("half-open spans: a clip starting exactly at the moved clip's end does not occupy", () => {
const t = el("t", 2, 0, 10);
const b = el("b", 1, 0, 10);
const adjacent = el("adj", 0, 10, 5); // [10, 15) touches [0, 10) but no overlap
expect(resolve("bring-forward", t, [adjacent, b, t], "b")).toEqual({
kind: "move",
displayTrack: 0,
persistTrack: 0,
});
});
it("freeness is file-agnostic: an other-file clip occupies the lane", () => {
const t = el("t", 2, 0, 10);
const b = el("b", 1, 0, 10);
const foreign = el("f", 0, 0, 10, { sourceFile: "sub.html" });
expect(resolve("bring-forward", t, [foreign, b, t], "b")).toEqual({
kind: "insert",
insertRow: 1,
});
});
});
describe("resolveZMirrorLaneMove — zone boundary (audio untouched)", () => {
// Visual lanes 0-1, audio lanes 2-3.
const zoned = () => {
const t = el("t", 0, 0, 10);
const b = el("b", 1, 0, 10);
const m = audio("music", 2, 0, 30);
const vo = audio("vo", 3, 0, 30);
return { t, b, m, vo, elements: [t, b, m, vo] };
};
it("send-backward never lands on an audio lane — inserts at the zone boundary", () => {
const { t, elements } = zoned();
// Lane 2 (audio) is out of bounds even though "below"; boundary row 2 sits
// between the bottom visual lane and the first audio lane — a visual insert.
expect(resolve("send-backward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
});
it("send-to-back stops at the visual zone edge", () => {
const { t, elements } = zoned();
expect(resolve("send-to-back", t, elements)).toEqual({ kind: "insert", insertRow: 2 });
});
it("audio clips never mirror (returns null)", () => {
const { m, elements } = zoned();
for (const action of [
"bring-to-front",
"bring-forward",
"send-backward",
"send-to-back",
] as const) {
expect(resolve(action, m, elements)).toBeNull();
}
});
it("audio clips do not count as overlap references for visual clips", () => {
// Only audio below the target → send-backward has no visual neighbor → null.
const t = el("t", 0, 0, 10);
const m = audio("music", 1, 0, 30);
expect(resolve("send-backward", t, [t, m])).toBeNull();
expect(resolve("send-to-back", t, [t, m])).toBeNull();
});
});
describe("resolveZMirrorLaneMove — authored (persist) space", () => {
it("persistTrack takes the target lane occupant's authoredTrack, not the display lane", () => {
// Occupant of the free-over-span target lane 0 (authored 3) anchors the persist value.
const { t, elements } = sparseAuthored();
expect(resolve("bring-forward", t, elements, "b")).toEqual({
kind: "move",
displayTrack: 0,
persistTrack: 3,
});
});
it("falls back to nearest-same-file lane offset when the target lane has no same-file occupant", () => {
// The moved clip is an expanded sub-comp child; the target lane's only
// occupant belongs to the host file, so the persist value offsets from the
// nearest same-file lane instead (authored 4 at lane 1 → lane 0 = 3).
const host = el("h", 0, 20, 5); // host-file clip on the target lane (not overlapping)
const sib = el("s", 1, 0, 10, { sourceFile: "sub.html", authoredTrack: 4 });
const t = el("t", 2, 0, 10, { sourceFile: "sub.html", authoredTrack: 5 });
expect(resolve("bring-forward", t, [host, sib, t], "s")).toEqual({
kind: "move",
displayTrack: 0,
persistTrack: 3,
});
});
});
describe("resolveZMirrorLaneMove — stacking-context (source file) scoping", () => {
it("other-file clips are not overlap references (extremes computed per file)", () => {
// A host clip overlaps above the sub-comp child, but the child's own file
// has nothing above it → bring-to-front is null (already at ITS front).
const host = el("h", 0, 0, 10);
const t = el("t", 1, 0, 10, { sourceFile: "sub.html" });
expect(resolve("bring-to-front", t, [host, t])).toBeNull();
});
it("same-file overlaps in an expanded sub-comp resolve within the child's lanes", () => {
const host = el("h", 0, 0, 10);
const sib = el("s", 1, 0, 10, { sourceFile: "sub.html", authoredTrack: 0 });
const t = el("t", 2, 0, 10, { sourceFile: "sub.html", authoredTrack: 1 });
// Topmost same-file overlap is sib (lane 1); lane 0 is occupied by the host
// over the span (freeness is file-agnostic) → insert above sib's lane.
expect(resolve("bring-to-front", t, [host, sib, t])).toEqual({
kind: "insert",
insertRow: 1,
});
});
});
describe("resolveZMirrorLaneMove — degenerate inputs and determinism", () => {
it("zero-duration element returns null", () => {
const t = el("t", 1, 0, 0);
const a = el("a", 0, 0, 10);
expect(resolve("bring-to-front", t, [a, t])).toBeNull();
});
it("single-clip timeline returns null for every action", () => {
const t = el("t", 0, 0, 10);
for (const action of [
"bring-to-front",
"bring-forward",
"send-backward",
"send-to-back",
] as const) {
expect(resolve(action, t, [t])).toBeNull();
}
});
it("identical inputs produce identical outputs (deterministic, input untouched)", () => {
const first = sparseAuthored();
const snapshot = structuredClone(first.elements);
const r1 = resolve("bring-forward", first.t, first.elements, "b");
const r2 = resolve("bring-forward", first.t, first.elements, "b");
const fresh = sparseAuthored();
const r3 = resolve("bring-forward", fresh.t, fresh.elements, "b");
expect(r1).toEqual(r2);
expect(r1).toEqual(r3);
expect(first.elements).toEqual(snapshot); // pure — never mutates its input
});
});
describe("resolveRepositionLaneMove (Layers-panel equal jump)", () => {
// Bottom→top render order helper: keys as the panel's reversed order.
const reposition = (
element: TimelineElement,
elements: TimelineElement[],
desiredOrderKeys: (string | null)[],
) => resolveRepositionLaneMove({ element, elements, desiredOrderKeys });
// Three stacked clips on lanes 0/1/2, all overlapping. Render order matches
// lanes today: bottom→top = c(2), b(1), t(0)... target starts on lane 2.
const stack3 = () => {
const a = el("a", 0, 0, 10);
const b = el("b", 1, 0, 10);
const t = el("t", 2, 0, 10);
return { a, b, t, elements: [a, b, t] };
};
it("multi-step jump to the top inserts a new lane above the new below-neighbor", () => {
const { t, elements } = stack3();
// t dragged to the TOP of the panel: bottom→top = [b, a, t].
// New below-neighbor is a (lane 0); lanes above are occupied/none free →
// insert at a's boundary (order.indexOf(0) = 0).
expect(reposition(t, elements, ["b", "a", "t"])).toEqual({ kind: "insert", insertRow: 0 });
});
it("multi-step jump lands on a free lane between the new neighbors", () => {
// Lanes 0,1,2,3: a(0), b(1) short-lived, c(2), t(3). Lane 1 is free over
// t's span. t dragged between a and c in paint order: above-neighbor a
// (lane 0), below-neighbor c (lane 2) → free lane 1, strictly between.
const a = el("a", 0, 0, 10);
const b = el("b", 1, 20, 5);
const c = el("c", 2, 0, 10);
const t = el("t", 3, 0, 10);
// bottom→top: c (bottom), t (middle), a (top); b not in the sibling set.
expect(reposition(t, [a, b, c, t], ["c", "t", "a"])).toEqual({
kind: "move",
displayTrack: 1,
persistTrack: 1,
});
});
it("drop toward the bottom lands on a free lane below the new above-neighbor", () => {
// Lanes: t(0), b(1), c(2) short-lived → lane 2 free over t's span.
const t = el("t", 0, 0, 10);
const b = el("b", 1, 0, 10);
const c = el("c", 2, 20, 5);
// t dragged below b: bottom→top = [t, b]. Above-neighbor b (lane 1) →
// closest free lane below it is lane 2.
expect(reposition(t, [t, b, c], ["t", "b"])).toEqual({
kind: "move",
displayTrack: 2,
persistTrack: 2,
});
});
it("drop below the bottom clip inserts a new bottom lane when none is free", () => {
const x = el("x", 0, 0, 10);
const y = el("y", 1, 0, 10);
const z = el("z", 2, 0, 10);
// x (top lane 0) dragged to the bottom of the panel: bottom→top = [x, z, y].
// Above-neighbor = z (lane 2); no free lane below it → insert below z
// (order.indexOf(2) + 1 = 3).
expect(reposition(x, [x, y, z], ["x", "z", "y"])).toEqual({ kind: "insert", insertRow: 3 });
});
it("skips non-clip siblings (null keys) when resolving neighbors", () => {
// t on TOP lane 0, a below on lane 1. t dragged below a in paint order,
// with two decorations (null keys) interleaved: the resolver must skip the
// nulls and find a (lane 1) as the above-neighbor → no free lane below it
// → insert below a (order.indexOf(1) + 1 = 2).
const t = el("t", 0, 0, 10);
const a = el("a", 1, 0, 10);
expect(reposition(t, [t, a], [null, "t", null, "a"])).toEqual({
kind: "insert",
insertRow: 2,
});
});
it("returns null when the drop leaves the clip where it already sits", () => {
const a = el("a", 0, 20, 5); // lane 0 free over t's span but t stays put
const b = el("b", 1, 0, 10);
const t = el("t", 2, 0, 10);
// bottom→top = [t, b] — t stays below b; nearest above-neighbor b (lane 1),
// scanning down from lane 1 finds t's own lane 2 first.
expect(reposition(t, [a, b, t], ["t", "b"])).toBeNull();
});
it("returns null for audio, zero-duration, decoration-only sets, and unknown self", () => {
const t = el("t", 1, 0, 10);
const a = el("a", 0, 0, 10);
expect(reposition(audio("s", 3, 0, 10), [t, a], ["s", "t"])).toBeNull();
expect(reposition(el("z0", 1, 0, 0), [t, a], ["z0", "t"])).toBeNull();
expect(reposition(t, [t, a], [null, "t", null])).toBeNull(); // no clip neighbor
expect(reposition(t, [t, a], ["a"])).toBeNull(); // self missing from order
});
it("audio lanes are never targeted (insert stays within the visual zone)", () => {
const a = el("a", 0, 0, 10);
const t = el("t", 1, 0, 10);
const music = audio("m", 2, 0, 30);
// t dropped below a... wait, t already below a. Drag a below t instead:
// bottom→top = [a, t]. Above-neighbor t (lane 1); no free visual lane below
// → insert below t (order.indexOf(1) + 1 = 2), never onto the audio lane.
expect(reposition(a, [a, t, music], ["a", "t"])).toEqual({ kind: "insert", insertRow: 2 });
});
it("is pure: identical inputs, identical outputs, input untouched", () => {
const build = () => {
const a = el("a", 0, 0, 10);
const b = el("b", 1, 0, 10);
const t = el("t", 2, 0, 10);
return { t, elements: [a, b, t] };
};
const first = build();
const snapshot = structuredClone(first.elements);
const r1 = reposition(first.t, first.elements, ["b", "a", "t"]);
const fresh = build();
const r2 = reposition(fresh.t, fresh.elements, ["b", "a", "t"]);
expect(r1).toEqual(r2);
expect(first.elements).toEqual(snapshot);
});
});
describe("paint scope (source file + stacking context)", () => {
it("forward never references a same-file clip in a DIFFERENT stacking context", () => {
// b overlaps t and sits above, but lives in a nested stacking context —
// leaf z is not comparable, so the mirror must not treat it as a neighbor.
const b = el("b", 0, 0, 10, { stackingContextId: "ctx-nested" });
const t = el("t", 1, 0, 10);
expect(resolve("bring-forward", t, [b, t])).toBeNull();
});
it("cross-file clips with matching null root contexts never compare (expanded view)", () => {
const foreign = el("f", 0, 0, 10, { sourceFile: "scenes/intro.html" });
const t = el("t", 1, 0, 10);
expect(resolve("bring-forward", t, [foreign, t])).toBeNull();
expect(resolve("bring-to-front", t, [foreign, t])).toBeNull();
});
it("reposition skips neighbors outside the paint scope when resolving lanes", () => {
// t (lane 0) dragged below a in paint order; o sits between them in the
// DESIRED order but lives in a nested stacking context, so it must be
// skipped: the above-neighbor is a (lane 1). Lane 2 is occupied by o over
// the span (freeness is scope-agnostic) → insert below a (row 2).
const t = el("t", 0, 0, 10);
const a = el("a", 1, 0, 10);
const other = el("o", 2, 0, 10, { stackingContextId: "ctx-a" });
expect(
resolveRepositionLaneMove({
element: t,
elements: [t, a, other],
desiredOrderKeys: ["t", "o", "a"],
}),
).toEqual({ kind: "insert", insertRow: 2 });
});
});
describe("expanded sub-comp children — lane scoping", () => {
const child = (id: string, track: number, start: number, duration: number) =>
el(id, track, start, duration, {
sourceFile: "scene.html",
expandedParentStart: 5,
});
it("a child mirrors onto a SIBLING's lane and persists the sibling's AUTHORED track", () => {
// Sibling c1 owns fractional display lane 0.25 (authored track 2 in its own
// file) but sits elsewhere in time → its lane is free over t's span.
const c1 = el("c1", 0.25, 20, 3, {
sourceFile: "scene.html",
expandedParentStart: 5,
authoredTrack: 2,
});
const c2 = child("c2", 0.5, 5, 5);
const t = child("t", 0.75, 5, 5);
expect(resolve("bring-forward", t, [c1, c2, t], "c2")).toEqual({
kind: "move",
displayTrack: 0.25,
persistTrack: 2,
});
});
it("a child NEVER lands on a host-space lane with no same-file occupant", () => {
// Free integer host lane 0 above the crossed sibling — out of scope for a
// child; with no sibling lane free either, the mirror refuses (null), it
// does not insert.
const host = el("h", 0, 20, 3); // host-space lane, free over the span
const c2 = child("c2", 0.5, 5, 5);
const t = child("t", 0.75, 5, 5);
expect(resolve("bring-forward", t, [host, c2, t], "c2")).toBeNull();
});
it("reposition of a child refuses instead of inserting a host lane", () => {
const c2 = child("c2", 0.5, 5, 5);
const t = child("t", 0.75, 5, 5);
// t dragged above c2 in the layers order; no free sibling lane → null.
expect(
resolveRepositionLaneMove({
element: t,
elements: [c2, t],
desiredOrderKeys: ["c2", "t"],
}),
).toBeNull();
});
});
@@ -0,0 +1,393 @@
import type { TimelineElement } from "../store/playerStore";
import { classifyZone } from "./timelineZones";
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
import { authoredTrackForLane, sameSourceFile } from "./timelineClipDragCommit";
import { samePaintScope } from "./timelineStackingSync";
/**
* Mirror a canvas z-order action (Bring to Front / Bring Forward / Send Backward /
* Send to Back) into a timeline LANE move the pure resolver, no UI wiring.
*
* The model
* Track order is the DEFAULT paint order; authored z is the ADVANCED override.
* Render truth stays z the renderer never reads track index and the studio
* maintains z track consistency at EDIT time: a deliberate vertical lane move
* syncs z (timelineStackingSync), and a z-order menu action calls THIS resolver
* to compute the accompanying lane move. When the user authors z that diverges
* from track order, the mirror never fights the authored override it only
* keeps the default in step.
*
* Locked rules (agreed design do not re-litigate here)
* - The mirror computes a lane move to ACCOMPANY a z action on a timeline clip;
* it never replaces the z patch.
* - ONE-ELEMENT STEP (bring-forward / send-backward): the z action stepped past
* exactly ONE element the reference neighbor so the lane move must too.
* Target = the free lane (whole-span, file-agnostic occupancy, same zone)
* closest to the reference, searched STRICTLY BETWEEN the reference's lane
* and the next temporally-overlapping same-file visual element's lane in the
* direction (exclusive bound). No free lane in that open interval (the common
* back-to-back case) CREATE one at the boundary immediately beyond the
* reference neighbor (commitTrackInsert semantics) never scan past the
* second element to a farther free lane, which would overshoot the paint
* order. With no second overlapping element
* beyond the reference, the bound is the zone edge: closest free lane beyond
* the neighbor, else insert immediately beyond it.
* - bring-to-front / send-to-back move past the WHOLE overlap set: closest free
* lane beyond the extreme overlap in the direction, else insert adjacent to
* the extreme.
* - Direction: bring-forward/front = toward LOWER display lanes (up = above);
* send-backward/back = toward HIGHER lanes, but only within the visual zone
* the audio zone is untouched and never crossed (a bottom-of-zone insert lands
* AT the visual/audio boundary, i.e. still a visual lane).
* - Reference scope: same source file AND same stacking context (see
* samePaintScope a file can contain several stacking contexts, and leaf z
* is only comparable within one). Lane FREENESS stays file-agnostic (any
* clip in the zone occupies its lane for everyone).
* - Non-clip decorations (no timeline presence) are out of scope callers keep
* z-only behavior for them. Audio elements never mirror (z on audio is
* meaningless); the resolver returns null.
*
* OPEN product question
* send-to-back / bring-to-front scope: below/above EVERYTHING visual, or only
* the clips that temporally overlap the moved clip? The default implemented
* here is TEMPORAL-OVERLAP scope (the extreme is computed over same-file clips
* that overlap the moved clip in time), pending M/Bin sign-off. A clip with no
* temporal overlaps in the direction is "already at the extreme" null.
*
* Deterministic: a pure function of its inputs no Date, no randomness, no DOM.
*/
export type ZMirrorAction = "bring-to-front" | "bring-forward" | "send-backward" | "send-to-back";
export interface ZMirrorInput {
action: ZMirrorAction;
/** The clip acted on store/display space (post-normalizeToZones lanes),
* carrying `authoredTrack` when the display lane diverges from the file. */
element: TimelineElement;
/** The expanded display element set (same set the drag commit reasons on). */
elements: TimelineElement[];
/** Timeline key of the neighbor the z action stepped over (forward/backward),
* when known see resolveCrossedNeighbor in canvasContextMenuZOrder. */
crossedKey?: string | null;
}
export type ZMirrorLaneMove =
| {
/** Land on an existing display lane. */
kind: "move";
/** Display lane to move to (store space). */
displayTrack: number;
/** Authored-space value to write (authoredTrackForLane translation). */
persistTrack: number;
}
| {
/** Create a new lane: boundary row compatible with commitTrackInsert's
* insertRow (index into the ascending display trackOrder; 0 = above the
* top lane, length = below the bottom). */
kind: "insert";
insertRow: number;
}
| null;
const keyOf = (el: TimelineElement): string => el.key ?? el.id;
/**
* Lane candidates for an EXPANDED sub-comp child: only its own siblings' lanes
* (same file). An expanded child's display row is synthetic host-space landing
* it on an arbitrary host lane has no same-file occupant to translate the
* authored track from, and a track INSERT would renumber host space from a
* child origin. Ordinary top-level clips return null (no restriction).
*/
function expandedChildAllowedLanes(
element: TimelineElement,
elements: TimelineElement[],
): ReadonlySet<number> | null {
if (element.expandedParentStart == null) return null;
const selfKey = keyOf(element);
return new Set(
elements
.filter((el) => keyOf(el) !== selfKey && sameSourceFile(el, element))
.map((el) => el.track),
);
}
/** Ascending unique display lanes of `elements` identical to how Timeline.tsx
* builds `trackOrder`, so `insertRow` indexes the same boundary space. Exported
* so the mirror wiring can hand commitZMirrorLaneMove the matching trackOrder. */
export function displayTrackOrder(elements: TimelineElement[]): number[] {
return [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
}
/**
* Resolve the timeline lane move that mirrors a z-order action on `element`.
* Returns null when no timeline mirror applies: audio / zero-length clips, no
* reference neighbor in the action's direction (the menu action was likely
* disabled or a no-op), or the clip is already laned where the action puts it.
*/
export function resolveZMirrorLaneMove(input: ZMirrorInput): ZMirrorLaneMove {
const { action, element, elements } = input;
if (classifyZone(element) === "audio") return null;
if (!(element.duration > 0)) return null;
const selfKey = keyOf(element);
const start = element.start;
const end = element.start + element.duration;
const up = action === "bring-forward" || action === "bring-to-front";
// Same paint scope (source file + stacking context), visual, temporally overlapping.
const overlapSet = elements.filter(
(el) =>
keyOf(el) !== selfKey &&
classifyZone(el) === "visual" &&
samePaintScope(el, element) &&
timeRangesOverlap(start, end, el.start, el.start + el.duration),
);
const referenceLane = resolveReferenceLane(input, overlapSet, up);
if (referenceLane == null) return null;
const order = displayTrackOrder(elements);
const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
const refIdx = visualLanes.indexOf(referenceLane);
if (refIdx === -1) return null; // reference is not a visual lane — no mirror
const boundLane = stepBoundLane(action, overlapSet, referenceLane, up);
const allowedLanes = expandedChildAllowedLanes(element, elements);
const lane = closestFreeLane({
elements,
visualLanes,
refIdx,
up,
boundLane,
start,
end,
selfKey,
allowedLanes,
});
if (lane != null) {
// The closest free lane is the clip's OWN lane (possible only when z and
// track had diverged): the clip already sits where the action puts it.
if (lane === element.track) return null;
return {
kind: "move",
displayTrack: lane,
persistTrack: authoredTrackForLane(lane, elements, element),
};
}
// Expanded children never INSERT: a new lane is a host-lane-space renumber,
// meaningless in the child's own file (buildTrackInsertEdits refuses too).
if (allowedLanes) return null;
// No free lane before the bound (or the zone edge) → create one adjacent to
// the reference: the boundary between its lane and the next in direction.
return { kind: "insert", insertRow: order.indexOf(referenceLane) + (up ? 0 : 1) };
}
export interface ZRepositionInput {
/** The clip the Layers-panel drag moved — store/display space. */
element: TimelineElement;
/** The expanded display element set (same set the drag commit reasons on). */
elements: TimelineElement[];
/**
* Timeline keys of the reordered sibling set in DESIRED render order,
* bottomtop, the moved element's own key included at its new slot. Siblings
* with no timeline presence carry null they are skipped when resolving the
* nearest clip neighbors.
*/
desiredOrderKeys: ReadonlyArray<string | null>;
}
/**
* Mirror an ARBITRARY z repositioning (a Layers-panel drag, which can jump
* several siblings in one drop) into a timeline lane move the "equal jump"
* generalization of {@link resolveZMirrorLaneMove}'s one-step rule: the clip
* lands between its NEW paint neighbors' lanes.
*
* The reference lanes are the nearest siblings in the desired render order
* that are visual, same-file timeline clips: `above` = the first such sibling
* that now paints above the moved clip, `below` = the first that paints below.
* Target lane = the free lane (whole-span, same zone) strictly between the
* above-neighbor's lane and the below-neighbor's lane, closest to the above
* neighbor; when only one neighbor exists the zone edge bounds the search on
* the open side. No free lane in the interval INSERT a new lane immediately
* beyond the above neighbor (below it), or immediately above the below
* neighbor when dropped on top commitTrackInsert semantics, exactly like the
* menu mirror's insert fallback.
*
* Null when no mirror applies: audio / zero-length clips, no clip neighbor in
* the set (a z-only decoration shuffle), or the clip already sits where the
* drop puts it.
*/
// fallow-ignore-next-line complexity
export function resolveRepositionLaneMove(input: ZRepositionInput): ZMirrorLaneMove {
const { element, elements, desiredOrderKeys } = input;
if (classifyZone(element) === "audio") return null;
if (!(element.duration > 0)) return null;
const selfKey = keyOf(element);
const selfIdx = desiredOrderKeys.indexOf(selfKey);
if (selfIdx === -1) return null;
const clipLaneForKey = (key: string | null): number | null => {
if (key == null) return null;
const el = elements.find((candidate) => keyOf(candidate) === key);
return el &&
classifyZone(el) === "visual" &&
el.duration > 0 &&
samePaintScope(el, element) &&
keyOf(el) !== selfKey
? el.track
: null;
};
// Nearest clip neighbor painting ABOVE (later in bottom→top order) / BELOW.
let aboveLane: number | null = null;
for (let i = selfIdx + 1; i < desiredOrderKeys.length && aboveLane == null; i++) {
aboveLane = clipLaneForKey(desiredOrderKeys[i]);
}
let belowLane: number | null = null;
for (let i = selfIdx - 1; i >= 0 && belowLane == null; i--) {
belowLane = clipLaneForKey(desiredOrderKeys[i]);
}
if (aboveLane == null && belowLane == null) return null;
const order = displayTrackOrder(elements);
const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
const allowedLanes = expandedChildAllowedLanes(element, elements);
const args = {
elements,
visualLanes,
start: element.start,
end: element.start + element.duration,
selfKey,
allowedLanes,
};
let lane: number | null;
let insertRow: number;
if (aboveLane != null) {
// Paints above = LOWER display lane: search DOWNWARD from the above
// neighbor (closest lane under it first), bounded by the below neighbor
// (exclusive) when one exists, else by the zone edge.
const refIdx = visualLanes.indexOf(aboveLane);
if (refIdx === -1) return null;
lane = closestFreeLane({ ...args, refIdx, up: false, boundLane: belowLane });
insertRow = order.indexOf(aboveLane) + 1;
} else {
// Dropped above everything that remains: search UPWARD from the below
// neighbor toward the zone top.
const refIdx = visualLanes.indexOf(belowLane!);
if (refIdx === -1) return null;
lane = closestFreeLane({ ...args, refIdx, up: true, boundLane: null });
insertRow = order.indexOf(belowLane!);
}
if (lane != null) {
if (lane === element.track) return null;
return {
kind: "move",
displayTrack: lane,
persistTrack: authoredTrackForLane(lane, elements, element),
};
}
// Expanded children never INSERT (host-lane-space renumber from a child
// origin) — see expandedChildAllowedLanes.
if (allowedLanes) return null;
return { kind: "insert", insertRow };
}
/**
* ONE-ELEMENT-STEP bound (bring-forward / send-backward only): the lane of the
* NEXT temporally-overlapping same-file visual element strictly beyond the
* reference in the direction the free-lane search may not reach it
* (exclusive). Front/back have no bound (they step past the whole overlap
* set), and neither does a step with no second overlapping element beyond the
* reference (the zone edge bounds instead).
*/
function stepBoundLane(
action: ZMirrorAction,
overlapSet: TimelineElement[],
referenceLane: number,
up: boolean,
): number | null {
if (action !== "bring-forward" && action !== "send-backward") return null;
const beyond = overlapSet
.map((el) => el.track)
.filter((lane) => (up ? lane < referenceLane : lane > referenceLane));
if (beyond.length === 0) return null;
return (up ? Math.max : Math.min)(...beyond);
}
/**
* Closest free lane strictly beyond the reference, lane-by-lane in direction,
* whole-span freeness, same zone (visual lanes only never into audio),
* stopping at the exclusive bound when one applies. Null no free lane in
* the open interval.
*/
function closestFreeLane(args: {
elements: TimelineElement[];
visualLanes: number[];
refIdx: number;
up: boolean;
boundLane: number | null;
start: number;
end: number;
selfKey: string;
/** When set, only these lanes are candidates (expanded-child scoping). */
allowedLanes?: ReadonlySet<number> | null;
}): number | null {
const { elements, visualLanes, refIdx, up, boundLane, start, end, selfKey, allowedLanes } = args;
const step = up ? -1 : 1;
for (let i = refIdx + step; i >= 0 && i < visualLanes.length; i += step) {
const lane = visualLanes[i];
if (pastSearchBound(lane, boundLane, up)) break;
if (allowedLanes && !allowedLanes.has(lane)) continue;
if (isLaneFree(elements, lane, start, end, selfKey)) return lane;
}
return null;
}
/** The exclusive one-element-step bound: stop at (never on/past) `boundLane`. */
function pastSearchBound(lane: number, boundLane: number | null, up: boolean): boolean {
if (boundLane == null) return false;
return up ? lane <= boundLane : lane >= boundLane;
}
/**
* The lane the search starts from (the "reference neighbor"):
* - forward/backward: the crossed neighbor when provided and valid (a visual
* clip in the set); otherwise the closest temporally-overlapping same-file
* clip in the direction. None null (the menu was probably disabled).
* - front/back: the extreme of the temporal-overlap set topmost (lowest lane)
* for front, bottommost (highest lane) for back restricted to overlaps
* strictly beyond the clip's own lane. None already at the extreme null.
*/
function resolveReferenceLane(
input: ZMirrorInput,
overlapSet: TimelineElement[],
up: boolean,
): number | null {
const stepAction = input.action === "bring-forward" || input.action === "send-backward";
if (stepAction) {
const crossedLane = crossedNeighborLane(input);
// Unknown / absent / non-visual crossed key → the temporal neighbor below.
if (crossedLane != null) return crossedLane;
}
// Overlapping same-file lanes strictly beyond the moved clip's lane, in direction.
const lanes = overlapSet
.map((el) => el.track)
.filter((lane) => (up ? lane < input.element.track : lane > input.element.track));
if (lanes.length === 0) return null;
// Step actions want the CLOSEST lane in direction (max when up, min when
// down); front/back want the EXTREME of the set (min when up, max when down).
return (stepAction === up ? Math.max : Math.min)(...lanes);
}
/** The crossed neighbor's display lane, when the key names a visual clip in the set. */
function crossedNeighborLane({ elements, crossedKey }: ZMirrorInput): number | null {
if (crossedKey == null) return null;
const crossed = elements.find((el) => keyOf(el) === crossedKey);
return crossed && classifyZone(crossed) === "visual" ? crossed.track : null;
}
@@ -45,11 +45,11 @@ describe("getTimelinePixelsPerSecond", () => {
describe("getNextTimelineZoomPercent", () => {
it("zooms out from fit relative to 100 percent", () => {
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(80);
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(50);
});
it("zooms in from fit relative to 100 percent", () => {
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(125);
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(200);
});
it("clamps the lower bound", () => {
@@ -2,9 +2,13 @@ import type { ZoomMode } from "../store/playerStore";
export const MIN_TIMELINE_ZOOM_PERCENT = 10;
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
const ZOOM_OUT_FACTOR = 0.8;
const ZOOM_IN_FACTOR = 1.25;
const PINCH_ZOOM_SENSITIVITY = 0.0035;
// CapCut-strength steps: one button press / pinch gesture moves the zoom
// meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
// "zooming several times to get anywhere", then 1.5× + 0.007 still too soft).
// Kept reciprocal (2 × 0.5 = 1) so in+out round-trips.
const ZOOM_OUT_FACTOR = 0.5;
const ZOOM_IN_FACTOR = 2;
const PINCH_ZOOM_SENSITIVITY = 0.014;
export function clampTimelineZoomPercent(percent: number): number {
if (!Number.isFinite(percent)) return 100;
@@ -76,7 +76,7 @@ interface UseTimelineClipDragInput {
* commitDraggedClipMove. Both optional absent = no-op (backward compatible).
*/
readZIndex?: (element: TimelineElement) => number;
onStackingPatches?: (patches: StackingPatch[]) => void;
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
}
export function useTimelineClipDrag({
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { buildTimelineGapStrips } from "./useTimelineGapHighlights";
function el(id: string, track: number, start: number, duration: number): TimelineElement {
return { id, key: id, tag: "video", start, duration, track, domId: id };
}
const laneA = [el("a1", 0, 1, 2), el("a2", 0, 5, 2)]; // gaps: [0,1), [3,5)
const laneB = [el("b1", 1, 0, 2), el("b2", 1, 2, 3)]; // contiguous
const tracks: [number, TimelineElement[]][] = [
[0, laneA],
[1, laneB],
];
const expandedElements = [...laneA, ...laneB];
const base = {
gapHighlight: null,
tracks,
selectedElementId: null,
selectedElementIds: new Set<string>(),
expandedElements,
dragActive: false,
displayDuration: 60,
};
describe("buildTimelineGapStrips", () => {
it("emits a loud hover strip set from the gap-menu highlight", () => {
const strips = buildTimelineGapStrips({
...base,
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
});
expect(strips).toEqual([{ track: 0, intervals: [{ start: 3, end: 5 }], kind: "hover" }]);
});
it("click-selection lights the WHOLE lane minus clips, trailing space included", () => {
const strips = buildTimelineGapStrips({ ...base, selectedElementId: "a1" });
expect(strips).toEqual([
{
track: 0,
intervals: [
{ start: 0, end: 1 },
{ start: 3, end: 5 },
{ start: 7, end: 60 },
],
kind: "selected",
},
]);
});
it("a contiguous lane still lights its trailing open space", () => {
expect(buildTimelineGapStrips({ ...base, selectedElementId: "b1" })).toEqual([
{ track: 1, intervals: [{ start: 5, end: 60 }], kind: "selected" },
]);
});
it("a lane filling the whole rendered extent emits nothing", () => {
const laneFull = [el("f1", 5, 0, 60)];
const strips = buildTimelineGapStrips({
...base,
tracks: [...tracks, [5, laneFull]],
expandedElements: [...expandedElements, ...laneFull],
selectedElementId: "f1",
});
expect(strips).toEqual([]);
});
it("a one-member selectedElementIds mirror of the click still counts as single", () => {
// The store mirrors a plain click into selectedElementIds = {clicked}.
const strips = buildTimelineGapStrips({
...base,
selectedElementId: "a1",
selectedElementIds: new Set(["a1"]),
});
expect(strips).toHaveLength(1);
expect(strips[0].kind).toBe("selected");
});
it("marquee multi-selection never emits the selected hint", () => {
const strips = buildTimelineGapStrips({
...base,
selectedElementId: "a1",
selectedElementIds: new Set(["a1", "b1"]),
});
expect(strips).toEqual([]);
});
it("hover wins on its own lane — no doubled strips for the same track", () => {
const strips = buildTimelineGapStrips({
...base,
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
selectedElementId: "a1",
});
expect(strips).toHaveLength(1);
expect(strips[0].kind).toBe("hover");
});
it("hover and selection on DIFFERENT lanes coexist", () => {
const laneC = [el("c1", 2, 4, 2)];
const strips = buildTimelineGapStrips({
...base,
tracks: [...tracks, [2, laneC]],
expandedElements: [...expandedElements, ...laneC],
gapHighlight: { track: 2, intervals: [{ start: 0, end: 4 }] },
selectedElementId: "a1",
});
expect(strips.map((s) => s.kind)).toEqual(["hover", "selected"]);
});
it("a live drag suppresses every strip", () => {
const strips = buildTimelineGapStrips({
...base,
dragActive: true,
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
selectedElementId: "a1",
});
expect(strips).toEqual([]);
});
});
@@ -0,0 +1,110 @@
import { useMemo } from "react";
import type { TimelineElement } from "../store/playerStore";
import { laneGapFloor, resolveLaneEmptyIntervals, type TrackGapInterval } from "./timelineGaps";
import type { TrackGapHighlight } from "./useTrackGapMenu";
/**
* One lane's gap strips for the TimelineCanvas overlay.
*
* kind "hover" the gap(s) a hovered "Close gap" / "Close all gaps" menu
* row would collapse: the loud affordance.
* kind "selected" every empty interval on a click-selected clip's lane: the
* quiet always-on hint (single click-selection only a
* marquee multi-select spans lanes and would paint noise).
*/
export interface TimelineLaneGapStrips {
track: number;
intervals: TrackGapInterval[];
kind: "hover" | "selected";
}
/**
* Derive the gap strips TimelineCanvas paints. The menu-hover highlight wins
* on its lane (painting both would just double the same strips); the
* selected-clip hint renders on the selection's lane otherwise. Suppressed
* entirely during a live drag the drop placeholder / insert line own that
* moment, and the lane set is in flux.
*/
interface GapHighlightInput {
gapHighlight: TrackGapHighlight | null;
tracks: [number, TimelineElement[]][];
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedElements: TimelineElement[];
dragActive: boolean;
/** Rendered timeline extent (seconds) the selected-lane highlight spans the
* WHOLE lane minus its clips, trailing open space included. */
displayDuration: number;
}
/**
* Single selection only: the store mirrors a plain click into a one-member
* selectedElementIds set (setSelectedElementId collapses the multi-select),
* so "single" means empty OR exactly the selected clip itself. A marquee
* multi-select spans lanes and stays hint-free.
*/
function isSingleSelection(selectedElementId: string, ids: ReadonlySet<string>): boolean {
return ids.size === 0 || (ids.size === 1 && ids.has(selectedElementId));
}
/** The subtle strips for a single click-selected clip's lane, or null. */
function selectedLaneStrips(input: GapHighlightInput): TimelineLaneGapStrips | null {
const { selectedElementId, selectedElementIds, expandedElements, tracks, gapHighlight } = input;
if (!selectedElementId || !isSingleSelection(selectedElementId, selectedElementIds)) return null;
const selected = expandedElements.find((el) => (el.key ?? el.id) === selectedElementId);
if (!selected || selected.track === gapHighlight?.track) return null;
const laneElements = tracks.find(([t]) => t === selected.track)?.[1] ?? [];
const intervals = resolveLaneEmptyIntervals(
laneElements,
input.displayDuration,
undefined,
laneGapFloor(laneElements),
);
return intervals.length > 0 ? { track: selected.track, intervals, kind: "selected" } : null;
}
/** Pure strip derivation — exported for direct unit testing. */
export function buildTimelineGapStrips(input: GapHighlightInput): TimelineLaneGapStrips[] {
if (input.dragActive) return [];
const strips: TimelineLaneGapStrips[] = [];
if (input.gapHighlight && input.gapHighlight.intervals.length > 0) {
strips.push({ ...input.gapHighlight, kind: "hover" });
}
// Single click-selection → subtle gap hint on that clip's lane.
const selected = selectedLaneStrips(input);
if (selected) strips.push(selected);
return strips;
}
export function useTimelineGapHighlights(input: GapHighlightInput): TimelineLaneGapStrips[] {
const {
gapHighlight,
tracks,
selectedElementId,
selectedElementIds,
expandedElements,
dragActive,
displayDuration,
} = input;
return useMemo(
() =>
buildTimelineGapStrips({
gapHighlight,
tracks,
selectedElementId,
selectedElementIds,
expandedElements,
dragActive,
displayDuration,
}),
[
gapHighlight,
tracks,
selectedElementId,
selectedElementIds,
expandedElements,
dragActive,
displayDuration,
],
);
}
@@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { getPinchTimelineZoomPercent } from "./timelineZoom";
import {
GUTTER,
TRACKS_LEFT_PAD,
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomTransition,
getTimelineScrollLeftForZoomAnchor,
@@ -73,7 +74,7 @@ export function useTimelinePlayhead({
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
pointerX: scroll.clientWidth / 2,
currentScrollLeft: scroll.scrollLeft,
gutter: GUTTER,
gutter: GUTTER + TRACKS_LEFT_PAD,
currentPixelsPerSecond: prevPps,
nextPixelsPerSecond: pps,
duration: durationRef.current,
@@ -129,7 +130,7 @@ export function useTimelinePlayhead({
const el = scrollRef.current;
if (!el || effectiveDuration <= 0) return;
const rect = el.getBoundingClientRect();
const x = clientX - rect.left + el.scrollLeft - GUTTER;
const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
if (x < 0) return;
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
liveTime.notify(time);
@@ -185,7 +186,7 @@ export function useTimelinePlayhead({
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
pointerX: e.clientX - rect.left,
currentScrollLeft: scroll.scrollLeft,
gutter: GUTTER,
gutter: GUTTER + TRACKS_LEFT_PAD,
currentPixelsPerSecond: ppsRef.current,
nextPixelsPerSecond: nextPps,
duration: durationRef.current,
@@ -7,7 +7,7 @@ import {
} from "./timelineEditing";
import type { TimelineElement } from "../store/playerStore";
import { liveTime, usePlayerStore } from "../store/playerStore";
import { GUTTER } from "./timelineLayout";
import { GUTTER, TRACKS_LEFT_PAD } from "./timelineLayout";
import {
computeMarqueeSelection,
getMarqueeRect,
@@ -228,7 +228,8 @@ export function useTimelineRangeSelection({
setShowPopover(false);
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
const x =
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
const time = Math.max(0, x / pps);
rangeAnchorTime.current = time;
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
@@ -285,7 +286,7 @@ export function useTimelineRangeSelection({
const el = scrollRef.current;
if (el) {
const rect = el.getBoundingClientRect();
const x = clientX - rect.left + el.scrollLeft - GUTTER;
const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
if (x >= 0) {
const dur = el.scrollWidth / pps;
liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
@@ -309,7 +310,8 @@ export function useTimelineRangeSelection({
if (isRangeSelecting.current) {
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
const x =
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
setRangeSelection((prev) =>
prev
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { shouldShowTimelineShortcutHint } from "./timelineLayout";
/**
* The timeline scroll container's viewport plumbing extracted verbatim from
* Timeline.tsx (600-line studio cap): the ResizeObserver-backed viewport width,
* the rAF-throttled shortcut-hint visibility sync, and the callback ref that
* wires both to the scroll element. `resyncShortcutHintOn` re-checks the hint
* whenever any of its values change (timeline readiness / element count /
* canvas height), matching the original effect.
*/
export function useTimelineScrollViewport(
scrollRef: RefObject<HTMLDivElement | null>,
resyncShortcutHintOn: ReadonlyArray<unknown>,
): {
viewportWidth: number;
showShortcutHint: boolean;
setScrollRef: (el: HTMLDivElement | null) => void;
} {
const [viewportWidth, setViewportWidth] = useState(0);
const [showShortcutHint, setShowShortcutHint] = useState(true);
const roRef = useRef<ResizeObserver | null>(null);
const shortcutHintRafRef = useRef(0);
const syncShortcutHintVisibility = useCallback(() => {
const scroll = scrollRef.current;
setShowShortcutHint(
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
);
}, [scrollRef]);
const scheduleShortcutHintVisibilitySync = useCallback(() => {
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
shortcutHintRafRef.current = requestAnimationFrame(() => {
shortcutHintRafRef.current = 0;
syncShortcutHintVisibility();
});
}, [syncShortcutHintVisibility]);
const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
scrollRef.current = el;
if (!el) return;
const syncScrollViewport = () => {
setViewportWidth(el.clientWidth);
scheduleShortcutHintVisibilitySync();
};
syncScrollViewport();
roRef.current = new ResizeObserver(syncScrollViewport);
roRef.current.observe(el);
},
[scrollRef, scheduleShortcutHintVisibilitySync],
);
useMountEffect(() => () => {
roRef.current?.disconnect();
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
});
useEffect(() => {
syncShortcutHintVisibility();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncShortcutHintVisibility, ...resyncShortcutHintOn]);
return { viewportWidth, showShortcutHint, setScrollRef };
}
@@ -40,7 +40,6 @@ describe("useTimelineStackingSync", () => {
start: 0,
duration: 2,
track: 0,
sourceFile: "nested.html",
};
let apply: ((patches: Array<{ key: string; zIndex: number }>, key?: string) => unknown) | null =
null;
@@ -37,7 +37,7 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
id: el.domId ?? el.id,
selector: el.selector,
selectorIndex: el.selectorIndex,
sourceFile: el.sourceFile,
sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
},
zSyncActiveCompPath,
);
@@ -61,7 +61,7 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
const applyStackingPatches = useCallback(
(patches: StackingPatch[], coalesceKey?: string) => {
if (!handleDomZIndexReorderCommit) return;
if (!handleDomZIndexReorderCommit) return Promise.resolve();
const entries = patches.flatMap((p) => {
const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key);
const node = el && resolveIframeElement(el);
@@ -82,7 +82,9 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
});
// Forward the drag-commit's shared coalesce key so the z-reorder history
// entry merges with the lane change's move entry into one undo step.
if (entries.length) handleDomZIndexReorderCommit(entries, coalesceKey);
return entries.length
? handleDomZIndexReorderCommit(entries, coalesceKey).then(() => undefined)
: Promise.resolve();
},
[handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
);
@@ -0,0 +1,38 @@
import { useMemo } from "react";
import type { TimelineElement } from "../store/playerStore";
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
/**
* Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane
* clip grouping (`tracks`, ascending), per-lane visual styles, the ascending
* `trackOrder`, and the z-override badge set. Extracted from Timeline.tsx as a
* cohesive unit (600-line studio cap); each memo keys on the expanded display
* element set exactly as before.
*/
export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
tracks: [number, TimelineElement[]][];
trackStyles: Map<number, TrackVisualStyle>;
trackOrder: number[];
} {
const tracks = useMemo(() => {
const map = new Map<number, TimelineElement[]>();
for (const el of expandedElements) {
const list = map.get(el.track) ?? [];
list.push(el);
map.set(el.track, list);
}
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>();
for (const [trackNum, els] of tracks) {
map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
}
return map;
}, [tracks]);
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
return { tracks, trackStyles, trackOrder };
}
@@ -0,0 +1,155 @@
import { useCallback, useMemo, useState, type MutableRefObject } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DragCommitDeps } from "./timelineClipDragCommit";
import {
laneGapFloor,
resolveAllGapIntervals,
resolveAllTrackGaps,
resolveCloseGapShifts,
resolveTrackGapAt,
type TrackGapInterval,
} from "./timelineGaps";
import {
canShiftTrackGapClips,
commitCloseAllTrackGaps,
commitCloseTrackGap,
} from "./timelineGapCommit";
/** Right-click anchor on EMPTY lane space: pointer position + clicked lane/time. */
interface TrackGapMenuAnchor {
x: number;
y: number;
track: number;
time: number;
}
/** Gap strips to paint on one lane while a menu row is hovered. */
export interface TrackGapHighlight {
track: number;
intervals: TrackGapInterval[];
}
/**
* Track-gap context menu (right-click on empty lane space) state, the
* derived menu model, and the two commit actions. Extracted from Timeline.tsx
* as a cohesive unit (600-line studio cap); behavior identical.
*
* Only the ANCHOR (and the hovered row) is state; the menu model (gap under
* the pointer, compaction, movability) derives from live `tracks` so an open
* menu reflects concurrent edits. `gapHighlight` the strips TimelineCanvas
* paints while "Close gap" / "Close all gaps" is hovered derives the same
* way. Commits are ONE atomic batch each via the existing move-persist
* pipeline (see timelineGapCommit.ts).
*/
export function useTrackGapMenu({
tracks,
expandedElementsRef,
trackOrderRef,
onMoveElement,
onMoveElements,
}: {
tracks: [number, TimelineElement[]][];
expandedElementsRef: MutableRefObject<TimelineElement[]>;
trackOrderRef: MutableRefObject<number[]>;
onMoveElement: DragCommitDeps["onMoveElement"];
onMoveElements: DragCommitDeps["onMoveElements"];
}) {
const updateElement = usePlayerStore((s) => s.updateElement);
const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
const gapMenuLaneElements = useMemo(
() => (gapContextMenu ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? []) : null),
[gapContextMenu, tracks],
);
const gapMenuModel = useMemo(() => {
if (!gapContextMenu || !gapMenuLaneElements) return null;
const floor = laneGapFloor(gapMenuLaneElements);
const gap = resolveTrackGapAt(gapMenuLaneElements, gapContextMenu.time, undefined, floor);
const allShifts = resolveAllTrackGaps(gapMenuLaneElements, undefined, floor);
return {
x: gapContextMenu.x,
y: gapContextMenu.y,
gapWidth: gap ? gap.gapEnd - gap.gapStart : null,
canCloseGap:
gap != null &&
canShiftTrackGapClips(gapMenuLaneElements, resolveCloseGapShifts(gapMenuLaneElements, gap)),
hasAnyGaps: allShifts.length > 0,
canCloseAllGaps:
allShifts.length > 0 && canShiftTrackGapClips(gapMenuLaneElements, allShifts),
};
}, [gapContextMenu, gapMenuLaneElements]);
// The strips to paint while a menu row is hovered: the one gap under the
// pointer for "Close gap", every current gap (leading included) for
// "Close all gaps". Null when nothing is hovered / nothing would close.
const gapHighlight = useMemo<TrackGapHighlight | null>(() => {
if (!gapContextMenu || !gapMenuLaneElements || !hoveredGapAction) return null;
const floor = laneGapFloor(gapMenuLaneElements);
if (hoveredGapAction === "close-gap") {
const gap = resolveTrackGapAt(gapMenuLaneElements, gapContextMenu.time, undefined, floor);
if (!gap) return null;
return {
track: gapContextMenu.track,
intervals: [{ start: gap.gapStart, end: gap.gapEnd }],
};
}
const intervals = resolveAllGapIntervals(gapMenuLaneElements, undefined, floor);
return intervals.length > 0 ? { track: gapContextMenu.track, intervals } : null;
}, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
const closeTrackGap = useCallback(() => {
if (!gapContextMenu || !gapMenuLaneElements) return;
commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
elements: expandedElementsRef.current,
trackOrder: trackOrderRef.current,
updateElement,
onMoveElement,
onMoveElements,
});
}, [
gapContextMenu,
gapMenuLaneElements,
expandedElementsRef,
trackOrderRef,
updateElement,
onMoveElement,
onMoveElements,
]);
const closeAllTrackGaps = useCallback(() => {
if (!gapMenuLaneElements) return;
commitCloseAllTrackGaps(gapMenuLaneElements, {
elements: expandedElementsRef.current,
trackOrder: trackOrderRef.current,
updateElement,
onMoveElement,
onMoveElements,
});
}, [
gapMenuLaneElements,
expandedElementsRef,
trackOrderRef,
updateElement,
onMoveElement,
onMoveElements,
]);
const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
setHoveredGapAction(null);
setGapContextMenu(anchor);
}, []);
const dismissGapMenu = useCallback(() => {
setHoveredGapAction(null);
setGapContextMenu(null);
}, []);
return {
gapMenuModel,
gapHighlight,
setHoveredGapAction,
openGapMenu,
dismissGapMenu,
closeTrackGap,
closeAllTrackGaps,
};
}