mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
## 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`.
65 lines
2.4 KiB
TypeScript
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),
|
|
);
|
|
}
|