mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 20:07:39 +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>
668 lines
26 KiB
TypeScript
668 lines
26 KiB
TypeScript
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();
|
|
});
|
|
});
|