mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +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:
@@ -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> => {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user