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
@@ -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,