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 }}
/>
);
}
@@ -8,7 +8,18 @@ export interface DomEditPatchBatch {
export type CommitDomEditPatchBatches = (
batches: DomEditPatchBatch[],
options: { label: string; coalesceKey: string },
options: {
label: string;
coalesceKey: string;
/**
* Request skipping the preview iframe reload after a successful persist.
* Only honored when the persist is provably in sync with the live DOM:
* every patch operation is inline-style-only AND the server matched every
* patch target. Any unmatched target (or a non-style op) falls back to the
* reload so the preview reconverges with disk. Default: always reload.
*/
skipReload?: boolean;
},
) => Promise<void>;
export type PersistDomEditOperations = (
@@ -265,7 +265,7 @@ describe("useDomEditCommits z-index reorder persistence", () => {
document.body.replaceChildren();
});
it("persists an N-element reorder with one batch POST, one undo entry, and one reload", async () => {
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
const original =
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
const after =
@@ -338,6 +338,41 @@ describe("useDomEditCommits z-index reorder persistence", () => {
coalesceKey: "z-reorder:test",
files: { "index.html": { before: original, after } },
});
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
// The live DOM + store already hold the final state and the server matched
// every style-only patch, so the reload is provably redundant.
expect(rendered.reloadPreview).not.toHaveBeenCalled();
} finally {
rendered.cleanup();
}
});
it("falls back to reloading when the server response omits matched[]", async () => {
// Without a matched[] confirmation the persist can't be proven in sync with
// the live DOM — the skip-reload path must not engage.
const original = '<div id="a" style="z-index: 1"></div>';
const after = '<div id="a" style="z-index: 2"></div>';
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
if (url.includes("/file-mutations/patch-elements-batch/")) {
return jsonResponse({ ok: true, changed: true, content: after });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { iframe, element } = createPreviewElement();
element.id = "a";
const rendered = renderDomEditCommits(createSelection(element), iframe);
try {
await act(async () => {
await rendered.hook.handleDomZIndexReorderCommit([
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
]);
});
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
} finally {
rendered.cleanup();
@@ -347,7 +382,9 @@ describe("useDomEditCommits z-index reorder persistence", () => {
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
// The server reports per-patch matched[]: #b was not found in the source.
// The matched subset persisted, so the commit must complete (no rollback of
// applied state) while surfacing the partial failure.
// applied state) while surfacing the partial failure. An unmatched target
// also means the live DOM shows z-order the disk lacks, so the skip-reload
// path must NOT engage — the reload reconverges the preview with disk.
const original = '<div id="a" style="z-index: 1"></div>';
const after = '<div id="a" style="z-index: 2"></div>';
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
+33 -1
View File
@@ -112,11 +112,32 @@ async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
return {
sourceFile: batch.sourceFile,
changed: result.changed === true,
// Skip-reload safety: the persist is only provably in sync with the live
// DOM when the server confirmed EVERY patch target matched. A missing /
// short matched[] is treated as unknown (false) so the caller falls back
// to reloading rather than silently diverging from disk.
allMatched:
Array.isArray(result.matched) &&
result.matched.length === batch.patches.length &&
result.matched.every(Boolean),
before,
after: typeof result.content === "string" ? result.content : before,
};
}
/**
* A batch is reload-skippable only when it is style-only: every operation is an
* `inline-style` write. The z-reorder commit applies those exact styles to the
* live iframe DOM synchronously, so persisting them adds nothing the preview
* doesn't already show. Any other op type (attribute / text-content / …) can
* have server-side semantics the live DOM hasn't mirrored — reload for those.
*/
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
return batches.every((batch) =>
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
);
}
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -390,7 +411,18 @@ export function useDomEditCommits({
files,
});
forceReloadSdkSession?.();
reloadPreview();
// A z-only reorder already applied its inline styles to the live iframe
// DOM (and the store) synchronously, so remounting the iframe here only
// produces a visible blink. Skip the reload when the caller asked for it
// AND the persist is provably in sync: style-only ops, every target
// matched. Any unmatched patch means the live DOM now shows state disk
// doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
// reload is independently suppressed by domEditSaveTimestampRef above.)
const skipSafe =
options.skipReload === true &&
batchesAreInlineStyleOnly(batches) &&
results.every((result) => result.allMatched);
if (!skipSafe) reloadPreview();
}).catch((error) => {
const alreadyToasted =
(error instanceof StudioSaveHttpError ||
@@ -18,6 +18,7 @@ afterEach(() => {
interface BatchOptions {
label: string;
coalesceKey: string;
skipReload?: boolean;
}
interface CapturedBatchCall {
@@ -110,6 +111,25 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
act(() => root.unmount());
});
it("requests skipReload on every z-reorder persist (live DOM already final)", async () => {
// The commit applies the z-index (and any injected position) to the live
// iframe DOM and the store synchronously, so the persisted style-only patch
// adds nothing the preview doesn't already show — the batch commit is asked
// to skip the iframe remount. commitDomEditPatchBatches still falls back to
// reloading when the server can't confirm every patch target matched.
const el = document.createElement("div");
el.id = "clip-z";
const { captured, root } = await runReorderCommit(el, [
{ element: el, zIndex: 4, id: "clip-z", sourceFile: "index.html" },
]);
expect(captured).toHaveLength(1);
expect(captured[0]?.options.skipReload).toBe(true);
act(() => root.unmount());
});
it("preserves a real id when the element has one", async () => {
const el = document.createElement("video");
el.id = "v-hero";
@@ -217,12 +217,20 @@ export function useElementLifecycleOps({
}));
// Resolves once every source-file batch is persisted so a same-file timing write
// can be ordered after it (see applyTimelineStackingReorder callers).
return commitDomEditPatchBatches(batches, { label: "Reorder layers", coalesceKey }).catch(
(error) => {
for (const rollback of rollbacks) rollback();
throw error;
},
);
//
// skipReload: the live iframe DOM and the player store already hold the
// final z state (applied synchronously above), and the persisted patch is
// inline-style-only — a full iframe remount would only blink the preview.
// commitDomEditPatchBatches still falls back to reloading whenever the
// server reports an unmatched patch target (live DOM ≠ disk).
return commitDomEditPatchBatches(batches, {
label: "Reorder layers",
coalesceKey,
skipReload: true,
}).catch((error) => {
for (const rollback of rollbacks) rollback();
throw error;
});
},
[commitDomEditPatchBatches, onReorderShadow],
);