mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
fix(studio): flashless z-order commits and visible-overlap stepping
Two legibility fixes for the canvas z-order menu, from user feel-testing: - z-only commits no longer remount the preview iframe. The commit hook already applies the inline z (+ injected position) to the live elements and updates the store synchronously; the post-commit reloadPreview() was a redundant full remount that read as a canvas 'blink' on every action. commitDomEditPatchBatches gains skipReload, engaged only when provably safe: every op is an inline-style patch AND the server reports every patch matched — anything else falls back to the reload so the preview reconverges with disk. The file-watcher's own reload stays suppressed by the existing domEditSaveTimestampRef window, so the skip is real. - Bring Forward / Send Backward step over the next VISIBLY overlapping sibling. The nearest z-neighbor in a composition is often invisible at the current frame (runtime hides time-inactive clips with inline visibility/display; GSAP parks elements at opacity 0), so the step crossed something the user couldn't see — 'enabled but nothing happens'. The forward/backward set now filters on element-level computed visibility (display/visibility/opacity, injectable for tests); enable/disable shares the resolver so the menu is honest: actions disable when no visible neighbor exists. Front/back keep the full painting family. - The neighbor that was stepped over gets a 600ms accent flash, drawn in the studio overlay layer (never in the iframe DOM), so the action shows its work.
This commit is contained in:
@@ -7,8 +7,23 @@
|
||||
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
|
||||
*
|
||||
* "Overlapping siblings" = siblings whose bounding rects intersect the
|
||||
* target's bounding rect. Forward/backward operate within that set;
|
||||
* front/back operate across all siblings.
|
||||
* 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:
|
||||
@@ -26,6 +41,8 @@
|
||||
* (project convention clamps z ≥ 0).
|
||||
*/
|
||||
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||||
|
||||
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
||||
|
||||
/** A resolved change: set `element`'s z-index to `zIndex`. */
|
||||
@@ -34,6 +51,48 @@ export interface ZOrderPatch {
|
||||
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;
|
||||
@@ -131,26 +190,35 @@ function rectsIntersect(
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a family to the target plus siblings whose bounding rect overlaps
|
||||
* the target's rect. The target is always retained. If the target's rect is
|
||||
* unavailable or empty (headless / happy-dom returns 0×0), all entries are
|
||||
* kept — matching the prior behavior.
|
||||
* 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[]): RenderEntry[] {
|
||||
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 entries;
|
||||
return visibleEntries;
|
||||
}
|
||||
if (targetRect.width === 0 && targetRect.height === 0) return entries;
|
||||
if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
|
||||
const tr = {
|
||||
left: targetRect.left,
|
||||
top: targetRect.top,
|
||||
right: targetRect.right,
|
||||
bottom: targetRect.bottom,
|
||||
};
|
||||
return entries.filter((entry) => {
|
||||
return visibleEntries.filter((entry) => {
|
||||
if (entry.element === target) return true;
|
||||
try {
|
||||
const r = entry.element.getBoundingClientRect();
|
||||
@@ -305,6 +373,33 @@ function buildGlobalOrder(
|
||||
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.
|
||||
*
|
||||
@@ -314,20 +409,11 @@ function buildGlobalOrder(
|
||||
export function resolveZOrderChange(
|
||||
target: HTMLElement,
|
||||
action: ZOrderAction,
|
||||
options?: ZOrderResolveOptions,
|
||||
): ZOrderPatch[] | 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 scoped =
|
||||
action === "bring-to-front" || action === "send-to-back"
|
||||
? entries
|
||||
: getOverlappingFamily(target, entries);
|
||||
if (scoped.length < 2) return null;
|
||||
|
||||
const order = toRenderOrder(scoped);
|
||||
const pos = order.findIndex((e) => e.element === target);
|
||||
if (pos === -1) return 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);
|
||||
@@ -354,9 +440,35 @@ export function resolveZOrderChange(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a z-order action is available for the target.
|
||||
* "disabled" = the element is already at that limit.
|
||||
* 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 isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
|
||||
return resolveZOrderChange(target, action) !== null;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user