/** * 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. * `