mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
An adversarial review of the canvas context-menu z-order pipeline (Bring to Front / Forward / Backward / Send to Back) found the resolver math sound but the glue between the menu and the commit hook broken: - The menu optimistically wrote style.zIndex AND position: relative to the live elements BEFORE the commit hook ran. The hook decides whether to persist position by checking getComputedStyle(el).position === 'static' — always false after the pre-apply — so the position patch was never persisted on the menu path and the reorder silently reverted at the post-commit reload for any nested/static element (root clips survive only because the runtime forces position:absolute). The same pre-apply made the failure rollback capture the already-mutated values, restoring the broken state on persist errors. The menu no longer pre-applies; the hook owns the live writes (it already applied both synchronously) and now sees true priors. Siblings without a persistable identity still get their z applied live-only so a renumber stays visually coherent. - The commit hook's entry.key store-sync plumbing had zero production callers; the store zIndex went stale until full reload. All three callers (canvas menu via PreviewOverlays, timeline lane z-sync, LayersPanel) now derive and pass the timeline store key (new deriveTimelineStoreKey helper). - patchElementBatch discarded the server's per-patch matched[]; unresolvable siblings persisted partially and silently. Unmatched targets now warn and report save-failure telemetry (z-reorder-unmatched) without rolling back the matched subset. - template/noscript elements counted as painting siblings, so renumber fallbacks wrote z-index/position into <template> tags in the source file. Excluded from the sibling family. - The default undo coalesce key merged DISTINCT z actions within 300ms into one undo entry; the action kind is now part of the key (LayersPanel drags keep coalescing within a drag; explicit lane-move gesture keys untouched). - rectsIntersect comment claimed touching rects intersect; the strict inequalities say otherwise — comment fixed.
597 lines
22 KiB
TypeScript
597 lines
22 KiB
TypeScript
import { memo, useCallback, 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 {
|
|
type BlockedMoveState,
|
|
type DomEditGroupPathOffsetCommit,
|
|
type FocusableDomEditOverlay,
|
|
type GestureState,
|
|
type GroupGestureState,
|
|
focusDomEditOverlayElement,
|
|
} from "./domEditOverlayGestures";
|
|
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
|
|
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
|
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
import { useDomEditNudge } from "./useDomEditNudge";
|
|
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
|
import { GridOverlay } from "./GridOverlay";
|
|
import type { GestureRecordingState } from "./GestureRecordControl";
|
|
import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
|
|
import { hugRectForElement } from "./domEditOverlayCrop";
|
|
import { useCropOverlay } from "../../hooks/useCropOverlay";
|
|
import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
|
|
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
|
|
|
// Re-exports for external consumers — preserving existing import paths.
|
|
export {
|
|
filterNestedDomEditGroupItems,
|
|
resolveDomEditCoordinateScale,
|
|
resolveDomEditGroupOverlayRect,
|
|
} from "./domEditOverlayGeometry";
|
|
export {
|
|
focusDomEditOverlayElement,
|
|
hasDomEditRotationChanged,
|
|
resolveDomEditRotationGesture,
|
|
} from "./domEditOverlayGestures";
|
|
export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
|
|
|
|
interface DomEditOverlayProps {
|
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
activeCompositionPath: string | null;
|
|
selection: DomEditSelection | null;
|
|
groupSelections?: DomEditSelection[];
|
|
hoverSelection: DomEditSelection | null;
|
|
allowCanvasMovement?: boolean;
|
|
onCanvasMouseDown: (
|
|
event: React.MouseEvent<HTMLDivElement>,
|
|
options?: PreviewMouseDownOptions,
|
|
) => void;
|
|
onCanvasPointerMove: (
|
|
event: React.PointerEvent<HTMLDivElement>,
|
|
options?: { preferClipAncestor?: boolean },
|
|
) => Promise<DomEditSelection | null>;
|
|
onCanvasPointerLeave: () => void;
|
|
onSelectionChange: (
|
|
selection: DomEditSelection,
|
|
options?: { revealPanel?: boolean; additive?: boolean },
|
|
) => void;
|
|
onBlockedMove: (selection: DomEditSelection) => void;
|
|
onManualDragStart?: () => void;
|
|
onPathOffsetCommit: (
|
|
selection: DomEditSelection,
|
|
next: { x: number; y: number },
|
|
modifiers?: { altKey?: boolean },
|
|
) => Promise<void> | void;
|
|
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void;
|
|
onBoxSizeCommit: (
|
|
selection: DomEditSelection,
|
|
next: { width: number; height: number },
|
|
offset?: { x: number; y: number },
|
|
restore?: () => void,
|
|
) => Promise<void> | void;
|
|
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
|
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
|
gridVisible?: boolean;
|
|
gridSpacing?: number;
|
|
recordingState?: GestureRecordingState;
|
|
onToggleRecording?: () => void;
|
|
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
|
|
/**
|
|
* Delete the selected canvas element.
|
|
* Wire to handleDomEditElementDelete from useDomEditActionsContext —
|
|
* same handler the Delete/Backspace hotkey uses.
|
|
*/
|
|
onDeleteSelection?: (selection: DomEditSelection) => void;
|
|
/**
|
|
* Called with the resolved z-order patch list and the menu action that
|
|
* produced it (feeds the undo coalesce key). The patch list is tie-aware and
|
|
* may include sibling elements (see canvasContextMenuZOrder); the live DOM is
|
|
* NOT yet mutated. Wire to handleDomZIndexReorderCommit from
|
|
* useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
|
|
*/
|
|
onApplyZIndex?: (
|
|
selection: DomEditSelection,
|
|
patches: ZOrderPatch[],
|
|
action: ZOrderAction,
|
|
) => void;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export const DomEditOverlay = memo(function DomEditOverlay({
|
|
iframeRef,
|
|
activeCompositionPath,
|
|
selection,
|
|
groupSelections = [],
|
|
hoverSelection,
|
|
allowCanvasMovement = true,
|
|
onCanvasMouseDown,
|
|
onCanvasPointerMove,
|
|
onCanvasPointerLeave,
|
|
onSelectionChange,
|
|
onBlockedMove,
|
|
gridVisible = false,
|
|
gridSpacing = 50,
|
|
onManualDragStart,
|
|
onPathOffsetCommit,
|
|
onGroupPathOffsetCommit,
|
|
onBoxSizeCommit,
|
|
onRotationCommit,
|
|
onStyleCommit,
|
|
onMarqueeSelect,
|
|
onDeleteSelection,
|
|
onApplyZIndex,
|
|
}: DomEditOverlayProps) {
|
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
|
onMarqueeSelectRef.current = onMarqueeSelect;
|
|
|
|
const selectionShapeStyles = readDomEditSelectionShapeStyles(selection);
|
|
const gestureRef = useRef<GestureState | null>(null);
|
|
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
|
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
|
const suppressNextBoxClickRef = useRef(false);
|
|
const suppressNextBoxMouseDownRef = useRef(false);
|
|
const suppressNextOverlayMouseDownRef = useRef(false);
|
|
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]);
|
|
|
|
const activeCompositionPathRef = useRef(activeCompositionPath);
|
|
activeCompositionPathRef.current = activeCompositionPath;
|
|
const groupSelectionsRef = useRef(groupSelections);
|
|
groupSelectionsRef.current = groupSelections;
|
|
const hoverSelectionRef = useRef(hoverSelection);
|
|
hoverSelectionRef.current = hoverSelection;
|
|
const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
|
|
onPathOffsetCommitRef.current = onPathOffsetCommit;
|
|
const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
|
|
onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit;
|
|
const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
|
|
onBoxSizeCommitRef.current = onBoxSizeCommit;
|
|
const onRotationCommitRef = useRef(onRotationCommit);
|
|
onRotationCommitRef.current = onRotationCommit;
|
|
const onStyleCommitRef = useRef(onStyleCommit);
|
|
onStyleCommitRef.current = onStyleCommit;
|
|
const onBlockedMoveRef = useRef(onBlockedMove);
|
|
onBlockedMoveRef.current = onBlockedMove;
|
|
const onManualDragStartRef = useRef(onManualDragStart);
|
|
onManualDragStartRef.current = onManualDragStart;
|
|
const onCanvasPointerMoveRef = useRef(onCanvasPointerMove);
|
|
onCanvasPointerMoveRef.current = onCanvasPointerMove;
|
|
const onCanvasPointerLeaveRef = useRef(onCanvasPointerLeave);
|
|
onCanvasPointerLeaveRef.current = onCanvasPointerLeave;
|
|
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
onSelectionChangeRef.current = onSelectionChange;
|
|
|
|
const {
|
|
overlayRect,
|
|
overlayRectRef,
|
|
setOverlayRect,
|
|
hoverRect,
|
|
groupOverlayItems,
|
|
groupOverlayItemsRef,
|
|
setGroupOverlayItems,
|
|
childRects,
|
|
} = useDomEditOverlayRects({
|
|
iframeRef,
|
|
overlayRef,
|
|
selectionRef,
|
|
activeCompositionPathRef,
|
|
groupSelectionsRef,
|
|
hoverSelectionRef,
|
|
rafPausedRef,
|
|
});
|
|
|
|
const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
|
|
const compRectRef = useRef(compRect);
|
|
compRectRef.current = compRect;
|
|
|
|
const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
|
|
selection,
|
|
overlayRect,
|
|
});
|
|
// Inset crops draw their own outline child; other clip shapes keep the raw mirror.
|
|
const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
|
|
const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
|
|
|
|
// Off-canvas element indicators — dashed outlines for elements positioned
|
|
// outside the composition bounds so users can find them.
|
|
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
|
const offCanvasDirtyRef = useRef(true);
|
|
const offCanvasSigRef = useRef("");
|
|
const offCanvasObserverRef = useRef<MutationObserver | null>(null);
|
|
const offCanvasObservedDocRef = useRef<Document | null>(null);
|
|
|
|
// Positions depend on live iframe layout, not selection — the selected-element
|
|
// suppression is a render-time filter, so selection/groupSelections stay out
|
|
// of the geometry walk.
|
|
useMountEffect(() =>
|
|
startOffCanvasIndicatorRefresh({
|
|
iframeRef,
|
|
overlayRef,
|
|
compRectRef,
|
|
activeCompositionPathRef,
|
|
dirtyRef: offCanvasDirtyRef,
|
|
sigRef: offCanvasSigRef,
|
|
observerRef: offCanvasObserverRef,
|
|
observedDocRef: offCanvasObservedDocRef,
|
|
elementsRef: offCanvasElementsRef,
|
|
setRects: setOffCanvasRects,
|
|
}),
|
|
);
|
|
|
|
// Switching compositions may not swap the iframe document (so the observer's
|
|
// doc-swap detection wouldn't fire) yet changes which elements are off-canvas.
|
|
// Force a recompute explicitly on comp change.
|
|
useEffect(() => {
|
|
offCanvasDirtyRef.current = true;
|
|
}, [activeCompositionPath]);
|
|
|
|
const gestures = createDomEditOverlayGestureHandlers({
|
|
overlayRef,
|
|
iframeRef,
|
|
boxRef,
|
|
selectionRef,
|
|
hoverSelectionRef,
|
|
overlayRectRef,
|
|
groupOverlayItemsRef,
|
|
gestureRef,
|
|
groupGestureRef,
|
|
blockedMoveRef,
|
|
rafPausedRef,
|
|
suppressNextBoxClickRef,
|
|
setOverlayRect,
|
|
setGroupOverlayItems,
|
|
onBlockedMoveRef,
|
|
onManualDragStartRef,
|
|
onPathOffsetCommitRef,
|
|
onGroupPathOffsetCommitRef,
|
|
onBoxSizeCommitRef,
|
|
onRotationCommitRef,
|
|
onCanvasPointerMoveRef,
|
|
onCanvasMouseDown,
|
|
snapGuidesRef,
|
|
});
|
|
|
|
// Arrow-key nudge (1px, Shift = 10px) — commits through the same
|
|
// path-offset callbacks as a drag, one undo entry per key burst.
|
|
const { flushNudge } = useDomEditNudge({
|
|
selection,
|
|
groupSelections,
|
|
allowCanvasMovement,
|
|
selectionRef,
|
|
overlayRectRef,
|
|
groupOverlayItemsRef,
|
|
gestureRef,
|
|
groupGestureRef,
|
|
blockedMoveRef,
|
|
onManualDragStartRef,
|
|
onPathOffsetCommitRef,
|
|
onGroupPathOffsetCommitRef,
|
|
});
|
|
|
|
const marquee = useMarqueeGestures({
|
|
iframeRef,
|
|
overlayRef,
|
|
activeCompositionPathRef,
|
|
onMarqueeSelectRef,
|
|
selectionRef,
|
|
gestures,
|
|
});
|
|
|
|
const selectionKey = useMemo(() => {
|
|
if (!selection) return "none";
|
|
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
|
|
}, [selection]);
|
|
|
|
const groupBounds = useMemo(
|
|
() => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
|
|
[groupOverlayItems],
|
|
);
|
|
const hasGroupSelection = groupSelections.length > 1;
|
|
const groupCanMove =
|
|
hasGroupSelection &&
|
|
groupOverlayItems.length > 1 &&
|
|
groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
|
|
|
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!allowCanvasMovement) return;
|
|
if (suppressNextOverlayMouseDownRef.current) {
|
|
suppressNextOverlayMouseDownRef.current = false;
|
|
suppressNextBoxMouseDownRef.current = false;
|
|
suppressNextBoxClickRef.current = false;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
// Allow clicks anywhere on the overlay — GSAP-translated elements can
|
|
// extend beyond the composition rect into the gray zone, and users need
|
|
// to select/deselect them by clicking there.
|
|
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
|
|
if (event.shiftKey) {
|
|
suppressNextBoxMouseDownRef.current = true;
|
|
suppressNextBoxClickRef.current = true;
|
|
}
|
|
};
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!allowCanvasMovement || event.button !== 0) return;
|
|
if (event.shiftKey) {
|
|
// Use the already-updated hover selection rather than re-resolving async
|
|
const candidate = hoverSelectionRef.current;
|
|
if (!candidate) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
suppressNextOverlayMouseDownRef.current = true;
|
|
suppressNextBoxMouseDownRef.current = true;
|
|
suppressNextBoxClickRef.current = true;
|
|
onSelectionChangeRef.current(candidate, { additive: true });
|
|
return;
|
|
}
|
|
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
|
|
// Start marquee if clicking on empty canvas (no element under pointer).
|
|
// The hover selection is an ASYNC cache: on a fast click (or when the
|
|
// pointer was already resting over an element) it can still be empty while
|
|
// an element IS under the pointer — starting a marquee here would swallow
|
|
// the selection mousedown and the click would silently select nothing.
|
|
// Confirm emptiness with a fresh SYNCHRONOUS hit-test before committing.
|
|
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
|
|
const iframe = iframeRef.current;
|
|
const freshTarget = iframe
|
|
? getPreviewTargetFromPointer(
|
|
iframe,
|
|
event.clientX,
|
|
event.clientY,
|
|
activeCompositionPathRef.current,
|
|
)
|
|
: null;
|
|
if (freshTarget) return;
|
|
const overlayEl = overlayRef.current;
|
|
if (overlayEl) {
|
|
const oRect = overlayEl.getBoundingClientRect();
|
|
const cx = event.clientX - oRect.left;
|
|
const cy = event.clientY - oRect.top;
|
|
const inComp =
|
|
cx >= compRect.left &&
|
|
cx <= compRect.left + compRect.width &&
|
|
cy >= compRect.top &&
|
|
cy <= compRect.top + compRect.height;
|
|
if (inComp) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
suppressNextOverlayMouseDownRef.current = true;
|
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
marquee.marqueeRef.current = {
|
|
startX: cx,
|
|
startY: cy,
|
|
currentX: cx,
|
|
currentY: cy,
|
|
pointerId: event.pointerId,
|
|
pastThreshold: false,
|
|
};
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!allowCanvasMovement) return;
|
|
if (gestureRef.current || groupGestureRef.current) return;
|
|
if (suppressNextBoxClickRef.current) {
|
|
suppressNextBoxClickRef.current = false;
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
|
|
};
|
|
|
|
const suppressBoxMouseDown = (e: React.MouseEvent) => {
|
|
if (!suppressNextBoxMouseDownRef.current) return;
|
|
suppressNextBoxMouseDownRef.current = false;
|
|
e.preventDefault();
|
|
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
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={overlayRef}
|
|
className="absolute inset-0 z-10 pointer-events-auto outline-none"
|
|
tabIndex={-1}
|
|
aria-label="Composition canvas"
|
|
// Cursor follows marquee rect *state* (re-renders), not the mutable ref.
|
|
style={marquee.marqueeRect ? { cursor: "crosshair" } : undefined}
|
|
onPointerDownCapture={(event) => {
|
|
// A pointer gesture supersedes a pending nudge burst — commit it first
|
|
// so the gesture's member snapshot starts from the nudged position.
|
|
flushNudge();
|
|
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
|
|
}}
|
|
onPointerDown={handleOverlayPointerDown}
|
|
onMouseDown={handleOverlayMouseDown}
|
|
onPointerMove={marquee.onPointerMove}
|
|
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
|
onPointerUp={marquee.onPointerUp}
|
|
onPointerCancel={marquee.onPointerCancel}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
{hoverSelection && hoverRect && compRect.width > 0 && (
|
|
<div
|
|
aria-hidden="true"
|
|
data-dom-edit-hover-box="true"
|
|
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
|
style={{
|
|
...hugRectForElement(hoverRect, hoverSelection.element),
|
|
transform: hoverRect.angle ? `rotate(${hoverRect.angle}deg)` : undefined,
|
|
}}
|
|
/>
|
|
)}
|
|
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
|
|
<DomEditGroupChrome
|
|
groupOverlayItems={groupOverlayItems}
|
|
groupBounds={groupBounds}
|
|
allowCanvasMovement={allowCanvasMovement}
|
|
groupCanMove={groupCanMove}
|
|
gestures={gestures}
|
|
onBoxMouseDown={suppressBoxMouseDown}
|
|
onBoxClick={handleBoxClick}
|
|
/>
|
|
)}
|
|
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
|
|
<DomEditSelectionChrome
|
|
selection={selection}
|
|
overlayRect={overlayRect}
|
|
allowCanvasMovement={allowCanvasMovement}
|
|
cropOutlineInsetPx={cropOutlineInsetPx ?? undefined}
|
|
boxRef={boxRef}
|
|
boxChromeClass={boxChromeClass}
|
|
boxClipPath={boxClipPath}
|
|
selectionKey={selectionKey}
|
|
groupSelectionCount={groupSelections.length}
|
|
blockedMoveRef={blockedMoveRef}
|
|
gestures={gestures}
|
|
onStyleCommit={onStyleCommitRef.current}
|
|
onBoxMouseDown={suppressBoxMouseDown}
|
|
onBoxClick={handleBoxClick}
|
|
/>
|
|
)}
|
|
{childRects.length > 0 &&
|
|
compRect.width > 0 &&
|
|
childRects.map((cr, i) => (
|
|
<div
|
|
key={i}
|
|
className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
|
|
style={{
|
|
left: cr.left,
|
|
top: cr.top,
|
|
width: cr.width,
|
|
height: cr.height,
|
|
}}
|
|
/>
|
|
))}
|
|
<OffCanvasIndicators
|
|
rects={offCanvasRects}
|
|
elements={offCanvasElementsRef}
|
|
compRect={compRect}
|
|
selection={selection}
|
|
groupSelections={groupSelections}
|
|
activeCompositionPathRef={activeCompositionPathRef}
|
|
onSelectionChangeRef={onSelectionChangeRef}
|
|
/>
|
|
<MarqueeOverlay candidateRects={marquee.candidateRects} marqueeRect={marquee.marqueeRect} />
|
|
{contextMenu && (
|
|
<CanvasContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
selection={contextMenu.sel}
|
|
onClose={() => setContextMenu(null)}
|
|
onDelete={
|
|
onDeleteSelection
|
|
? (sel) => {
|
|
setContextMenu(null);
|
|
onDeleteSelection(sel);
|
|
}
|
|
: undefined
|
|
}
|
|
onApplyZIndex={
|
|
onApplyZIndex
|
|
? (patches, action) => {
|
|
onApplyZIndex(contextMenu.sel, patches, action);
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
<GridOverlay
|
|
visible={gridVisible}
|
|
spacing={gridSpacing}
|
|
scaleX={compRect.scaleX}
|
|
scaleY={compRect.scaleY}
|
|
compositionLeft={compRect.left}
|
|
compositionTop={compRect.top}
|
|
compositionWidth={compRect.width}
|
|
compositionHeight={compRect.height}
|
|
/>
|
|
<SnapGuideOverlay
|
|
snapGuidesRef={snapGuidesRef}
|
|
compositionLeft={compRect.left}
|
|
compositionTop={compRect.top}
|
|
compositionWidth={compRect.width}
|
|
compositionHeight={compRect.height}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|