feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user