mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
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:
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user