import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups'; import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types'; import { listVisibleSceneNpcTokens, nextSessionNpcSpawnPoint, } from '../../shared/types/appPlayers'; import { normalizeNpcDisposition } from '../../shared/types/npcDisposition'; import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl'; import matStyles from '../editor/MaterialsModals.module.css'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml'; import { getDndApi } from '../shared/dndApi'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag'; import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession'; import { Button, Input } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './NpcsApp.module.css'; function ZoomInIcon() { return ( ); } function ZoomOutIcon() { return ( ); } /** Убрать пустые ветки групп (удобно при поиске). */ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] { const out: NpcGroupTreeNode[] = []; for (const node of nodes) { const children = pruneEmptyGroupNodes(node.children); if (node.npcs.length === 0 && children.length === 0) continue; out.push({ ...node, children }); } return out; } function RuntimeNpcTile({ npc, selected, shown, accentColor, mapDragEnabled, onActivate, onToggleShow, onAddToMap, onMapDragBegin, onMapDragEnd, }: { npc: ProjectNpc; selected: boolean; shown: boolean; accentColor?: string | null; mapDragEnabled: boolean; onActivate: () => void; onToggleShow: () => void; onAddToMap: () => void; onMapDragBegin: (npcId: NpcId) => void; onMapDragEnd: () => void; }) { const { t } = useEditorI18n(); const url = useAssetUrl(npc.avatarAssetId); const menuBtnRef = useRef(null); const menuRef = useRef(null); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const dragRef = useRef<{ pointerId: number; x: number; y: number; started: boolean; } | null>(null); const suppressClickRef = useRef(false); useEffect(() => { if (!menu) return; const onPointerDown = (e: PointerEvent) => { const tgt = e.target; if (!(tgt instanceof Node)) return; if (menuBtnRef.current?.contains(tgt)) return; if (menuRef.current?.contains(tgt)) return; setMenu(null); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenu(null); }; window.addEventListener('pointerdown', onPointerDown, true); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('pointerdown', onPointerDown, true); window.removeEventListener('keydown', onKey); }; }, [menu]); return ( e.stopPropagation()} onClick={(e) => { e.preventDefault(); e.stopPropagation(); const pad = 8; const menuW = 180; const menuH = 48; const rect = e.currentTarget.getBoundingClientRect(); const x = Math.max(pad, Math.min(rect.right + 4, window.innerWidth - menuW - pad)); const y = Math.max(pad, Math.min(rect.top, window.innerHeight - menuH - pad)); setMenu((cur) => (cur ? null : { x, y })); }} > ⋮ { if (suppressClickRef.current) { suppressClickRef.current = false; e.preventDefault(); e.stopPropagation(); return; } onActivate(); }} onPointerDown={(e) => { if (e.button !== 0 || !mapDragEnabled) return; dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false }; }} onPointerMove={(e) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId || d.started) return; if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return; d.started = true; try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ } onMapDragBegin(npc.id); }} onPointerUp={(e) => { const d = dragRef.current; if (d && d.pointerId !== e.pointerId) return; dragRef.current = null; try { if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } } catch { /* ignore */ } if (d?.started) { suppressClickRef.current = true; e.preventDefault(); onMapDragEnd(); } }} onPointerCancel={() => { dragRef.current = null; }} > {url ? : null} {npc.name} e.stopPropagation()} onClick={(e) => e.stopPropagation()} > { onToggleShow(); }} /> {t('npcs.showOnScreen')} {menu ? createPortal( e.stopPropagation()} > { setMenu(null); if (!mapDragEnabled) return; onAddToMap(); }} > {t('npcs.addToMap')} , document.body, ) : null} ); } function RuntimeGroupSection({ node, depth, isExpanded, onToggleExpanded, selectedIds, shownIds, mapDragEnabled, onActivate, onToggleShow, onAddToMap, onMapDragBegin, onMapDragEnd, }: { node: NpcGroupTreeNode; depth: number; isExpanded: (id: NpcGroupId) => boolean; onToggleExpanded: (id: NpcGroupId) => void; selectedIds: ReadonlySet; shownIds: ReadonlySet; mapDragEnabled: boolean; onActivate: (id: NpcId) => void; onToggleShow: (id: NpcId) => void; onAddToMap: (id: NpcId) => void; onMapDragBegin: (npcId: NpcId) => void; onMapDragEnd: () => void; }) { const g = node.group; const expanded = isExpanded(g.id); return ( 0 ? 12 : 0 }}> onToggleExpanded(g.id)} aria-expanded={expanded} > {expanded ? '▾' : '▸'} {g.name} {expanded ? ( {node.npcs.map((n) => ( onActivate(n.id)} onToggleShow={() => onToggleShow(n.id)} onAddToMap={() => onAddToMap(n.id)} onMapDragBegin={onMapDragBegin} onMapDragEnd={onMapDragEnd} /> ))} {node.children.map((child) => ( ))} ) : null} ); } function NpcExternalWebview({ src }: { src: string }) { const hostRef = useRef(null); useEffect(() => { const host = hostRef.current; if (!host) return; const el = document.createElement('webview'); el.setAttribute('src', src); el.setAttribute('partition', 'persist:npc-external-link'); el.style.width = '100%'; el.style.height = '100%'; el.style.border = '0'; el.style.display = 'flex'; host.replaceChildren(el); return () => { host.replaceChildren(); }; }, [src]); return ; } function NpcInspectCard({ npc, relations, expanded, onToggleExpanded, }: { npc: ProjectNpc; relations: { id: string; text: string }[]; expanded: boolean; onToggleExpanded: () => void; }) { const { t } = useEditorI18n(); const externalUrl = normalizeNpcExternalUrl(npc.externalUrl); const [tab, setTab] = useState<'description' | 'link'>('description'); const safeHtml = sanitizeSceneDescriptionHtml(npc.description); const hasLink = Boolean(externalUrl); const showLink = hasLink && tab === 'link'; useEffect(() => { if (!hasLink && tab === 'link') setTab('description'); }, [hasLink, tab]); return ( {expanded ? '▾' : '▸'} {npc.name} {expanded ? ( <> {hasLink ? ( setTab('description')} > {t('npcs.tabDescription')} setTab('link')} > {t('npcs.tabExternalLink')} ) : null} {showLink && externalUrl ? ( ) : ( <> {safeHtml ? ( {!hasLink ? {t('npcs.description')} : null} ) : ( {t('npcs.descriptionEmpty')} )} {relations.length > 0 ? ( {t('npcs.relations')} {relations.map((r) => ( {r.text} ))} ) : null} > )} > ) : null} ); } export function NpcsApp() { const { t } = useEditorI18n(); const api = getDndApi(); const [session, setSession] = useState(null); const [overlay, overlayApi] = useNpcsOverlayState(); const [npcSession, npcSessionApi] = useSceneNpcTokensSession(); const [mapDrag, mapDragApi] = useNpcMapSpawnDrag(); const [query, setQuery] = useState(''); const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()); const [collapsedInspect, setCollapsedInspect] = useState>(() => new Set()); useEffect(() => { void api.invoke(ipcChannels.project.get, {}).then(({ project }) => { setSession({ project, currentSceneId: project?.currentSceneId ?? null }); }); return api.on(ipcChannels.session.stateChanged, ({ state }) => { setSession(state); }); }, [api]); const activeIds = overlay?.activeNpcIds ?? []; const [inspectIds, setInspectIds] = useState([]); const hasActive = activeIds.length > 0; const shownIds = useMemo(() => new Set(activeIds), [activeIds]); useEffect(() => { if (!overlay) return; setInspectIds(overlay.inspectNpcIds ?? []); }, [overlay?.inspectNpcIds]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (mapDrag.dragging) { void mapDragApi.cancel(); return; } if (hasActive) { void overlayApi.dispatch({ kind: 'hide' }); return; } if (overlay?.zoomTool) { void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null }); return; } window.close(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [hasActive, mapDrag.dragging, mapDragApi, overlay?.zoomTool, overlayApi]); const zoomTool = overlay?.zoomTool ?? null; const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]); const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]); const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]); const selectedIds = useMemo(() => new Set(inspectIds), [inspectIds]); const detailNpcs = useMemo( () => inspectIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)), [inspectIds, npcs], ); useEffect(() => { const focusId = overlay?.inspectFocusNpcId ?? null; if (!focusId) return; setCollapsedInspect((prev) => { if (!prev.has(focusId)) return prev; const next = new Set(prev); next.delete(focusId); return next; }); }, [overlay?.inspectFocusNpcId, overlay?.revision]); useEffect(() => { setCollapsedInspect((prev) => { let changed = false; const next = new Set(prev); for (const id of prev) { if (!selectedIds.has(id)) { next.delete(id); changed = true; } } return changed ? next : prev; }); }, [selectedIds]); const filteredNpcs = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return npcs; return npcs.filter((n) => n.name.toLowerCase().includes(q)); }, [npcs, query]); const searching = query.trim().length > 0; const { roots, ungrouped } = useMemo(() => { const forest = buildNpcGroupForest(npcGroups, filteredNpcs); if (!searching) return forest; return { roots: pruneEmptyGroupNodes(forest.roots), ungrouped: forest.ungrouped, }; }, [filteredNpcs, npcGroups, searching]); const isExpanded = useCallback( (id: NpcGroupId) => { if (searching) return true; return !collapsedGroups.has(id); }, [collapsedGroups, searching], ); const toggleExpanded = useCallback( (id: NpcGroupId) => { if (searching) return; setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, [searching], ); const relationsByNpcId = useMemo(() => { const map = new Map(); for (const npc of detailNpcs) { const list = relations .filter((r) => r.sourceNpcId === npc.id) .map((r) => { const other = npcs.find((n) => n.id === r.targetNpcId); return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` }; }); map.set(npc.id, list); } return map; }, [detailNpcs, npcs, relations]); const onSelectTile = useCallback( (id: NpcId) => { setInspectIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); void overlayApi.dispatch({ kind: 'inspect.toggle', npcId: id }); }, [overlayApi], ); const onToggleShow = useCallback( (id: NpcId) => { void overlayApi.dispatch({ kind: 'toggle', npcId: id }); }, [overlayApi], ); const sceneId = session?.currentSceneId ?? null; const currentScene = sceneId && session?.project ? session.project.scenes[sceneId] : undefined; const mapDragEnabled = Boolean(sceneId); const spawnNpcOnMap = useCallback( (npcId: NpcId) => { if (!sceneId) return; const npc = npcs.find((item) => item.id === npcId); if (!npc) return; const visible = listVisibleSceneNpcTokens( currentScene?.npcTokens, npcSession.spawned, sceneId, ); const occupied = visible.map((tok) => { const override = npcSession.byPlacementId[String(tok.id)]; return { nx: override?.nx ?? tok.nx, ny: override?.ny ?? tok.ny }; }); const point = nextSessionNpcSpawnPoint(occupied); npcSessionApi.dispatch({ kind: 'spawn', npcId, sceneId, nx: point.nx, ny: point.ny, disposition: normalizeNpcDisposition(npc.disposition), }); }, [currentScene?.npcTokens, npcSession.byPlacementId, npcSession.spawned, npcSessionApi, npcs, sceneId], ); const onMapDragBegin = useCallback( (id: NpcId) => { void mapDragApi.beginDrag(id); }, [mapDragApi], ); const onMapDragEnd = useCallback(() => { void mapDragApi.commit(); }, [mapDragApi]); return ( { void overlayApi.dispatch({ kind: 'zoomTool.set', tool: zoomTool === 'zoomIn' ? null : 'zoomIn', }); }} > { void overlayApi.dispatch({ kind: 'zoomTool.set', tool: zoomTool === 'zoomOut' ? null : 'zoomOut', }); }} > {zoomTool === 'zoomIn' ? t('npcs.zoomInHint') : zoomTool === 'zoomOut' ? t('npcs.zoomOutHint') : t('npcs.zoomIdleHint')} {sceneId ? t('npcs.dragToMapHint') : t('npcs.addToMapNoScene')} { void overlayApi.dispatch({ kind: 'hide' }); }} > {t('npcs.closeOverlay')} {detailNpcs.length > 0 ? ( detailNpcs.map((npc) => ( { setCollapsedInspect((prev) => { const next = new Set(prev); if (next.has(npc.id)) next.delete(npc.id); else next.add(npc.id); return next; }); }} /> )) ) : ( {npcs.length === 0 ? t('npcs.windowEmpty') : t('npcs.selectToShow')} )} {npcGroups.length === 0 ? ( filteredNpcs.map((n) => ( onSelectTile(n.id)} onToggleShow={() => onToggleShow(n.id)} onAddToMap={() => spawnNpcOnMap(n.id)} onMapDragBegin={onMapDragBegin} onMapDragEnd={onMapDragEnd} /> )) ) : ( <> {roots.map((node) => ( ))} {ungrouped.length > 0 || !searching ? ( {t('npcs.ungrouped')} {ungrouped.map((n) => ( onSelectTile(n.id)} onToggleShow={() => onToggleShow(n.id)} onAddToMap={() => spawnNpcOnMap(n.id)} onMapDragBegin={onMapDragBegin} onMapDragEnd={onMapDragEnd} /> ))} ) : null} > )} {npcs.length === 0 ? {t('npcs.windowEmpty')} : null} {npcs.length > 0 && filteredNpcs.length === 0 ? ( {t('npcs.searchEmpty')} ) : null} ); }