mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
213 lines
7.6 KiB
TypeScript
213 lines
7.6 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
import { useCallback, useRef, useState } from "react";
|
|
import type { DomEditSelection } from "./domEditing";
|
|
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
|
|
import { isElementComputedVisible } from "./domEditingElement";
|
|
import { coversComposition } from "../../utils/studioPreviewHelpers";
|
|
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
|
import { toVisibleOverlayRect } from "./domEditOverlayGeometry";
|
|
|
|
interface MarqueeState {
|
|
startX: number;
|
|
startY: number;
|
|
currentX: number;
|
|
currentY: number;
|
|
pointerId: number;
|
|
pastThreshold: boolean;
|
|
}
|
|
|
|
const MARQUEE_THRESHOLD_PX = 4;
|
|
|
|
interface MarqueeHit {
|
|
element: HTMLElement;
|
|
rect: Rect;
|
|
}
|
|
|
|
/**
|
|
* Synchronous core of the marquee: the elements whose overlay-space rect
|
|
* intersects the marquee rect. Uses the SAME `toOverlayRect` basis as the
|
|
* single-selection / group-selection boxes, so what the marquee highlights
|
|
* and selects is exactly the box the user sees when they click an element.
|
|
* Shared by the live candidate highlight (per pointer-move) and the mouse-up
|
|
* commit. No async source probe — that only happens once, on commit.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
function collectMarqueeHits(
|
|
rect: Rect,
|
|
iframe: HTMLIFrameElement,
|
|
overlayEl: HTMLDivElement,
|
|
activeCompositionPath: string,
|
|
): MarqueeHit[] {
|
|
const doc = iframe.contentDocument;
|
|
if (!doc) return [];
|
|
|
|
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
|
|
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
|
|
const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView });
|
|
|
|
const rootEl = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
|
|
const declW = Number.parseFloat(rootEl?.getAttribute("data-width") ?? "");
|
|
const declH = Number.parseFloat(rootEl?.getAttribute("data-height") ?? "");
|
|
const viewport = {
|
|
width: declW > 0 ? declW : rootEl.getBoundingClientRect().width || 1,
|
|
height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
|
|
};
|
|
|
|
const hits: MarqueeHit[] = [];
|
|
for (const item of items) {
|
|
const el = item.element;
|
|
if (!isElementComputedVisible(el)) continue;
|
|
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
|
|
const overlayRect = toVisibleOverlayRect(overlayEl, iframe, el);
|
|
if (!overlayRect) continue;
|
|
const r: Rect = {
|
|
left: overlayRect.left,
|
|
top: overlayRect.top,
|
|
width: overlayRect.width,
|
|
height: overlayRect.height,
|
|
};
|
|
if (!rectsOverlap(rect, r)) continue;
|
|
hits.push({ element: el, rect: r });
|
|
}
|
|
|
|
return hits;
|
|
}
|
|
|
|
async function runMarqueeIntersection(
|
|
rect: Rect,
|
|
iframe: HTMLIFrameElement,
|
|
overlayEl: HTMLDivElement,
|
|
activeCompositionPath: string,
|
|
): Promise<DomEditSelection[]> {
|
|
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
|
|
const hits: DomEditSelection[] = [];
|
|
for (const { element } of collectMarqueeHits(rect, iframe, overlayEl, activeCompositionPath)) {
|
|
const sel = await resolveDomEditSelection(element, {
|
|
activeCompositionPath,
|
|
isMasterView,
|
|
skipSourceProbe: true,
|
|
});
|
|
if (sel) hits.push(sel);
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
interface MarqueeGesturesDeps {
|
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
overlayRef: React.RefObject<HTMLDivElement | null>;
|
|
activeCompositionPathRef: React.RefObject<string | null>;
|
|
onMarqueeSelectRef: React.RefObject<
|
|
((selections: DomEditSelection[], additive: boolean) => void) | undefined
|
|
>;
|
|
selectionRef: React.RefObject<DomEditSelection | null>;
|
|
gestures: {
|
|
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
|
|
};
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
|
const marqueeRef = useRef<MarqueeState | null>(null);
|
|
const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
|
|
// Live "candidate" highlight: the elements the marquee currently touches,
|
|
// shown before mouse-up so you can see what you're about to select. The
|
|
// iframe DOM doesn't mutate during a drag, so a sync intersection per move
|
|
// is cheap (clean layout → no thrash).
|
|
const [candidateRects, setCandidateRects] = useState<Rect[]>([]);
|
|
|
|
const commitMarquee = useCallback(
|
|
async (
|
|
rect: { left: number; top: number; width: number; height: number },
|
|
additive: boolean,
|
|
) => {
|
|
const iframe = deps.iframeRef.current;
|
|
const overlay = deps.overlayRef.current;
|
|
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
|
|
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
|
|
deps.onMarqueeSelectRef.current(hits, additive);
|
|
},
|
|
[deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
|
|
);
|
|
|
|
const onPointerMove = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
const m = marqueeRef.current;
|
|
if (m) {
|
|
const oRect = deps.overlayRef.current?.getBoundingClientRect();
|
|
if (!oRect) return;
|
|
m.currentX = event.clientX - oRect.left;
|
|
m.currentY = event.clientY - oRect.top;
|
|
if (!m.pastThreshold) {
|
|
const dx = m.currentX - m.startX;
|
|
const dy = m.currentY - m.startY;
|
|
if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
|
|
m.pastThreshold = true;
|
|
}
|
|
const rect: Rect = {
|
|
left: Math.min(m.startX, m.currentX),
|
|
top: Math.min(m.startY, m.currentY),
|
|
width: Math.abs(m.currentX - m.startX),
|
|
height: Math.abs(m.currentY - m.startY),
|
|
};
|
|
setMarqueeRect(rect);
|
|
const iframe = deps.iframeRef.current;
|
|
const overlay = deps.overlayRef.current;
|
|
if (iframe && overlay) {
|
|
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
setCandidateRects(collectMarqueeHits(rect, iframe, overlay, acp).map((h) => h.rect));
|
|
}
|
|
return;
|
|
}
|
|
deps.gestures.onPointerMove(event);
|
|
},
|
|
[deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
|
|
);
|
|
|
|
const onPointerUp = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
const m = marqueeRef.current;
|
|
if (m) {
|
|
marqueeRef.current = null;
|
|
try {
|
|
(event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
|
|
} catch {
|
|
/* already released */
|
|
}
|
|
if (m.pastThreshold) {
|
|
commitMarquee(
|
|
{
|
|
left: Math.min(m.startX, m.currentX),
|
|
top: Math.min(m.startY, m.currentY),
|
|
width: Math.abs(m.currentX - m.startX),
|
|
height: Math.abs(m.currentY - m.startY),
|
|
},
|
|
event.shiftKey,
|
|
);
|
|
} else {
|
|
deps.onMarqueeSelectRef.current?.([], false);
|
|
}
|
|
setMarqueeRect(null);
|
|
setCandidateRects([]);
|
|
return;
|
|
}
|
|
deps.gestures.onPointerUp(event);
|
|
},
|
|
[deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
|
|
);
|
|
|
|
const onPointerCancel = useCallback(() => {
|
|
if (marqueeRef.current) {
|
|
marqueeRef.current = null;
|
|
setMarqueeRect(null);
|
|
setCandidateRects([]);
|
|
return;
|
|
}
|
|
deps.gestures.clearPointerState(deps.selectionRef);
|
|
}, [deps.gestures, deps.selectionRef]);
|
|
|
|
return { marqueeRef, marqueeRect, candidateRects, onPointerMove, onPointerUp, onPointerCancel };
|
|
}
|