mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
265 lines
9.8 KiB
TypeScript
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;
|
|
}
|