mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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:
co-authored by
Miguel Angel Simon Sierra
parent
d7204ac47f
commit
89db718899
@@ -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 lane→z 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. Time↔pixel mapping: content x = GUTTER + TRACKS_LEFT_PAD
|
||||
* + t·pps, and every pointer→time 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,
|
||||
* bottom→top, 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user