import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import type { NpcDisposition, 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 { normalizeNpcDisposition, npcDispositionRingColor, otherNpcDispositions, } from '../../shared/types/npcDisposition'; import { asSceneTokenId, asTokenId, clampSceneTokenSizeN, DEFAULT_SCENE_TOKEN_SIZE_N, } from '../../shared/types/appTokens'; import { clampSceneGridSizeN, DEFAULT_SCENE_GRID, SCENE_GRID_SIZE_MAX, SCENE_GRID_SIZE_MIN, sceneGridTokenFitFactor, sceneGridTypeLabelRu, } from '../../shared/types/sceneGrid'; import { asSceneTrapId, DEFAULT_SCENE_TRAP_SIZE_N, SCENE_TRAP_TYPES, trapTypeLabelRu, } from '../../shared/types/sceneTraps'; import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import editorStyles from '../editor/EditorApp.module.css'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; import { EllipsisText } from '../shared/ui/EllipsisText'; import ellipsisStyles from '../shared/ui/ellipsisText.module.css'; 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 styles from './SceneEditorApp.module.css'; import { SceneTokenMarker } from './SceneTokenMarker'; import { TokenEditModal } from './TokenEditModal'; import { TOKEN_DND_MIME, TokenTile } from './TokenTile'; function dispositionMakeKey(d: NpcDisposition): string { if (d === 'hostile') return 'npcs.makeHostile'; if (d === 'friendly') return 'npcs.makeFriendly'; return 'npcs.makeNeutral'; } function isTypingTarget(el: EventTarget | null): boolean { if (!(el instanceof HTMLElement)) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; return el.isContentEditable; } type LocalView = { scale: number; ox: number; oy: number }; 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: 'resizeTrap'; trapId: string; startSize: number; startDist: 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; startRotation: number; startPointerAngle: number; centerClientX: number; centerClientY: number; } | null; function randomId(prefix: string): string { return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; } function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number { return (Math.atan2(y - cy, x - cx) * 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; } const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id'; function SceneNpcMarker({ placement, npc, left, top, sizePx, selected, onSelect, onContextMenu, onMovePointerDown, onResizePointerDown, }: { placement: SceneNpcToken; npc: ProjectNpc; left: number; top: number; sizePx: number; selected: boolean; onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void; onMovePointerDown: (e: React.PointerEvent) => void; onResizePointerDown: (e: React.PointerEvent) => void; }) { const imageUrl = useAssetUrl(npc.avatarAssetId); const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition); return (
{ e.preventDefault(); e.stopPropagation(); onContextMenu(e); }} onPointerDown={(e) => { if (e.button !== 0) return; onSelect(); onMovePointerDown(e); }} > {selected ?
: null}
); } function NpcPaletteTile({ npc }: { npc: ProjectNpc }) { const imageUrl = useAssetUrl(npc.avatarAssetId); const disposition = normalizeNpcDisposition(npc.disposition); return (
{ e.dataTransfer.setData(SCENE_NPC_DND_MIME, npc.id); e.dataTransfer.effectAllowed = 'copy'; }} title={npc.name} >
); } export function SceneEditorApp() { const api = getDndApi(); const { t } = useEditorI18n(); const appTokens = useAppTokens(); const [session, setSession] = useState(null); 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 [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 [npcCtxMenu, setNpcCtxMenu] = useState<{ x: number; y: number; placementId: string; } | null>(null); const [selected, setSelected] = useState(null); const [view, setView] = useState({ scale: 1, ox: 0.5, oy: 0.5 }); const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const hostRef = useRef(null); const dragRef = useRef(null); const saveTrapsTimerRef = useRef(0); const saveTokensTimerRef = useRef(0); const saveNpcTokensTimerRef = useRef(0); const saveGridTimerRef = useRef(0); const spaceDownRef = useRef(false); const project = session?.project ?? null; const sceneId = project?.currentSceneId ?? null; const scene = sceneId && project ? project.scenes[sceneId] : undefined; const url = useAssetUrl(scene?.previewAssetId ?? null); const rot = scene?.previewRotationDeg ?? 0; const [localTraps, setLocalTraps] = useState([]); const [localTokens, setLocalTokens] = useState([]); const [localNpcTokens, setLocalNpcTokens] = useState([]); const [localGrid, setLocalGrid] = useState({ ...DEFAULT_SCENE_GRID }); const trapsRef = useRef([]); const tokensRef = useRef([]); const npcTokensRef = useRef([]); 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 }); }, [sceneId, scene?.previewAssetId]); useEffect(() => { if (dragRef.current) return; setLocalTraps(scene?.traps ?? []); }, [scene?.traps]); useEffect(() => { if (dragRef.current) return; const known = new Set(appTokens.map((t) => t.id)); 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 }); }, [scene?.grid]); useEffect(() => { void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => { setSession({ project: p, currentSceneId: p?.currentSceneId ?? null }); }); return api.on(ipcChannels.session.stateChanged, ({ state }) => { setSession(state); }); }, [api]); const persistTraps = useCallback( (next: SceneTrap[]) => { if (!sceneId) return; setLocalTraps(next); trapsRef.current = next; if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current); saveTrapsTimerRef.current = window.setTimeout(() => { void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } }); }, 120); }, [api, sceneId], ); const persistTokens = useCallback( (next: SceneToken[]) => { if (!sceneId) return; setLocalTokens(next); tokensRef.current = next; if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current); saveTokensTimerRef.current = window.setTimeout(() => { void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens: next } }); }, 120); }, [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; setLocalGrid(next); if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current); saveGridTimerRef.current = window.setTimeout(() => { void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { grid: next } }); }, 120); }, [api, sceneId], ); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (isTypingTarget(e.target)) return; if (e.code === 'Space') spaceDownRef.current = true; if ((e.key === 'Delete' || e.key === 'Backspace') && selected && sceneId) { e.preventDefault(); if (selected.kind === 'trap') { persistTraps(trapsRef.current.filter((t) => t.id !== selected.id)); } 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); } }; const onKeyUp = (e: KeyboardEvent) => { if (isTypingTarget(e.target)) return; if (e.code === 'Space') spaceDownRef.current = false; }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); }; }, [persistNpcTokens, persistTraps, persistTokens, sceneId, selected]); const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => { const host = hostRef.current; const cr = contentRect; if (!host || !cr || cr.w < 1 || cr.h < 1) return null; const r = host.getBoundingClientRect(); return { x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)), y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)), }; }; const viewCamera = useMemo(() => view, [view]); useEffect(() => { const host = hostRef.current; if (!host) return; const nativeWheel = (e: WheelEvent) => { e.preventDefault(); const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; const cr = contentRect; if (!cr) { setView((v) => { const nextScale = Math.max(1, Math.min(8, v.scale * factor)); if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 }; return { ...v, scale: nextScale }; }); return; } const r = host.getBoundingClientRect(); setView((v) => { const containW = cr.w / Math.max(1e-6, v.scale); const containH = cr.h / Math.max(1e-6, v.scale); return sceneViewZoomAt(v, { hostW: r.width, hostH: r.height, containW, containH, hostX: e.clientX - r.left, hostY: e.clientY - r.top, factor, }); }); }; host.addEventListener('wheel', nativeWheel, { passive: false }); return () => host.removeEventListener('wheel', nativeWheel); }, [contentRect]); const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => { const trap: SceneTrap = { id: asSceneTrapId(randomId('trap')), type, nx, ny, sizeN: DEFAULT_SCENE_TRAP_SIZE_N, }; setSelected({ kind: 'trap', id: trap.id }); persistTraps([...trapsRef.current, trap]); }; const addTokenAt = (tokenId: TokenId, nx: number, ny: number) => { const placement: SceneToken = { id: asSceneTokenId(randomId('stoken')), tokenId, nx, ny, sizeN: DEFAULT_SCENE_TOKEN_SIZE_N, rotationDeg: 0, }; setSelected({ kind: 'token', id: placement.id }); 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 npc = project?.npcs.find((item) => item.id === npcId); const placement: SceneNpcToken = { id: asSceneNpcTokenId(randomId('snpc')), npcId, nx, ny, sizeN: gridCellSize, disposition: normalizeNpcDisposition(npc?.disposition), }; setSelected({ kind: 'npcToken', id: placement.id }); persistNpcTokens([...npcTokensRef.current, placement]); }; const onStageDrop = (e: React.DragEvent) => { e.preventDefault(); const p = hostToNorm(e.clientX, e.clientY); if (!p) return; const tokenId = e.dataTransfer.getData(TOKEN_DND_MIME); if (tokenId) { 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); }; const updateTrap = (id: string, patch: Partial) => { persistTraps(trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t))); }; const updateToken = (id: string, patch: Partial) => { persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t))); }; const updateNpcToken = (id: string, patch: Partial) => { 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 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 (
{!isImage ? (
Нужно изображение сцены
) : (
e.preventDefault()} onDrop={onStageDrop} onPointerDown={(e) => { if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) { e.preventDefault(); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY }; } }} onPointerMove={(e) => { const d = dragRef.current; if (!d) return; if (d.kind === 'pan') { const cr = contentRect; if (!cr) return; const dx = e.clientX - d.lastX; const dy = e.clientY - d.lastY; d.lastX = e.clientX; d.lastY = e.clientY; setView((v) => { const containW = cr.w / Math.max(1e-6, v.scale); const containH = cr.h / Math.max(1e-6, v.scale); return sceneViewPanBy(v, { containW, containH, dx, dy }); }); return; } if (d.kind === 'moveTrap') { const p = hostToNorm(e.clientX, e.clientY); if (!p) return; updateTrap(d.trapId, { 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 === 'resizeTrap') { const p = hostToNorm(e.clientX, e.clientY); const trap = trapsRef.current.find((t) => t.id === d.trapId); if (!p || !trap) return; const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny); const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1; updateTrap(d.trapId, { sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)), }); return; } if (d.kind === 'moveToken') { const p = hostToNorm(e.clientX, e.clientY); if (!p) return; updateToken(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 === 'resizeToken') { const p = hostToNorm(e.clientX, e.clientY); const tok = tokensRef.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; updateToken(d.tokenId, { sizeN: clampSceneTokenSizeN(d.startSize * ratio), }); 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); updateToken(d.tokenId, { rotationDeg: d.startRotation + delta }); } }} onPointerUp={() => { dragRef.current = null; }} onPointerCancel={() => { dragRef.current = null; }} > {contentRect ? localTokens.map((tok) => { const minDim = Math.min(contentRect.w, contentRect.h); const sizePx = Math.max(16, tok.sizeN * minDim); const left = contentRect.x + tok.nx * contentRect.w; const top = contentRect.y + tok.ny * contentRect.h; return ( setSelected({ kind: 'token', id: tok.id })} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); persistTokens(tokensRef.current.filter((t) => t.id !== tok.id)); setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur)); }} 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: 'moveToken', 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: 'resizeToken', tokenId: tok.id, startSize: tok.sizeN, startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)), }; }} onRotatePointerDown={(e) => { e.stopPropagation(); e.preventDefault(); const host = hostRef.current; if (!host || !contentRect) return; const r = host.getBoundingClientRect(); const cx = r.left + contentRect.x + tok.nx * contentRect.w; const cy = r.top + contentRect.y + tok.ny * contentRect.h; (e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId); dragRef.current = { kind: 'rotateToken', tokenId: tok.id, startRotation: tok.rotationDeg, startPointerAngle: pointerAngleDeg(cx, cy, e.clientX, e.clientY), centerClientX: cx, centerClientY: cy, }; }} /> ); }) : 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 ( setSelected({ kind: 'npcToken', id: tok.id })} onContextMenu={(e) => { setSelected({ kind: 'npcToken', id: tok.id }); setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id }); }} 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); const sizePx = Math.max(16, trap.sizeN * minDim); const left = contentRect.x + trap.nx * contentRect.w; const top = contentRect.y + trap.ny * contentRect.h; const isSelected = selected?.kind === 'trap' && selected.id === trap.id; return (
{ if (e.button !== 0 || spaceDownRef.current) return; e.stopPropagation(); setSelected({ kind: 'trap', id: trap.id }); const p = hostToNorm(e.clientX, e.clientY); if (!p) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { kind: 'moveTrap', trapId: trap.id, startNx: trap.nx, startNy: trap.ny, pointerNx: p.x, pointerNy: p.y, }; }} > {trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
{trap.label}
) : null} {isSelected ? (
{ e.stopPropagation(); e.preventDefault(); const p = hostToNorm(e.clientX, e.clientY); if (!p) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { kind: 'resizeTrap', trapId: trap.id, startSize: trap.sizeN, startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)), }; }} /> ) : null}
); }) : null}
)}
t.name)} onClose={() => setTokenModal(null)} onSaved={() => setTokenModal(null)} /> {pendingDeleteToken ? createPortal( <>
Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены.
, document.body, ) : null} {npcCtxMenu ? createPortal( <> {(() => { const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId); const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined; if (!tok || !npc) return null; const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition); return otherNpcDispositions(current).map((d) => ( )); })()}
, document.body, ) : null} ); }