feat(control): spawn session NPCs and resize tokens in preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-18 14:42:06 +08:00
parent 1ab6ffd593
commit 1a9f0f2557
28 changed files with 1446 additions and 89 deletions
@@ -11,7 +11,7 @@
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.2);
overflow: hidden;
overflow: visible;
pointer-events: none;
touch-action: none;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
@@ -31,10 +31,34 @@
cursor: context-menu;
}
.tokenFace {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
pointer-events: none;
}
.tokenImg {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
border-radius: 8px;
}
.resizeHandle {
position: absolute;
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
border-radius: 2px;
background: #f5c542;
border: 1px solid #000;
cursor: nwse-resize;
touch-action: none;
pointer-events: auto;
z-index: 2;
}
@@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css';
@@ -20,6 +22,8 @@ type Props = {
viewport: Viewport | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
/** Snap во время drag (пульт, привязка к сетке). */
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
@@ -29,26 +33,36 @@ type Props = {
dragEnabledById?: Record<string, boolean> | null;
};
function layerFromEvent(e: React.PointerEvent<HTMLElement>): HTMLElement | null {
return e.currentTarget.closest('[data-token-layer="1"]');
}
function TokenSprite({
placement,
nx,
ny,
sizeN,
rotationDeg,
viewport,
editable,
dragEnabled,
onMove,
onResize,
resizeTitle,
snapNorm,
onContextMenu,
}: {
placement: SceneToken;
nx: number;
ny: number;
sizeN: number;
rotationDeg: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
}) {
@@ -62,14 +76,30 @@ function TokenSprite({
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const frameRef = useRef(0);
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(placement.id),
);
useEffect(() => {
if (dragRef.current) return;
setLocalPos(null);
}, [nx, ny]);
useEffect(() => {
if (resizeRef.current) return;
setLocalSize(null);
}, [sizeN]);
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
@@ -78,13 +108,15 @@ function TokenSprite({
const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny;
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * minDim);
const sizePx = Math.max(16, shownSizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove);
const interactive = canDrag || Boolean(onContextMenu);
const canResize = editable && dragEnabled && Boolean(onResize);
const interactive = canDrag || canResize || Boolean(onContextMenu);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
@@ -113,6 +145,19 @@ function TokenSprite({
}
};
const endResize = (el: HTMLDivElement, pointerId: number) => {
const d = resizeRef.current;
if (!d || d.pointerId !== pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
try {
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
};
return (
<div
className={[
@@ -189,7 +234,52 @@ function TokenSprite({
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
>
{url ? <img className={styles.tokenImg} src={url} alt="" draggable={false} /> : null}
{url ? (
<div className={styles.tokenFace}>
<img className={styles.tokenImg} src={url} alt="" draggable={false} />
</div>
) : null}
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const host = layerFromEvent(e);
if (!host) return;
const p = hostToNorm(e.clientX, e.clientY, host);
const dist = Math.max(1e-4, Math.hypot(p.x - posNx, p.y - posNy));
resizeRef.current = {
pointerId: e.pointerId,
startSize: shownSizeN,
startDist: dist,
lastSize: shownSizeN,
};
setLocalSize(shownSizeN);
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = resizeRef.current;
if (!d || d.pointerId !== e.pointerId) return;
const host = layerFromEvent(e);
if (!host) return;
const p = hostToNorm(e.clientX, e.clientY, host);
const dist = Math.max(1e-4, Math.hypot(p.x - posNx, p.y - posNy));
const next = clampSceneTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={(e) => {
endResize(e.currentTarget, e.pointerId);
}}
onPointerCancel={(e) => {
endResize(e.currentTarget, e.pointerId);
}}
/>
) : null}
</div>
);
}
@@ -201,6 +291,8 @@ export function SceneTokensOverlay({
viewport,
editable = false,
onMove,
onResize,
resizeTitle,
snapNorm,
onContextMenu,
poseOverrides = null,
@@ -210,7 +302,7 @@ export function SceneTokensOverlay({
const known = new Set(library.map((t) => t.id));
return (
<div className={styles.layer}>
<div className={styles.layer} data-token-layer="1">
{placements
.filter((p) => known.has(p.tokenId))
.map((placement) => {
@@ -219,6 +311,7 @@ export function SceneTokensOverlay({
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
const nx = pose?.nx ?? override?.nx ?? placement.nx;
const ny = pose?.ny ?? override?.ny ?? placement.ny;
const sizeN = override?.sizeN ?? placement.sizeN;
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
@@ -227,11 +320,14 @@ export function SceneTokensOverlay({
placement={placement}
nx={nx}
ny={ny}
sizeN={sizeN}
rotationDeg={rotationDeg}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(snapNorm ? { snapNorm } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
@@ -13,11 +13,30 @@ function applyEvent(
return { revision: base.revision + 1, byPlacementId: {} };
}
const placementId = String(event.placementId ?? '');
if (!placementId) return base;
const cur = base.byPlacementId[placementId];
if (event.kind === 'setSize') {
return {
revision: base.revision + 1,
byPlacementId: {
...base.byPlacementId,
[placementId]: {
...(typeof cur?.nx === 'number' ? { nx: cur.nx } : {}),
...(typeof cur?.ny === 'number' ? { ny: cur.ny } : {}),
sizeN: event.sizeN,
},
},
};
}
return {
revision: base.revision + 1,
byPlacementId: {
...base.byPlacementId,
[placementId]: { nx: event.nx, ny: event.ny },
[placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
},
},
};
}