mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user