feat(npcs): free rotate overlays and drop session zoom tools
Drag the top-edge handle to rotate the whole frame with the avatar; remove unused zoom controls from the session NPC window. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,10 +20,26 @@ type NpcsSceneOverlayProps = {
|
||||
showClose?: boolean;
|
||||
onClose?: () => void;
|
||||
closeLabel?: string;
|
||||
rotateLabel?: string;
|
||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
|
||||
};
|
||||
|
||||
function RotateIcon() {
|
||||
return (
|
||||
<svg className={styles.frameRotateSvg} viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
||||
<path
|
||||
d="M20 12a8 8 0 1 1-2.2-5.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function nextBaseSize(
|
||||
viewW: number,
|
||||
viewH: number,
|
||||
@@ -37,12 +53,48 @@ function nextBaseSize(
|
||||
return { w: naturalW * fit, h: naturalH * fit };
|
||||
}
|
||||
|
||||
function pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number {
|
||||
return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
|
||||
let d = toDeg - fromDeg;
|
||||
while (d > 180) d -= 360;
|
||||
while (d < -180) d += 360;
|
||||
return d;
|
||||
}
|
||||
|
||||
function screenToLocal(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
rotationDeg: number,
|
||||
): { x: number; y: number } {
|
||||
const rad = (-rotationDeg * Math.PI) / 180;
|
||||
const dx = clientX - centerX;
|
||||
const dy = clientY - centerY;
|
||||
return {
|
||||
x: dx * Math.cos(rad) - dy * Math.sin(rad),
|
||||
y: dx * Math.sin(rad) + dy * Math.cos(rad),
|
||||
};
|
||||
}
|
||||
|
||||
function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } {
|
||||
const rad = (rotationDeg * Math.PI) / 180;
|
||||
return {
|
||||
x: localX * Math.cos(rad) - localY * Math.sin(rad),
|
||||
y: localX * Math.sin(rad) + localY * Math.cos(rad),
|
||||
};
|
||||
}
|
||||
|
||||
function NpcAvatarFrame({
|
||||
item,
|
||||
view,
|
||||
rootRef,
|
||||
editable,
|
||||
zoomTool,
|
||||
rotateLabel,
|
||||
onLayoutChange,
|
||||
}: {
|
||||
item: NpcsSceneOverlayItem;
|
||||
@@ -50,6 +102,7 @@ function NpcAvatarFrame({
|
||||
rootRef: React.RefObject<HTMLDivElement | null>;
|
||||
editable: boolean;
|
||||
zoomTool: NpcsZoomTool;
|
||||
rotateLabel: string;
|
||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||
}) {
|
||||
const url = useAssetUrl(item.assetId);
|
||||
@@ -59,13 +112,20 @@ function NpcAvatarFrame({
|
||||
| {
|
||||
mode: 'resize';
|
||||
corner: Corner;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origin: NpcsOverlayLayout;
|
||||
baseW: number;
|
||||
baseH: number;
|
||||
viewW: number;
|
||||
viewH: number;
|
||||
centerClientX: number;
|
||||
centerClientY: number;
|
||||
}
|
||||
| {
|
||||
mode: 'rotate';
|
||||
origin: NpcsOverlayLayout;
|
||||
startPointerAngle: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
@@ -73,6 +133,7 @@ function NpcAvatarFrame({
|
||||
if (!item.assetId || !url) return null;
|
||||
|
||||
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
||||
const rotationDeg = layout.rotationDeg ?? 0;
|
||||
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
||||
const w = base.w * layout.scale;
|
||||
const h = base.h * layout.scale;
|
||||
@@ -82,13 +143,24 @@ function NpcAvatarFrame({
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !onLayoutChange) return;
|
||||
|
||||
if (drag.mode === 'rotate') {
|
||||
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
|
||||
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
|
||||
onLayoutChange(item.npcId, {
|
||||
...drag.origin,
|
||||
rotationDeg: drag.origin.rotationDeg + delta,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const r = root.getBoundingClientRect();
|
||||
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
||||
|
||||
if (drag.mode === 'move') {
|
||||
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
||||
onLayoutChange(item.npcId, {
|
||||
...drag.origin,
|
||||
cx: drag.origin.cx + dx,
|
||||
@@ -97,13 +169,13 @@ function NpcAvatarFrame({
|
||||
return;
|
||||
}
|
||||
|
||||
const { baseW, baseH, viewW, viewH, origin, corner } = drag;
|
||||
const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
|
||||
const originW = baseW * origin.scale;
|
||||
const originH = baseH * origin.scale;
|
||||
const originLeft = origin.cx * viewW - originW / 2;
|
||||
const originTop = origin.cy * viewH - originH / 2;
|
||||
const originRight = originLeft + originW;
|
||||
const originBottom = originTop + originH;
|
||||
const originLeft = -originW / 2;
|
||||
const originTop = -originH / 2;
|
||||
const originRight = originW / 2;
|
||||
const originBottom = originH / 2;
|
||||
|
||||
let anchorX = originLeft;
|
||||
let anchorY = originTop;
|
||||
@@ -118,10 +190,15 @@ function NpcAvatarFrame({
|
||||
anchorY = originTop;
|
||||
}
|
||||
|
||||
const pointerX = e.clientX - r.left;
|
||||
const pointerY = e.clientY - r.top;
|
||||
const newW = Math.max(8, Math.abs(pointerX - anchorX));
|
||||
const newH = Math.max(8, Math.abs(pointerY - anchorY));
|
||||
const local = screenToLocal(
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
centerClientX,
|
||||
centerClientY,
|
||||
origin.rotationDeg ?? 0,
|
||||
);
|
||||
const newW = Math.max(8, Math.abs(local.x - anchorX));
|
||||
const newH = Math.max(8, Math.abs(local.y - anchorY));
|
||||
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
|
||||
const ww = baseW * nextScale;
|
||||
const hh = baseH * nextScale;
|
||||
@@ -139,9 +216,14 @@ function NpcAvatarFrame({
|
||||
nextTop = anchorY;
|
||||
}
|
||||
|
||||
const localCenterX = nextLeft + ww / 2;
|
||||
const localCenterY = nextTop + hh / 2;
|
||||
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
|
||||
|
||||
onLayoutChange(item.npcId, {
|
||||
cx: (nextLeft + ww / 2) / Math.max(1, viewW),
|
||||
cy: (nextTop + hh / 2) / Math.max(1, viewH),
|
||||
...origin,
|
||||
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
|
||||
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
|
||||
scale: nextScale,
|
||||
});
|
||||
};
|
||||
@@ -152,24 +234,52 @@ function NpcAvatarFrame({
|
||||
window.removeEventListener('pointerup', endDrag);
|
||||
};
|
||||
|
||||
const layoutCenterClient = () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return { x: 0, y: 0 };
|
||||
const r = root.getBoundingClientRect();
|
||||
return {
|
||||
x: r.left + layout.cx * r.width,
|
||||
y: r.top + layout.cy * r.height,
|
||||
};
|
||||
};
|
||||
|
||||
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
|
||||
if (!editable || !onLayoutChange || zoomTool) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragRef.current =
|
||||
mode === 'move'
|
||||
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } }
|
||||
: {
|
||||
mode: 'resize',
|
||||
corner: corner ?? 'se',
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origin: { ...layout },
|
||||
baseW: base.w,
|
||||
baseH: base.h,
|
||||
viewW: view.w,
|
||||
viewH: view.h,
|
||||
};
|
||||
if (mode === 'move') {
|
||||
dragRef.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } };
|
||||
} else {
|
||||
const center = layoutCenterClient();
|
||||
dragRef.current = {
|
||||
mode: 'resize',
|
||||
corner: corner ?? 'se',
|
||||
origin: { ...layout },
|
||||
baseW: base.w,
|
||||
baseH: base.h,
|
||||
viewW: view.w,
|
||||
viewH: view.h,
|
||||
centerClientX: center.x,
|
||||
centerClientY: center.y,
|
||||
};
|
||||
}
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', endDrag);
|
||||
};
|
||||
|
||||
const startRotate = (e: React.PointerEvent) => {
|
||||
if (!editable || !onLayoutChange || zoomTool) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const center = layoutCenterClient();
|
||||
dragRef.current = {
|
||||
mode: 'rotate',
|
||||
origin: { ...layout },
|
||||
startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY),
|
||||
centerX: center.x,
|
||||
centerY: center.y,
|
||||
};
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', endDrag);
|
||||
};
|
||||
@@ -178,7 +288,14 @@ function NpcAvatarFrame({
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||
data-npc-id={item.npcId}
|
||||
style={{ left, top, width: w, height: h }}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: w,
|
||||
height: h,
|
||||
transform: `rotate(${String(rotationDeg)}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (zoomTool) return;
|
||||
startDrag(e, 'move');
|
||||
@@ -210,6 +327,17 @@ function NpcAvatarFrame({
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{editable && !zoomTool && onLayoutChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.frameRotate}
|
||||
aria-label={rotateLabel}
|
||||
title={rotateLabel}
|
||||
onPointerDown={startRotate}
|
||||
>
|
||||
<RotateIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -221,6 +349,7 @@ export function NpcsSceneOverlay({
|
||||
showClose = false,
|
||||
onClose,
|
||||
closeLabel = 'Close',
|
||||
rotateLabel = 'Rotate',
|
||||
onLayoutChange,
|
||||
onZoomAt,
|
||||
}: NpcsSceneOverlayProps) {
|
||||
@@ -285,6 +414,7 @@ export function NpcsSceneOverlay({
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
) : (
|
||||
@@ -295,6 +425,7 @@ export function NpcsSceneOverlay({
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user