Files
hyperframes/packages/studio/src/components/editor/domEditingRootLayer.ts
T
Miguel Ángel 1ea0ed55e3 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`.
2026-06-12 19:16:36 -04:00

65 lines
2.4 KiB
TypeScript

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),
);
}