{
- let br: string | number = 4;
- let cp: string | undefined;
- try {
- const el = hoverSelection.element;
- const tag = el.tagName.toLowerCase();
- if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
- const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
- if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
- if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
- }
- } catch {
- /* cross-origin guard */
- }
- return {
- left: hoverRect.left,
- top: hoverRect.top,
- width: hoverRect.width,
- height: hoverRect.height,
- borderRadius: br,
- clipPath: cp,
- };
- })()}
+ className="pointer-events-none absolute rounded-md border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
+ style={{
+ left: hoverRect.left,
+ top: hoverRect.top,
+ width: hoverRect.width,
+ height: hoverRect.height,
+ }}
/>
)}
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
@@ -437,13 +501,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
key={selectionKey}
ref={boxRef}
data-dom-edit-selection-box="true"
- className={`pointer-events-auto absolute ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
+ className={`pointer-events-auto absolute rounded-md ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
- borderRadius: selectionShapeStyles.borderRadius,
clipPath: selectionShapeStyles.clipPath,
cursor:
allowCanvasMovement && selection.capabilities.canApplyManualOffset
@@ -496,6 +559,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}}
/>
))}
+
+ {marquee.marqueeRect && (
+
+ )}
>;
+ compRect: { left: number; top: number; width: number; height: number };
+ selection: DomEditSelection | null;
+ groupSelections: DomEditSelection[];
+ activeCompositionPathRef: React.MutableRefObject;
+ onSelectionChangeRef: React.MutableRefObject<
+ (selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
+ >;
+}
+
+/**
+ * Dashed teal indicators for elements whose bounds extend past the composition
+ * (the "gray zone"). The in-canvas portion is clipped away so only the
+ * protruding sliver is dashed; the inside portion gets a solid outline.
+ * Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
+ */
+export function OffCanvasIndicators({
+ rects,
+ elements,
+ compRect,
+ selection,
+ groupSelections,
+ activeCompositionPathRef,
+ onSelectionChangeRef,
+}: OffCanvasIndicatorsProps): React.ReactElement {
+ return (
+ <>
+ {rects
+ .filter((r) => {
+ // Suppress the indicator for any currently-selected element (primary
+ // OR a marquee group member) — those already render a selection box.
+ const el = elements.current.get(r.key);
+ if (!el) return true;
+ if (selection?.element === el) return false;
+ return !groupSelections.some((g) => g.element === el);
+ })
+ .map((r) => {
+ const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
+ const cL = Math.max(0, compRect.left - r.left);
+ const cT = Math.max(0, compRect.top - r.top);
+ const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
+ const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
+ const hasInside = cL < cR && cT < cB;
+ const clipOutside = hasInside
+ ? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
+ : undefined;
+ const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
+ const selectOffCanvas = async () => {
+ const el = elements.current.get(r.key);
+ if (!el) return;
+ const { resolveDomEditSelection } = await import("./domEditingLayers");
+ const acp = activeCompositionPathRef.current ?? "index.html";
+ const sel = await resolveDomEditSelection(el, {
+ activeCompositionPath: acp,
+ isMasterView: !acp || acp === "index.html",
+ skipSourceProbe: true,
+ });
+ if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
+ };
+ const handleClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ void selectOffCanvas();
+ };
+ return (
+
+ {/* Dashed layer — clipped to exclude canvas area.
+ Note: clip-path is visual only — hit-testing still covers the
+ full bounding rect, so clicking the in-canvas portion selects
+ via this handler. That's acceptable: it resolves the same
+ element the normal canvas path would, just with
+ skipSourceProbe (the element is already known here). */}
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ void selectOffCanvas();
+ }
+ }}
+ />
+ {/* Solid layer — clipped to canvas bounds, covers inside portion */}
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/packages/studio/src/components/editor/marqueeCommit.ts b/packages/studio/src/components/editor/marqueeCommit.ts
new file mode 100644
index 000000000..6502d48c2
--- /dev/null
+++ b/packages/studio/src/components/editor/marqueeCommit.ts
@@ -0,0 +1,168 @@
+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 { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
+
+interface MarqueeState {
+ startX: number;
+ startY: number;
+ currentX: number;
+ currentY: number;
+ pointerId: number;
+ pastThreshold: boolean;
+}
+
+const MARQUEE_THRESHOLD_PX = 4;
+
+// fallow-ignore-next-line complexity
+async function runMarqueeIntersection(
+ rect: { left: number; top: number; width: number; height: number },
+ iframe: HTMLIFrameElement,
+ overlayEl: HTMLDivElement,
+ activeCompositionPath: string,
+): Promise
{
+ const doc = iframe.contentDocument;
+ if (!doc) return [];
+
+ const root = doc.querySelector("[data-composition-id]") ?? doc.body;
+ const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
+ const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView });
+
+ const rootEl = doc.querySelector("[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: DomEditSelection[] = [];
+ for (const item of items) {
+ const el = item.element;
+ if (!isElementComputedVisible(el)) continue;
+ if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
+ const corners = elementObbCorners(el, overlayEl, iframe);
+ if (!corners) continue;
+ if (!marqueeIntersectsObb(rect, corners)) continue;
+ const sel = await resolveDomEditSelection(el, {
+ activeCompositionPath,
+ isMasterView,
+ skipSourceProbe: true,
+ });
+ if (sel) hits.push(sel);
+ }
+
+ return hits;
+}
+
+interface MarqueeGesturesDeps {
+ iframeRef: React.RefObject;
+ overlayRef: React.RefObject;
+ activeCompositionPathRef: React.RefObject;
+ onMarqueeSelectRef: React.RefObject<
+ ((selections: DomEditSelection[], additive: boolean) => void) | undefined
+ >;
+ selectionRef: React.RefObject;
+ gestures: {
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ clearPointerState: (ref: React.RefObject) => void;
+ };
+}
+
+// fallow-ignore-next-line complexity
+export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
+ const marqueeRef = useRef(null);
+ const [marqueeRect, setMarqueeRect] = useState<{
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ } | null>(null);
+
+ 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) => {
+ 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;
+ }
+ setMarqueeRect({
+ 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),
+ });
+ return;
+ }
+ deps.gestures.onPointerMove(event);
+ },
+ [deps.gestures, deps.overlayRef],
+ );
+
+ const onPointerUp = useCallback(
+ (event: React.PointerEvent) => {
+ 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);
+ return;
+ }
+ deps.gestures.onPointerUp(event);
+ },
+ [deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
+ );
+
+ const onPointerCancel = useCallback(() => {
+ if (marqueeRef.current) {
+ marqueeRef.current = null;
+ setMarqueeRect(null);
+ return;
+ }
+ deps.gestures.clearPointerState(deps.selectionRef);
+ }, [deps.gestures, deps.selectionRef]);
+
+ return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
+}
diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts
index 8a2bed282..8bc4063f5 100644
--- a/packages/studio/src/components/editor/propertyPanelHelpers.ts
+++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts
@@ -66,6 +66,7 @@ export interface PropertyPanelProps {
value: number | string,
) => void;
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
+ onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
onConvertToKeyframes?: (animationId: string) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,
diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx
index 21e60be48..ea504a888 100644
--- a/packages/studio/src/contexts/DomEditContext.tsx
+++ b/packages/studio/src/contexts/DomEditContext.tsx
@@ -61,6 +61,8 @@ export interface DomEditActionsValue extends Pick<
| "invalidateGsapCache"
| "previewIframeRef"
| "commitMutation"
+ | "applyMarqueeSelection"
+ | "handleUpdateKeyframeEase"
> {}
export interface DomEditSelectionValue extends Pick<
@@ -167,6 +169,8 @@ export function DomEditProvider({
invalidateGsapCache,
previewIframeRef,
commitMutation,
+ applyMarqueeSelection,
+ handleUpdateKeyframeEase,
},
children,
}: {
@@ -238,6 +242,8 @@ export function DomEditProvider({
invalidateGsapCache,
previewIframeRef,
commitMutation: stableCommitMutation,
+ applyMarqueeSelection,
+ handleUpdateKeyframeEase,
}),
[
handleTimelineElementSelect,
@@ -295,6 +301,8 @@ export function DomEditProvider({
invalidateGsapCache,
previewIframeRef,
stableCommitMutation,
+ applyMarqueeSelection,
+ handleUpdateKeyframeEase,
],
);
diff --git a/packages/studio/src/hooks/useDomEditPreviewSync.ts b/packages/studio/src/hooks/useDomEditPreviewSync.ts
index 2648be2aa..4d5310830 100644
--- a/packages/studio/src/hooks/useDomEditPreviewSync.ts
+++ b/packages/studio/src/hooks/useDomEditPreviewSync.ts
@@ -68,6 +68,11 @@ export function useDomEditPreviewSync({
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
if (!nextElement) {
+ // The selected element no longer resolves in the (re-synced) document
+ // — comp/hot reload, activeCompPath swap, or post-save replacement.
+ // Clear so overlay geometry isn't computed on a stale, detached node.
+ // (Drag-release-in-gray-zone is handled separately by
+ // suppressNextBoxClickRef; the dragged element still resolves here.)
applyDomSelection(null, { revealPanel: false });
return;
}
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index d4c582a0d..dd7d053c0 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -1,3 +1,4 @@
+import { useCallback } from "react";
import type { TimelineElement } from "../player";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
@@ -123,6 +124,7 @@ export function useDomEditSession({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
+ applyMarqueeSelection,
} = useDomSelection({
projectId,
activeCompPath,
@@ -389,6 +391,25 @@ export function useDomEditSession({
updateArcSegment,
});
+ const handleUpdateKeyframeEase = useCallback(
+ (animationId: string, percentage: number, ease: string) => {
+ const sel = domEditSelectionRef.current;
+ if (!sel) return;
+ gsapCommitMutation(
+ sel,
+ {
+ type: "update-keyframe",
+ animationId,
+ percentage,
+ properties: {},
+ ease,
+ },
+ { label: "Update keyframe ease", softReload: true },
+ );
+ },
+ [gsapCommitMutation, domEditSelectionRef],
+ );
+
return {
// State
domEditSelection,
@@ -429,6 +450,7 @@ export function useDomEditSession({
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
+ applyMarqueeSelection,
resolveImportedFontAsset,
setAgentModalOpen,
setAgentPromptSelectionContext,
@@ -454,6 +476,7 @@ export function useDomEditSession({
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
+ handleUpdateKeyframeEase,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts
index 898cede6e..6c9fc36ab 100644
--- a/packages/studio/src/hooks/useDomSelection.ts
+++ b/packages/studio/src/hooks/useDomSelection.ts
@@ -85,6 +85,7 @@ export interface UseDomSelectionReturn {
handleTimelineElementSelect: (element: TimelineElement | null) => Promise;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise;
+ applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
// ── Hook ──
@@ -419,6 +420,50 @@ export function useDomSelection({
applyDomSelection(null, { revealPanel: false });
}, [applyDomSelection, captionEditMode]);
+ const applyMarqueeSelection = useCallback(
+ // fallow-ignore-next-line complexity
+ (selections: DomEditSelection[], additive: boolean) => {
+ // Honor the inspector-panels kill switch like applyDomSelection does, so
+ // marquee can't land selections while the inspector UI is suppressed.
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
+ domEditSelectionRef.current = null;
+ domEditGroupSelectionsRef.current = [];
+ setDomEditSelection(null);
+ setDomEditGroupSelections([]);
+ return;
+ }
+ if (selections.length === 0) {
+ if (!additive) applyDomSelection(null, { revealPanel: false });
+ return;
+ }
+ const current = domEditSelectionRef.current;
+ const currentGroup = domEditGroupSelectionsRef.current;
+ let nextGroup: DomEditSelection[];
+ if (additive) {
+ nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
+ for (const s of selections) {
+ if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
+ }
+ } else {
+ nextGroup = selections;
+ }
+ const nextSelection = additive && current ? current : selections[0];
+ domEditSelectionRef.current = nextSelection;
+ domEditGroupSelectionsRef.current = nextGroup;
+ setDomEditSelection(nextSelection);
+ setDomEditGroupSelections(nextGroup);
+ const nextTimelineId =
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
+ findTimelineIdByAncestor(
+ nextSelection.element,
+ timelineElements,
+ nextSelection.sourceFile || "index.html",
+ );
+ setSelectedTimelineElementId(nextTimelineId);
+ },
+ [applyDomSelection, timelineElements, setSelectedTimelineElementId],
+ );
+
// Disabled inspector effect
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
@@ -451,5 +496,6 @@ export function useDomSelection({
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
+ applyMarqueeSelection,
};
}
diff --git a/packages/studio/src/utils/marqueeGeometry.test.ts b/packages/studio/src/utils/marqueeGeometry.test.ts
new file mode 100644
index 000000000..23688da34
--- /dev/null
+++ b/packages/studio/src/utils/marqueeGeometry.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+import { marqueeIntersectsObb, rectsOverlap, type Point, type Rect } from "./marqueeGeometry";
+
+type Corners = [Point, Point, Point, Point];
+
+function rotateCorners(cx: number, cy: number, w: number, h: number, deg: number): Corners {
+ const rad = (deg * Math.PI) / 180;
+ const cos = Math.cos(rad);
+ const sin = Math.sin(rad);
+ const hw = w / 2;
+ const hh = h / 2;
+ const local: [number, number][] = [
+ [-hw, -hh],
+ [hw, -hh],
+ [hw, hh],
+ [-hw, hh],
+ ];
+ return local.map(([lx, ly]) => ({
+ x: cx + cos * lx - sin * ly,
+ y: cy + sin * lx + cos * ly,
+ })) as Corners;
+}
+
+function aabbCorners(r: Rect): Corners {
+ return [
+ { x: r.left, y: r.top },
+ { x: r.left + r.width, y: r.top },
+ { x: r.left + r.width, y: r.top + r.height },
+ { x: r.left, y: r.top + r.height },
+ ];
+}
+
+describe("rectsOverlap", () => {
+ it("overlapping rects", () => {
+ expect(
+ rectsOverlap(
+ { left: 0, top: 0, width: 10, height: 10 },
+ { left: 5, top: 5, width: 10, height: 10 },
+ ),
+ ).toBe(true);
+ });
+
+ it("non-overlapping rects", () => {
+ expect(
+ rectsOverlap(
+ { left: 0, top: 0, width: 10, height: 10 },
+ { left: 20, top: 20, width: 10, height: 10 },
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("marqueeIntersectsObb", () => {
+ it("axis-aligned overlap", () => {
+ const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
+ const corners = aabbCorners({ left: 50, top: 50, width: 80, height: 80 });
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
+ });
+
+ it("axis-aligned no overlap", () => {
+ const marquee: Rect = { left: 0, top: 0, width: 50, height: 50 };
+ const corners = aabbCorners({ left: 100, top: 100, width: 50, height: 50 });
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
+ });
+
+ it("marquee fully contains element", () => {
+ const marquee: Rect = { left: 0, top: 0, width: 200, height: 200 };
+ const corners = aabbCorners({ left: 50, top: 50, width: 20, height: 20 });
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
+ });
+
+ it("element fully contains marquee", () => {
+ const marquee: Rect = { left: 50, top: 50, width: 10, height: 10 };
+ const corners = aabbCorners({ left: 0, top: 0, width: 200, height: 200 });
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
+ });
+
+ it("45-degree rotated square: AABB overlaps but OBB does not", () => {
+ // 100x100 square rotated 45° centered at (200,200)
+ // Its AABB extends to ~(129,129)-(271,271)
+ // A marquee at (0,0)-(135,135) overlaps the AABB but NOT the diamond
+ const corners = rotateCorners(200, 200, 100, 100, 45);
+ const marquee: Rect = { left: 0, top: 0, width: 135, height: 135 };
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
+ });
+
+ it("45-degree rotated square: OBB overlaps", () => {
+ // Same rotated square, marquee reaches the diamond's left point
+ const corners = rotateCorners(200, 200, 100, 100, 45);
+ const marquee: Rect = { left: 0, top: 150, width: 155, height: 100 };
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
+ });
+
+ it("zero-width marquee returns false", () => {
+ const corners = aabbCorners({ left: 0, top: 0, width: 100, height: 100 });
+ expect(marqueeIntersectsObb({ left: 50, top: 50, width: 0, height: 50 }, corners)).toBe(false);
+ });
+
+ it("zero-area element returns false for degenerate OBB", () => {
+ const corners: Corners = [
+ { x: 50, y: 50 },
+ { x: 50, y: 50 },
+ { x: 50, y: 50 },
+ { x: 50, y: 50 },
+ ];
+ const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
+ // Degenerate point — SAT still works (projections are zero-length intervals)
+ // A point inside the marquee should still intersect
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
+ });
+
+ it("30-degree rotated rectangle clips marquee corner", () => {
+ const corners = rotateCorners(150, 150, 200, 50, 30);
+ const marquee: Rect = { left: 0, top: 0, width: 80, height: 130 };
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
+ });
+
+ it("30-degree rotated rectangle misses marquee", () => {
+ const corners = rotateCorners(300, 300, 50, 50, 30);
+ const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
+ expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
+ });
+});
diff --git a/packages/studio/src/utils/marqueeGeometry.ts b/packages/studio/src/utils/marqueeGeometry.ts
new file mode 100644
index 000000000..4bf5d7e98
--- /dev/null
+++ b/packages/studio/src/utils/marqueeGeometry.ts
@@ -0,0 +1,172 @@
+export interface Point {
+ x: number;
+ y: number;
+}
+
+export interface Rect {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+type Corners = [Point, Point, Point, Point];
+
+function isIdentityMatrix(m: DOMMatrix): boolean {
+ const e = 1e-6;
+ return Math.abs(m.a - 1) < e && Math.abs(m.b) < e && Math.abs(m.c) < e && Math.abs(m.d - 1) < e;
+}
+
+function rectsOverlap(a: Rect, b: Rect): boolean {
+ return (
+ a.left < b.left + b.width &&
+ a.left + a.width > b.left &&
+ a.top < b.top + b.height &&
+ a.top + a.height > b.top
+ );
+}
+
+function projectOntoAxis(corners: Corners, ax: number, ay: number): [number, number] {
+ let min = Infinity;
+ let max = -Infinity;
+ for (const c of corners) {
+ const dot = c.x * ax + c.y * ay;
+ if (dot < min) min = dot;
+ if (dot > max) max = dot;
+ }
+ return [min, max];
+}
+
+function projectionsOverlap(a: [number, number], b: [number, number]): boolean {
+ return a[0] <= b[1] && b[0] <= a[1];
+}
+
+/**
+ * SAT intersection test between an axis-aligned marquee rect and a
+ * convex quadrilateral (the element's OBB corners in overlay space).
+ *
+ * Separating axes: 2 from the AABB (horizontal, vertical) + 2 from
+ * the OBB's edge normals. If projections overlap on all 4 axes, the
+ * shapes intersect.
+ */
+export function marqueeIntersectsObb(marquee: Rect, corners: Corners): boolean {
+ if (marquee.width <= 0 || marquee.height <= 0) return false;
+
+ const mCorners: Corners = [
+ { x: marquee.left, y: marquee.top },
+ { x: marquee.left + marquee.width, y: marquee.top },
+ { x: marquee.left + marquee.width, y: marquee.top + marquee.height },
+ { x: marquee.left, y: marquee.top + marquee.height },
+ ];
+
+ // AABB axes: (1,0) and (0,1)
+ const mProjX: [number, number] = [marquee.left, marquee.left + marquee.width];
+ const mProjY: [number, number] = [marquee.top, marquee.top + marquee.height];
+
+ const oProjX = projectOntoAxis(corners, 1, 0);
+ const oProjY = projectOntoAxis(corners, 0, 1);
+
+ if (!projectionsOverlap(mProjX, oProjX)) return false;
+ if (!projectionsOverlap(mProjY, oProjY)) return false;
+
+ // OBB edge normals (only need 2 — edges 0→1 and 1→2)
+ for (let i = 0; i < 2; i++) {
+ const edge = {
+ x: corners[i + 1].x - corners[i].x,
+ y: corners[i + 1].y - corners[i].y,
+ };
+ const len = Math.hypot(edge.x, edge.y);
+ if (len < 1e-9) continue;
+ const ax = -edge.y / len;
+ const ay = edge.x / len;
+
+ const mProj = projectOntoAxis(mCorners, ax, ay);
+ const oProj = projectOntoAxis(corners, ax, ay);
+ if (!projectionsOverlap(mProj, oProj)) return false;
+ }
+
+ return true;
+}
+
+/**
+ * Compute the four corners of an element's OBB in overlay-pixel space.
+ *
+ * For elements with an identity transform, returns the axis-aligned
+ * corners from the element's BCR mapped to overlay space (fast path).
+ */
+// fallow-ignore-next-line complexity
+export function elementObbCorners(
+ element: HTMLElement,
+ overlayEl: HTMLDivElement,
+ iframe: HTMLIFrameElement,
+): Corners | null {
+ const doc = iframe.contentDocument;
+ if (!doc) return null;
+
+ const iframeRect = iframe.getBoundingClientRect();
+ const overlayRect = overlayEl.getBoundingClientRect();
+ const root = doc.querySelector("[data-composition-id]") ?? doc.documentElement;
+ const declaredW = Number.parseFloat(root?.getAttribute("data-width") ?? "");
+ const declaredH = Number.parseFloat(root?.getAttribute("data-height") ?? "");
+ const rootW = declaredW > 0 ? declaredW : root?.getBoundingClientRect().width || 1;
+ const rootH = declaredH > 0 ? declaredH : root?.getBoundingClientRect().height || 1;
+
+ const scaleX = iframeRect.width / rootW;
+ const scaleY = iframeRect.height / rootH;
+ const offsetX = iframeRect.left - overlayRect.left;
+ const offsetY = iframeRect.top - overlayRect.top;
+
+ const win = element.ownerDocument.defaultView;
+ if (!win) return null;
+
+ const transform = win.getComputedStyle(element).transform;
+ const m = transform && transform !== "none" ? new DOMMatrix(transform) : new DOMMatrix();
+
+ if (isIdentityMatrix(m)) {
+ const r = element.getBoundingClientRect();
+ const left = offsetX + r.left * scaleX;
+ const top = offsetY + r.top * scaleY;
+ const w = r.width * scaleX;
+ const h = r.height * scaleY;
+ return [
+ { x: left, y: top },
+ { x: left + w, y: top },
+ { x: left + w, y: top + h },
+ { x: left, y: top + h },
+ ];
+ }
+
+ // Walk offsetParent chain for pre-transform position
+ let ox = 0;
+ let oy = 0;
+ let el: HTMLElement | null = element;
+ while (el && el !== doc.body && el !== doc.documentElement) {
+ ox += el.offsetLeft;
+ oy += el.offsetTop;
+ el = el.offsetParent as HTMLElement | null;
+ }
+
+ const w = element.offsetWidth;
+ const h = element.offsetHeight;
+ // ponytail: center-based transform — CSS transforms originate at 50% 50%
+ const cx = ox + w / 2;
+ const cy = oy + h / 2;
+
+ const localCorners: [number, number][] = [
+ [-w / 2, -h / 2],
+ [w / 2, -h / 2],
+ [w / 2, h / 2],
+ [-w / 2, h / 2],
+ ];
+
+ return localCorners.map(([lx, ly]) => {
+ const tx = m.a * lx + m.c * ly + cx;
+ const ty = m.b * lx + m.d * ly + cy;
+ return {
+ x: offsetX + tx * scaleX,
+ y: offsetY + ty * scaleY,
+ };
+ }) as Corners;
+}
+
+export { rectsOverlap };