import React, { Suspense, lazy, useCallback, useEffect, useMemo, 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, NpcRelationId, ProjectNpc, ProjectNpcGroup, ProjectNpcRelation, } from '../../shared/types'; import editorStyles from '../editor/EditorApp.module.css'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView'; import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcEditModal } from './NpcEditModal'; import type { GraphGroupFilter } from './NpcGraph'; import { NpcGroupModal } from './NpcGroupModal'; import { flattenGroupOptions, moveNpcToGroupEnd, reorderNpcIds, reorderSiblingGroups, } from './npcListHelpers'; import { NpcRelationModal } from './NpcRelationModal'; import styles from './NpcsEditorApp.module.css'; const NpcGraph = lazy(async () => { const mod = await import('./NpcGraph'); return { default: mod.NpcGraph }; }); const DND_NPC_ID_MIME = 'application/x-dnd-npc-id'; const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id'; type GroupModalState = | { mode: 'create'; parentId: NpcGroupId | null } | { mode: 'edit'; group: ProjectNpcGroup }; function NpcTile({ npc, selected, dragging, dropPlace, accentColor, onSelect, onMenu, onDragStart, onDragEnd, onDragOver, onDropReorder, }: { npc: ProjectNpc; selected: boolean; dragging: boolean; dropPlace: 'before' | 'after' | null; accentColor?: string | 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(); }} >
); } function GroupSection({ node, depth, isExpanded, onToggleExpanded, selectedId, dragId, dropPlace, dragGroupId, groupDropPlace, dropTargetGroup, groupColorById, onSelectNpc, onNpcMenu, onGroupMenu, onNpcDragStart, onNpcDragEnd, onNpcDragOver, onNpcDropReorder, onGroupDragStart, onGroupDragEnd, onGroupDragOver, onGroupDropReorder, onGroupBodyDragOver, onGroupBodyDrop, }: { node: NpcGroupTreeNode; depth: number; isExpanded: (id: NpcGroupId) => boolean; onToggleExpanded: (id: NpcGroupId) => void; selectedId: NpcId | null; dragId: NpcId | null; dropPlace: { id: NpcId; place: 'before' | 'after' } | null; dragGroupId: NpcGroupId | null; groupDropPlace: { id: NpcGroupId; place: 'before' | 'after' } | null; dropTargetGroup: NpcGroupId | null; groupColorById: Map; onSelectNpc: (id: NpcId) => void; onNpcMenu: (id: NpcId, e: React.MouseEvent) => void; onGroupMenu: (id: NpcGroupId, e: React.MouseEvent) => void; onNpcDragStart: (id: NpcId) => void; onNpcDragEnd: () => void; onNpcDragOver: (id: NpcId, place: 'before' | 'after') => void; onNpcDropReorder: (targetId: NpcId) => void; onGroupDragStart: (id: NpcGroupId) => void; onGroupDragEnd: () => void; onGroupDragOver: (id: NpcGroupId, place: 'before' | 'after') => void; onGroupDropReorder: (targetId: NpcGroupId) => void; onGroupBodyDragOver: (groupId: NpcGroupId) => void; onGroupBodyDrop: (groupId: NpcGroupId) => void; }) { const { t } = useEditorI18n(); const g = node.group; const expanded = isExpanded(g.id); const isGroupDragging = dragGroupId === g.id; const groupDropBefore = groupDropPlace?.id === g.id && groupDropPlace.place === 'before'; const groupDropAfter = groupDropPlace?.id === g.id && groupDropPlace.place === 'after'; const bodyHighlight = dropTargetGroup === g.id && dragId !== null; return (
0 ? 12 : 0 }}>
{ e.dataTransfer.setData(DND_NPC_GROUP_ID_MIME, g.id); e.dataTransfer.effectAllowed = 'move'; onGroupDragStart(g.id); }} onDragEnd={onGroupDragEnd} onDragOver={(e) => { if (dragGroupId && dragGroupId !== g.id) { e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const mid = rect.top + rect.height / 2; onGroupDragOver(g.id, e.clientY < mid ? 'before' : 'after'); return; } if (dragId) { e.preventDefault(); onGroupBodyDragOver(g.id); } }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); if (dragGroupId && dragGroupId !== g.id && groupDropPlace?.id === g.id) { onGroupDropReorder(g.id); return; } if (dragId) onGroupBodyDrop(g.id); }} > {g.name}
{expanded ? (
{ if (!dragId) return; e.preventDefault(); onGroupBodyDragOver(g.id); }} onDrop={(e) => { if (!dragId) return; e.preventDefault(); onGroupBodyDrop(g.id); }} > {node.npcs.map((n) => ( onSelectNpc(n.id)} onMenu={(e) => onNpcMenu(n.id, e)} onDragStart={() => onNpcDragStart(n.id)} onDragEnd={onNpcDragEnd} onDragOver={(place) => onNpcDragOver(n.id, place)} onDropReorder={() => onNpcDropReorder(n.id)} /> ))} {node.children.map((child) => ( ))}
) : null}
); } 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 [groupMenuFor, setGroupMenuFor] = 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 [dragGroupId, setDragGroupId] = useState(null); const [groupDropPlace, setGroupDropPlace] = useState<{ id: NpcGroupId; place: 'before' | 'after'; } | null>(null); const [dropTargetGroup, setDropTargetGroup] = useState(null); const [expandedGroups, setExpandedGroups] = useState>(() => new Set()); const [groupModal, setGroupModal] = useState(null); const [pendingDeleteGroup, setPendingDeleteGroup] = useState(null); const [graphFilter, setGraphFilter] = useState('all'); 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); const groups = project?.npcGroups ?? []; setExpandedGroups(new Set(groups.map((g) => g.id))); }); return api.on(ipcChannels.session.stateChanged, ({ state }) => { setSession(state); }); }, [api]); const project = session?.project ?? null; const npcs = useMemo(() => project?.npcs ?? [], [project?.npcs]); const npcGroups = useMemo(() => project?.npcGroups ?? [], [project?.npcGroups]); const relations = useMemo(() => project?.npcRelations ?? [], [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 && !groupMenuFor) return; const onDown = (e: MouseEvent) => { const tgt = e.target as HTMLElement | null; if (tgt?.closest('[data-npc-menu-root="1"]')) return; setMenuFor(null); setGroupMenuFor(null); setMenuPos(null); }; window.addEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown); }, [groupMenuFor, menuFor]); const filteredNpcs = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return npcs; return npcs.filter((n) => n.name.toLowerCase().includes(q)); }, [npcs, query]); const { roots, ungrouped } = useMemo( () => buildNpcGroupForest(npcGroups, filteredNpcs), [filteredNpcs, npcGroups], ); const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]); const groupOptions = useMemo(() => flattenGroupOptions(roots), [roots]); 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 commitNpcGroupChange = useCallback( async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => { const npc = npcs.find((n) => n.id === npcId); if (npc && npc.groupId !== groupId) { await api.invoke(ipcChannels.project.updateNpcFields, { npcId, groupId }); } if (orderIds) { await api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: orderIds }); } }, [api, npcs], ); const handleNpcDropOnTile = useCallback( (targetId: NpcId) => { if (!dragId || dropPlace?.id !== targetId || dragId === targetId) return; const targetNpc = npcs.find((n) => n.id === targetId); if (!targetNpc) return; const dragNpc = npcs.find((n) => n.id === dragId); if (!dragNpc) return; const newGroupId = targetNpc.groupId; const orderIds = reorderNpcIds( dragNpc.groupId === newGroupId ? npcs : npcs.map((n) => (n.id === dragId ? { ...n, groupId: newGroupId } : n)), dragId, targetId, dropPlace.place, ); setDragId(null); setDropPlace(null); setDropTargetGroup(null); void commitNpcGroupChange(dragId, newGroupId, orderIds); }, [commitNpcGroupChange, dragId, dropPlace, npcs], ); const handleNpcDropOnGroup = useCallback( (groupId: NpcGroupId | null) => { if (!dragId) return; const dragNpc = npcs.find((n) => n.id === dragId); if (!dragNpc) return; const orderIds = moveNpcToGroupEnd( dragNpc.groupId === groupId ? npcs : npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n)), dragId, groupId, ); setDragId(null); setDropPlace(null); setDropTargetGroup(null); void commitNpcGroupChange(dragId, groupId, orderIds); }, [commitNpcGroupChange, dragId, npcs], ); const handleGroupDropReorder = useCallback( (targetId: NpcGroupId) => { if (!dragGroupId || !groupDropPlace || dragGroupId === groupDropPlace.id) return; const order = reorderSiblingGroups(npcGroups, dragGroupId, targetId, groupDropPlace.place); setDragGroupId(null); setGroupDropPlace(null); void api.invoke(ipcChannels.project.setNpcGroupsOrder, { groupIds: order }); }, [api, dragGroupId, groupDropPlace, npcGroups], ); const openMenuAt = (e: React.MouseEvent) => { const r = e.currentTarget.getBoundingClientRect(); const menuW = 180; const menuH = 120; 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 }); }; 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'), graphFilter: t('npcs.graphFilter'), graphFilterAll: t('npcs.graphFilterAll'), graphFilterUngrouped: t('npcs.graphFilterUngrouped'), }), [t], ); const isExpanded = (id: NpcGroupId) => expandedGroups.has(id); const toggleExpanded = (id: NpcGroupId) => { setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const siblingNamesForGroup = (parentId: NpcGroupId | null, exceptId?: NpcGroupId) => npcGroups.filter((g) => g.parentId === parentId && g.id !== exceptId).map((g) => g.name); const hasListContent = npcGroups.length > 0 || ungrouped.length > 0; return (
{t('npcs.editorTitle')}
{roots.map((node) => ( { openMenuAt(e); setGroupMenuFor(null); setMenuFor((cur) => (cur === id ? null : id)); }} onGroupMenu={(id, e) => { openMenuAt(e); setMenuFor(null); setGroupMenuFor((cur) => (cur === id ? null : id)); }} onNpcDragStart={setDragId} onNpcDragEnd={() => { setDragId(null); setDropPlace(null); setDropTargetGroup(null); }} onNpcDragOver={(id, place) => { if (!dragId || dragId === id) { setDropPlace(null); return; } setDropPlace({ id, place }); }} onNpcDropReorder={handleNpcDropOnTile} onGroupDragStart={setDragGroupId} onGroupDragEnd={() => { setDragGroupId(null); setGroupDropPlace(null); }} onGroupDragOver={(id, place) => { if (!dragGroupId || dragGroupId === id) { setGroupDropPlace(null); return; } setGroupDropPlace({ id, place }); }} onGroupDropReorder={handleGroupDropReorder} onGroupBodyDragOver={setDropTargetGroup} onGroupBodyDrop={(groupId) => handleNpcDropOnGroup(groupId)} /> ))}
{t('npcs.ungrouped')}
{ if (!dragId) return; e.preventDefault(); setDropTargetGroup('ungrouped'); }} onDrop={(e) => { if (!dragId) return; e.preventDefault(); handleNpcDropOnGroup(null); }} > {ungrouped.map((n) => ( setSelectedId(n.id)} onMenu={(e) => { openMenuAt(e); setGroupMenuFor(null); setMenuFor((cur) => (cur === n.id ? null : n.id)); }} onDragStart={() => setDragId(n.id)} onDragEnd={() => { setDragId(null); setDropPlace(null); setDropTargetGroup(null); }} onDragOver={(place) => { if (!dragId || dragId === n.id) { setDropPlace(null); return; } setDropPlace({ id: n.id, place }); }} onDropReorder={() => handleNpcDropOnTile(n.id)} /> ))}
{!hasListContent && npcs.length === 0 ? (
{t('npcs.empty')}
) : null} {npcs.length > 0 && filteredNpcs.length === 0 ? (
{t('npcs.searchEmpty')}
) : null}
{t('npcs.graphLoading')}
}> { setRelationModal({ mode: 'create', sourceNpcId, targetNpcId }); }} onNodePositionCommit={(npcId, x, y) => { void (async () => { setSession((prev) => { if (!prev?.project) return prev; return { ...prev, project: { ...prev.project, npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)), }, }; }); try { const res = await api.invoke(ipcChannels.project.updateNpcPosition, { npcId, x, y, }); setSession({ project: res.project, currentSceneId: res.project?.currentSceneId ?? null, }); } catch { /* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */ } })(); }} 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 && project ? ( <>
{t('npcs.avatar')}
{ void api.invoke(ipcChannels.project.updateNpcFields, { npcId: selected.id, imageOffset, }); }} onImageScaleChange={(imageScale) => { void api.invoke(ipcChannels.project.updateNpcFields, { npcId: selected.id, imageScale, }); }} />
{t('npcs.name')}
{ 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.group')}