mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +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>
514 lines
21 KiB
TypeScript
514 lines
21 KiB
TypeScript
/**
|
||
* Pure z-order helpers for the canvas right-click context menu.
|
||
*
|
||
* Layering strategy: z-index + CSS stacking context (position ≠ static).
|
||
* All sibling z-index values are read from the live iframe DOM via
|
||
* element.style.zIndex (inline style, set by the editor) falling back to
|
||
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
|
||
*
|
||
* "Overlapping siblings" = siblings whose bounding rects intersect the
|
||
* target's bounding rect AND are actually visible at the current frame.
|
||
* Forward/backward operate within that set; front/back operate across all
|
||
* siblings (full painting family, visible or not — unchanged semantics).
|
||
*
|
||
* ── Visibility ───────────────────────────────────────────────────────────────
|
||
* In HyperFrames compositions the nearest z-neighbor is often INVISIBLE at the
|
||
* paused frame: the runtime hides time-inactive clips with inline
|
||
* `visibility:hidden` / `display:none` (see core runtime
|
||
* syncTimedElementVisibility), and GSAP timelines park elements at `opacity:0`.
|
||
* Stepping "forward" over such a sibling looks like a silent no-op. The
|
||
* forward/backward comparison set therefore keeps only siblings whose
|
||
* element-level computed style is visible (display ≠ none, visibility ≠
|
||
* hidden, opacity > 0.01) — all runtime hiding signals are inline styles, so
|
||
* computed style covers them. Ancestor-chain checks are unnecessary here:
|
||
* siblings share the target's ancestors. The probe is injectable
|
||
* (ZOrderResolveOptions.isVisible) so the pure-module tests stay meaningful
|
||
* without a real style engine, mirroring how tests stub rect reading.
|
||
*
|
||
* ── Tie-awareness ────────────────────────────────────────────────────────────
|
||
* CSS paint order for elements that share a z-index is DOM document order:
|
||
* the element that comes LATER in the DOM paints ON TOP. The old resolver
|
||
* compared z-index alone, so a target tied with the element visually below it
|
||
* (equal z, target later in DOM) had an empty "below" set and silently
|
||
* no-op'd. This module computes true render order — sort by
|
||
* (zIndex asc, DOM position asc), bottom→top — moves the target one step (or
|
||
* to an end) in that order, then realizes the new order back into z values.
|
||
*
|
||
* The result is a MULTI-element patch: a single-element patch when a
|
||
* strictly-between z value can express the new order given DOM-order
|
||
* tie-breaking, otherwise a minimal renumber of the affected set (emitting
|
||
* patches only for elements whose z actually changes). z is never negative
|
||
* (project convention clamps z ≥ 0).
|
||
*/
|
||
|
||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||
import { readLayerRevealPriorZ } from "../../player/lib/timelineElementHelpers";
|
||
|
||
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
||
|
||
/** A resolved change: set `element`'s z-index to `zIndex`. */
|
||
export interface ZOrderPatch {
|
||
element: HTMLElement;
|
||
zIndex: number;
|
||
}
|
||
|
||
/** Injectable knobs for the pure resolver (kept mockable like rect reading). */
|
||
export interface ZOrderResolveOptions {
|
||
/**
|
||
* Element-level visibility probe used to scope the forward/backward
|
||
* comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
|
||
* display/visibility/opacity). Injectable so tests can run without a real
|
||
* style engine.
|
||
*/
|
||
isVisible?: (el: HTMLElement) => boolean;
|
||
}
|
||
|
||
/**
|
||
* Default visibility probe: is this element itself visible at the current
|
||
* frame? Element-level only (siblings share the target's ancestor chain).
|
||
* Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
|
||
* `display:none`) and animation-parked `opacity:0`, all of which surface
|
||
* through computed style. A color-grading source (hidden at opacity:0 while
|
||
* its canvas paints in its place) still counts as visible, matching
|
||
* isElementVisibleThroughAncestors in domEditingDom.
|
||
*/
|
||
export function isElementVisibleForZOrder(el: HTMLElement): boolean {
|
||
try {
|
||
const win = el.ownerDocument?.defaultView;
|
||
if (!win) return true;
|
||
const computed = win.getComputedStyle(el);
|
||
if (computed.display === "none") return false;
|
||
if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
|
||
const opacity = Number.parseFloat(computed.opacity);
|
||
if (
|
||
Number.isFinite(opacity) &&
|
||
opacity <= 0.01 &&
|
||
!el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
|
||
) {
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch {
|
||
/* cross-origin / detached — assume visible (fail open, matches rect fallback) */
|
||
return true;
|
||
}
|
||
}
|
||
|
||
interface RenderEntry {
|
||
element: HTMLElement;
|
||
zIndex: number;
|
||
/** Position within the shared parent's children (DOM document order). */
|
||
domIndex: number;
|
||
}
|
||
|
||
/** Parse a z-index string to a number; treats "auto" / empty as 0. */
|
||
export function parseZIndex(value: string | null | undefined): number {
|
||
if (!value || value === "auto") return 0;
|
||
const n = parseInt(value, 10);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
|
||
/** Read the effective z-index for an element (inline style preferred).
|
||
* Reveal-lift transparent: an active Layers-panel lift reports the TRUE z. */
|
||
export function readEffectiveZIndex(el: HTMLElement): number {
|
||
const prior = readLayerRevealPriorZ(el);
|
||
if (prior != null) return prior;
|
||
const inline = el.style.zIndex;
|
||
if (inline && inline !== "auto") return parseZIndex(inline);
|
||
try {
|
||
const win = el.ownerDocument?.defaultView;
|
||
if (win) return parseZIndex(win.getComputedStyle(el).zIndex);
|
||
} catch {
|
||
/* cross-origin / detached */
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* Realm-safe HTMLElement check. The target lives in the preview IFRAME's
|
||
* document, but this module runs in the top window, so `child instanceof
|
||
* HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
|
||
* which silently emptied the sibling list and left every z-order action
|
||
* permanently disabled. Compare against the element's own realm instead, with
|
||
* a nodeType fallback for detached / cross-realm edge cases.
|
||
*/
|
||
function isElementNode(node: Node): node is HTMLElement {
|
||
const view = node.ownerDocument?.defaultView;
|
||
if (view && node instanceof view.HTMLElement) return true;
|
||
return node.nodeType === 1;
|
||
}
|
||
|
||
/**
|
||
* Tags that never paint pixels and so must be excluded from z-order siblings.
|
||
* `<audio>` is the real offender here: a prior renumber wrote a meaningless
|
||
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
|
||
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
|
||
* non-painting and could otherwise pad the family / eat a z slot.
|
||
* `<template>/<noscript>` never paint either — letting them in meant renumber
|
||
* fallbacks wrote z-index/position into template source markup.
|
||
*/
|
||
const NON_PAINTING_TAGS = new Set([
|
||
"AUDIO",
|
||
"SCRIPT",
|
||
"STYLE",
|
||
"LINK",
|
||
"META",
|
||
"TEMPLATE",
|
||
"NOSCRIPT",
|
||
]);
|
||
|
||
/** A painting element: an element node whose tag actually renders pixels. */
|
||
function isPaintingElement(node: Node): node is HTMLElement {
|
||
return isElementNode(node) && !NON_PAINTING_TAGS.has(node.tagName);
|
||
}
|
||
|
||
/**
|
||
* Collect the target plus every PAINTING HTMLElement sibling (same parent),
|
||
* tagged with DOM document position. Non-painting siblings (audio/script/style/
|
||
* link/meta) are skipped so they neither pad the family nor consume a z slot in
|
||
* the renumber path. Returns the target's own index within the result.
|
||
*/
|
||
function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex: number } {
|
||
const parent = target.parentElement;
|
||
if (!parent) return { entries: [], targetIndex: -1 };
|
||
const entries: RenderEntry[] = [];
|
||
let targetIndex = -1;
|
||
let domIndex = 0;
|
||
for (const child of Array.from(parent.children)) {
|
||
// The target is always retained even if its own tag is non-painting.
|
||
if (child !== target && !isPaintingElement(child)) continue;
|
||
if (!isElementNode(child)) continue;
|
||
if (child === target) targetIndex = entries.length;
|
||
entries.push({ element: child, zIndex: readEffectiveZIndex(child), domIndex });
|
||
domIndex += 1;
|
||
}
|
||
return { entries, targetIndex };
|
||
}
|
||
|
||
/** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
|
||
function rectsIntersect(
|
||
a: { left: number; top: number; right: number; bottom: number },
|
||
b: { left: number; top: number; right: number; bottom: number },
|
||
): boolean {
|
||
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||
}
|
||
|
||
/**
|
||
* Restrict a family to the target plus siblings that are VISIBLE and whose
|
||
* bounding rect overlaps the target's rect. The target is always retained
|
||
* (even when itself hidden at the current frame — it is the user's explicit
|
||
* selection). If the target's rect is unavailable or empty (headless /
|
||
* happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
|
||
* entries are kept — matching the prior rect-fallback behavior.
|
||
*/
|
||
function getOverlappingFamily(
|
||
target: HTMLElement,
|
||
entries: RenderEntry[],
|
||
isVisible: (el: HTMLElement) => boolean,
|
||
): RenderEntry[] {
|
||
const visibleEntries = entries.filter(
|
||
(entry) => entry.element === target || isVisible(entry.element),
|
||
);
|
||
let targetRect: DOMRect;
|
||
try {
|
||
targetRect = target.getBoundingClientRect();
|
||
} catch {
|
||
return visibleEntries;
|
||
}
|
||
if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
|
||
const tr = {
|
||
left: targetRect.left,
|
||
top: targetRect.top,
|
||
right: targetRect.right,
|
||
bottom: targetRect.bottom,
|
||
};
|
||
return visibleEntries.filter((entry) => {
|
||
if (entry.element === target) return true;
|
||
try {
|
||
const r = entry.element.getBoundingClientRect();
|
||
return rectsIntersect(tr, { left: r.left, top: r.top, right: r.right, bottom: r.bottom });
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Sort a family into render order (bottom→top): z asc, then DOM position asc. */
|
||
function toRenderOrder(entries: RenderEntry[]): RenderEntry[] {
|
||
return [...entries].sort((a, b) => a.zIndex - b.zIndex || a.domIndex - b.domIndex);
|
||
}
|
||
|
||
/**
|
||
* A z that lands the target strictly between `below` and `above` in render order,
|
||
* or null when no such value exists (a tie-prone gap, or no room below the floor)
|
||
* and the caller must renumber. Equal-z ties break by DOM order, so a plain
|
||
* equality can flip order unpredictably; require a strict gap and clamp at 0.
|
||
*/
|
||
function computeBetweenZ(
|
||
below: RenderEntry | undefined,
|
||
above: RenderEntry | undefined,
|
||
): number | null {
|
||
if (below && above) {
|
||
return above.zIndex - below.zIndex >= 2 ? below.zIndex + 1 : null;
|
||
}
|
||
if (below) return below.zIndex + 1; // move to top
|
||
if (above) {
|
||
const candidate = Math.max(0, above.zIndex - 1); // move to bottom
|
||
return candidate >= above.zIndex ? null : candidate; // no room below → renumber
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Realize a desired render order (bottom→top) into z-index patches for the
|
||
* given family, emitting patches ONLY for elements whose z actually changes.
|
||
*
|
||
* Fast path: if the SCOPED z values are all distinct, the render order is fully
|
||
* determined by z alone — a single-element move can be expressed by placing the
|
||
* target's z strictly between its new neighbours (or at an end), so at most one
|
||
* element changes (and it never disturbs an untouched pair, since only the target
|
||
* moves). When ties exist a between value can be impossible, so renumber — but the
|
||
* scoped set is only a SUBSET of the family (the target's overlapping siblings),
|
||
* so a naive 0..n-1 renumber can drop a scoped sibling below an untouched
|
||
* non-scoped one, reordering an untouched pair (#2202). `renumberScoped` keeps the
|
||
* scoped block inside its original z-band, bounded by the non-scoped siblings.
|
||
*/
|
||
function realizeOrder(
|
||
currentOrder: RenderEntry[],
|
||
desiredOrder: RenderEntry[],
|
||
target: HTMLElement,
|
||
family: RenderEntry[],
|
||
): ZOrderPatch[] | null {
|
||
const targetPos = desiredOrder.findIndex((e) => e.element === target);
|
||
if (targetPos === -1) return null;
|
||
|
||
const targetZ = readEffectiveZIndex(target);
|
||
|
||
// ── Fast path: distinct z values → a single between-value move suffices.
|
||
const zValues = currentOrder.map((e) => e.zIndex);
|
||
const hasDupes = zValues.some((v, i) => zValues.indexOf(v) !== i);
|
||
if (!hasDupes) {
|
||
const candidate = computeBetweenZ(desiredOrder[targetPos - 1], desiredOrder[targetPos + 1]);
|
||
if (candidate !== null) {
|
||
if (candidate === targetZ) return null;
|
||
return [{ element: target, zIndex: candidate }];
|
||
}
|
||
// else fall through to renumber
|
||
}
|
||
|
||
return renumberScoped(currentOrder, desiredOrder, target, family);
|
||
}
|
||
|
||
/**
|
||
* Renumber the SCOPED set (the reordered subset) to distinct z, keeping the whole
|
||
* block within the band its members already occupied so no untouched scoped /
|
||
* non-scoped pair is reordered (#2202). The block is placed near its original base
|
||
* `lo`, but clamped to sit strictly above the highest non-scoped sibling below the
|
||
* band and strictly below the lowest non-scoped sibling above it. Only scoped
|
||
* members are patched; non-scoped siblings keep their authored z.
|
||
*
|
||
* If a non-scoped sibling sits INSIDE or tied to the band (no clean bracket), or
|
||
* the bracket is too narrow to hold `n` distinct integers, fall back to a
|
||
* whole-family renumber — less minimal but still preserves every relative order.
|
||
*/
|
||
function renumberScoped(
|
||
currentOrder: RenderEntry[],
|
||
desiredOrder: RenderEntry[],
|
||
target: HTMLElement,
|
||
family: RenderEntry[],
|
||
): ZOrderPatch[] | null {
|
||
const scoped = new Set(desiredOrder.map((e) => e.element));
|
||
const nonScoped = family.filter((e) => !scoped.has(e.element));
|
||
const n = desiredOrder.length;
|
||
const zs = currentOrder.map((e) => e.zIndex);
|
||
const lo = Math.min(...zs);
|
||
const hi = Math.max(...zs);
|
||
|
||
const bracketed = !nonScoped.some((e) => e.zIndex >= lo && e.zIndex <= hi);
|
||
if (bracketed) {
|
||
const below = nonScoped.filter((e) => e.zIndex < lo).map((e) => e.zIndex);
|
||
const above = nonScoped.filter((e) => e.zIndex > hi).map((e) => e.zIndex);
|
||
const minStart = below.length > 0 ? Math.max(...below) + 1 : 0; // z ≥ 0 convention
|
||
const hasUpper = above.length > 0;
|
||
const maxStart = hasUpper ? Math.min(...above) - n : Number.POSITIVE_INFINITY;
|
||
if (minStart <= maxStart) {
|
||
let start = Math.max(lo, minStart);
|
||
if (hasUpper) start = Math.min(start, maxStart);
|
||
const patches: ZOrderPatch[] = [];
|
||
desiredOrder.forEach((entry, i) => {
|
||
if (entry.zIndex !== start + i) patches.push({ element: entry.element, zIndex: start + i });
|
||
});
|
||
return patches.length === 0 ? null : patches;
|
||
}
|
||
}
|
||
|
||
// ── Fallback: renumber the whole family so relative order is still preserved.
|
||
const desiredGlobal = buildGlobalOrder(family, desiredOrder, target);
|
||
const patches: ZOrderPatch[] = [];
|
||
desiredGlobal.forEach((entry, i) => {
|
||
if (entry.zIndex !== i) patches.push({ element: entry.element, zIndex: i });
|
||
});
|
||
return patches.length === 0 ? null : patches;
|
||
}
|
||
|
||
/**
|
||
* A whole-family render order (bottom→top) with the non-scoped siblings kept in
|
||
* their current relative order and the target reinserted beside its new SCOPED
|
||
* neighbour (just above the scoped element below it, else just below the scoped
|
||
* element above it). Used only by the renumber fallback.
|
||
*/
|
||
function buildGlobalOrder(
|
||
family: RenderEntry[],
|
||
desiredOrder: RenderEntry[],
|
||
target: HTMLElement,
|
||
): RenderEntry[] {
|
||
const full = toRenderOrder(family);
|
||
const targetEntry = full.find((e) => e.element === target);
|
||
const rest = full.filter((e) => e.element !== target);
|
||
if (!targetEntry) return rest;
|
||
const targetPos = desiredOrder.findIndex((e) => e.element === target);
|
||
const prev = desiredOrder[targetPos - 1];
|
||
const next = desiredOrder[targetPos + 1];
|
||
const prevIdx = prev ? rest.findIndex((e) => e.element === prev.element) : -1;
|
||
const nextIdx = next ? rest.findIndex((e) => e.element === next.element) : -1;
|
||
if (prevIdx >= 0) rest.splice(prevIdx + 1, 0, targetEntry);
|
||
else if (nextIdx >= 0) rest.splice(nextIdx, 0, targetEntry);
|
||
else rest.unshift(targetEntry);
|
||
return rest;
|
||
}
|
||
|
||
/**
|
||
* The shared scoping pipeline: full painting family for front/back, visible
|
||
* overlapping siblings for forward/backward, sorted into render order with the
|
||
* target's position. Null when the family/scope is too small to act on.
|
||
*/
|
||
function resolveScopedRenderOrder(
|
||
target: HTMLElement,
|
||
action: ZOrderAction,
|
||
options?: ZOrderResolveOptions,
|
||
): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
|
||
const { entries } = getFamily(target);
|
||
// Family always includes the target; fewer than 2 means no siblings at all.
|
||
if (entries.length < 2) return null;
|
||
|
||
const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
|
||
const scoped =
|
||
action === "bring-to-front" || action === "send-to-back"
|
||
? entries
|
||
: getOverlappingFamily(target, entries, isVisible);
|
||
if (scoped.length < 2) return null;
|
||
|
||
const order = toRenderOrder(scoped);
|
||
const pos = order.findIndex((e) => e.element === target);
|
||
if (pos === -1) return null;
|
||
return { entries, order, pos };
|
||
}
|
||
|
||
/**
|
||
* Resolve the z-order patches for an action.
|
||
*
|
||
* Returns null when the action is a no-op (target already at the relevant
|
||
* end of its set), otherwise the minimal list of {element, zIndex} changes.
|
||
*/
|
||
export function resolveZOrderChange(
|
||
target: HTMLElement,
|
||
action: ZOrderAction,
|
||
options?: ZOrderResolveOptions,
|
||
): ZOrderPatch[] | null {
|
||
const resolved = resolveScopedRenderOrder(target, action, options);
|
||
if (!resolved) return null;
|
||
const { entries, order, pos } = resolved;
|
||
|
||
const desired = [...order];
|
||
const [moved] = desired.splice(pos, 1);
|
||
switch (action) {
|
||
case "bring-forward":
|
||
if (pos >= order.length - 1) return null; // already top of set
|
||
desired.splice(pos + 1, 0, moved);
|
||
break;
|
||
case "send-backward":
|
||
if (pos <= 0) return null; // already bottom of set
|
||
desired.splice(pos - 1, 0, moved);
|
||
break;
|
||
case "bring-to-front":
|
||
if (pos >= order.length - 1) return null;
|
||
desired.push(moved);
|
||
break;
|
||
case "send-to-back":
|
||
if (pos <= 0) return null;
|
||
desired.unshift(moved);
|
||
break;
|
||
}
|
||
|
||
return realizeOrder(order, desired, target, entries);
|
||
}
|
||
|
||
/**
|
||
* Realize an ARBITRARY repositioning of `target` within a scoped sibling set —
|
||
* the Layers-panel drag, which can jump several siblings in one drop, unlike
|
||
* the menu's four fixed actions. `desiredOrderBottomToTop` is the scoped set
|
||
* (target included at its new slot) in the intended render order. Reuses the
|
||
* menu's minimal-write realization (realizeOrder): one between-z write when a
|
||
* strict gap exists, band-safe scoped renumber otherwise — replacing the old
|
||
* LayersPanel computeReorderZValues path that stamped EVERY sibling.
|
||
*
|
||
* Null when nothing changes, the set is too small, or an element in the
|
||
* desired order is not actually a painting sibling of `target`.
|
||
*/
|
||
export function resolveZOrderReposition(
|
||
target: HTMLElement,
|
||
desiredOrderBottomToTop: readonly HTMLElement[],
|
||
): ZOrderPatch[] | null {
|
||
const { entries } = getFamily(target);
|
||
if (entries.length < 2) return null;
|
||
const byElement = new Map(entries.map((entry) => [entry.element, entry]));
|
||
const desired: RenderEntry[] = [];
|
||
for (const el of desiredOrderBottomToTop) {
|
||
const entry = byElement.get(el);
|
||
if (!entry) return null;
|
||
desired.push(entry);
|
||
}
|
||
if (desired.length < 2 || !desired.some((entry) => entry.element === target)) return null;
|
||
const currentOrder = toRenderOrder(desired);
|
||
// A drop back into the same slot is a no-op. The menu actions guard this via
|
||
// their position checks before realizeOrder; an arbitrary reposition must
|
||
// compare the orders itself — realizeOrder would otherwise "normalize" an
|
||
// end-of-set target to a fresh z value it doesn't need.
|
||
if (currentOrder.every((entry, i) => entry.element === desired[i].element)) return null;
|
||
return realizeOrder(currentOrder, desired, target, entries);
|
||
}
|
||
|
||
/**
|
||
* The sibling a forward/backward step crosses: the visible overlapping
|
||
* neighbor directly above (bring-forward) or below (send-backward) the target
|
||
* in render order. Null for front/back, for a no-op step, or when the scope is
|
||
* too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
|
||
* same options BEFORE any live styles are applied.
|
||
*/
|
||
export function resolveCrossedNeighbor(
|
||
target: HTMLElement,
|
||
action: ZOrderAction,
|
||
options?: ZOrderResolveOptions,
|
||
): HTMLElement | null {
|
||
if (action !== "bring-forward" && action !== "send-backward") return null;
|
||
const resolved = resolveScopedRenderOrder(target, action, options);
|
||
if (!resolved) return null;
|
||
const { order, pos } = resolved;
|
||
const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
|
||
return neighbor?.element ?? null;
|
||
}
|
||
|
||
/**
|
||
* Whether a z-order action is available for the target.
|
||
* "disabled" = the element is already at that limit. Shares the resolver (and
|
||
* its visibility scoping), so enable/disable always matches what the action
|
||
* would actually do.
|
||
*/
|
||
export function isZOrderActionEnabled(
|
||
target: HTMLElement,
|
||
action: ZOrderAction,
|
||
options?: ZOrderResolveOptions,
|
||
): boolean {
|
||
return resolveZOrderChange(target, action, options) !== null;
|
||
}
|