import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types'; import editorStyles from '../editor/EditorApp.module.css'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; import { Button, Input } from '../shared/ui/controls'; import controlStyles from '../shared/ui/Controls.module.css'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcEditModal } from './NpcEditModal'; import { NpcGraph } from './NpcGraph'; import { NpcRelationModal } from './NpcRelationModal'; import styles from './NpcsEditorApp.module.css'; const DND_NPC_ID_MIME = 'application/x-dnd-npc-id'; function NpcTile({ npc, selected, dragging, dropPlace, onSelect, onMenu, onDragStart, onDragEnd, onDragOver, onDropReorder, }: { npc: ProjectNpc; selected: boolean; dragging: boolean; dropPlace: 'before' | 'after' | null; onSelect: () => void; onMenu: (e: React.MouseEvent) => void; onDragStart: () => void; onDragEnd: () => void; onDragOver: (place: 'before' | 'after') => void; onDropReorder: () => void; }) { const { t } = useEditorI18n(); const url = useAssetUrl(npc.avatarAssetId); return (
{ e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id); e.dataTransfer.effectAllowed = 'move'; onDragStart(); }} onDragEnd={onDragEnd} onDragOver={(e) => { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); const mid = rect.top + rect.height / 2; onDragOver(e.clientY < mid ? 'before' : 'after'); }} onDrop={(e) => { e.preventDefault(); onDropReorder(); }} >
); } export function NpcsEditorApp() { const { t } = useEditorI18n(); const api = getDndApi(); const [session, setSession] = useState(null); const [selectedId, setSelectedId] = useState(null); const [query, setQuery] = useState(''); const [editOpen, setEditOpen] = useState(false); const [editInitial, setEditInitial] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); const [menuFor, setMenuFor] = useState(null); const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); const [dragId, setDragId] = useState(null); const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null); const [nameDraft, setNameDraft] = useState(''); const [relationModal, setRelationModal] = useState< | { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId } | { mode: 'edit'; relationId: NpcRelationId; label: string } | null >(null); const [pendingDeleteRelation, setPendingDeleteRelation] = useState(null); const [avatarBusy, setAvatarBusy] = useState(false); 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]); const npcs = session?.project?.npcs ?? []; const relations = session?.project?.npcRelations ?? []; const selected = npcs.find((n) => n.id === selectedId) ?? null; useEffect(() => { setNameDraft(selected?.name ?? ''); }, [selected?.id, selected?.name]); useEffect(() => { if (selectedId && npcs.some((n) => n.id === selectedId)) return; setSelectedId(npcs[0]?.id ?? null); }, [npcs, selectedId]); useEffect(() => { if (!menuFor) return; const onDown = (e: MouseEvent) => { const tgt = e.target as HTMLElement | null; if (tgt?.closest('[data-npc-menu-root="1"]')) return; setMenuFor(null); setMenuPos(null); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [menuFor]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return npcs; return npcs.filter((n) => n.name.toLowerCase().includes(q)); }, [npcs, query]); const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null); 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 { relation: r, otherName: other?.name ?? '—' }; }); }, [npcs, relations, selected]); const pickAvatar = useCallback(async () => { const res = await api.invoke(ipcChannels.project.pickNpcAvatar, {}); if (res.canceled) return null; return { filePath: res.filePath, previewDataUrl: res.previewDataUrl }; }, [api]); const graphUi = useMemo( () => ({ zoomBar: t('npcs.graphZoomBar'), zoomIn: t('npcs.graphZoomIn'), zoomOut: t('npcs.graphZoomOut'), fitAll: t('npcs.graphFitAll'), editRelation: t('npcs.relationEdit'), deleteRelation: t('npcs.relationDelete'), untitled: t('npcs.untitled'), }), [t], ); return (
{t('npcs.editorTitle')}
{filtered.map((n) => ( setSelectedId(n.id)} onMenu={(e) => { const r = e.currentTarget.getBoundingClientRect(); const menuW = 180; const menuH = 88; const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8)); const top = r.bottom + 8 + menuH > window.innerHeight - 8 ? Math.max(8, r.top - menuH - 8) : r.bottom + 8; setMenuPos({ left, top }); setMenuFor((cur) => (cur === n.id ? null : n.id)); }} onDragStart={() => setDragId(n.id)} onDragEnd={() => { setDragId(null); setDropPlace(null); }} onDragOver={(place) => { if (!dragId || dragId === n.id) { setDropPlace(null); return; } setDropPlace({ id: n.id, place }); }} onDropReorder={() => { if (!dragId || !dropPlace || dragId === dropPlace.id) return; const ids = npcs.map((x) => x.id); const from = ids.indexOf(dragId); if (from < 0) return; ids.splice(from, 1); let to = ids.indexOf(dropPlace.id); if (to < 0) return; if (dropPlace.place === 'after') to += 1; ids.splice(to, 0, dragId); setDragId(null); setDropPlace(null); void api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: ids }); }} /> ))} {npcs.length === 0 ?
{t('npcs.empty')}
: null} {npcs.length > 0 && filtered.length === 0 ? (
{t('npcs.searchEmpty')}
) : null}
{ setRelationModal({ mode: 'create', sourceNpcId, targetNpcId }); }} onNodePositionCommit={(npcId, x, y) => { void api.invoke(ipcChannels.project.updateNpcPosition, { npcId, x, y }); }} onEditRelation={(relationId) => { const rel = relations.find((r) => r.id === relationId); if (!rel) return; setRelationModal({ mode: 'edit', relationId, label: rel.label }); }} onDeleteRelation={(relationId) => { const rel = relations.find((r) => r.id === relationId); if (!rel) return; setPendingDeleteRelation(rel); }} />
{selected ? ( <>
{t('npcs.avatar')}
{selectedUrl ? ( ) : null}
{t('npcs.name')}
setNameDraft(e.target.value)} onBlur={() => { const next = nameDraft.trim(); if (!next || next === selected.name) { setNameDraft(selected.name); return; } void api .invoke(ipcChannels.project.updateNpcFields, { npcId: selected.id, name: next, }) .catch(() => setNameDraft(selected.name)); }} placeholder={t('npcs.namePlaceholder')} />
{t('npcs.description')}
{ if (html === selected.description) return; void api.invoke(ipcChannels.project.updateNpcFields, { npcId: selected.id, description: html, }); }} />
{relationsForSelected.length > 0 ? (
{t('npcs.relations')}
{relationsForSelected.map(({ relation, otherName }) => (
{relation.label} {otherName}
))}
) : null} ) : (
{t('npcs.selectPrompt')}
)}
n.name)} onClose={() => setEditOpen(false)} onPickImage={pickAvatar} onSave={async (input) => { const res = await api.invoke(ipcChannels.project.upsertNpc, { ...(editInitial ? { npcId: editInitial.id } : {}), name: input.name, ...(input.filePath ? { filePath: input.filePath } : {}), }); const created = res.project.npcs.find((n) => n.name === input.name.trim()); if (created) setSelectedId(created.id); }} /> setRelationModal(null)} onSave={async (label) => { if (!relationModal) return; if (relationModal.mode === 'create') { await api.invoke(ipcChannels.project.upsertNpcRelation, { sourceNpcId: relationModal.sourceNpcId, targetNpcId: relationModal.targetNpcId, label, }); } else { const rel = relations.find((r) => r.id === relationModal.relationId); if (!rel) return; await api.invoke(ipcChannels.project.upsertNpcRelation, { relationId: rel.id, sourceNpcId: rel.sourceNpcId, targetNpcId: rel.targetNpcId, label, }); } }} /> {menuFor && menuPos ? createPortal(
e.stopPropagation()} >
, document.body, ) : null} {pendingDelete ? createPortal( <>
{t('npcs.deleteConfirm', { name: pendingDelete.name })}
, document.body, ) : null} {pendingDeleteRelation ? createPortal( <>
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
, document.body, ) : null} ); }