mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
* 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>
372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
/**
|
|
* timelineStackingSync — lane ↔ stacking unification (pure).
|
|
*
|
|
* The approved design: **lane order implies stacking**. A clip on a higher lane
|
|
* (rendered ABOVE another in the timeline) should render ON TOP of any clip it
|
|
* OVERLAPS IN TIME. But authored z-indexes are sacred: z only changes on a user
|
|
* edit, and ONLY for the clip(s) the user actually edited.
|
|
*
|
|
* Lane → screen mapping (see Timeline.tsx trackOrder / TimelineCanvas rows):
|
|
* tracks are sorted ASCENDING and rendered top → bottom, so a LOWER `track`
|
|
* value renders HIGHER on screen. Standard NLE convention = the top row wins,
|
|
* therefore **lower track ⇒ higher z-index**. We express this with a single
|
|
* comparator so callers never have to remember the polarity.
|
|
*
|
|
* This module is DOM-free and store-free. Callers project their world onto
|
|
* `StackingElement` (supplying the live z-index they read from the DOM/inline
|
|
* style) and apply the returned `StackingPatch[]` however they persist styles.
|
|
*/
|
|
|
|
/** Minimal element view this module reasons over. */
|
|
export interface StackingElement {
|
|
/** Stable identity (TimelineElement.key ?? id). */
|
|
key: string;
|
|
/** Absolute start time (seconds). */
|
|
start: number;
|
|
/** Duration (seconds). */
|
|
duration: number;
|
|
/**
|
|
* Display lane (the normalized timeline `track`). Lower = higher on screen =
|
|
* should stack on top. This is the post-edit lane for edited clips.
|
|
*/
|
|
track: number;
|
|
/**
|
|
* Current z-index (parsed from inline style / computed; "auto" ⇒ 0), or a
|
|
* NON-FINITE value (NaN) when the caller could NOT resolve the clip's live node
|
|
* (e.g. an unmounted / nested sub-comp element, or one outside the active file).
|
|
* A non-finite-z clip is EXCLUDED from the computation — it is neither a stacking
|
|
* neighbour nor resolvable as an edit — so an unresolved node never fabricates a
|
|
* z=0 neighbour that poisons the boundary math (item 13). The reader signals a
|
|
* miss with NaN rather than null so the value stays assignable to the existing
|
|
* `(el) => number` reader contract the drag hook / commit deps declare.
|
|
*/
|
|
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
|
|
* ancestors' z decides paint order — so the sync partitions by this key and
|
|
* never patches across contexts. Null/undefined ⇒ the root context.
|
|
*/
|
|
stackingContextId?: string | null;
|
|
/**
|
|
* Discovery / DOM document position (optional). Two clips with EQUAL z paint by
|
|
* DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
|
|
* B" uses (zIndex, domIndex); without it equal-z is ambiguous and the sync can
|
|
* under-patch (the reported bug: a clip dragged to the bottom lane over an
|
|
* equal-z neighbour changed nothing on canvas). Callers pass the index of the
|
|
* element in the discovery order array.
|
|
*/
|
|
domIndex?: number;
|
|
}
|
|
|
|
/** A minimal z-index change for one clip. */
|
|
export interface StackingPatch {
|
|
key: string;
|
|
zIndex: number;
|
|
}
|
|
|
|
const EPS = 1e-6;
|
|
|
|
/**
|
|
* 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 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.
|
|
*
|
|
* NOTE the `- EPS`: this DELIBERATELY diverges from `timeRangesOverlap`'s exact
|
|
* strict-`<` (timelineCollision.ts). A boolean collision decision is idempotent, so
|
|
* exact `<` is fine there; here the result drives a VISIBLE stacking re-lane, so the
|
|
* 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: 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;
|
|
}
|
|
|
|
/**
|
|
* Is `a` visually ABOVE `b` (should stack on top)? Lower track renders higher on
|
|
* screen, so a lower track number means "above". Exposed for tests / callers.
|
|
*/
|
|
export function laneIsAbove(
|
|
a: Pick<StackingElement, "track">,
|
|
b: Pick<StackingElement, "track">,
|
|
): boolean {
|
|
return a.track < b.track;
|
|
}
|
|
|
|
/**
|
|
* Working record for the cascade resolver: a live-mutable, RESOLVED (non-null) z
|
|
* the resolver can bump, plus the immutable identity/lane/time/dom fields. Clips
|
|
* whose z could not be resolved (null) are dropped before this stage.
|
|
*/
|
|
interface MutZ extends StackingElement {
|
|
zIndex: number;
|
|
}
|
|
|
|
/**
|
|
* 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). Exported (like laneIsAbove) as the
|
|
* ONE paint-order predicate so every consumer agrees on what "paints above" means.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/** Reduce a neighbour set's z-indices to a single bound, or null when empty. */
|
|
function boundaryZ(neighbours: MutZ[], reduce: (zs: number[]) => number): number | null {
|
|
return neighbours.length > 0 ? reduce(neighbours.map((o) => o.zIndex)) : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve `edited` so that, among the clips it OVERLAPS IN TIME, its paint order
|
|
* matches its lane order (lower lane ⇒ paints on top). Records every z change
|
|
* (edited clip AND any neighbours that must be bumped) into `patchZ`.
|
|
*
|
|
* Fast path (unchanged behaviour): when a single non-negative z for the edited
|
|
* clip alone realises the order — strictly between the neighbours if there is
|
|
* integer room, else just above the lower neighbour, else just below the upper —
|
|
* emit only that. This keeps every existing single-patch test passing.
|
|
*
|
|
* Cascade path: when ties/clamping make the single-clip patch impossible or
|
|
* ineffective (must sit below an overlapping z=0 neighbour, or between adjacent /
|
|
* equal-z neighbours where DOM order alone can't express it), bump the minimum set
|
|
* of overlapping neighbours that must stay ABOVE by +1 (cascading only as far as
|
|
* needed) so the edited clip's intended lane order is realised with all z ≥ 0.
|
|
* "Authored z sacred" stays the default — neighbours are touched only when the
|
|
* user's explicit lane move is otherwise inexpressible (same precedent as the
|
|
* canvas context-menu tie-aware fix).
|
|
*
|
|
* Returns true when any z changed (recorded in `patchZ`), false for a no-op.
|
|
*/
|
|
function resolveEditedZ(
|
|
edited: MutZ,
|
|
overlapping: MutZ[],
|
|
overlappersOf: (clip: MutZ) => MutZ[],
|
|
patchZ: (clip: MutZ, z: number) => void,
|
|
): boolean {
|
|
const visualOverlap = overlapping.filter((o) => !o.isAudio);
|
|
if (visualOverlap.length === 0) return false;
|
|
|
|
// Neighbours that must end up BELOW edited (lower lane) vs ABOVE (higher lane).
|
|
const below = visualOverlap.filter((o) => laneIsAbove(edited, o));
|
|
const above = visualOverlap.filter((o) => laneIsAbove(o, edited));
|
|
|
|
// Already correct against every overlapping neighbour → no-op (authored z kept).
|
|
const correct =
|
|
below.every((o) => paintsAbove(edited, o)) && above.every((o) => paintsAbove(o, edited));
|
|
if (correct) return false;
|
|
|
|
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
|
|
|
|
// ── Fast path: try to realise the order by moving only `edited`. ──────────────
|
|
const single = trySingleZ(edited, below, above);
|
|
if (single != null) {
|
|
if (single !== edited.zIndex) patchZ(edited, single);
|
|
// Even at an unchanged z the DOM-order ties may already be satisfied; if not,
|
|
// `trySingleZ` returned null and we fall through to the cascade.
|
|
return single !== edited.zIndex;
|
|
}
|
|
|
|
// ── Cascade path: can't fit `edited` between the neighbours with one z ≥ 0. ───
|
|
// Sit edited at maxBelow+1 (or 0 when it only has above-neighbours) and lift the
|
|
// above-neighbours that are now not strictly above, minimally, one step past it.
|
|
const target = maxBelow != null ? maxBelow + 1 : 0;
|
|
const clamped = Math.max(0, target);
|
|
if (clamped !== edited.zIndex) patchZ(edited, clamped);
|
|
liftAbove(edited, overlappersOf, patchZ);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Pick a single non-negative z for `edited` that lands it correctly against its
|
|
* neighbours (paints above every below-neighbour, below every above-neighbour), or
|
|
* null when no such z exists and the caller must cascade.
|
|
*
|
|
* The candidate is verified with the SAME `paintsAbove` predicate the resolver uses
|
|
* (z + DOM tie-break), so an authored z that already paints correctly by DOM order
|
|
* is honoured instead of over-patched: with below=z3 and an above-neighbour at z4
|
|
* that is LATER in the DOM, edited=4 ties the neighbour but the neighbour still
|
|
* paints on top by DOM order — a valid single patch, no neighbour bump (item 12).
|
|
* When the tie would INVERT (the above-neighbour is earlier in DOM) the candidate
|
|
* fails verification and the caller cascades.
|
|
*/
|
|
// edited has neighbours on BOTH sides: prefer the integer midpoint of a real gap;
|
|
// with no strict gap a DOM tie-break may still let it sit AT minAbove (item 12),
|
|
// else the caller cascades.
|
|
function zBetweenNeighbours(
|
|
maxBelow: number,
|
|
minAbove: number,
|
|
correctAt: (z: number) => boolean,
|
|
): number | null {
|
|
if (minAbove - maxBelow >= 2) {
|
|
const mid = Math.floor((maxBelow + minAbove) / 2);
|
|
return mid > maxBelow && mid < minAbove ? mid : null;
|
|
}
|
|
return correctAt(minAbove) ? minAbove : null;
|
|
}
|
|
|
|
// edited has only above-neighbours: sit one step below minAbove, or at the z=0
|
|
// floor tie minAbove when a DOM tie-break keeps that neighbour on top.
|
|
function zBelowOnly(minAbove: number, correctAt: (z: number) => boolean): number | null {
|
|
const candidate = minAbove - 1;
|
|
if (candidate >= 0) return candidate;
|
|
return correctAt(minAbove) ? minAbove : null;
|
|
}
|
|
|
|
function trySingleZ(edited: MutZ, below: MutZ[], above: MutZ[]): number | null {
|
|
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
|
|
const minAbove = boundaryZ(above, (zs) => Math.min(...zs));
|
|
|
|
const correctAt = (z: number): boolean => {
|
|
const probe: MutZ = { ...edited, zIndex: z };
|
|
return below.every((b) => paintsAbove(probe, b)) && above.every((a) => paintsAbove(a, probe));
|
|
};
|
|
|
|
if (maxBelow != null && minAbove != null)
|
|
return zBetweenNeighbours(maxBelow, minAbove, correctAt);
|
|
if (maxBelow != null) return maxBelow + 1; // only below-neighbours → grow upward
|
|
if (minAbove != null) return zBelowOnly(minAbove, correctAt);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Enforce the module invariant — for every OVERLAPPING pair, the clip on the upper
|
|
* lane paints on top — starting from `edited` and cascading TRANSITIVELY.
|
|
*
|
|
* Seeded with `edited`: each of its upper-lane overlappers must paint strictly
|
|
* above it (the deliberate lane move). Raising a clip can then tie or cross ANOTHER
|
|
* clip it overlaps that sits on an even higher lane — that clip must be lifted too,
|
|
* and so on. Without the cascade a lifted neighbour could tie an untouched clip on
|
|
* a higher lane and, being later in the DOM, paint above it — an untouched pair
|
|
* visibly inverting (#2198). The condition is LANE order (not "was originally
|
|
* above"), so a clip that was already violating lane order — e.g. a bottom-lane
|
|
* clip painting on top — is fixed, never preserved. Only clips whose z actually
|
|
* changes are patched; z climbs by +1 each step so the walk terminates.
|
|
*/
|
|
function liftAbove(
|
|
edited: MutZ,
|
|
overlappersOf: (clip: MutZ) => MutZ[],
|
|
patchZ: (clip: MutZ, z: number) => void,
|
|
): void {
|
|
const queue: MutZ[] = [edited];
|
|
const raiseAbove = (clip: MutZ, floor: MutZ): void => {
|
|
if (paintsAbove(clip, floor)) return; // already strictly on top
|
|
patchZ(clip, floor.zIndex + 1); // patchZ mutates clip.zIndex in place
|
|
queue.push(clip);
|
|
};
|
|
while (queue.length > 0) {
|
|
const floor = queue.shift()!;
|
|
for (const other of overlappersOf(floor)) {
|
|
if (laneIsAbove(other, floor) && !paintsAbove(other, floor)) raiseAbove(other, floor);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute z-index patches so each edited clip's stacking matches its lane order.
|
|
*
|
|
* @param elements The FULL post-edit element set (edited clips already carry
|
|
* their new lane/time). Untouched clips keep their current z.
|
|
* @param editedKeys Keys of the clip(s) the user just edited.
|
|
* @returns Minimal z patches. When a single-clip patch realises the order it is
|
|
* the only patch (authored z of neighbours untouched); when ties or a
|
|
* z=0 floor make that impossible, the minimum set of overlapping
|
|
* neighbours is bumped too so the lane move is always realisable with
|
|
* all z ≥ 0. Non-overlapping / already-correct edits yield nothing.
|
|
*
|
|
* Multi-clip edits: each edited clip is resolved against the CURRENT (already-
|
|
* patched) z of all OTHER clips, lower lane first, so a group dragged onto a busy
|
|
* region stacks consistently.
|
|
*/
|
|
export function computeStackingPatches(
|
|
elements: StackingElement[],
|
|
editedKeys: Iterable<string>,
|
|
): StackingPatch[] {
|
|
const editedSet = new Set(editedKeys);
|
|
if (editedSet.size === 0) return [];
|
|
|
|
// Drop clips whose live z couldn't be resolved (non-finite / NaN): a fabricated
|
|
// z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
|
|
// unresolved clip is neither a neighbour nor resolvable as an edit, so it is
|
|
// excluded outright (item 13).
|
|
const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
|
|
|
|
// 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 }]));
|
|
const edited = resolved
|
|
.filter((e) => editedSet.has(e.key) && !e.isAudio)
|
|
.map((e) => byKey.get(e.key)!)
|
|
// Resolve lower-lane (renders below) clips first so their new z is visible
|
|
// to higher-lane siblings resolved after them.
|
|
.sort((a, b) => b.track - a.track);
|
|
|
|
const changed = new Map<string, number>();
|
|
const patchZ = (clip: MutZ, z: number): void => {
|
|
clip.zIndex = z;
|
|
changed.set(clip.key, z);
|
|
};
|
|
|
|
// 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 overlappersOf = (clip: MutZ): MutZ[] =>
|
|
all.filter(
|
|
(o) => o.key !== clip.key && !o.isAudio && samePaintScope(clip, o) && overlapsInTime(clip, o),
|
|
);
|
|
|
|
for (const clip of edited) {
|
|
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
|
|
}
|
|
|
|
// Emit in a stable order (edited clips first in their resolve order, then any
|
|
// cascaded neighbours) — deterministic for tests and undo grouping.
|
|
const emitted = new Set<string>();
|
|
const patches: StackingPatch[] = [];
|
|
for (const clip of edited) {
|
|
if (changed.has(clip.key) && !emitted.has(clip.key)) {
|
|
patches.push({ key: clip.key, zIndex: changed.get(clip.key)! });
|
|
emitted.add(clip.key);
|
|
}
|
|
}
|
|
for (const [key, zIndex] of changed) {
|
|
if (!emitted.has(key)) {
|
|
patches.push({ key, zIndex });
|
|
emitted.add(key);
|
|
}
|
|
}
|
|
return patches;
|
|
}
|