From 10fb968583624039b36ad6924014bfbab2ba7c36 Mon Sep 17 00:00:00 2001 From: Phuong Le <39565248+func25@users.noreply.github.com> Date: Wed, 13 May 2026 14:08:59 +0700 Subject: [PATCH] fix(studio): show inspector selection bounds after click (#789) --- .../components/editor/DomEditOverlay.test.ts | 166 +++++++++++++++++- .../src/components/editor/DomEditOverlay.tsx | 1 - .../editor/useDomEditOverlayGestures.ts | 11 +- 3 files changed, 166 insertions(+), 12 deletions(-) diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index b609a1f14..d04e8e2d8 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "vitest"; +// @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, @@ -9,6 +14,75 @@ import { 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", () => { @@ -21,6 +95,96 @@ describe("focusDomEditOverlayElement", () => { }); }); +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, + 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( diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index c74e2dd01..bb4b3d4d0 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -223,7 +223,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ suppressNextOverlayMouseDownRef.current = true; selectionRef.current = candidate; - overlayRectRef.current = candidateRect; setOverlayRect(candidateRect); const didStartGesture = gestures.startGesture("drag", event, { selection: candidate, diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index 1ead82426..e00279128 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -23,12 +23,7 @@ import { restoreStudioPathOffset, restoreStudioRotation, } from "./manualEdits"; -import { - type GroupOverlayItem, - type OverlayRect, - groupOverlayItemsEqual, - rectsEqual, -} from "./domEditOverlayGeometry"; +import { type GroupOverlayItem, type OverlayRect } from "./domEditOverlayGeometry"; import { BLOCKED_MOVE_THRESHOLD_PX, type BlockedMoveState, @@ -87,8 +82,6 @@ export type UseDomEditOverlayGesturesOptions = { export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) { const setDraftOverlayRect = (next: OverlayRect) => { - if (rectsEqual(opts.overlayRectRef.current, next)) return; - opts.overlayRectRef.current = next; opts.setOverlayRect(next); }; const restoreGestureOverlayRect = (g: GestureState) => { @@ -102,8 +95,6 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu }); }; const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => { - if (groupOverlayItemsEqual(opts.groupOverlayItemsRef.current, next)) return; - opts.groupOverlayItemsRef.current = next; opts.setGroupOverlayItems(next); };