diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index ba95b95ebe..7a5ebe8228 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,6 +55,9 @@ "packages/studio/src/hooks/gsapRuntimePreview.ts", // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. + "packages/studio/src/components/sidebar/AssetCard.tsx", + // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; + // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/components/EditorShell.tsx", "packages/studio/src/components/nle/TimelinePane.tsx", "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", @@ -456,6 +459,12 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/hooks/useDomEditWiring.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/hooks/useGsapSelectionHandlers.ts", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/components/StudioPreviewArea.tsx", @@ -659,6 +668,9 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); + // removed by studio-dnd/pr22 when the final config lands. + "packages/studio/src/components/sidebar/AssetCard.tsx", // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); // removed by studio-dnd/pr22 when the final config lands. "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index 7ac3b485b0..ff90de33af 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -31,12 +31,16 @@ interface DomEditCropHandlesProps { onStyleCommit?: (property: string, value: string) => Promise | void; } -// Gap (px) between an edge handle and the element edge, so the handle sits -// clear of the element body and can't intercept a move-drag. -const EDGE_HANDLE_GAP = 8; +// Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from +// the crop edge (flush against it, never over the element body, so a body +// drag always MOVES), LENGTH runs along the edge. The visible pill is smaller +// and centered inside the strip. +const EDGE_HIT_THICKNESS = 12; +const EDGE_HIT_LENGTH = 32; -/** Place an edge handle just OUTSIDE the given crop edge (translate pushes it - * fully past the boundary). Keeps the element body free for moving. */ +/** Place an edge handle's hit strip just OUTSIDE the given crop edge + * (translate pushes it fully past the boundary). Keeps the element body free + * for moving. Corners stay free for the selection's own resize handles. */ function edgeHandlePlacement( edge: CropEdge, rect: { left: number; top: number; width: number; height: number }, @@ -44,27 +48,36 @@ function edgeHandlePlacement( const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; if (edge === "top") { - return { left: cx, top: rect.top - EDGE_HANDLE_GAP, transform: "translate(-50%, -100%)" }; + return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" }; } if (edge === "bottom") { - return { - left: cx, - top: rect.top + rect.height + EDGE_HANDLE_GAP, - transform: "translate(-50%, 0)", - }; + return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" }; } if (edge === "left") { - return { left: rect.left - EDGE_HANDLE_GAP, top: cy, transform: "translate(-100%, -50%)" }; + return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" }; } - return { - left: rect.left + rect.width + EDGE_HANDLE_GAP, - top: cy, - transform: "translate(0, -50%)", - }; + return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" }; } const EDGES: CropEdge[] = ["top", "right", "bottom", "left"]; +/** Hit-strip + pill dimensions for an edge handle, keyed on its orientation. */ +function edgeHandleMetrics(vertical: boolean): { + hitWidth: number; + hitHeight: number; + cursor: string; + pillWidth: number; + pillHeight: number; +} { + return { + hitWidth: vertical ? EDGE_HIT_THICKNESS : EDGE_HIT_LENGTH, + hitHeight: vertical ? EDGE_HIT_LENGTH : EDGE_HIT_THICKNESS, + cursor: vertical ? "ew-resize" : "ns-resize", + pillWidth: vertical ? 4 : 24, + pillHeight: vertical ? 24 : 4, + }; +} + /** * Always-on crop, integrated with the selection (no crop "mode"): while a * croppable element is selected its clip is lifted so the FULL content shows and @@ -83,6 +96,7 @@ export function DomEditCropHandles({ }: DomEditCropHandlesProps) { const gestureRef = useRef(null); const [dragging, setDragging] = useState(false); + const [hotEdge, setHotEdge] = useState(null); // readElementCropInsets returns null for a clip this tool can't represent // (circle/polygon/non-px inset): the crop UI must fully stand down for that // element — no lift, no handles — or select+deselect replaces the authored @@ -304,6 +318,7 @@ export function DomEditCropHandles({ ); })} diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index fbc8eccb26..736bd7ecb2 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -11,10 +11,10 @@ import { hasDomEditRotationChanged, resolveDomEditCoordinateScale, resolveDomEditGroupOverlayRect, - resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./DomEditOverlay"; import type { DomEditSelection } from "./domEditing"; +import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures"; // React 19 warns unless the test environment opts into act(). globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -84,21 +84,37 @@ vi.mock("./useDomEditOverlayRects", async () => { }; }); +const previewHelperSpies = vi.hoisted(() => ({ + getPreviewTargetFromPointer: vi.fn<() => HTMLElement | null>(() => null), +})); + +vi.mock("../../utils/studioPreviewHelpers", async () => { + const actual = await vi.importActual( + "../../utils/studioPreviewHelpers", + ); + return { + ...actual, + getPreviewTargetFromPointer: previewHelperSpies.getPreviewTargetFromPointer, + }; +}); + vi.mock("./domEditOverlayGeometry", async () => { const actual = await vi.importActual( "./domEditOverlayGeometry", ); + const stubRect = { + left: 24, + top: 36, + width: 180, + height: 72, + editScaleX: 1, + editScaleY: 1, + }; return { ...actual, - toOverlayRect: () => ({ - left: 24, - top: 36, - width: 180, - height: 72, - editScaleX: 1, - editScaleY: 1, - }), + toOverlayRect: () => stubRect, + orientedOverlayRect: () => stubRect, }; }); @@ -126,6 +142,103 @@ function createOverlayProps(args: { }; } +/** + * Stub element-level getBoundingClientRect to a fixed 800×450 rect (happy-dom + * returns all-zeros for unlaid-out elements, which gates the RAF compRect + * update). Returns a restore function to call in teardown. + */ +function stubViewportRect(): () => void { + const original = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (): DOMRect { + return { + left: 0, + top: 0, + right: 800, + bottom: 450, + width: 800, + height: 450, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }; + return () => { + Element.prototype.getBoundingClientRect = original; + }; +} + +/** + * Flush the mount's RAF ticks so the compRect update lands. Two animation-frame + * ticks: the first scheduled by useMountEffect's update(), the second by + * update()'s tail recursion. + */ +async function flushOverlayRaf(): Promise { + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); +} + +/** A fully-populated DomEditSelection with per-test overrides (capabilities are + * merged so a test can flip a single flag without restating the whole set). */ +function makeDomEditSelection( + overrides: Partial = {}, + capabilityOverrides: Partial = {}, +): DomEditSelection { + const base: DomEditSelection = { + element: document.createElement("div"), + id: "hero-title", + selector: ".hero-title", + selectorIndex: 0, + sourceFile: "index.html", + tagName: "div", + label: "Hero Title", + textContent: "Hello", + textFields: [], + capabilities: { + canEditText: true, + canEditLayout: true, + canMove: true, + canApplyManualOffset: true, + canApplyManualSize: false, + canApplyManualRotation: false, + canAdjustOpacity: true, + canAdjustFill: true, + canAdjustBorderRadius: true, + canAdjustStroke: true, + canAdjustShadow: true, + canAdjustZIndex: true, + }, + computedStyle: { + display: "block", + position: "absolute", + }, + }; + return { + ...base, + ...overrides, + capabilities: { ...base.capabilities, ...capabilityOverrides }, + }; +} + +/** Query the composition-canvas overlay and assert it mounted. */ +function getOverlay(host: HTMLElement): HTMLDivElement { + const overlay = host.querySelector('[aria-label="Composition canvas"]'); + expect(overlay).toBeTruthy(); + if (!overlay) throw new Error("Expected composition canvas overlay"); + return overlay; +} + +/** Dispatch a left-button pointerdown at (clientX, clientY) inside act(). */ +function dispatchOverlayPointerDown(target: Element, clientX = 120, clientY = 80): void { + act(() => { + target.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY }), + ); + }); +} + describe("focusDomEditOverlayElement", () => { it("focuses the canvas overlay without scrolling", () => { const calls: Array = []; @@ -144,41 +257,84 @@ describe("DomEditOverlay", () => { gestureSpies.onPointerMove.mockClear(); gestureSpies.onPointerUp.mockClear(); gestureSpies.clearPointerState.mockClear(); + previewHelperSpies.getPreviewTargetFromPointer.mockReset(); + previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(null); + }); + + it("selects on the first click over an element even before a hover is resolved", async () => { + // Regression: this used to start a marquee whenever hoverSelectionRef was null. + // The RAF hover loop populates that ref ASYNCHRONOUSLY, so a genuine first + // click over an element read null and was misread as empty canvas — the + // marquee swallowed the selecting onMouseDown, so nothing selected until the + // SECOND click. With a synchronous pointer hit-test finding an element, the + // marquee must NOT start and onCanvasMouseDown must fire on the first click. + const restoreRect = stubViewportRect(); + const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture; + HTMLDivElement.prototype.setPointerCapture = () => {}; + + // An element IS under the pointer, but no hover has been resolved yet. + previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(document.createElement("div")); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; + const onCanvasMouseDown = vi.fn(); + const onMarqueeSelect = vi.fn(); + + function Harness() { + return React.createElement(DomEditOverlay, { + ...createOverlayProps({ + iframeRef, + selection: null, + hoverSelection: null, + onSelectionChange: () => {}, + }), + onCanvasMouseDown, + onMarqueeSelect, + }); + } + + act(() => { + root.render(React.createElement(Harness)); + }); + await flushOverlayRaf(); + + const overlay = getOverlay(host); + + act(() => { + overlay.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }), + ); + overlay.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }), + ); + }); + + // No marquee started; the click reached the selecting mouse-down handler. + expect(onMarqueeSelect).not.toHaveBeenCalled(); + expect(onCanvasMouseDown).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + HTMLDivElement.prototype.setPointerCapture = originalPointerCapture; + restoreRect(); + host.remove(); }); it("does not start a drag from a stale hover target on canvas pointer-down", () => { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), + const selection = makeDomEditSelection({ id: "cta-label", selector: ".cta-label", - selectorIndex: 0, - sourceFile: "index.html", tagName: "span", label: "CTA Label", textContent: "Add to basket", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: true, - canApplyManualOffset: true, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "inline", - position: "static", - }, - }; + computedStyle: { display: "inline", position: "static" }, + }); let currentSelection: DomEditSelection | null = null; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -202,19 +358,9 @@ describe("DomEditOverlay", () => { root.render(React.createElement(Harness)); }); - const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; - expect(overlay).toBeTruthy(); + const overlay = getOverlay(host); - act(() => { - overlay.dispatchEvent( - new PointerEvent("pointerdown", { - bubbles: true, - button: 0, - clientX: 120, - clientY: 80, - }), - ); - }); + dispatchOverlayPointerDown(overlay); expect(gestureSpies.startGesture).not.toHaveBeenCalled(); expect(currentSelection).toBe(null); @@ -233,53 +379,12 @@ describe("DomEditOverlay", () => { // box (and other bounded UI) behind `compRect.width > 0` (added in the // keyframes PR a468550f). Stub element-level getBoundingClientRect for // the test so the RAF compRect update produces a real width. - const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function (): DOMRect { - return { - left: 0, - top: 0, - right: 800, - bottom: 450, - width: 800, - height: 450, - x: 0, - y: 0, - toJSON: () => ({}), - }; - }; + const restoreRect = stubViewportRect(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), - id: "hero-title", - selector: ".hero-title", - selectorIndex: 0, - sourceFile: "index.html", - tagName: "div", - label: "Hero Title", - textContent: "Hello", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: true, - canApplyManualOffset: true, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "block", - position: "absolute", - }, - }; + const selection = makeDomEditSelection(); let currentSelection: DomEditSelection | null = selection; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -307,30 +412,16 @@ describe("DomEditOverlay", () => { // Flush the mount's RAF tick so the compRect update lands before the // pointer-down. Two animation-frame ticks: the first scheduled by // useMountEffect's update(), the second by update()'s tail recursion. - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); - }); + await flushOverlayRaf(); - const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; - expect(overlay).toBeTruthy(); + getOverlay(host); const selectionBox = host.querySelector( '[data-dom-edit-selection-box="true"]', ) as HTMLDivElement; expect(selectionBox).toBeTruthy(); - act(() => { - selectionBox.dispatchEvent( - new PointerEvent("pointerdown", { - bubbles: true, - button: 0, - clientX: 120, - clientY: 80, - }), - ); - }); + dispatchOverlayPointerDown(selectionBox); expect(currentSelection).toBe(selection); expect(gestureSpies.startGesture).toHaveBeenCalledWith( @@ -342,58 +433,17 @@ describe("DomEditOverlay", () => { root.unmount(); }); HTMLDivElement.prototype.setPointerCapture = originalPointerCapture; - Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + restoreRect(); host.remove(); }); it("passes the tracked hover selection when clicking the existing selection box", async () => { - const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function (): DOMRect { - return { - left: 0, - top: 0, - right: 800, - bottom: 450, - width: 800, - height: 450, - x: 0, - y: 0, - toJSON: () => ({}), - }; - }; + const restoreRect = stubViewportRect(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), - id: "hero-title", - selector: ".hero-title", - selectorIndex: 0, - sourceFile: "index.html", - tagName: "div", - label: "Hero Title", - textContent: "Hello", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: false, - canApplyManualOffset: false, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "block", - position: "absolute", - }, - }; + const selection = makeDomEditSelection({}, { canMove: false, canApplyManualOffset: false }); const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" }; const onCanvasMouseDown = vi.fn(); const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -414,11 +464,7 @@ describe("DomEditOverlay", () => { root.render(React.createElement(Harness)); }); - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); - }); + await flushOverlayRaf(); const selectionBox = host.querySelector( '[data-dom-edit-selection-box="true"]', @@ -437,7 +483,7 @@ describe("DomEditOverlay", () => { act(() => { root.unmount(); }); - Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + restoreRect(); host.remove(); }); }); @@ -513,130 +559,10 @@ describe("filterNestedDomEditGroupItems", () => { }); }); -describe("resolveDomEditResizeGesture", () => { - it("resizes width and height independently by default", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - dx: 30, - dy: 12, - uniform: false, - }), - ).toEqual({ - overlayWidth: 270, - overlayHeight: 132, - width: 270, - height: 132, - }); - }); - - it("divides the cursor delta by the element's content scale (rescaled element)", () => { - // Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the - // CSS box by only 15px so the RENDERED box tracks the pointer 1:1. - const next = resolveDomEditResizeGesture({ - originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1) - originHeight: 240, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - contentScaleX: 2, - contentScaleY: 2, - dx: 30, - dy: 12, - uniform: false, - }); - expect(next.width).toBe(255); - expect(next.height).toBe(126); - // The overlay box keeps tracking the raw cursor. - expect(next.overlayWidth).toBe(510); - expect(next.overlayHeight).toBe(252); - }); - - it("treats a missing/invalid content scale as 1 (unscaled element)", () => { - const next = resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - contentScaleX: 0, - contentScaleY: Number.NaN, - dx: 30, - dy: 12, - uniform: false, - }); - expect(next.width).toBe(270); - expect(next.height).toBe(132); - }); - - it("snaps width and height to the same value when Shift is held", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - dx: 30, - dy: 12, - uniform: true, - }), - ).toEqual({ - overlayWidth: 270, - overlayHeight: 270, - width: 270, - height: 270, - }); - }); - - it("uses the dominant pointer delta for uniform shrink", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 300, - originHeight: 180, - actualWidth: 300, - actualHeight: 180, - scaleX: 1, - scaleY: 1, - dx: 8, - dy: -40, - uniform: true, - }), - ).toMatchObject({ - width: 260, - height: 260, - }); - }); - - it("writes source-local dimensions when the edited source is scaled down in master view", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 100, - originHeight: 50, - actualWidth: 400, - actualHeight: 200, - scaleX: 0.25, - scaleY: 0.25, - dx: 25, - dy: 10, - uniform: false, - }), - ).toEqual({ - overlayWidth: 125, - overlayHeight: 60, - width: 500, - height: 240, - }); - }); -}); +// Note: the resize SIZE math moved from the AABB screen-space +// resolveDomEditResizeGesture (removed) to the local-space (OBB) model in +// domEditResizeLocal.ts — see domEditResizeLocal.test.ts, which re-covers the +// independent-axis, aspect-lock, and scaled-master-view cases plus rotated axes. describe("resolveDomEditRotationGesture", () => { it("rotates by the pointer angle around the element center", () => { @@ -701,3 +627,43 @@ describe("resolveDomEditRotationGesture", () => { expect(hasDomEditRotationChanged(0, 0)).toBe(false); }); }); + +// resolveResizeCenterAnchorOffset is the UNROTATED (AABB) fallback used only when +// the element's real transformed corners can't be measured. Center-anchored: a +// width/height change grows the box from its top-left, drifting the center by half +// the size change per axis, so the pin translates back by that half-delta. It is +// handle-independent — all four corners scale about the same center. +describe("resolveResizeCenterAnchorOffset", () => { + it("grow: translates back by half the size change on both axes", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 230, + overlayHeight: 112, + }), + ).toEqual({ dx: -15, dy: -6 }); + }); + + it("shrink: translates forward by half the (positive) size change", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 160, + overlayHeight: 80, + }), + ).toEqual({ dx: 20, dy: 10 }); + }); + + it("no size change: zero offset", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 200, + overlayHeight: 100, + }), + ).toEqual({ dx: 0, dy: 0 }); + }); +}); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 8d89a3637d..e80799f315 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -1,5 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; +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"; @@ -16,17 +15,20 @@ import { 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 { DomEditCropHandles } from "./DomEditCropHandles"; -import { DomEditRotateHandle } from "./DomEditRotateHandle"; +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 { ZOrderPatch } from "./canvasContextMenuZOrder"; +import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; // Re-exports for external consumers — preserving existing import paths. export { @@ -37,7 +39,6 @@ export { export { focusDomEditOverlayElement, hasDomEditRotationChanged, - resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./domEditOverlayGestures"; export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; @@ -82,6 +83,19 @@ interface DomEditOverlayProps { 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 after an optimistic DOM update. + * The patch list is tie-aware and may include sibling elements (see + * canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from + * useDomEditActionsContext. See CanvasContextMenu.tsx module comment. + */ + onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void; } // fallow-ignore-next-line complexity @@ -106,6 +120,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onRotationCommit, onStyleCommit, onMarqueeSelect, + onDeleteSelection, + onApplyZIndex, }: DomEditOverlayProps) { const overlayRef = useRef(null); const boxRef = useRef(null); @@ -122,8 +138,31 @@ 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]); + const activeCompositionPathRef = useRef(activeCompositionPath); activeCompositionPathRef.current = activeCompositionPath; const groupSelectionsRef = useRef(groupSelections); @@ -242,6 +281,23 @@ export const DomEditOverlay = memo(function DomEditOverlay({ 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, @@ -371,6 +427,38 @@ 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 + [], + ); + return (
- focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay) - } + 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 && (
)} {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && ( - <> - {groupOverlayItems.map((item) => ( -