Files
hyperframes/packages/studio/src/components/editor/domEditOverlayGeometry.ts
T

265 lines
9.8 KiB
TypeScript

import { type DomEditSelection, findElementForSelection } from "./domEditing";
import { isElementVisibleThroughAncestors } from "./domEditingDom";
import { hugRectForElement } from "./domEditOverlayCrop";
export interface OverlayRect {
left: number;
top: number;
width: number;
height: number;
editScaleX: number;
editScaleY: number;
}
export interface GroupOverlayItem {
key: string;
selection: DomEditSelection;
element: HTMLElement;
rect: OverlayRect;
}
export type ResolvedElementRef = {
current: { key: string; element: HTMLElement } | null;
};
export function isElementVisibleForOverlay(el: HTMLElement): boolean {
return isElementVisibleThroughAncestors(el);
}
// Sample points (as fractions of the element box) for the occlusion hit-test:
// the four inner corners plus the center. This is a coarse approximation of the
// element's painted area — we assume a sampled point that lands inside the box also
// lands on something the element actually paints.
//
// LIMITATION: a donut/ring-shaped element (a hole in the middle, content only around
// the edges) breaks that assumption — the center sample, and even the corner samples,
// can fall in the transparent hole and hit-test through to whatever is behind, so the
// element could read as occluded (or as covering) incorrectly. Today's scene element
// shapes (rectangular cards, text, full-bleed media) don't have interior holes, so this
// doesn't bite. If ring/cutout shapes become editable targets, sample more densely or
// hit-test against the element's actual painted geometry instead of its bounding box.
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 findSourceBoundary(element: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = element;
while (current) {
if (
current.hasAttribute("data-composition-file") ||
current.hasAttribute("data-composition-src")
) {
return current;
}
current = current.parentElement;
}
return null;
}
export function resolveDomEditCoordinateScale(input: {
rootScaleX: number;
rootScaleY: number;
sourceRectWidth?: number;
sourceRectHeight?: number;
sourceWidth?: number | null;
sourceHeight?: number | null;
}): { scaleX: number; scaleY: number } {
const rootScaleX = input.rootScaleX > 0 ? input.rootScaleX : 1;
const rootScaleY = input.rootScaleY > 0 ? input.rootScaleY : 1;
const sourceScaleX =
input.sourceRectWidth && input.sourceRectWidth > 0 && input.sourceWidth && input.sourceWidth > 0
? (input.sourceRectWidth * rootScaleX) / input.sourceWidth
: rootScaleX;
const sourceScaleY =
input.sourceRectHeight &&
input.sourceRectHeight > 0 &&
input.sourceHeight &&
input.sourceHeight > 0
? (input.sourceRectHeight * rootScaleY) / input.sourceHeight
: rootScaleY;
return {
scaleX: sourceScaleX > 0 ? sourceScaleX : rootScaleX,
scaleY: sourceScaleY > 0 ? sourceScaleY : rootScaleY,
};
}
/** toOverlayRect, then shrunk to the element's visible (inset-cropped) region.
* For consumers that reason about what's ON SCREEN — snap targets, marquee
* hit-tests, display outlines. The selection box must keep the full rect
* (it is the gesture coordinate basis). */
export function toVisibleOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
): OverlayRect | null {
const rect = toOverlayRect(overlayEl, iframe, element);
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
}
export function toOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
): OverlayRect | null {
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const doc = iframe.contentDocument;
const root =
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
const rootRect = root?.getBoundingClientRect();
// Use the composition's declared dimensions (data-width/data-height) for scale
// calculation instead of rootRect.width/height. When GSAP applies transforms
// (scale, translate) to the root element, rootRect dimensions change but the
// composition's canonical size stays the same. Using rootRect causes overlay
// misalignment during animated playback.
const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
const rootWidth = declaredWidth ?? rootRect?.width;
const rootHeight = declaredHeight ?? rootRect?.height;
if (!rootWidth || !rootHeight || !rootRect) return null;
const elementRect = element.getBoundingClientRect();
const rootScaleX = iframeRect.width / rootWidth;
const rootScaleY = iframeRect.height / rootHeight;
const sourceBoundary = findSourceBoundary(element);
const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect();
const editScale = resolveDomEditCoordinateScale({
rootScaleX,
rootScaleY,
sourceRectWidth: sourceBoundaryRect?.width,
sourceRectHeight: sourceBoundaryRect?.height,
sourceWidth: readPositiveDimension(sourceBoundary?.getAttribute("data-width") ?? null),
sourceHeight: readPositiveDimension(sourceBoundary?.getAttribute("data-height") ?? null),
});
return {
left: iframeRect.left - overlayRect.left + elementRect.left * rootScaleX,
top: iframeRect.top - overlayRect.top + elementRect.top * rootScaleY,
width: elementRect.width * rootScaleX,
height: elementRect.height * rootScaleY,
editScaleX: editScale.scaleX,
editScaleY: editScale.scaleY,
};
}
const OVERLAY_RECT_EPSILON_PX = 0.5;
export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
Math.abs(a.left - b.left) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.top - b.top) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.editScaleX - b.editScaleX) < 0.001 &&
Math.abs(a.editScaleY - b.editScaleY) < 0.001
);
}
export function groupOverlayItemsEqual(a: GroupOverlayItem[], b: GroupOverlayItem[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every((item, index) => {
const other = b[index];
return Boolean(
other &&
item.key === other.key &&
item.element === other.element &&
item.selection === other.selection &&
rectsEqual(item.rect, other.rect),
);
});
}
export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRect | null {
const first = rects[0];
if (!first) return null;
let left = first.left;
let top = first.top;
let right = first.left + first.width;
let bottom = first.top + first.height;
for (const rect of rects.slice(1)) {
left = Math.min(left, rect.left);
top = Math.min(top, rect.top);
right = Math.max(right, rect.left + rect.width);
bottom = Math.max(bottom, rect.top + rect.height);
}
return {
left,
top,
width: right - left,
height: bottom - top,
editScaleX: 1,
editScaleY: 1,
};
}
// A group's overlay box encompasses its members' actual rendered bounds, not just
// the wrapper's own box — so members moved or transformed out of the wrapper still
// sit inside the box. Used by the selection, hover, and off-canvas overlays so they
// all agree on where a group is.
export function groupAwareOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
el: HTMLElement,
): OverlayRect | null {
const rect = toOverlayRect(overlayEl, iframe, el);
if (!rect || !el.hasAttribute("data-hf-group")) return rect;
// Union the MEMBERS' rendered rects — where the content actually is — not the
// wrapper's own box. The wrapper is invisible and its box can sit apart from the
// members once they've been moved/transformed, which would otherwise drag the
// group's bounds (and its off-canvas marker) off to a stale position.
const rects: OverlayRect[] = [];
for (const child of Array.from(el.children)) {
const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement);
if (childRect) rects.push(childRect);
}
const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
if (!union) return rect; // empty group → fall back to the wrapper box
// resolveDomEditGroupOverlayRect hardcodes editScaleX/Y to 1; keep the wrapper's
// real edit (display) scale, which the drag uses to convert pointer→offset — a
// reset-to-1 makes the group move at ~display-scale speed and lag the cursor.
return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
}
export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
return items.filter(
(item) => !items.some((other) => other !== item && other.element.contains(item.element)),
);
}
export function selectionCacheKey(
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
): string {
return [
selection.sourceFile ?? "",
selection.hfId ?? "",
selection.id ?? "",
selection.selector ?? "",
selection.selectorIndex ?? "",
].join("|");
}
export function resolveElementForOverlay(
doc: Document,
sel: DomEditSelection,
activeCompositionPath: string | null,
cacheRef: ResolvedElementRef,
): HTMLElement | null {
const key = selectionCacheKey(sel);
const cached = cacheRef.current;
if (cached?.key === key && cached.element.isConnected && cached.element.ownerDocument === doc) {
return cached.element;
}
const next = findElementForSelection(doc, sel, activeCompositionPath);
cacheRef.current = next ? { key, element: next } : null;
return next;
}