Files
hyperframes/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts
T
Ular KimsanovandMiguel Angel Simon Sierra 89db718899 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>
2026-07-14 14:31:58 -04:00

677 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
isElementVisibleForZOrder,
isZOrderActionEnabled,
parseZIndex,
resolveCrossedNeighbor,
resolveZOrderChange,
resolveZOrderReposition,
type ZOrderAction,
type ZOrderPatch,
} from "./canvasContextMenuZOrder";
// ── helpers ───────────────────────────────────────────────────────────────────
//
// In jsdom getBoundingClientRect returns all zeros, so the target rect is 0×0
// and getOverlappingFamily returns the whole family — i.e. every sibling is
// treated as overlapping. That makes forward/backward and front/back exercise
// the same scoped set here, which is exactly what we want for order logic.
function makeEl(id: string, zIndex?: string): HTMLElement {
const el = document.createElement("div");
el.id = id;
if (zIndex !== undefined) el.style.zIndex = zIndex;
return el;
}
/**
* Build a parent and append `[target, ...siblings]` in DOM order. Each spec is
* [id, z]. The target's z is `targetZ`; it is appended FIRST unless
* `targetLast` is set, in which case it is appended LAST (later in DOM).
*/
function makeFamily(
targetZ: string,
siblingSpecs: Array<[string, string]>,
opts: { targetLast?: boolean } = {},
): { target: HTMLElement; parent: HTMLElement; byId: Record<string, HTMLElement> } {
const parent = document.createElement("div");
const target = makeEl("target", targetZ);
const siblings = siblingSpecs.map(([id, z]) => makeEl(id, z));
const byId: Record<string, HTMLElement> = { target };
for (const s of siblings) byId[s.id] = s;
if (opts.targetLast) {
for (const s of siblings) parent.appendChild(s);
parent.appendChild(target);
} else {
parent.appendChild(target);
for (const s of siblings) parent.appendChild(s);
}
return { target, parent, byId };
}
/** Resolve a z-order change and assert it produced patches (fails otherwise). */
function resolveZOrderPatches(
target: HTMLElement,
action: ZOrderAction,
options?: { isVisible?: (el: HTMLElement) => boolean },
): ZOrderPatch[] {
const patches = resolveZOrderChange(target, action, options);
expect(patches).not.toBeNull();
if (!patches) throw new Error("expected z-order patches");
return patches;
}
/** Look up a patch for a given element id in a patch list. */
function patchFor(patches: ZOrderPatch[], byId: Record<string, HTMLElement>, id: string) {
return patches.find((p) => p.element === byId[id]);
}
/** Apply patches, then return the render-ordered ids (bottom→top). */
function renderOrderIds(
parent: HTMLElement,
byId: Record<string, HTMLElement>,
patches: ZOrderPatch[],
): string[] {
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const children = Array.from(parent.children) as HTMLElement[];
const withPos = children.map((el, domIndex) => ({
id: Object.keys(byId).find((k) => byId[k] === el) ?? el.id,
z: parseZIndex(el.style.zIndex || "0"),
domIndex,
}));
withPos.sort((a, b) => a.z - b.z || a.domIndex - b.domIndex);
return withPos.map((e) => e.id);
}
// ── parseZIndex ───────────────────────────────────────────────────────────────
describe("parseZIndex", () => {
it("parses integers", () => {
expect(parseZIndex("5")).toBe(5);
expect(parseZIndex("0")).toBe(0);
expect(parseZIndex("-3")).toBe(-3);
});
it("treats 'auto' / null / undefined / empty as 0", () => {
expect(parseZIndex("auto")).toBe(0);
expect(parseZIndex(null)).toBe(0);
expect(parseZIndex(undefined)).toBe(0);
expect(parseZIndex("")).toBe(0);
});
});
// ── distinct-z fast path (single-element patch) ────────────────────────────────
describe("resolveZOrderChange distinct z values (fast path)", () => {
it("bring-to-front moves target above all with a single patch", () => {
const { target, byId } = makeFamily("2", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
const patches = resolveZOrderPatches(target, "bring-to-front");
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(6);
});
it("bring-to-front returns null when already on top", () => {
const { target } = makeFamily("6", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
});
it("send-to-back moves target below all", () => {
const { target, byId, parent } = makeFamily("3", [
["a", "1"],
["b", "5"],
["c", "2"],
]);
const patches = resolveZOrderPatches(target, "send-to-back");
// target must end up strictly below the current min (1) in render order.
expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
});
it("send-to-back returns null when already at back", () => {
const { target } = makeFamily("0", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
});
it("bring-forward steps up exactly one in render order", () => {
const { target, byId, parent } = makeFamily("2", [
["a", "1"],
["b", "4"],
["c", "7"],
]);
// render order bottom→top: a(1), target(2), b(4), c(7). forward → above b.
const patches = resolveZOrderPatches(target, "bring-forward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "b", "target", "c"]);
});
it("send-backward steps down exactly one in render order", () => {
const { target, byId, parent } = makeFamily("5", [
["a", "1"],
["b", "3"],
["c", "8"],
]);
// bottom→top: a(1), b(3), target(5), c(8). backward → below b.
const patches = resolveZOrderPatches(target, "send-backward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target", "b", "c"]);
});
it("bring-forward returns null when already top of set", () => {
const { target } = makeFamily("8", [
["a", "1"],
["b", "4"],
["c", "7"],
]);
expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
});
it("send-backward returns null when already bottom of set", () => {
const { target } = makeFamily("0", [
["a", "1"],
["b", "3"],
["c", "8"],
]);
expect(resolveZOrderChange(target, "send-backward")).toBeNull();
});
it("returns null when no siblings", () => {
const target = makeEl("solo", "2");
document.createElement("div").appendChild(target);
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(resolveZOrderChange(target, action)).toBeNull();
}
});
});
// ── DOM-order ties (the repro) ─────────────────────────────────────────────────
describe("resolveZOrderChange DOM-order ties (repro: equal z)", () => {
it("send-backward: tied target LATER in DOM (visually on top) can go below", () => {
// img#a (z=0, earlier in DOM) then video#target (z=0, later) → video paints
// on top. send-backward must put target below the image.
const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
const patches = resolveZOrderPatches(target, "send-backward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["target", "a"]);
// target ends strictly below the image.
const tz = patchFor(patches, byId, "target")?.zIndex ?? 0;
expect(tz).toBeGreaterThanOrEqual(0);
});
it("send-to-back: tied target LATER in DOM goes to the very back", () => {
const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
const patches = resolveZOrderPatches(target, "send-to-back");
expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
});
it("bring-forward: tied target EARLIER in DOM (visually below) can go above", () => {
// target#target (z=0, earlier) then #a (z=0, later) → a paints on top.
// bring-forward on target must lift it above a.
const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
const patches = resolveZOrderPatches(target, "bring-forward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target"]);
});
it("bring-to-front: tied target EARLIER in DOM goes to the very front", () => {
const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
const patches = resolveZOrderPatches(target, "bring-to-front");
const order = renderOrderIds(parent, byId, patches);
expect(order[order.length - 1]).toBe("target");
});
it("send-backward: tied target EARLIER in DOM is already at back → null", () => {
// target earlier + a later, both z=0. target already paints below a.
const { target } = makeFamily("0", [["a", "0"]]);
expect(resolveZOrderChange(target, "send-backward")).toBeNull();
expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
});
it("bring-forward: tied target LATER in DOM is already on top → null", () => {
const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
});
it("renumber emits a real patch per changed element and none for the unchanged (minimal, no no-ops)", () => {
// Three tied at z=0, target in the middle of DOM order. Sending it back must
// renumber to distinct values but leave the target (which keeps its bottom
// slot's value 0) unpatched — and every emitted patch must be a genuine change.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const target = makeEl("target", "0");
const b = makeEl("b", "0");
parent.append(a, target, b);
const originalZ = new Map<HTMLElement, number>([
[a, 0],
[target, 0],
[b, 0],
]);
// render order bottom→top by (z, dom): a, target, b. send-backward → below a.
const patches = resolveZOrderPatches(target, "send-backward");
// Every emitted patch is a REAL change: its new z differs from the old z.
for (const p of patches) expect(p.zIndex).not.toBe(originalZ.get(p.element));
// target renumbers to 0 (its existing value) → it must NOT be in the patch set.
expect(patchFor(patches, { a, target, b }, "target")).toBeUndefined();
// No two patches collide on the same element (a well-formed minimal set).
expect(new Set(patches.map((p) => p.element)).size).toBe(patches.length);
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
expect(renderOrderIds(parent, { a, target, b }, [])).toEqual(["target", "a", "b"]);
});
});
// ── overlap scoping (real getBoundingClientRect) ────────────────────────────────
//
// jsdom's getBoundingClientRect is 0×0, so getOverlappingFamily keeps the whole
// family and the SCOPED (overlapping-only) path is never exercised above. These
// mock rects so a sibling can be genuinely NON-overlapping and thus non-scoped.
interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
function setRect(el: HTMLElement, r: Rect): void {
el.getBoundingClientRect = (): DOMRect =>
({
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
width: r.right - r.left,
height: r.bottom - r.top,
x: r.left,
y: r.top,
toJSON: () => ({}),
}) as DOMRect;
}
describe("resolveZOrderChange overlap scoping preserves untouched non-scoped pairs (#2202)", () => {
it("send-backward renumber keeps a scoped sibling above an untouched NON-overlapping one", () => {
// A (z5, overlaps target) and target (z5) are tied and overlap; C (z3) does NOT
// overlap target, so it is non-scoped. Old renumber sent the scoped set to
// 0..n-1 (A→1), dropping A BELOW C (z3) — an untouched (A, C) pair inverting.
// The band-preserving renumber keeps the scoped block above C: A→6, C untouched.
const parent = document.createElement("div");
const a = makeEl("a", "5");
const target = makeEl("target", "5");
const c = makeEl("c", "3");
parent.append(a, target, c);
setRect(a, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(c, { left: 100, top: 100, right: 110, bottom: 110 }); // disjoint → non-scoped
const byId = { a, target, c };
const patches = resolveZOrderPatches(target, "send-backward");
// C (untouched, non-scoped) is never patched.
expect(patchFor(patches, byId, "c")).toBeUndefined();
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const order = renderOrderIds(parent, byId, []);
// Deliberate move: target below a. Preserved untouched pair: a stays above c.
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
expect(order.indexOf("a")).toBeGreaterThan(order.indexOf("c"));
});
it("scopes forward/backward to the overlapping set (a non-overlapping sibling is ignored)", () => {
// target (z1) overlaps a (z2) only; far (z5) does not overlap target. bring-
// forward must step target above a (its sole overlapping neighbour), NOT chase
// the non-overlapping far — proving the scoping actually runs with real rects.
const parent = document.createElement("div");
const target = makeEl("target", "1");
const a = makeEl("a", "2");
const far = makeEl("far", "5");
parent.append(target, a, far);
setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(a, { left: 5, top: 5, right: 15, bottom: 15 }); // overlaps target
setRect(far, { left: 200, top: 200, right: 210, bottom: 210 }); // disjoint
const byId = { target, a, far };
const patches = resolveZOrderPatches(target, "bring-forward");
// far is untouched (not in the overlapping scope).
expect(patchFor(patches, byId, "far")).toBeUndefined();
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const order = renderOrderIds(parent, byId, []);
// target rose just above its overlapping neighbour a, staying below far.
expect(order.indexOf("target")).toBeGreaterThan(order.indexOf("a"));
expect(order.indexOf("target")).toBeLessThan(order.indexOf("far"));
});
});
// ── non-painting sibling hygiene ───────────────────────────────────────────────
describe("resolveZOrderChange excludes non-painting siblings", () => {
it("ignores <audio>/<script>/<style> siblings in the family", () => {
// Parent holds: img#a (z0), <audio> (a prior renumber wrote z=2 onto it),
// video#target (z0, later in DOM), plus a <script> and <style>. Only the two
// painting elements should form the family — the audio's z=2 must NOT pad the
// renumber or count as a sibling above the target.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const audio = document.createElement("audio");
audio.style.zIndex = "2";
const script = document.createElement("script");
const style = document.createElement("style");
const target = makeEl("target", "0");
parent.append(a, audio, script, style, target);
// target is later in DOM than a, tied at z=0 → paints on top. send-to-back
// must put it below a. If audio (z=2) were counted, the renumber would differ.
const patches = resolveZOrderPatches(target, "send-to-back");
// No patch may target the audio/script/style elements.
for (const p of patches) {
expect(p.element).not.toBe(audio);
expect(p.element).not.toBe(script);
expect(p.element).not.toBe(style);
}
// Order among the painting pair: target below a.
const order = renderOrderIds(parent, { a, target }, patches);
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
});
it("ignores <template>/<noscript> siblings in the family", () => {
// A renumber fallback once wrote z-index/position into <template> source
// markup because templates entered the sibling family. They never paint —
// exclude them like audio/script/style.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const template = document.createElement("template");
template.style.zIndex = "2";
const noscript = document.createElement("noscript");
const target = makeEl("target", "0");
parent.append(a, template, noscript, target);
const patches = resolveZOrderPatches(target, "send-to-back");
for (const p of patches) {
expect(p.element).not.toBe(template);
expect(p.element).not.toBe(noscript);
}
const order = renderOrderIds(parent, { a, target }, patches);
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
});
it("a lone painting element beside only non-painting siblings has no family → null", () => {
const parent = document.createElement("div");
const target = makeEl("target", "1");
const audio = document.createElement("audio");
parent.append(target, audio);
// Only sibling is <audio> (excluded) → family size 1 → every action is a no-op.
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(resolveZOrderChange(target, action)).toBeNull();
}
});
});
// ── visibility scoping (forward/backward step over VISIBLE siblings only) ──────
//
// The visibility probe is injectable (ZOrderResolveOptions.isVisible) exactly
// like rect reading is stubbable — these tests drive the scoping logic with a
// stub, without a real style engine.
describe("resolveZOrderChange visibility scoping (injectable stub)", () => {
/** Probe that hides exactly the given elements. */
function hiding(...hidden: HTMLElement[]) {
return { isVisible: (el: HTMLElement) => !hidden.includes(el) };
}
it("bring-forward steps over the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
// Render order: target(1), hidden(2), vis(3). The nearest z-neighbor above
// is invisible at the current frame — forward must land the target above
// `vis` (the next VISIBLE overlapping sibling), leaving `hidden` untouched.
const { target, byId } = makeFamily("1", [
["hidden", "2"],
["vis", "3"],
]);
const patches = resolveZOrderPatches(target, "bring-forward", hiding(byId.hidden!));
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(4);
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
});
it("send-backward steps below the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
// Render order: vis(1), hidden(2), target(3). Backward must drop the target
// below `vis`, not merely below the invisible `hidden`.
const { target, byId } = makeFamily("3", [
["vis", "1"],
["hidden", "2"],
]);
const patches = resolveZOrderPatches(target, "send-backward", hiding(byId.hidden!));
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(0);
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
});
it("forward/backward are no-ops when every overlapping sibling is invisible", () => {
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
expect(resolveZOrderChange(target, "bring-forward", opts)).toBeNull();
expect(resolveZOrderChange(target, "send-backward", opts)).toBeNull();
});
it("bring-to-front / send-to-back keep the FULL painting family (invisible siblings included)", () => {
// Unchanged semantics: front/back operate across all siblings, so an
// invisible sibling still counts and the actions stay meaningful.
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
const patches = resolveZOrderPatches(target, "bring-to-front", opts);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
expect(resolveZOrderChange(target, "send-to-back", opts)).toBeNull(); // already bottom
});
it("the target itself is retained even when the probe reports it invisible", () => {
const { target, byId } = makeFamily("1", [["vis", "2"]]);
const patches = resolveZOrderPatches(target, "bring-forward", hiding(target));
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
});
it("isZOrderActionEnabled matches the resolver under the same visibility scope", () => {
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
// Forward/backward: scoped set collapses to the target alone → disabled.
expect(isZOrderActionEnabled(target, "bring-forward", opts)).toBe(false);
expect(isZOrderActionEnabled(target, "send-backward", opts)).toBe(false);
// Front/back: full family → enabled exactly where the resolver acts.
expect(isZOrderActionEnabled(target, "bring-to-front", opts)).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back", opts)).toBe(false);
});
});
// ── default visibility probe (element-level computed style) ───────────────────
describe("isElementVisibleForZOrder default probe", () => {
function attachedEl(style: Partial<CSSStyleDeclaration> = {}): HTMLElement {
const el = document.createElement("div");
Object.assign(el.style, style);
document.body.appendChild(el);
return el;
}
it("treats display:none / visibility:hidden / opacity≈0 as invisible", () => {
expect(isElementVisibleForZOrder(attachedEl({ display: "none" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ visibility: "hidden" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.005" }))).toBe(false);
});
it("treats normal, translucent, and unstyled elements as visible", () => {
expect(isElementVisibleForZOrder(attachedEl())).toBe(true);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.5" }))).toBe(true);
expect(isElementVisibleForZOrder(attachedEl({ visibility: "visible" }))).toBe(true);
});
it("exempts a hidden color-grading source (its canvas paints in its place)", () => {
const el = attachedEl({ opacity: "0" });
el.setAttribute("data-hf-color-grading-source-hidden", "");
expect(isElementVisibleForZOrder(el)).toBe(true);
});
it("is the default probe: the runtime's inline visibility:hidden on a time-inactive clip is skipped", () => {
// End-to-end through resolveZOrderChange with NO injected probe: the
// runtime hides inactive clips with inline `visibility:hidden` (see core
// runtime syncTimedElementVisibility) — computed style picks that up.
const parent = document.createElement("div");
const target = makeEl("target", "1");
const hidden = makeEl("hidden", "2");
hidden.style.visibility = "hidden";
const vis = makeEl("vis", "3");
parent.append(target, hidden, vis);
document.body.appendChild(parent);
const patches = resolveZOrderPatches(target, "bring-forward");
expect(patchFor(patches, { target, hidden, vis }, "target")?.zIndex).toBe(4);
expect(patchFor(patches, { target, hidden, vis }, "hidden")).toBeUndefined();
});
});
// ── resolveCrossedNeighbor (the "show your work" flash target) ────────────────
describe("resolveCrossedNeighbor", () => {
it("returns the visible sibling directly above for bring-forward", () => {
const { target, byId } = makeFamily("1", [
["hidden", "2"],
["vis", "3"],
]);
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
expect(resolveCrossedNeighbor(target, "bring-forward", opts)).toBe(byId.vis);
});
it("returns the visible sibling directly below for send-backward", () => {
const { target, byId } = makeFamily("3", [
["vis", "1"],
["hidden", "2"],
]);
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
expect(resolveCrossedNeighbor(target, "send-backward", opts)).toBe(byId.vis);
});
it("returns null for front/back actions and for no-op steps", () => {
const { target } = makeFamily("1", [["a", "2"]]);
expect(resolveCrossedNeighbor(target, "bring-to-front")).toBeNull();
expect(resolveCrossedNeighbor(target, "send-to-back")).toBeNull();
expect(resolveCrossedNeighbor(target, "send-backward")).toBeNull(); // already bottom
const { target: top } = makeFamily("5", [["a", "2"]]);
expect(resolveCrossedNeighbor(top, "bring-forward")).toBeNull(); // already top
});
it("returns null when there are no siblings", () => {
const solo = makeEl("solo", "1");
document.createElement("div").appendChild(solo);
expect(resolveCrossedNeighbor(solo, "bring-forward")).toBeNull();
});
});
// ── isZOrderActionEnabled ─────────────────────────────────────────────────────
describe("isZOrderActionEnabled", () => {
it("mirrors resolveZOrderChange non-null", () => {
// target z=2 (DOM 0), a z=5 (DOM 1): render order = target, a. target is at
// the bottom, so forward/front are enabled and backward/back are no-ops.
const { target } = makeFamily("2", [["a", "5"]]);
expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(true);
expect(isZOrderActionEnabled(target, "bring-forward")).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back")).toBe(false);
expect(isZOrderActionEnabled(target, "send-backward")).toBe(false);
});
it("false when already on top", () => {
const { target } = makeFamily("6", [
["a", "1"],
["b", "5"],
]);
expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(false);
expect(isZOrderActionEnabled(target, "bring-forward")).toBe(false);
});
it("tie repro: send-backward enabled for a visually-on-top tied target", () => {
const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
expect(isZOrderActionEnabled(target, "send-backward")).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back")).toBe(true);
});
it("all actions disabled when there are no siblings", () => {
const target = makeEl("solo", "1");
document.createElement("div").appendChild(target);
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(isZOrderActionEnabled(target, action)).toBe(false);
}
});
});
describe("resolveZOrderReposition (Layers-panel arbitrary drop)", () => {
it("multi-position jump with distinct z resolves to ONE between-z write", () => {
// Render order bottom→top today: target(1), a(3), b(5). Drop target between
// a and b → single write: z strictly between 3 and 5.
const { target, byId } = makeFamily("1", [
["a", "3"],
["b", "5"],
]);
const patches = resolveZOrderReposition(target, [byId.a, target, byId.b]);
expect(patches).toEqual([{ element: target, zIndex: 4 }]);
});
it("jump to the very top writes one z above the previous top", () => {
const { target, byId } = makeFamily("1", [
["a", "3"],
["b", "5"],
]);
const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
expect(patches).toEqual([{ element: target, zIndex: 6 }]);
});
it("no-op drop (unchanged order) returns null", () => {
const { target, byId } = makeFamily("1", [
["a", "3"],
["b", "5"],
]);
expect(resolveZOrderReposition(target, [target, byId.a, byId.b])).toBeNull();
});
it("tied z values renumber the scoped set minimally (band-safe)", () => {
const { target, byId } = makeFamily("2", [
["a", "2"],
["b", "2"],
]);
// All tied at 2; DOM order target,a,b → render bottom→top target,a,b.
// Move target to the top: scoped renumber within the band.
const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
expect(patches).not.toBeNull();
const z = new Map(patches!.map((p) => [(p.element as HTMLElement).id, p.zIndex]));
const zOf = (id: string) => z.get(id) ?? 2;
expect(zOf("a")).toBeLessThan(zOf("b"));
expect(zOf("b")).toBeLessThan(zOf("target"));
});
it("rejects elements that are not painting siblings of the target", () => {
const { target, byId } = makeFamily("1", [["a", "3"]]);
const stranger = makeEl("stranger", "2");
expect(resolveZOrderReposition(target, [stranger, target, byId.a])).toBeNull();
});
it("returns null for sets too small to reorder", () => {
const { target } = makeFamily("1", []);
expect(resolveZOrderReposition(target, [target])).toBeNull();
});
});