9d82e74272
Deselect effect tools by default and on re-click, block the editor during session launch, and fix NPC overlay/window empty-state layout. Co-authored-by: Cursor <cursoragent@cursor.com>
500 lines
14 KiB
TypeScript
500 lines
14 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
|
||
import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types';
|
||
import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||
import styles from '../materials/MaterialOverlay.module.css';
|
||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||
import { useAssetUrl } from '../useAssetImageUrl';
|
||
|
||
type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||
|
||
export type NpcsSceneOverlayItem = {
|
||
npcId: NpcId;
|
||
assetId: AssetId | null;
|
||
layout: NpcsOverlayLayout;
|
||
};
|
||
|
||
type NpcsSceneOverlayProps = {
|
||
items: readonly NpcsSceneOverlayItem[];
|
||
editable?: boolean;
|
||
zoomTool?: NpcsZoomTool;
|
||
showClose?: boolean;
|
||
onClose?: () => void;
|
||
closeLabel?: string;
|
||
rotateLabel?: string;
|
||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
|
||
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
||
embedded?: boolean;
|
||
};
|
||
|
||
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,
|
||
naturalW: number,
|
||
naturalH: number,
|
||
): { w: number; h: number } {
|
||
if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
|
||
const maxW = viewW * 0.92;
|
||
const maxH = viewH * 0.88;
|
||
const fit = Math.min(maxW / naturalW, maxH / naturalH);
|
||
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;
|
||
view: { w: number; h: number };
|
||
rootRef: React.RefObject<HTMLDivElement | null>;
|
||
editable: boolean;
|
||
zoomTool: NpcsZoomTool;
|
||
rotateLabel: string;
|
||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||
}) {
|
||
const url = useAssetUrl(item.assetId);
|
||
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
|
||
const [draftLayout, setDraftLayout] = useState<NpcsOverlayLayout | null>(null);
|
||
const pendingLayoutRef = useRef<NpcsOverlayLayout | null>(null);
|
||
const draftRafRef = useRef(0);
|
||
const onLayoutChangeRef = useRef(onLayoutChange);
|
||
onLayoutChangeRef.current = onLayoutChange;
|
||
const dragRef = useRef<
|
||
| { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout }
|
||
| {
|
||
mode: 'resize';
|
||
corner: Corner;
|
||
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);
|
||
|
||
useEffect(() => {
|
||
if (dragRef.current) return;
|
||
setDraftLayout(null);
|
||
pendingLayoutRef.current = null;
|
||
}, [item.layout]);
|
||
|
||
if (!item.assetId || !url) return null;
|
||
|
||
const layout = draftLayout ?? 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;
|
||
const left = layout.cx * view.w - w / 2;
|
||
const top = layout.cy * view.h - h / 2;
|
||
|
||
const publishDraft = (next: NpcsOverlayLayout): void => {
|
||
pendingLayoutRef.current = next;
|
||
if (draftRafRef.current !== 0) return;
|
||
draftRafRef.current = window.requestAnimationFrame(() => {
|
||
draftRafRef.current = 0;
|
||
const pending = pendingLayoutRef.current;
|
||
if (!pending) return;
|
||
setDraftLayout(pending);
|
||
onLayoutChangeRef.current?.(item.npcId, pending);
|
||
});
|
||
};
|
||
|
||
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);
|
||
publishDraft({
|
||
...drag.origin,
|
||
rotationDeg: drag.origin.rotationDeg + delta,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const root = rootRef.current;
|
||
if (!root) return;
|
||
const r = root.getBoundingClientRect();
|
||
|
||
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);
|
||
publishDraft({
|
||
...drag.origin,
|
||
cx: drag.origin.cx + dx,
|
||
cy: drag.origin.cy + dy,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
|
||
const originW = baseW * origin.scale;
|
||
const originH = baseH * origin.scale;
|
||
const originLeft = -originW / 2;
|
||
const originTop = -originH / 2;
|
||
const originRight = originW / 2;
|
||
const originBottom = originH / 2;
|
||
|
||
let anchorX = originLeft;
|
||
let anchorY = originTop;
|
||
if (corner === 'nw') {
|
||
anchorX = originRight;
|
||
anchorY = originBottom;
|
||
} else if (corner === 'ne') {
|
||
anchorX = originLeft;
|
||
anchorY = originBottom;
|
||
} else if (corner === 'sw') {
|
||
anchorX = originRight;
|
||
anchorY = originTop;
|
||
}
|
||
|
||
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;
|
||
|
||
let nextLeft = anchorX;
|
||
let nextTop = anchorY;
|
||
if (corner === 'nw') {
|
||
nextLeft = anchorX - ww;
|
||
nextTop = anchorY - hh;
|
||
} else if (corner === 'ne') {
|
||
nextLeft = anchorX;
|
||
nextTop = anchorY - hh;
|
||
} else if (corner === 'sw') {
|
||
nextLeft = anchorX - ww;
|
||
nextTop = anchorY;
|
||
}
|
||
|
||
const localCenterX = nextLeft + ww / 2;
|
||
const localCenterY = nextTop + hh / 2;
|
||
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
|
||
|
||
publishDraft({
|
||
...origin,
|
||
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
|
||
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
|
||
scale: nextScale,
|
||
});
|
||
};
|
||
|
||
const endDrag = () => {
|
||
dragRef.current = null;
|
||
window.removeEventListener('pointermove', onPointerMove);
|
||
window.removeEventListener('pointerup', endDrag);
|
||
if (draftRafRef.current !== 0) {
|
||
window.cancelAnimationFrame(draftRafRef.current);
|
||
draftRafRef.current = 0;
|
||
}
|
||
const finalLayout = pendingLayoutRef.current;
|
||
if (finalLayout) {
|
||
setDraftLayout(finalLayout);
|
||
onLayoutChangeRef.current?.(item.npcId, finalLayout);
|
||
}
|
||
};
|
||
|
||
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();
|
||
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);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||
data-npc-id={item.npcId}
|
||
data-overlay-kind="npc"
|
||
style={{
|
||
left,
|
||
top,
|
||
width: w,
|
||
height: h,
|
||
transform: `rotate(${String(rotationDeg)}deg)`,
|
||
transformOrigin: 'center center',
|
||
}}
|
||
onPointerDown={(e) => {
|
||
if (zoomTool) return;
|
||
startDrag(e, 'move');
|
||
}}
|
||
>
|
||
<img
|
||
className={styles.image}
|
||
src={url}
|
||
alt=""
|
||
draggable={false}
|
||
onLoad={(e) => {
|
||
const img = e.currentTarget;
|
||
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||
}}
|
||
/>
|
||
{editable && !zoomTool
|
||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||
<button
|
||
key={corner}
|
||
type="button"
|
||
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
|
||
aria-label={corner}
|
||
onPointerDown={(e) => startDrag(e, 'resize', corner)}
|
||
/>
|
||
))
|
||
: null}
|
||
{editable && !zoomTool && onLayoutChange ? (
|
||
<button
|
||
type="button"
|
||
className={styles.frameRotate}
|
||
aria-label={rotateLabel}
|
||
title={rotateLabel}
|
||
onPointerDown={startRotate}
|
||
>
|
||
<RotateIcon />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function NpcsSceneOverlay({
|
||
items,
|
||
editable = false,
|
||
zoomTool = null,
|
||
showClose = false,
|
||
onClose,
|
||
closeLabel = 'Close',
|
||
rotateLabel = 'Rotate',
|
||
onLayoutChange,
|
||
onZoomAt,
|
||
embedded = false,
|
||
}: NpcsSceneOverlayProps) {
|
||
const host = useSceneOverlayView();
|
||
const localRootRef = useRef<HTMLDivElement | null>(null);
|
||
const rootRef = embedded && host ? host.rootRef : localRootRef;
|
||
const [localView, setLocalView] = useState({ w: 1, h: 1 });
|
||
const view = embedded && host ? host.view : localView;
|
||
|
||
useEffect(() => {
|
||
if (embedded) return;
|
||
const el = localRootRef.current;
|
||
if (!el) return;
|
||
let raf = 0;
|
||
const sync = () => {
|
||
if (raf !== 0) return;
|
||
raf = window.requestAnimationFrame(() => {
|
||
raf = 0;
|
||
const w = Math.max(1, el.clientWidth);
|
||
const h = Math.max(1, el.clientHeight);
|
||
setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
|
||
});
|
||
};
|
||
sync();
|
||
const ro = new ResizeObserver(sync);
|
||
ro.observe(el);
|
||
return () => {
|
||
ro.disconnect();
|
||
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||
};
|
||
}, [embedded, items.length]);
|
||
|
||
if (items.length === 0) return null;
|
||
|
||
const interactive = editable || showClose || Boolean(zoomTool);
|
||
const zoomCursor =
|
||
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
|
||
|
||
const toNorm = (clientX: number, clientY: number) => {
|
||
const root = rootRef.current;
|
||
if (!root) return { nx: 0.5, ny: 0.5 };
|
||
const r = root.getBoundingClientRect();
|
||
return {
|
||
nx: (clientX - r.left) / Math.max(1, r.width),
|
||
ny: (clientY - r.top) / Math.max(1, r.height),
|
||
};
|
||
};
|
||
|
||
const npcIdFromTarget = (target: EventTarget | null): NpcId | undefined => {
|
||
if (!(target instanceof Element)) return undefined;
|
||
const frame = target.closest('[data-npc-id]');
|
||
const raw = frame?.getAttribute('data-npc-id');
|
||
return raw ? (raw as NpcId) : undefined;
|
||
};
|
||
|
||
const frames = items.map((item) =>
|
||
onLayoutChange ? (
|
||
<NpcAvatarFrame
|
||
key={item.npcId}
|
||
item={item}
|
||
view={view}
|
||
rootRef={rootRef}
|
||
editable={editable}
|
||
zoomTool={zoomTool}
|
||
rotateLabel={rotateLabel}
|
||
onLayoutChange={onLayoutChange}
|
||
/>
|
||
) : (
|
||
<NpcAvatarFrame
|
||
key={item.npcId}
|
||
item={item}
|
||
view={view}
|
||
rootRef={rootRef}
|
||
editable={editable}
|
||
zoomTool={zoomTool}
|
||
rotateLabel={rotateLabel}
|
||
/>
|
||
),
|
||
);
|
||
|
||
if (embedded) {
|
||
return <>{frames}</>;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={localRootRef}
|
||
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
onClick={(e) => {
|
||
if (!zoomTool || !onZoomAt) return;
|
||
e.stopPropagation();
|
||
const { nx, ny } = toNorm(e.clientX, e.clientY);
|
||
onZoomAt(npcIdFromTarget(e.target), nx, ny);
|
||
}}
|
||
>
|
||
<div className={styles.dim} />
|
||
{frames}
|
||
{showClose ? (
|
||
<button
|
||
type="button"
|
||
className={styles.close}
|
||
onClick={onClose}
|
||
aria-label={closeLabel}
|
||
title={closeLabel}
|
||
>
|
||
×
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|