import React, { useCallback, useEffect, useMemo, useState } from 'react'; 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 { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml'; import { getDndApi } from '../shared/dndApi'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { Button, Input } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './NpcsApp.module.css'; /** Убрать пустые ветки групп (удобно при поиске). */ 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, accentColor, onActivate, }: { npc: ProjectNpc; selected: boolean; accentColor?: string | null; onActivate: () => void; }) { const url = useAssetUrl(npc.avatarAssetId); return ( ); } function RuntimeGroupSection({ node, depth, isExpanded, onToggleExpanded, selectedIds, onActivate, }: { node: NpcGroupTreeNode; depth: number; isExpanded: (id: NpcGroupId) => boolean; onToggleExpanded: (id: NpcGroupId) => void; selectedIds: ReadonlySet; onActivate: (id: NpcId) => void; }) { const g = node.group; const expanded = isExpanded(g.id); return (
0 ? 12 : 0 }}>
{g.name}
{expanded ? (
{node.npcs.map((n) => ( onActivate(n.id)} /> ))} {node.children.map((child) => ( ))}
) : null}
); } export function NpcsApp() { const { t } = useEditorI18n(); const api = getDndApi(); const [session, setSession] = useState(null); const [overlay, overlayApi] = useNpcsOverlayState(); const [query, setQuery] = useState(''); const [collapsedGroups, setCollapsedGroups] = 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 hasActive = activeIds.length > 0; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (hasActive) { void overlayApi.dispatch({ kind: 'hide' }); return; } window.close(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [hasActive, overlayApi]); 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(activeIds), [activeIds]); const selectedNpcs = useMemo( () => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)), [activeIds, npcs], ); 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 selectedNpcs) { 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; }, [npcs, relations, selectedNpcs]); const onSelectTile = useCallback( (id: NpcId) => { void overlayApi.dispatch({ kind: 'toggle', npcId: id }); }, [overlayApi], ); return (
{hasActive ? (
) : null}
{selectedNpcs.length > 0 ? ( selectedNpcs.map((npc) => { const safeHtml = sanitizeSceneDescriptionHtml(npc.description); const npcRelations = relationsByNpcId.get(npc.id) ?? []; return (
{npc.name}
{safeHtml ? (
{t('npcs.description')}
) : (
{t('npcs.descriptionEmpty')}
)} {npcRelations.length > 0 ? (
{t('npcs.relations')}
{npcRelations.map((r) => (
{r.text}
))}
) : null}
); }) ) : (
{npcs.length === 0 ? t('npcs.windowEmpty') : t('npcs.selectToShow')}
)}
{npcGroups.length === 0 ? ( filteredNpcs.map((n) => ( onSelectTile(n.id)} /> )) ) : ( <> {roots.map((node) => ( ))} {ungrouped.length > 0 || !searching ? (
{t('npcs.ungrouped')}
{ungrouped.map((n) => ( onSelectTile(n.id)} /> ))}
) : null} )} {npcs.length === 0 ?
{t('npcs.windowEmpty')}
: null} {npcs.length > 0 && filteredNpcs.length === 0 ? (
{t('npcs.searchEmpty')}
) : null}
); }