fix(studio): remove off-screen element indicators for UX redesign (#1376)

This commit is contained in:
Miguel Ángel
2026-06-12 11:23:18 -04:00
committed by GitHub
parent cee6fd02d6
commit 3ba01129e7
2 changed files with 0 additions and 257 deletions
@@ -13,7 +13,6 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
import { useOffScreenIndicators } from "./useOffScreenIndicators";
// Re-exports for external consumers — preserving existing import paths.
export {
@@ -215,7 +214,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return () => cancelAnimationFrame(frame);
});
const offScreenIndicators = useOffScreenIndicators({ iframeRef, overlayRef, compRect });
const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
@@ -521,64 +519,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}}
/>
))}
{offScreenIndicators.length > 0 &&
compRect.width > 0 &&
offScreenIndicators.map((ind) => {
const isSelected = selection?.id === ind.elementId;
return (
<div
key={`offscreen-${ind.key}`}
className={`absolute rounded-sm ${isSelected ? "pointer-events-none" : "cursor-grab"}`}
style={{
left: ind.left,
top: ind.top,
width: ind.width,
height: ind.height,
border: `1.5px dashed var(--panel-accent, #34d399)`,
opacity: isSelected ? 0.3 : 0.5,
zIndex: isSelected ? 1 : 5,
}}
onPointerDown={
isSelected
? undefined
: (e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
let deltaX = 0;
let deltaY = 0;
let moved = false;
const onMove = (me: PointerEvent) => {
deltaX = me.clientX - startX;
deltaY = me.clientY - startY;
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) moved = true;
if (moved) {
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
}
};
const onUp = async (ue: PointerEvent) => {
el.releasePointerCapture(ue.pointerId);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.style.transform = "";
const sel = await onSelectElementById?.(ind.elementId);
if (moved && sel && onPathOffsetCommit) {
const scale = compRect.scaleX || 1;
onPathOffsetCommit(sel, { x: deltaX / scale, y: deltaY / scale });
}
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp);
}
}
title={isSelected ? undefined : `Drag #${ind.elementId}`}
/>
);
})}
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
@@ -1,197 +0,0 @@
/**
* Detects GSAP-animated elements whose center is outside the visible composition
* area and returns edge-clamped indicator positions for each.
*/
import { useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
export interface OffScreenIndicator {
key: string;
elementId: string;
left: number;
top: number;
width: number;
height: number;
}
interface CompRect {
left: number;
top: number;
width: number;
height: number;
scaleX: number;
scaleY: number;
}
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
function isHtmlElement(node: unknown): node is HTMLElement {
return (
typeof node === "object" &&
node !== null &&
typeof (node as HTMLElement).getBoundingClientRect === "function" &&
typeof (node as HTMLElement).tagName === "string"
);
}
function collectGsapTargetElements(iframe: HTMLIFrameElement): HTMLElement[] {
const win = iframe.contentWindow as
| (Window & { __timelines?: Record<string, TimelineLike> })
| null;
if (!win) return [];
let timelines: Record<string, TimelineLike> | undefined;
try {
timelines = win.__timelines;
} catch {
return [];
}
if (!timelines) return [];
const seen = new Set<HTMLElement>();
for (const tl of Object.values(timelines)) {
if (!tl?.getChildren) continue;
try {
for (const child of tl.getChildren(true)) {
if (!child.targets) continue;
for (const t of child.targets()) {
if (isHtmlElement(t)) seen.add(t);
}
}
} catch {
// cross-origin or detached timeline — skip
}
}
return Array.from(seen);
}
function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const ai = a[i]!;
const bi = b[i]!;
if (
ai.key !== bi.key ||
Math.abs(ai.left - bi.left) > 0.5 ||
Math.abs(ai.top - bi.top) > 0.5 ||
Math.abs(ai.width - bi.width) > 0.5 ||
Math.abs(ai.height - bi.height) > 0.5
)
return false;
}
return true;
}
export function useOffScreenIndicators({
iframeRef,
overlayRef,
compRect,
}: {
iframeRef: RefObject<HTMLIFrameElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
compRect: CompRect;
}): OffScreenIndicator[] {
const [indicators, setIndicators] = useState<OffScreenIndicator[]>([]);
const prevRef = useRef<OffScreenIndicator[]>([]);
const compRectRef = useRef(compRect);
compRectRef.current = compRect;
useMountEffect(() => {
let frame = 0;
const update = () => {
frame = requestAnimationFrame(update);
const iframe = iframeRef.current;
const overlayEl = overlayRef.current;
const cr = compRectRef.current;
if (!iframe || !overlayEl || cr.width <= 0 || cr.height <= 0) {
if (prevRef.current.length > 0) {
prevRef.current = [];
setIndicators([]);
}
return;
}
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const doc = iframe.contentDocument;
const root =
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
if (!root) return;
const declaredWidth =
Number.parseFloat(root.getAttribute("data-width") ?? "") || iframeRect.width;
const declaredHeight =
Number.parseFloat(root.getAttribute("data-height") ?? "") || iframeRect.height;
const rootScaleX = iframeRect.width / declaredWidth;
const rootScaleY = iframeRect.height / declaredHeight;
const targets = collectGsapTargetElements(iframe);
if (targets.length === 0) {
if (prevRef.current.length > 0) {
prevRef.current = [];
setIndicators([]);
}
return;
}
// Composition bounds in overlay coordinates
const compLeft = cr.left;
const compTop = cr.top;
const compRight = compLeft + cr.width;
const compBottom = compTop + cr.height;
const next: OffScreenIndicator[] = [];
const keyCounts = new Map<string, number>();
for (const el of targets) {
if (!el.isConnected) continue;
const elRect = el.getBoundingClientRect();
if (elRect.width <= 0 && elRect.height <= 0) continue;
// Element rect in overlay coordinates
const elLeft = iframeRect.left - overlayRect.left + elRect.left * rootScaleX;
const elTop = iframeRect.top - overlayRect.top + elRect.top * rootScaleY;
const elW = elRect.width * rootScaleX;
const elH = elRect.height * rootScaleY;
// Check if the element is fully inside the composition
if (
elLeft >= compLeft &&
elTop >= compTop &&
elLeft + elW <= compRight &&
elTop + elH <= compBottom
) {
continue;
}
// Only elements with a real id attribute can be selected via getElementById
if (!el.id) continue;
const count = keyCounts.get(el.id) ?? 0;
keyCounts.set(el.id, count + 1);
const key = count > 0 ? `${el.id}:${count}` : el.id;
next.push({
key,
elementId: el.id,
left: elLeft,
top: elTop,
width: elW,
height: elH,
});
}
if (!indicatorsEqual(prevRef.current, next)) {
prevRef.current = next;
setIndicators(next);
}
};
frame = requestAnimationFrame(update);
return () => cancelAnimationFrame(frame);
});
return indicators;
}