import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import type { NpcId, ProjectNpc } from '../../shared/types'; import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; 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 ZoomInIcon() { return ( ); } function ZoomOutIcon() { return ( ); } function RuntimeNpcTile({ npc, selected, active, onActivate, }: { npc: ProjectNpc; selected: boolean; active: boolean; onActivate: () => void; }) { const url = useAssetUrl(npc.avatarAssetId); return ( ); } export function NpcsApp() { const { t } = useEditorI18n(); const api = getDndApi(); const [session, setSession] = useState(null); const [overlay, overlayApi] = useNpcsOverlayState(); const [selectedId, setSelectedId] = useState(null); const [query, setQuery] = useState(''); useEffect(() => { void api.invoke(ipcChannels.project.get, {}).then(({ project }) => { setSession({ project, currentSceneId: project?.currentSceneId ?? null }); const list = project?.npcs ?? []; setSelectedId(list[0]?.id ?? null); }); return api.on(ipcChannels.session.stateChanged, ({ state }) => { setSession(state); }); }, [api]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (overlay?.activeNpcId) { 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); }, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]); const npcs = session?.project?.npcs ?? []; const relations = session?.project?.npcRelations ?? []; const activeId = overlay?.activeNpcId ?? null; const zoomTool = overlay?.zoomTool ?? null; const selected = npcs.find((n) => n.id === selectedId) ?? null; const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return npcs; return npcs.filter((n) => n.name.toLowerCase().includes(q)); }, [npcs, query]); const relationsForSelected = useMemo(() => { if (!selected) return []; return relations .filter((r) => r.sourceNpcId === selected.id) .map((r) => { const other = npcs.find((n) => n.id === r.targetNpcId); return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` }; }); }, [npcs, relations, selected]); const onSelectTile = useCallback( (id: NpcId) => { setSelectedId(id); void overlayApi.dispatch({ kind: 'toggle', npcId: id }); }, [overlayApi], ); const safeHtml = selected ? sanitizeSceneDescriptionHtml(selected.description) : ''; return (
{activeId ? ( ) : null}
{zoomTool === 'zoomIn' ? t('npcs.zoomInHint') : zoomTool === 'zoomOut' ? t('npcs.zoomOutHint') : t('npcs.zoomIdleHint')}
{selected ? ( <>
{selected.name}
{safeHtml ? (
{t('npcs.description')}
) : (
{t('npcs.descriptionEmpty')}
)} {relationsForSelected.length > 0 ? (
{t('npcs.relations')}
{relationsForSelected.map((r) => (
{r.text}
))}
) : null} ) : (
{t('npcs.windowEmpty')}
)}
{filtered.map((n) => ( onSelectTile(n.id)} /> ))} {npcs.length === 0 ?
{t('npcs.windowEmpty')}
: null} {npcs.length > 0 && filtered.length === 0 ? (
{t('npcs.searchEmpty')}
) : null}
); }