// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { describe, expect, it, vi } from "vitest"; import { Window } from "happy-dom"; import { DomEditOverlay, filterNestedDomEditGroupItems, focusDomEditOverlayElement, hasDomEditRotationChanged, resolveDomEditCoordinateScale, resolveDomEditGroupOverlayRect, resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./DomEditOverlay"; import type { DomEditSelection } from "./domEditing"; // React 19 warns unless the test environment opts into act(). globalThis.IS_REACT_ACT_ENVIRONMENT = true; vi.mock("./useDomEditOverlayGestures", () => ({ createDomEditOverlayGestureHandlers: () => ({ startGesture: () => true, startGroupDrag: () => {}, onPointerMove: () => {}, onPointerUp: () => {}, clearPointerState: () => {}, }), })); vi.mock("./useDomEditOverlayRects", async () => { const React = await import("react"); const { rectsEqual } = await import("./domEditOverlayGeometry"); return { useDomEditOverlayRects: () => { const [overlayRect, setOverlayRectState] = React.useState(null); const overlayRectRef = React.useRef(null); const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]); const groupOverlayItemsRef = React.useRef([]); const setOverlayRect = (next: unknown) => { if (rectsEqual(overlayRectRef.current, next)) return; overlayRectRef.current = next; setOverlayRectState(next); }; const setGroupOverlayItems = (next: unknown[]) => { groupOverlayItemsRef.current = next; setGroupOverlayItemsState(next); }; return { overlayRect, overlayRectRef, setOverlayRect, hoverRect: null, hoverRectRef: { current: null }, setHoverRect: () => {}, groupOverlayItems, groupOverlayItemsRef, setGroupOverlayItems, }; }, }; }); vi.mock("./domEditOverlayGeometry", async () => { const actual = await vi.importActual( "./domEditOverlayGeometry", ); return { ...actual, toOverlayRect: () => ({ left: 24, top: 36, width: 180, height: 72, editScaleX: 1, editScaleY: 1, }), }; }); describe("focusDomEditOverlayElement", () => { it("focuses the canvas overlay without scrolling", () => { const calls: Array = []; focusDomEditOverlayElement({ focus: (options?: FocusOptions) => calls.push(options), }); expect(calls).toEqual([{ preventScroll: true }]); }); }); describe("DomEditOverlay", () => { it("renders selected bounds right after clicking a movable selection", () => { 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", }, }; let currentSelection: DomEditSelection | null = null; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture; HTMLDivElement.prototype.setPointerCapture = () => {}; function Harness() { const [selected, setSelected] = React.useState(null); currentSelection = selected; return React.createElement(DomEditOverlay, { iframeRef, activeCompositionPath: null, selection: selected, hoverSelection: null, groupSelections: [], onCanvasMouseDown: () => {}, onCanvasPointerMove: () => selection, onCanvasPointerLeave: () => {}, onSelectionChange: (next: DomEditSelection) => setSelected(next), onBlockedMove: () => {}, onPathOffsetCommit: () => {}, onGroupPathOffsetCommit: () => {}, onBoxSizeCommit: () => {}, onRotationCommit: () => {}, }); } act(() => { root.render(React.createElement(Harness)); }); const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; expect(overlay).toBeTruthy(); act(() => { overlay.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 120, clientY: 80, }), ); }); expect(currentSelection).toBe(selection); expect(host.querySelector('[data-dom-edit-selection-box="true"]')).toBeTruthy(); act(() => { root.unmount(); }); HTMLDivElement.prototype.setPointerCapture = originalPointerCapture; host.remove(); }); }); describe("resolveDomEditCoordinateScale", () => { it("uses the top-level preview scale when no source boundary dimensions are available", () => { expect( resolveDomEditCoordinateScale({ rootScaleX: 0.5, rootScaleY: 0.5, }), ).toEqual({ scaleX: 0.5, scaleY: 0.5, }); }); it("converts source-local pixels through a scaled nested composition host", () => { expect( resolveDomEditCoordinateScale({ rootScaleX: 0.5, rootScaleY: 0.5, sourceRectWidth: 960, sourceRectHeight: 540, sourceWidth: 1920, sourceHeight: 1080, }), ).toEqual({ scaleX: 0.25, scaleY: 0.25, }); }); }); describe("resolveDomEditGroupOverlayRect", () => { it("returns a bounding box that contains every selected element", () => { expect( resolveDomEditGroupOverlayRect([ { left: 40, top: 30, width: 80, height: 50, editScaleX: 1, editScaleY: 1 }, { left: 150, top: 10, width: 30, height: 120, editScaleX: 0.5, editScaleY: 0.5 }, { left: 20, top: 90, width: 50, height: 20, editScaleX: 2, editScaleY: 2 }, ]), ).toEqual({ left: 20, top: 10, width: 160, height: 120, editScaleX: 1, editScaleY: 1, }); }); it("returns null for an empty group", () => { expect(resolveDomEditGroupOverlayRect([])).toBeNull(); }); }); describe("filterNestedDomEditGroupItems", () => { it("keeps top-level selected elements so descendants are not moved twice", () => { const window = new Window(); const parent = window.document.createElement("div"); const child = window.document.createElement("div"); const sibling = window.document.createElement("div"); parent.append(child); expect( filterNestedDomEditGroupItems([ { key: "parent", element: parent }, { key: "child", element: child }, { key: "sibling", element: sibling }, ]).map((item) => item.key), ).toEqual(["parent", "sibling"]); }); }); 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("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, }); }); }); describe("resolveDomEditRotationGesture", () => { it("rotates by the pointer angle around the element center", () => { expect( resolveDomEditRotationGesture({ centerX: 0, centerY: 0, startX: 0, startY: -10, currentX: 10, currentY: 0, actualAngle: 5, snap: false, }), ).toEqual({ angle: 95 }); }); it("uses the shortest delta across the 180 degree boundary", () => { expect( resolveDomEditRotationGesture({ centerX: 0, centerY: 0, startX: -10, startY: 1.76, currentX: -10, currentY: -1.76, actualAngle: 0, snap: false, }).angle, ).toBeCloseTo(20, 1); }); it("snaps to 15 degree increments when requested", () => { expect( resolveDomEditRotationGesture({ centerX: 0, centerY: 0, startX: 10, startY: 0, currentX: 10, currentY: 3.25, actualAngle: 0, snap: true, }), ).toEqual({ angle: 15 }); }); it("allows small pointer movements when the rounded angle changes", () => { const nextRotation = resolveDomEditRotationGesture({ centerX: 0, centerY: 0, startX: 0, startY: -40, currentX: 1, currentY: -40, actualAngle: 0, snap: false, }); expect(nextRotation.angle).toBe(1.4); expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true); expect(hasDomEditRotationChanged(0, 0)).toBe(false); }); });