feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,21 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types';
|
||||
import type {
|
||||
NpcId,
|
||||
ProjectNpc,
|
||||
SceneGrid,
|
||||
SceneNpcToken,
|
||||
SceneToken,
|
||||
SceneTrap,
|
||||
SceneTrapType,
|
||||
TokenId,
|
||||
} from '../../shared/types';
|
||||
import {
|
||||
asSceneNpcTokenId,
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
asSceneTokenId,
|
||||
asTokenId,
|
||||
@@ -14,6 +28,7 @@ import {
|
||||
DEFAULT_SCENE_GRID,
|
||||
SCENE_GRID_SIZE_MAX,
|
||||
SCENE_GRID_SIZE_MIN,
|
||||
sceneGridTokenFitFactor,
|
||||
sceneGridTypeLabelRu,
|
||||
} from '../../shared/types/sceneGrid';
|
||||
import {
|
||||
@@ -26,14 +41,15 @@ import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { SceneTokenMarker } from './SceneTokenMarker';
|
||||
import styles from './SceneEditorApp.module.css';
|
||||
import { SceneTokenMarker } from './SceneTokenMarker';
|
||||
import { TokenEditModal } from './TokenEditModal';
|
||||
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
|
||||
|
||||
@@ -45,14 +61,37 @@ function isTypingTarget(el: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
type LocalView = { scale: number; ox: number; oy: number };
|
||||
type Selection = { kind: 'trap' | 'token'; id: string } | null;
|
||||
type Selection = { kind: 'trap' | 'token' | 'npcToken'; id: string } | null;
|
||||
|
||||
type DragMode =
|
||||
| { kind: 'pan'; lastX: number; lastY: number }
|
||||
| { kind: 'moveTrap'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| {
|
||||
kind: 'moveTrap';
|
||||
trapId: string;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
}
|
||||
| { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
|
||||
| { kind: 'moveToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| {
|
||||
kind: 'moveToken';
|
||||
tokenId: string;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
}
|
||||
| { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
|
||||
| {
|
||||
kind: 'moveNpcToken';
|
||||
tokenId: string;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
}
|
||||
| { kind: 'resizeNpcToken'; tokenId: string; startSize: number; startDist: number }
|
||||
| {
|
||||
kind: 'rotateToken';
|
||||
tokenId: string;
|
||||
@@ -78,6 +117,86 @@ function shortestAngleDelta(fromDeg: number, toDeg: number): number {
|
||||
return d;
|
||||
}
|
||||
|
||||
const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id';
|
||||
|
||||
function SceneNpcMarker({
|
||||
placement,
|
||||
npc,
|
||||
left,
|
||||
top,
|
||||
sizePx,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onMovePointerDown,
|
||||
onResizePointerDown,
|
||||
}: {
|
||||
placement: SceneNpcToken;
|
||||
npc: ProjectNpc;
|
||||
left: number;
|
||||
top: number;
|
||||
sizePx: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onMovePointerDown: (e: React.PointerEvent) => void;
|
||||
onResizePointerDown: (e: React.PointerEvent) => void;
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<div
|
||||
data-testid={`scene-npc-token-${placement.id}`}
|
||||
className={[styles.sceneNpcToken, selected ? styles.sceneTokenSelected : ''].filter(Boolean).join(' ')}
|
||||
style={{ left, top, width: sizePx, height: sizePx }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
onSelect();
|
||||
onMovePointerDown(e);
|
||||
}}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
{selected ? <div className={styles.handle} onPointerDown={onResizePointerDown} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<div
|
||||
className={styles.npcTile}
|
||||
data-testid={`scene-npc-tile-${npc.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(SCENE_NPC_DND_MIME, npc.id);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
title={npc.name}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={70}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneEditorApp() {
|
||||
const api = getDndApi();
|
||||
const appTokens = useAppTokens();
|
||||
@@ -85,20 +204,21 @@ export function SceneEditorApp() {
|
||||
const [trapsOpen, setTrapsOpen] = useState(false);
|
||||
const [gridOpen, setGridOpen] = useState(false);
|
||||
const [tokensOpen, setTokensOpen] = useState(false);
|
||||
const [npcsOpen, setNpcsOpen] = useState(false);
|
||||
const [tokenSearch, setTokenSearch] = useState('');
|
||||
const [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>(
|
||||
null,
|
||||
);
|
||||
const [npcSearch, setNpcSearch] = useState('');
|
||||
const [tokenModal, setTokenModal] = useState<
|
||||
{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
|
||||
>(null);
|
||||
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
|
||||
const [selected, setSelected] = useState<Selection>(null);
|
||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragMode>(null);
|
||||
const saveTrapsTimerRef = useRef(0);
|
||||
const saveTokensTimerRef = useRef(0);
|
||||
const saveNpcTokensTimerRef = useRef(0);
|
||||
const saveGridTimerRef = useRef(0);
|
||||
const spaceDownRef = useRef(false);
|
||||
|
||||
@@ -109,15 +229,24 @@ export function SceneEditorApp() {
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
|
||||
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
|
||||
const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
|
||||
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
|
||||
const trapsRef = useRef<SceneTrap[]>([]);
|
||||
const tokensRef = useRef<SceneToken[]>([]);
|
||||
trapsRef.current = localTraps;
|
||||
tokensRef.current = localTokens;
|
||||
const npcTokensRef = useRef<SceneNpcToken[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
trapsRef.current = localTraps;
|
||||
tokensRef.current = localTokens;
|
||||
npcTokensRef.current = localNpcTokens;
|
||||
}, [localNpcTokens, localTokens, localTraps]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTraps(scene?.traps ?? []);
|
||||
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
|
||||
setLocalNpcTokens(
|
||||
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
|
||||
);
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
setSelected(null);
|
||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
@@ -134,6 +263,12 @@ export function SceneEditorApp() {
|
||||
setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId)));
|
||||
}, [scene?.tokens, appTokens]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
const known = new Set((project?.npcs ?? []).map((npc) => npc.id));
|
||||
setLocalNpcTokens((scene?.npcTokens ?? []).filter((t) => known.has(t.npcId)));
|
||||
}, [scene?.npcTokens, project?.npcs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
@@ -174,6 +309,19 @@ export function SceneEditorApp() {
|
||||
[api, sceneId],
|
||||
);
|
||||
|
||||
const persistNpcTokens = useCallback(
|
||||
(next: SceneNpcToken[]) => {
|
||||
if (!sceneId) return;
|
||||
setLocalNpcTokens(next);
|
||||
npcTokensRef.current = next;
|
||||
if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
|
||||
saveNpcTokensTimerRef.current = window.setTimeout(() => {
|
||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens: next } });
|
||||
}, 120);
|
||||
},
|
||||
[api, sceneId],
|
||||
);
|
||||
|
||||
const persistGrid = useCallback(
|
||||
(next: SceneGrid) => {
|
||||
if (!sceneId) return;
|
||||
@@ -194,8 +342,10 @@ export function SceneEditorApp() {
|
||||
e.preventDefault();
|
||||
if (selected.kind === 'trap') {
|
||||
persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
|
||||
} else {
|
||||
} else if (selected.kind === 'token') {
|
||||
persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
|
||||
} else {
|
||||
persistNpcTokens(npcTokensRef.current.filter((t) => t.id !== selected.id));
|
||||
}
|
||||
setSelected(null);
|
||||
}
|
||||
@@ -210,7 +360,7 @@ export function SceneEditorApp() {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [persistTraps, persistTokens, sceneId, selected]);
|
||||
}, [persistNpcTokens, persistTraps, persistTokens, sceneId, selected]);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||
const host = hostRef.current;
|
||||
@@ -284,6 +434,22 @@ export function SceneEditorApp() {
|
||||
persistTokens([...tokensRef.current, placement]);
|
||||
};
|
||||
|
||||
const addNpcTokenAt = (npcId: NpcId, nx: number, ny: number) => {
|
||||
const gridCellSize =
|
||||
localGrid.enabled && Number.isFinite(localGrid.sizeN)
|
||||
? clampSceneNpcTokenSizeN(localGrid.sizeN)
|
||||
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
||||
const placement: SceneNpcToken = {
|
||||
id: asSceneNpcTokenId(randomId('snpc')),
|
||||
npcId,
|
||||
nx,
|
||||
ny,
|
||||
sizeN: gridCellSize,
|
||||
};
|
||||
setSelected({ kind: 'npcToken', id: placement.id });
|
||||
persistNpcTokens([...npcTokensRef.current, placement]);
|
||||
};
|
||||
|
||||
const onStageDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
@@ -293,6 +459,11 @@ export function SceneEditorApp() {
|
||||
addTokenAt(asTokenId(tokenId), p.x, p.y);
|
||||
return;
|
||||
}
|
||||
const npcId = e.dataTransfer.getData(SCENE_NPC_DND_MIME);
|
||||
if (npcId) {
|
||||
addNpcTokenAt(npcId as NpcId, p.x, p.y);
|
||||
return;
|
||||
}
|
||||
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
|
||||
if (!SCENE_TRAP_TYPES.includes(type)) return;
|
||||
addTrapAt(type, p.x, p.y);
|
||||
@@ -306,22 +477,31 @@ export function SceneEditorApp() {
|
||||
persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
|
||||
};
|
||||
|
||||
const updateNpcToken = (id: string, patch: Partial<SceneNpcToken>) => {
|
||||
persistNpcTokens(npcTokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
|
||||
};
|
||||
|
||||
const filteredTokens = useMemo(() => {
|
||||
const q = tokenSearch.trim().toLowerCase();
|
||||
if (!q) return appTokens;
|
||||
return appTokens.filter((t) => t.name.toLowerCase().includes(q));
|
||||
}, [appTokens, tokenSearch]);
|
||||
|
||||
const editingToken = tokenModal?.mode === 'edit' ? appTokens.find((t) => t.id === tokenModal.tokenId) ?? null : null;
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = npcSearch.trim().toLowerCase();
|
||||
const npcs = project?.npcs ?? [];
|
||||
return q ? npcs.filter((npc) => npc.name.toLowerCase().includes(q)) : npcs;
|
||||
}, [npcSearch, project?.npcs]);
|
||||
|
||||
const editingToken =
|
||||
tokenModal?.mode === 'edit' ? (appTokens.find((t) => t.id === tokenModal.tokenId) ?? null) : null;
|
||||
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
|
||||
<div className={styles.hint}>
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
|
||||
</div>
|
||||
<div className={styles.hint}>Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.</div>
|
||||
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
|
||||
@@ -390,6 +570,28 @@ export function SceneEditorApp() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.accordion} data-testid="scene-npc-accordion">
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
|
||||
НПС {npcsOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{npcsOpen ? (
|
||||
<div className={styles.tokensPanel}>
|
||||
<Input
|
||||
value={npcSearch}
|
||||
onChange={setNpcSearch}
|
||||
placeholder="Поиск НПС…"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={styles.tokenGrid}>
|
||||
{filteredNpcs.map((npc) => (
|
||||
<NpcPaletteTile key={npc.id} npc={npc} />
|
||||
))}
|
||||
{filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
|
||||
Неигровые токены {tokensOpen ? '▾' : '▸'}
|
||||
@@ -403,7 +605,6 @@ export function SceneEditorApp() {
|
||||
value={tokenSearch}
|
||||
onChange={setTokenSearch}
|
||||
placeholder="Поиск…"
|
||||
autoFocus={tokensOpen}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={styles.tokenGrid}>
|
||||
@@ -451,10 +652,14 @@ export function SceneEditorApp() {
|
||||
|
||||
<div className={styles.clearSceneBtn}>
|
||||
<Button
|
||||
disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)}
|
||||
data-testid="scene-clear-btn"
|
||||
disabled={
|
||||
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
|
||||
}
|
||||
onClick={() => {
|
||||
persistTraps([]);
|
||||
persistTokens([]);
|
||||
persistNpcTokens([]);
|
||||
setSelected(null);
|
||||
}}
|
||||
>
|
||||
@@ -536,6 +741,26 @@ export function SceneEditorApp() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'moveNpcToken') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateNpcToken(d.tokenId, {
|
||||
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
|
||||
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'resizeNpcToken') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
const tok = npcTokensRef.current.find((t) => t.id === d.tokenId);
|
||||
if (!p || !tok) return;
|
||||
const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
|
||||
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
|
||||
updateNpcToken(d.tokenId, {
|
||||
sizeN: clampSceneNpcTokenSizeN(d.startSize * ratio),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'rotateToken') {
|
||||
const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY);
|
||||
const delta = shortestAngleDelta(d.startPointerAngle, ang);
|
||||
@@ -628,6 +853,60 @@ export function SceneEditorApp() {
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{contentRect
|
||||
? localNpcTokens.map((tok) => {
|
||||
const npc = project?.npcs.find((item) => item.id === tok.npcId);
|
||||
if (!npc) return null;
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
const sizePx = Math.max(16, tok.sizeN * sceneGridTokenFitFactor(localGrid) * minDim);
|
||||
const left = contentRect.x + tok.nx * contentRect.w;
|
||||
const top = contentRect.y + tok.ny * contentRect.h;
|
||||
return (
|
||||
<SceneNpcMarker
|
||||
key={tok.id}
|
||||
placement={tok}
|
||||
npc={npc}
|
||||
left={left}
|
||||
top={top}
|
||||
sizePx={sizePx}
|
||||
selected={selected?.kind === 'npcToken' && selected.id === tok.id}
|
||||
onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
|
||||
onDelete={() => {
|
||||
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== tok.id));
|
||||
setSelected((current) => (current?.id === tok.id ? null : current));
|
||||
}}
|
||||
onMovePointerDown={(e) => {
|
||||
if (spaceDownRef.current) return;
|
||||
e.stopPropagation();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'moveNpcToken',
|
||||
tokenId: tok.id,
|
||||
startNx: tok.nx,
|
||||
startNy: tok.ny,
|
||||
pointerNx: p.x,
|
||||
pointerNy: p.y,
|
||||
};
|
||||
}}
|
||||
onResizePointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'resizeNpcToken',
|
||||
tokenId: tok.id,
|
||||
startSize: tok.sizeN,
|
||||
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{contentRect
|
||||
? localTraps.map((trap) => {
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
@@ -638,7 +917,9 @@ export function SceneEditorApp() {
|
||||
return (
|
||||
<div
|
||||
key={trap.id}
|
||||
className={[styles.trap, isSelected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
|
||||
className={[styles.trap, isSelected ? styles.trapSelected : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left, top, width: sizePx, height: sizePx }}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || spaceDownRef.current) return;
|
||||
|
||||
Reference in New Issue
Block a user