fix(studio): show inspector selection bounds after click (#789)

This commit is contained in:
Phuong Le
2026-05-13 09:08:59 +02:00
committed by GitHub
parent 9058ee39c8
commit 10fb968583
3 changed files with 166 additions and 12 deletions
@@ -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<typeof import("./domEditOverlayGeometry")>(
"./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<DomEditSelection | null>(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(
@@ -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,
@@ -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);
};