fix(studio): redesign off-screen indicators as edge-clamped pills

Replace full-size dotted rectangle indicators with small pill chips
pinned to the composition border. Each pill shows the element name
and a directional arrow. Edge clamping computed from element center
relative to composition bounds. Click to select, drag to move.
This commit is contained in:
Miguel Ángel
2026-06-12 11:19:12 -04:00
parent cee6fd02d6
commit 1b6d6027d4
2 changed files with 85 additions and 15 deletions
@@ -525,19 +525,23 @@ export const DomEditOverlay = memo(function DomEditOverlay({
compRect.width > 0 &&
offScreenIndicators.map((ind) => {
const isSelected = selection?.id === ind.elementId;
const isVerticalEdge = ind.edge === "left" || ind.edge === "right";
return (
<div
key={`offscreen-${ind.key}`}
className={`absolute rounded-sm ${isSelected ? "pointer-events-none" : "cursor-grab"}`}
className={`absolute flex items-center justify-center transition-opacity duration-150 ${
isSelected
? "pointer-events-none opacity-20"
: "cursor-grab opacity-40 hover:opacity-90 group"
}`}
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,
left: ind.edgeX,
top: ind.edgeY,
width: isVerticalEdge ? 16 : 28,
height: isVerticalEdge ? 28 : 16,
zIndex: isSelected ? 1 : 10,
}}
title={ind.name}
onPointerDown={
isSelected
? undefined
@@ -575,8 +579,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
el.addEventListener("pointerup", onUp);
}
}
title={isSelected ? undefined : `Drag #${ind.elementId}`}
/>
>
<svg
width={isVerticalEdge ? 8 : 12}
height={isVerticalEdge ? 12 : 8}
viewBox={isVerticalEdge ? "0 0 8 12" : "0 0 12 8"}
className="text-emerald-400"
>
{ind.edge === "left" && (
<path d="M7 1L2 6L7 11" stroke="currentColor" strokeWidth="1.5" fill="none" />
)}
{ind.edge === "right" && (
<path d="M1 1L6 6L1 11" stroke="currentColor" strokeWidth="1.5" fill="none" />
)}
{ind.edge === "top" && (
<path d="M1 7L6 2L11 7" stroke="currentColor" strokeWidth="1.5" fill="none" />
)}
{ind.edge === "bottom" && (
<path d="M1 1L6 6L11 1" stroke="currentColor" strokeWidth="1.5" fill="none" />
)}
</svg>
</div>
);
})}
<GridOverlay
@@ -5,13 +5,20 @@
import { useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
export type IndicatorEdge = "top" | "bottom" | "left" | "right";
export interface OffScreenIndicator {
key: string;
elementId: string;
name: string;
left: number;
top: number;
width: number;
height: number;
edge: IndicatorEdge;
edgeX: number;
edgeY: number;
arrow: string;
}
interface CompRect {
@@ -72,10 +79,9 @@ function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): bool
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
ai.edge !== bi.edge ||
Math.abs(ai.edgeX - bi.edgeX) > 0.5 ||
Math.abs(ai.edgeY - bi.edgeY) > 0.5
)
return false;
}
@@ -168,18 +174,59 @@ export function useOffScreenIndicators({
continue;
}
// Only elements with a real id attribute can be selected via getElementById
if (!el.id) continue;
const name = el.getAttribute("data-name") || el.id;
const count = keyCounts.get(el.id) ?? 0;
keyCounts.set(el.id, count + 1);
const key = count > 0 ? `${el.id}:${count}` : el.id;
const centerX = elLeft + elW / 2;
const centerY = elTop + elH / 2;
const compCenterX = (compLeft + compRight) / 2;
const compCenterY = (compTop + compBottom) / 2;
const dx = centerX - compCenterX;
const dy = centerY - compCenterY;
let edge: IndicatorEdge;
let arrow: string;
if (Math.abs(dx) / cr.width > Math.abs(dy) / cr.height) {
edge = dx > 0 ? "right" : "left";
arrow = dx > 0 ? "→" : "←";
} else {
edge = dy > 0 ? "bottom" : "top";
arrow = dy > 0 ? "↓" : "↑";
}
let edgeX: number;
let edgeY: number;
const clampX = Math.max(compLeft + 4, Math.min(compRight - 80, centerX));
const clampY = Math.max(compTop + 4, Math.min(compBottom - 24, centerY));
if (edge === "top") {
edgeX = clampX;
edgeY = compTop;
} else if (edge === "bottom") {
edgeX = clampX;
edgeY = compBottom - 24;
} else if (edge === "left") {
edgeX = compLeft;
edgeY = clampY;
} else {
edgeX = compRight - 80;
edgeY = clampY;
}
next.push({
key,
elementId: el.id,
name,
left: elLeft,
top: elTop,
width: elW,
height: elH,
edge,
edgeX,
edgeY,
arrow,
});
}