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:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 84963ea8ba
commit 760b88a6f3
12 changed files with 654 additions and 94 deletions
@@ -29,6 +29,7 @@ afterEach(() => {
function renderMenu(props: {
selection: DomEditSelection;
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
onDelete?: (selection: DomEditSelection) => void;
}) {
root = createRoot(host);
@@ -40,6 +41,7 @@ function renderMenu(props: {
selection: props.selection,
onClose: () => {},
onApplyZIndex: props.onApplyZIndex,
onZOrderCrossed: props.onZOrderCrossed,
onDelete: props.onDelete,
}),
);
@@ -223,4 +225,42 @@ describe("CanvasContextMenu — z-action commit path", () => {
cleanup();
});
it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
// target (earlier in DOM) and other are tied — bring-forward steps over
// `other`, and the flash callback must receive exactly that element, after
// onApplyZIndex ran (call order lets the host measure post-commit rects).
const { target, other } = makeStaticFamily();
const selection = makeSelection("Target", target);
const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];
renderMenu({
selection,
onApplyZIndex: () => calls.push({ kind: "apply" }),
onZOrderCrossed: (crossed, action) => {
expect(action).toBe("bring-forward");
calls.push({ kind: "crossed", crossed });
},
});
await act(async () => pressMenuItem("Bring forward"));
expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
expect(calls[1]?.crossed).toBe(other);
});
it("does not call onZOrderCrossed for bring-to-front", async () => {
const { target } = makeStaticFamily();
const onZOrderCrossed = vi.fn();
renderMenu({
selection: makeSelection("Target", target),
onApplyZIndex: vi.fn(),
onZOrderCrossed,
});
await act(async () => pressMenuItem("Bring to front"));
expect(onZOrderCrossed).not.toHaveBeenCalled();
});
});
@@ -29,6 +29,7 @@ import type { DomEditSelection } from "./domEditing";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import {
isZOrderActionEnabled,
resolveCrossedNeighbor,
resolveZOrderChange,
type ZOrderAction,
type ZOrderPatch,
@@ -51,6 +52,13 @@ interface CanvasContextMenuProps {
* (see module-level wiring comment).
*/
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
/**
* Called after a successful bring-forward / send-backward with the sibling
* the target stepped over (resolved from the SAME pre-mutation state as the
* patches), so the host can flash a highlight on it in the studio overlay.
* Never called for front/back or no-op actions.
*/
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
/**
* Delete the selected element. Wire to handleDomEditElementDelete from
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
@@ -75,6 +83,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
selection,
onClose,
onApplyZIndex,
onZOrderCrossed,
onDelete,
}: CanvasContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
@@ -103,12 +112,16 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
if (!onApplyZIndex) return;
const patches = resolveZOrderChange(el, action);
if (patches === null) return;
// Resolve the crossed neighbor BEFORE the commit path mutates live styles —
// both resolvers must read the same pre-change render order.
const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null;
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
// live z-index (and injects position:relative for static elements) in the
// same synchronous flow, so feedback is still instant — and it must read
// the PRE-change styles itself, both to capture true rollback values and
// to detect a static position that needs persisting.
onApplyZIndex(patches, action);
if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
onClose();
}
@@ -1,9 +1,11 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
import { useMarqueeGestures } from "./marqueeCommit";
import { MarqueeOverlay } from "./MarqueeOverlay";
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFlash";
import { useCanvasContextMenuState } from "./useCanvasContextMenuState";
import {
type BlockedMoveState,
type DomEditGroupPathOffsetCommit,
@@ -144,30 +146,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const snapGuidesRef = useRef<SnapGuidesState | null>(null);
const rafPausedRef = useRef(false);
// Context menu state: position of the right-click that opened it.
// contextMenuSelection is the element the menu targets — captured at right-click
// time so the menu can open even before the React selection state settles.
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
sel: DomEditSelection;
} | null>(null);
const selectionRef = useRef(selection);
selectionRef.current = selection;
// Close the context menu whenever the selection moves off the element the menu
// targets (a click that reselects elsewhere, a deselect, or a preview reload
// that rebuilds the selection). Without this the menu can linger — orphaned —
// over a stale target after the underlying element is gone. A right-click that
// OPENS the menu also selects its target, so the common open path keeps the
// menu (same element) rather than immediately dismissing it.
useEffect(() => {
if (!contextMenu) return;
if (!selection || selection.element !== contextMenu.sel.element) {
setContextMenu(null);
}
}, [selection, contextMenu]);
// Brief highlight on the sibling a forward/backward z step crossed — drawn
// in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef });
const activeCompositionPathRef = useRef(activeCompositionPath);
activeCompositionPathRef.current = activeCompositionPath;
@@ -433,37 +417,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
e.stopPropagation();
};
// Right-click: select element first (if not already selected), then open menu.
const handleContextMenu = useCallback(
async (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
// If no element is selected yet, resolve it from the pointer position first.
const currentSel = selectionRef.current;
let activeSel: DomEditSelection | null = currentSel;
if (!currentSel) {
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
if (!resolved) return; // Nothing under the cursor — skip menu.
onSelectionChangeRef.current(resolved, { revealPanel: true });
// Use `resolved` directly: React state (and therefore selectionRef) won't
// update synchronously after onSelectionChange — we'd be reading stale null.
activeSel = resolved;
} else {
// Check if the user right-clicked on an unselected element (hover target).
const hover = hoverSelectionRef.current;
if (hover && hover.element !== currentSel.element) {
onSelectionChangeRef.current(hover, { revealPanel: true });
activeSel = hover;
}
}
if (!activeSel) return;
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// Right-click state + handler: select the element under the pointer (if
// needed), then open the menu; closes when the selection moves off-target.
const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
selection,
selectionRef,
hoverSelectionRef,
onCanvasPointerMoveRef,
onSelectionChangeRef,
});
return (
<div
@@ -556,11 +518,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
x={contextMenu.x}
y={contextMenu.y}
selection={contextMenu.sel}
onClose={() => setContextMenu(null)}
onClose={closeContextMenu}
onDelete={
onDeleteSelection
? (sel) => {
setContextMenu(null);
closeContextMenu();
onDeleteSelection(sel);
}
: undefined
@@ -572,8 +534,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}
: undefined
}
onZOrderCrossed={handleZOrderCrossed}
/>
)}
<ZOrderCrossedFlash rect={zOrderFlashRect} />
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
isElementVisibleForZOrder,
isZOrderActionEnabled,
parseZIndex,
resolveCrossedNeighbor,
resolveZOrderChange,
type ZOrderAction,
type ZOrderPatch,
@@ -48,8 +50,12 @@ function makeFamily(
}
/** Resolve a z-order change and assert it produced patches (fails otherwise). */
function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
const patches = resolveZOrderChange(target, action);
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;
@@ -413,6 +419,163 @@ describe("resolveZOrderChange excludes non-painting siblings", () => {
});
});
// ── 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", () => {
@@ -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;
}
@@ -0,0 +1,92 @@
/**
* Canvas right-click context-menu state for DomEditOverlay: where the menu is
* open (viewport x/y) and which selection it targets, plus the right-click
* handler that resolves/selects the element under the pointer before opening.
*/
import { useCallback, useEffect, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
export interface CanvasContextMenuState {
x: number;
y: number;
sel: DomEditSelection;
}
interface UseCanvasContextMenuStateParams {
selection: DomEditSelection | null;
selectionRef: RefObject<DomEditSelection | null>;
hoverSelectionRef: RefObject<DomEditSelection | null>;
onCanvasPointerMoveRef: RefObject<
(
event: React.PointerEvent<HTMLDivElement>,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>
>;
onSelectionChangeRef: RefObject<
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
>;
}
export function useCanvasContextMenuState({
selection,
selectionRef,
hoverSelectionRef,
onCanvasPointerMoveRef,
onSelectionChangeRef,
}: UseCanvasContextMenuStateParams): {
contextMenu: CanvasContextMenuState | null;
closeContextMenu: () => void;
handleContextMenu: (event: React.MouseEvent<HTMLDivElement>) => Promise<void>;
} {
// Context menu state: position of the right-click that opened it.
// contextMenu.sel is the element the menu targets — captured at right-click
// time so the menu can open even before the React selection state settles.
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(null);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
// Close the context menu whenever the selection moves off the element the menu
// targets (a click that reselects elsewhere, a deselect, or a preview reload
// that rebuilds the selection). Without this the menu can linger — orphaned —
// over a stale target after the underlying element is gone. A right-click that
// OPENS the menu also selects its target, so the common open path keeps the
// menu (same element) rather than immediately dismissing it.
useEffect(() => {
if (!contextMenu) return;
if (!selection || selection.element !== contextMenu.sel.element) {
setContextMenu(null);
}
}, [selection, contextMenu]);
// Right-click: select element first (if not already selected), then open menu.
const handleContextMenu = useCallback(
async (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
// If no element is selected yet, resolve it from the pointer position first.
const currentSel = selectionRef.current;
let activeSel: DomEditSelection | null = currentSel;
if (!currentSel) {
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
if (!resolved) return; // Nothing under the cursor — skip menu.
onSelectionChangeRef.current(resolved, { revealPanel: true });
// Use `resolved` directly: React state (and therefore selectionRef) won't
// update synchronously after onSelectionChange — we'd be reading stale null.
activeSel = resolved;
} else {
// Check if the user right-clicked on an unselected element (hover target).
const hover = hoverSelectionRef.current;
if (hover && hover.element !== currentSel.element) {
onSelectionChangeRef.current(hover, { revealPanel: true });
activeSel = hover;
}
}
if (!activeSel) return;
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
},
[selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef],
);
return { contextMenu, closeContextMenu, handleContextMenu };
}
@@ -0,0 +1,68 @@
/**
* Z-order "show your work" flash: after a bring-forward / send-backward, the
* sibling that was stepped over gets a brief (600ms) highlight so the action
* is legible even when the visual change is subtle.
*
* Drawn in the STUDIO's own overlay layer above the preview iframe nothing
* is written into the iframe DOM or the composition, so a concurrent preview
* reload can never leave a stuck highlight; the timeout merely clears
* studio-local state. The crossed element is resolved by the context menu
* (resolveCrossedNeighbor) from the same pre-mutation render order as the
* z patches; z-index writes don't move layout, so measuring its rect after
* the commit applied live styles is still accurate.
*/
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { toVisibleOverlayRect, type OverlayRect } from "./domEditOverlayGeometry";
const Z_ORDER_CROSSED_FLASH_MS = 600;
interface UseZOrderCrossedFlashParams {
overlayRef: RefObject<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
}
export function useZOrderCrossedFlash({ overlayRef, iframeRef }: UseZOrderCrossedFlashParams): {
zOrderFlashRect: OverlayRect | null;
handleZOrderCrossed: (crossed: HTMLElement) => void;
} {
const [zOrderFlashRect, setZOrderFlashRect] = useState<OverlayRect | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);
const handleZOrderCrossed = useCallback(
(crossed: HTMLElement) => {
const overlayEl = overlayRef.current;
const iframe = iframeRef.current;
if (!overlayEl || !iframe) return;
const rect = toVisibleOverlayRect(overlayEl, iframe, crossed);
if (!rect || rect.width <= 0 || rect.height <= 0) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setZOrderFlashRect(rect);
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
setZOrderFlashRect(null);
}, Z_ORDER_CROSSED_FLASH_MS);
},
[overlayRef, iframeRef],
);
return { zOrderFlashRect, handleZOrderCrossed };
}
/** The flash chrome itself — a pulsing accent outline over the crossed sibling. */
export function ZOrderCrossedFlash({ rect }: { rect: OverlayRect | null }) {
if (!rect) return null;
return (
<div
aria-hidden="true"
data-dom-edit-z-flash="true"
className="pointer-events-none absolute rounded-md border-2 border-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.35)] animate-pulse"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
/>
);
}