From 760b88a6f3d75ab492ce7ab4718abfe889c5c1eb Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Mon, 13 Jul 2026 14:11:34 -0700 Subject: [PATCH] fix(studio): flashless z-order commits and visible-overlap stepping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../editor/CanvasContextMenu.test.tsx | 40 +++++ .../components/editor/CanvasContextMenu.tsx | 13 ++ .../src/components/editor/DomEditOverlay.tsx | 74 ++------ .../editor/canvasContextMenuZOrder.test.ts | 167 +++++++++++++++++- .../editor/canvasContextMenuZOrder.ts | 166 ++++++++++++++--- .../editor/useCanvasContextMenuState.ts | 92 ++++++++++ .../editor/useZOrderCrossedFlash.tsx | 68 +++++++ .../studio/src/hooks/domEditCommitTypes.ts | 13 +- .../src/hooks/useDomEditCommits.test.tsx | 41 ++++- .../studio/src/hooks/useDomEditCommits.ts | 34 +++- .../src/hooks/useElementLifecycleOps.test.tsx | 20 +++ .../src/hooks/useElementLifecycleOps.ts | 20 ++- 12 files changed, 654 insertions(+), 94 deletions(-) create mode 100644 packages/studio/src/components/editor/useCanvasContextMenuState.ts create mode 100644 packages/studio/src/components/editor/useZOrderCrossedFlash.tsx diff --git a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx index 10b9c23e6..57a093e81 100644 --- a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx +++ b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx @@ -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(); + }); }); diff --git a/packages/studio/src/components/editor/CanvasContextMenu.tsx b/packages/studio/src/components/editor/CanvasContextMenu.tsx index 2d1e87db2..cf06d9548 100644 --- a/packages/studio/src/components/editor/CanvasContextMenu.tsx +++ b/packages/studio/src/components/editor/CanvasContextMenu.tsx @@ -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(); } diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 75cc29818..18bfe32c1 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -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(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) => { - 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; - 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 (
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} /> )} + 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 = {}): 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", () => { diff --git a/packages/studio/src/components/editor/canvasContextMenuZOrder.ts b/packages/studio/src/components/editor/canvasContextMenuZOrder.ts index 67ce6d6b2..52b200562 100644 --- a/packages/studio/src/components/editor/canvasContextMenuZOrder.ts +++ b/packages/studio/src/components/editor/canvasContextMenuZOrder.ts @@ -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; } diff --git a/packages/studio/src/components/editor/useCanvasContextMenuState.ts b/packages/studio/src/components/editor/useCanvasContextMenuState.ts new file mode 100644 index 000000000..0e2a17ce3 --- /dev/null +++ b/packages/studio/src/components/editor/useCanvasContextMenuState.ts @@ -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; + hoverSelectionRef: RefObject; + onCanvasPointerMoveRef: RefObject< + ( + event: React.PointerEvent, + options?: { preferClipAncestor?: boolean }, + ) => Promise + >; + 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) => Promise; +} { + // 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(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) => { + 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; + 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 }; +} diff --git a/packages/studio/src/components/editor/useZOrderCrossedFlash.tsx b/packages/studio/src/components/editor/useZOrderCrossedFlash.tsx new file mode 100644 index 000000000..2be2a093b --- /dev/null +++ b/packages/studio/src/components/editor/useZOrderCrossedFlash.tsx @@ -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; + iframeRef: RefObject; +} + +export function useZOrderCrossedFlash({ overlayRef, iframeRef }: UseZOrderCrossedFlashParams): { + zOrderFlashRect: OverlayRect | null; + handleZOrderCrossed: (crossed: HTMLElement) => void; +} { + const [zOrderFlashRect, setZOrderFlashRect] = useState(null); + const timeoutRef = useRef | 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 ( +