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
+12 -4
View File
@@ -36,7 +36,10 @@ import { ContainedVideo } from './ContainedVideo';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
import {
DEFAULT_NPC_TOKEN_SESSION_SCALE,
listVisibleSceneNpcTokens,
} from '../../shared/types/appPlayers';
export type PresentationViewProps = {
session: SessionState | null;
@@ -73,9 +76,14 @@ export function PresentationView({
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const project = session?.project;
const visibleNpcTokens = listVisibleSceneNpcTokens(
scene?.npcTokens,
sceneNpcTokensSession.spawned,
session?.currentSceneId,
);
const pathLivePoses = useTokenPathLivePoses({
tokens: scene?.tokens ?? [],
npcTokens: scene?.npcTokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
enabled: Boolean(scene),
onMarkDone: (kind, placementId) => {
@@ -211,7 +219,7 @@ export function PresentationView({
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
<TokenPathsOverlay
tokens={scene.tokens ?? []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (scene.npcTokens ?? []) : []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
viewport={contentRect}
mode="presentation"
pathSession={tokenPathSession}
@@ -230,7 +238,7 @@ export function PresentationView({
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
contentRect ? (
<SceneNpcTokensOverlay
placements={scene.npcTokens ?? []}
placements={visibleNpcTokens}
library={project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={contentRect}
@@ -17,6 +17,10 @@
pointer-events: none;
}
.token > .resizeHandle {
pointer-events: auto;
}
.editable {
pointer-events: auto;
cursor: grab;
@@ -25,3 +29,18 @@
.editable:active {
cursor: grabbing;
}
.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;
}
@@ -7,7 +7,7 @@ import type {
SceneNpcToken,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import { clampSceneNpcTokenSizeN, DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
@@ -16,7 +16,7 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
@@ -35,6 +35,7 @@ function NpcSprite({
npc,
nx,
ny,
sizeN,
viewport,
editable,
dragEnabled,
@@ -43,6 +44,8 @@ function NpcSprite({
disposition,
inactive,
onMove,
onResize,
resizeTitle,
onContextMenu,
snapNorm,
}: {
@@ -50,6 +53,7 @@ function NpcSprite({
npc: ProjectNpc;
nx: number;
ny: number;
sizeN: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
@@ -58,11 +62,14 @@ function NpcSprite({
disposition: NpcDisposition;
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
@@ -72,15 +79,26 @@ function NpcSprite({
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(placement.id),
);
const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
const canDrag = editable && dragEnabled && onMove !== undefined;
const canResize = editable && dragEnabled && onResize !== undefined;
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
const rect = host?.getBoundingClientRect();
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
@@ -95,6 +113,14 @@ function NpcSprite({
setLocalPos(null);
};
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
};
return (
<div
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
@@ -104,7 +130,7 @@ function NpcSprite({
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
}}
onContextMenu={
onContextMenu
@@ -163,6 +189,41 @@ function NpcSprite({
sizePx={sizePx}
inactive={inactive}
/>
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
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?.pointerId !== e.pointerId) return;
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
) : null}
</div>
);
}
@@ -175,6 +236,8 @@ export function SceneNpcTokensOverlay({
grid = null,
editable = false,
onMove,
onResize,
resizeTitle,
onContextMenu,
snapNorm,
poseOverrides = null,
@@ -188,6 +251,8 @@ export function SceneNpcTokensOverlay({
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
@@ -198,7 +263,7 @@ export function SceneNpcTokensOverlay({
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
const gridFit = sceneGridTokenFitFactor(grid);
return (
<div className={styles.layer}>
<div className={styles.layer} data-token-layer="1">
{placements.map((placement) => {
const npc = byId.get(placement.npcId);
if (!npc) return null;
@@ -215,6 +280,7 @@ export function SceneNpcTokensOverlay({
npc={npc}
nx={pose?.nx ?? override?.nx ?? placement.nx}
ny={pose?.ny ?? override?.ny ?? placement.ny}
sizeN={override?.sizeN ?? placement.sizeN}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
@@ -223,6 +289,8 @@ export function SceneNpcTokensOverlay({
disposition={disposition}
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
@@ -5,12 +5,15 @@ import type {
SceneGrid,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import {
clampSceneNpcTokenSizeN,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
} from '../../../shared/types/appPlayers';
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { usePlayerImageUrl } from './usePlayerImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
@@ -25,6 +28,8 @@ function PlayerSprite({
displayScale,
gridFit,
onMove,
onResize,
resizeTitle,
snapNorm,
}: {
player: AppPlayer;
@@ -36,10 +41,13 @@ function PlayerSprite({
displayScale: number;
gridFit: number;
onMove?: (playerId: string, nx: number, ny: number) => void;
onResize?: (playerId: string, sizeN: number) => void;
resizeTitle?: string;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = usePlayerImageUrl(player.id);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
@@ -49,14 +57,25 @@ function PlayerSprite({
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(player.id),
);
const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
const canResize = editable && onResize !== undefined;
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
const rect = host?.getBoundingClientRect();
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
@@ -71,6 +90,14 @@ function PlayerSprite({
setLocalPos(null);
};
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
};
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
@@ -128,6 +155,41 @@ function PlayerSprite({
imageScale={player.imageScale}
sizePx={sizePx}
/>
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
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?.pointerId !== e.pointerId) return;
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
) : null}
</div>
);
}
@@ -140,6 +202,8 @@ export function ScenePlayerTokensOverlay({
grid = null,
editable = false,
onMove,
onResize,
resizeTitle,
snapNorm,
}: {
library: readonly AppPlayer[];
@@ -150,13 +214,15 @@ export function ScenePlayerTokensOverlay({
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (playerId: string, nx: number, ny: number) => void;
onResize?: (playerId: string, sizeN: number) => void;
resizeTitle?: string;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
if (!viewport || !session?.visible) return null;
const byId = new Map(library.map((p) => [String(p.id), p]));
const gridFit = sceneGridTokenFitFactor(grid);
return (
<div className={styles.layer}>
<div className={styles.layer} data-token-layer="1">
{session.selectedPlayerIds.map((playerId) => {
const player = byId.get(playerId);
const placement = session.byPlayerId[playerId];
@@ -173,6 +239,8 @@ export function ScenePlayerTokensOverlay({
displayScale={displayScale}
gridFit={gridFit}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
@@ -46,3 +46,50 @@ export function useLiveDragBroadcast(
return { schedule, flush };
}
/** Во время resize шлёт onResize не чаще одного раза за кадр. */
export function useLiveResizeBroadcast(
onResize: ((id: string, sizeN: number) => void) | undefined,
id: string,
): {
schedule: (sizeN: number) => void;
flush: (sizeN: number) => void;
} {
const rafRef = useRef(0);
const pendingRef = useRef<number | null>(null);
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const idRef = useRef(id);
idRef.current = id;
useEffect(
() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
},
[],
);
const schedule = useCallback((sizeN: number) => {
if (!onResizeRef.current) return;
pendingRef.current = sizeN;
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
const pending = pendingRef.current;
const cb = onResizeRef.current;
if (pending === null || !cb) return;
cb(idRef.current, pending);
});
}, []);
const flush = useCallback((sizeN: number) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
pendingRef.current = null;
onResizeRef.current?.(idRef.current, sizeN);
}, []);
return { schedule, flush };
}
@@ -0,0 +1,56 @@
import { useEffect, useMemo, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { NpcMapSpawnDragState } from '../../../shared/types';
import { asNpcId } from '../../../shared/types/ids';
import { getDndApi } from '../dndApi';
const EMPTY: NpcMapSpawnDragState = {
dragging: false,
npcId: null,
hoverNx: null,
hoverNy: null,
};
export function useNpcMapSpawnDrag(): [
NpcMapSpawnDragState,
{
beginDrag: (npcId: string) => Promise<void>;
setHover: (nx: number | null, ny: number | null) => Promise<void>;
commit: () => Promise<boolean>;
cancel: () => Promise<void>;
},
] {
const api = getDndApi();
const [state, setState] = useState<NpcMapSpawnDragState>(EMPTY);
useEffect(() => {
void api.invoke(ipcChannels.npcMapSpawn.getState, {}).then(({ state: s }) => {
setState(s);
});
return api.on(ipcChannels.npcMapSpawn.stateChanged, ({ state: s }) => {
setState(s);
});
}, [api]);
const apiWrap = useMemo(
() => ({
beginDrag: async (npcId: string) => {
await api.invoke(ipcChannels.npcMapSpawn.beginDrag, { npcId: asNpcId(npcId) });
},
setHover: async (nx: number | null, ny: number | null) => {
await api.invoke(ipcChannels.npcMapSpawn.setHover, { nx, ny });
},
commit: async () => {
const res = await api.invoke(ipcChannels.npcMapSpawn.commit, {});
return Boolean(res.spawned);
},
cancel: async () => {
await api.invoke(ipcChannels.npcMapSpawn.cancel, {});
},
}),
[api],
);
return [state, apiWrap];
}
@@ -2,14 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
SceneNpcSessionSpawn,
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionPlacement,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import {
asSceneNpcTokenId,
clampNpcTokenSessionScale,
clampSceneNpcTokenSizeN,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
emptySceneNpcTokensSessionState,
newSceneNpcSessionSpawnId,
} from '../../../shared/types/appPlayers';
import { asNpcId } from '../../../shared/types/ids';
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
import { getDndApi } from '../dndApi';
@@ -17,35 +24,89 @@ function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionStat
return {
...state,
scale: clampNpcTokenSessionScale(state.scale),
spawned: state.spawned ?? {},
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
return (
p.nx === undefined &&
p.ny === undefined &&
p.sizeN === undefined &&
!p.disposition &&
!p.inactive
);
}
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
return {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
};
}
function applyEvent(
prev: SceneNpcTokensSessionState,
event: SceneNpcTokensSessionEvent,
): SceneNpcTokensSessionState {
const spawned = prev.spawned ?? {};
if (event.kind === 'clear') {
return {
revision: prev.revision + 1,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
return emptySceneNpcTokensSessionState(prev.revision + 1);
}
if (event.kind === 'setScale') {
const scale = clampNpcTokenSessionScale(event.scale);
if (prev.scale === scale) return prev;
return { ...prev, revision: prev.revision + 1, scale };
return { ...prev, revision: prev.revision + 1, scale, spawned };
}
if (event.kind === 'spawn') {
const npcId = String(event.npcId ?? '');
const sceneId = String(event.sceneId ?? '');
if (!npcId || !sceneId) return prev;
const id = event.placementId
? asSceneNpcTokenId(String(event.placementId))
: newSceneNpcSessionSpawnId();
const spawn: SceneNpcSessionSpawn = {
id,
sceneId,
npcId: asNpcId(npcId),
nx: Math.max(0, Math.min(1, event.nx)),
ny: Math.max(0, Math.min(1, event.ny)),
sizeN: clampSceneNpcTokenSizeN(
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
),
disposition: normalizeNpcDisposition(event.disposition),
};
return {
...prev,
revision: prev.revision + 1,
spawned: { ...spawned, [String(id)]: spawn },
};
}
if (event.kind === 'despawn') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
if (!(placementId in spawned) && !(placementId in prev.byPlacementId)) return prev;
const { [placementId]: _s, ...nextSpawned } = spawned;
const { [placementId]: _p, ...byPlacementId } = prev.byPlacementId;
return {
...prev,
revision: prev.revision + 1,
byPlacementId,
spawned: nextSpawned,
};
}
if (event.kind === 'setDisposition') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const disposition = normalizeNpcDisposition(event.disposition);
const cur = prev.byPlacementId[placementId];
if (cur?.disposition === disposition) return prev;
const spawn = spawned[placementId];
if (cur?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
return prev;
}
return {
revision: prev.revision + 1,
byPlacementId: {
@@ -53,11 +114,13 @@ function applyEvent(
[placementId]: {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
disposition,
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, disposition } } : spawned,
};
}
if (event.kind === 'setInactive') {
@@ -69,6 +132,7 @@ function applyEvent(
const next: SceneNpcTokensSessionPlacement = {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
@@ -78,6 +142,7 @@ function applyEvent(
revision: prev.revision + 1,
byPlacementId: rest,
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned,
};
}
return {
@@ -87,12 +152,31 @@ function applyEvent(
[placementId]: next,
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned,
};
}
if (event.kind === 'setSize') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const cur = prev.byPlacementId[placementId];
const spawn = spawned[placementId];
if (cur?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return prev;
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: { ...restOf(cur), sizeN },
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, sizeN } } : spawned,
};
}
// move
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const cur = prev.byPlacementId[placementId];
const spawn = spawned[placementId];
return {
revision: prev.revision + 1,
byPlacementId: {
@@ -100,11 +184,13 @@ function applyEvent(
[placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, nx: event.nx, ny: event.ny } } : spawned,
};
}
@@ -113,17 +199,14 @@ export function useSceneNpcTokensSession(): [
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<SceneNpcTokensSessionState>({
revision: 0,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
});
const [state, setState] = useState<SceneNpcTokensSessionState>(() =>
emptySceneNpcTokensSessionState(0),
);
const localRevRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => {
const next = withScale(res.state);
// Не затираем более свежий optimistic/broadcast стейт устаревшим getState.
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
@@ -55,6 +55,18 @@ function applyEvent(
},
};
}
if (event.kind === 'setSize') {
const prevPlacement = prev.byPlayerId[event.playerId];
if (!prevPlacement) return prev;
return {
...prev,
revision: prev.revision + 1,
byPlayerId: {
...prev.byPlayerId,
[event.playerId]: { ...prevPlacement, sizeN: event.sizeN },
},
};
}
return prev;
}
@@ -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 } : {}),
},
},
};
}