fix(studio): stabilize manual drag targets (#1393)

## Problem

Studio manual drag had two bad target paths in `hf-keyframes-test`:

- Drag could start from a cached hover selection instead of the selected overlay, so moving `#cta` could write an offset to the wrong element while the overlay appeared correct.
- `#stage` is the visual 1920x1080 canvas, but the composition metadata lives on `<html data-composition-id ...>`. Studio treated `#stage` as a normal movable layer, so dragging it moved the coordinate basis the overlay depends on and left Undo with no reliable saved edit to reverse.

## Fix

- Manual drag now starts only from the selected overlay bounds; canvas pointer-down no longer starts movement from `hoverSelectionRef`.
- Added a structural root-layer heuristic: a direct body child matching the composition root's declared `data-width` / `data-height` is selectable and style-editable, but cannot receive manual offset/size/rotation edits.
- Manual geometry commits now return/rethrow the save promise so failed writes roll back optimistic DOM changes instead of silently sticking.
- GSAP-targeted CSS fallback edits are blocked before applying manual geometry when the GSAP drag intercept is unavailable.

## Tests

- New regression coverage for stale-hover drag start and full-canvas root-stage capability resolution.
- Focused DOM-edit suite: 119/119 pass.
- Full Studio suite: 800 pass, 18 todo.
- Studio typecheck clean; oxlint/oxfmt clean; lefthook pre-commit clean.
- Studio build passes with the existing Vite chunk-size warning.
- Browser verified with `agent-browser`: attempted `#stage` drag writes no offset and keeps Undo disabled; `#cta` drag writes exactly one offset to `#cta`; Undo returns the fixture to zero offsets.

Local browser artifacts: `artifacts/studio-cta-drag/stage-cta-drag-fix.webm`, `artifacts/studio-cta-drag/stage-cta-drag-fix-final.png`.
This commit is contained in:
Miguel Ángel
2026-06-12 19:16:36 -04:00
committed by GitHub
parent d0a7f7d839
commit 1ea0ed55e3
8 changed files with 390 additions and 96 deletions
@@ -2,7 +2,7 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Window } from "happy-dom";
import {
DomEditOverlay,
@@ -19,13 +19,21 @@ import type { DomEditSelection } from "./domEditing";
// React 19 warns unless the test environment opts into act().
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const gestureSpies = vi.hoisted(() => ({
startGesture: vi.fn(() => true),
startGroupDrag: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
clearPointerState: vi.fn(),
}));
vi.mock("./useDomEditOverlayGestures", () => ({
createDomEditOverlayGestureHandlers: () => ({
startGesture: () => true,
startGroupDrag: () => {},
onPointerMove: () => {},
onPointerUp: () => {},
clearPointerState: () => {},
startGesture: gestureSpies.startGesture,
startGroupDrag: gestureSpies.startGroupDrag,
onPointerMove: gestureSpies.onPointerMove,
onPointerUp: gestureSpies.onPointerUp,
clearPointerState: gestureSpies.clearPointerState,
}),
}));
@@ -34,9 +42,18 @@ vi.mock("./useDomEditOverlayRects", async () => {
const { rectsEqual } = await import("./domEditOverlayGeometry");
return {
useDomEditOverlayRects: () => {
const [overlayRect, setOverlayRectState] = React.useState(null);
const overlayRectRef = React.useRef(null);
useDomEditOverlayRects: (options: { selectionRef: { current: unknown } }) => {
const defaultSelectionRect = {
left: 24,
top: 36,
width: 180,
height: 72,
editScaleX: 1,
editScaleY: 1,
};
const initialOverlayRect = options.selectionRef.current ? defaultSelectionRect : null;
const [overlayRect, setOverlayRectState] = React.useState(initialOverlayRect);
const overlayRectRef = React.useRef(initialOverlayRect);
const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]);
const groupOverlayItemsRef = React.useRef([]);
@@ -85,6 +102,30 @@ vi.mock("./domEditOverlayGeometry", async () => {
};
});
function createOverlayProps(args: {
iframeRef: { current: HTMLIFrameElement | null };
selection: DomEditSelection | null;
hoverSelection: DomEditSelection | null;
onSelectionChange: (next: DomEditSelection) => void;
}) {
return {
iframeRef: args.iframeRef,
activeCompositionPath: null,
selection: args.selection,
hoverSelection: args.hoverSelection,
groupSelections: [],
onCanvasMouseDown: () => {},
onCanvasPointerMove: () => Promise.resolve(args.hoverSelection ?? args.selection),
onCanvasPointerLeave: () => {},
onSelectionChange: args.onSelectionChange,
onBlockedMove: () => {},
onPathOffsetCommit: () => {},
onGroupPathOffsetCommit: () => {},
onBoxSizeCommit: () => {},
onRotationCommit: () => {},
};
}
describe("focusDomEditOverlayElement", () => {
it("focuses the canvas overlay without scrolling", () => {
const calls: Array<FocusOptions | undefined> = [];
@@ -97,7 +138,94 @@ describe("focusDomEditOverlayElement", () => {
});
describe("DomEditOverlay", () => {
it("renders selected bounds right after clicking a movable selection", async () => {
beforeEach(() => {
gestureSpies.startGesture.mockClear();
gestureSpies.startGroupDrag.mockClear();
gestureSpies.onPointerMove.mockClear();
gestureSpies.onPointerUp.mockClear();
gestureSpies.clearPointerState.mockClear();
});
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"),
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",
},
};
let currentSelection: DomEditSelection | null = null;
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
function Harness() {
const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
currentSelection = selected;
return React.createElement(
DomEditOverlay,
createOverlayProps({
iframeRef,
selection: selected,
hoverSelection: selection,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
);
}
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(gestureSpies.startGesture).not.toHaveBeenCalled();
expect(currentSelection).toBe(null);
act(() => {
root.unmount();
});
host.remove();
});
it("starts movement from the selected bounds", async () => {
// The overlay's compRect updates via a RAF loop reading iframe + overlay
// getBoundingClientRect. happy-dom returns all zeros for newly-created
// elements with no layout, so without stubs the RAF early-returns
@@ -153,32 +281,24 @@ describe("DomEditOverlay", () => {
},
};
let currentSelection: DomEditSelection | null = null;
let currentSelection: DomEditSelection | null = selection;
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);
const [selected, setSelected] = React.useState<DomEditSelection | null>(selection);
currentSelection = selected;
return React.createElement(DomEditOverlay, {
iframeRef,
activeCompositionPath: null,
selection: selected,
// Simulate the element being hovered before pointer-down (real users always hover first)
hoverSelection: selection,
groupSelections: [],
onCanvasMouseDown: () => {},
onCanvasPointerMove: () => Promise.resolve(selection),
onCanvasPointerLeave: () => {},
onSelectionChange: (next: DomEditSelection) => setSelected(next),
onBlockedMove: () => {},
onPathOffsetCommit: () => {},
onGroupPathOffsetCommit: () => {},
onBoxSizeCommit: () => {},
onRotationCommit: () => {},
});
return React.createElement(
DomEditOverlay,
createOverlayProps({
iframeRef,
selection: selected,
hoverSelection: null,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
);
}
act(() => {
@@ -197,8 +317,13 @@ describe("DomEditOverlay", () => {
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
expect(overlay).toBeTruthy();
const selectionBox = host.querySelector(
'[data-dom-edit-selection-box="true"]',
) as HTMLDivElement;
expect(selectionBox).toBeTruthy();
act(() => {
overlay.dispatchEvent(
selectionBox.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
@@ -209,7 +334,10 @@ describe("DomEditOverlay", () => {
});
expect(currentSelection).toBe(selection);
expect(host.querySelector('[data-dom-edit-selection-box="true"]')).toBeTruthy();
expect(gestureSpies.startGesture).toHaveBeenCalledWith(
"drag",
expect.objectContaining({ button: 0 }),
);
act(() => {
root.unmount();
@@ -1,7 +1,7 @@
import { memo, useMemo, useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { type DomEditSelection } from "./domEditing";
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import {
type BlockedMoveState,
type FocusableDomEditOverlay,
@@ -304,28 +304,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
const candidate = hoverSelectionRef.current;
if (!candidate?.capabilities.canApplyManualOffset) return;
const overlayEl = overlayRef.current;
const iframe = iframeRef.current;
const candidateRect =
overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null;
if (!candidateRect) return;
suppressNextOverlayMouseDownRef.current = true;
selectionRef.current = candidate;
setOverlayRect(candidateRect);
const didStartGesture = gestures.startGesture("drag", event, {
selection: candidate,
rect: candidateRect,
});
if (!didStartGesture) {
suppressNextOverlayMouseDownRef.current = false;
return;
}
onSelectionChangeRef.current(candidate);
};
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -34,10 +34,6 @@ export {
setCssFilterFunctionPx,
} from "./propertyPanelHelpers";
/* ------------------------------------------------------------------ */
/* PropertyPanel */
/* ------------------------------------------------------------------ */
// fallow-ignore-next-line complexity
export const PropertyPanel = memo(function PropertyPanel({
projectId,
@@ -177,10 +173,12 @@ export const PropertyPanel = memo(function PropertyPanel({
return;
}
const current = readStudioPathOffset(element.element);
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
});
void Promise.resolve(
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
}),
).catch(() => undefined);
};
// fallow-ignore-next-line complexity
@@ -204,17 +202,19 @@ export const PropertyPanel = memo(function PropertyPanel({
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
});
void Promise.resolve(
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
}),
).catch(() => undefined);
};
const manualRotation = readStudioRotation(element.element);
const commitManualRotation = (nextValue: string) => {
const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return;
onSetManualRotation(element, { angle: parsed });
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
};
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
@@ -431,6 +431,90 @@ describe("resolveDomEditSelection", () => {
});
});
it("keeps the full-canvas stage layer transform disabled while allowing style edits", async () => {
const document = createDocument(`
<div data-hf-id="hf-stage" id="stage">
<button id="cta">Add to basket</button>
</div>
`);
document.documentElement.setAttribute("data-composition-id", "root");
document.documentElement.setAttribute("data-width", "1920");
document.documentElement.setAttribute("data-height", "1080");
setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 });
const stage = document.getElementById("stage") as HTMLElement;
setElementRect(stage, { left: 0, top: 0, width: 1920, height: 1080 });
const selection = await resolveDomEditSelection(stage, {
activeCompositionPath: null,
isMasterView: true,
skipSourceProbe: true,
});
expect(selection?.id).toBe("stage");
expect(selection?.capabilities).toMatchObject({
canSelect: true,
canEditStyles: true,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "The root composition defines the preview bounds.",
});
});
it("keeps direct full-bleed absolute layers editable", async () => {
const document = createDocument(`
<div id="hero" style="position: absolute; left: 0; top: 0; width: 1920px; height: 1080px;"></div>
`);
document.documentElement.setAttribute("data-composition-id", "root");
document.documentElement.setAttribute("data-width", "1920");
document.documentElement.setAttribute("data-height", "1080");
setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 });
const hero = document.getElementById("hero") as HTMLElement;
setElementRect(hero, { left: 0, top: 0, width: 1920, height: 1080 });
const selection = await resolveDomEditSelection(hero, {
activeCompositionPath: null,
isMasterView: true,
skipSourceProbe: true,
});
expect(selection?.id).toBe("hero");
expect(selection?.capabilities).toMatchObject({
canSelect: true,
canEditStyles: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
});
});
it("lets full-canvas layers opt out of root-layer classification", async () => {
const document = createDocument(`
<div data-hf-allow-root-edit id="editable-stage">
<button id="cta">Add to basket</button>
</div>
`);
document.documentElement.setAttribute("data-composition-id", "root");
document.documentElement.setAttribute("data-width", "1920");
document.documentElement.setAttribute("data-height", "1080");
setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 });
const editableStage = document.getElementById("editable-stage") as HTMLElement;
setElementRect(editableStage, { left: 0, top: 0, width: 1920, height: 1080 });
const selection = await resolveDomEditSelection(editableStage, {
activeCompositionPath: null,
isMasterView: true,
skipSourceProbe: true,
});
expect(selection?.id).toBe("editable-stage");
expect(selection?.capabilities.canApplyManualOffset).toBe(true);
});
it("resolves child clicks inside a composition host to the child in master view", async () => {
const document = createDocument(`
<div data-composition-id="main">
@@ -31,6 +31,7 @@ import {
getDirectLayerChildren,
getSelectionCandidate,
} from "./domEditingElement";
import { isCompositionRootLayer } from "./domEditingRootLayer";
// ─── Text fields ────────────────────────────────────────────────────────────
@@ -179,6 +180,7 @@ export function resolveDomEditCapabilities(args: {
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
isCompositionHost: boolean;
isCompositionRoot?: boolean;
isInsideLockedComposition: boolean;
isMasterView: boolean;
existsInSource?: boolean;
@@ -211,6 +213,19 @@ export function resolveDomEditCapabilities(args: {
};
}
if (args.isCompositionRoot) {
return {
canSelect: true,
canEditStyles: true,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "The root composition defines the preview bounds.",
};
}
const position = args.computedStyles.position;
const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
@@ -341,6 +356,9 @@ export async function resolveDomEditSelection(
undefined;
const inlineStyles = getInlineStyles(current);
const computedStyles = getCuratedComputedStyles(current);
const isCompositionRoot =
(current.hasAttribute("data-composition-id") && !compositionSrc) ||
isCompositionRootLayer(current, doc, computedStyles);
const textFields = collectDomEditTextFields(current);
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
let existsInSource: boolean | undefined;
@@ -361,6 +379,7 @@ export async function resolveDomEditSelection(
inlineStyles,
computedStyles,
isCompositionHost: Boolean(compositionSrc),
isCompositionRoot,
isInsideLockedComposition: isInsideLocked,
isMasterView: options.isMasterView,
existsInSource,
@@ -0,0 +1,64 @@
import { parsePx } from "./domEditingDom";
const COMPOSITION_ROOT_LAYER_EPSILON_PX = 1;
function readPositiveDimension(value: string | null): number | null {
if (!value) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function approximatelyEqual(a: number, b: number) {
return Math.abs(a - b) <= COMPOSITION_ROOT_LAYER_EPSILON_PX;
}
function getCompositionRootBounds(doc: Document) {
const root =
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
const rootWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
const rootHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
if (!root || !rootWidth || !rootHeight) return null;
return { rect: root.getBoundingClientRect(), width: rootWidth, height: rootHeight };
}
function getRenderedLayerSize(element: HTMLElement, computedStyles: Record<string, string>) {
const rect = element.getBoundingClientRect();
const width = rect.width || parsePx(computedStyles.width);
const height = rect.height || parsePx(computedStyles.height);
return width && height ? { width, height } : null;
}
function matchesCompositionRootBounds(
elementRect: DOMRect,
elementSize: { width: number; height: number },
rootBounds: { rect: DOMRect; width: number; height: number },
) {
return (
approximatelyEqual(elementRect.left, rootBounds.rect.left) &&
approximatelyEqual(elementRect.top, rootBounds.rect.top) &&
approximatelyEqual(elementSize.width, rootBounds.width) &&
approximatelyEqual(elementSize.height, rootBounds.height)
);
}
function isExplicitFullBleedLayer(computedStyles: Record<string, string>) {
return computedStyles.position === "absolute" || computedStyles.position === "fixed";
}
export function isCompositionRootLayer(
element: HTMLElement,
doc: Document,
computedStyles: Record<string, string>,
) {
if (element.parentElement !== doc.body) return false;
if (element.hasAttribute("data-hf-allow-root-edit")) return false;
if (isExplicitFullBleedLayer(computedStyles)) return false;
const rootBounds = getCompositionRootBounds(doc);
const elementSize = getRenderedLayerSize(element, computedStyles);
return Boolean(
rootBounds &&
elementSize &&
matchesCompositionRootBounds(element.getBoundingClientRect(), elementSize, rootBounds),
);
}