import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { noneBinding } from '../../shared/npcs/npcBinding'; import { buildNpcGroupForest } from '../../shared/npcs/npcGroups'; import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types'; import editorStyles from '../editor/EditorApp.module.css'; import { filterMaterialImagePaths, getDroppedFileEntries, pickFirstMaterialImagePath, useFileDropZone, } from '../editor/fileDrop'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import matStyles from '../editor/MaterialsModals.module.css'; import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import { NpcBindingFields } from './NpcBindingFields'; function normalizeName(input: string): string { return input.trim().toLowerCase(); } function flattenGroupOptions( nodes: ReturnType['roots'], depth = 0, ): { id: NpcGroupId; label: string }[] { const out: { id: NpcGroupId; label: string }[] = []; for (const node of nodes) { const prefix = depth > 0 ? ' '.repeat(depth) : ''; out.push({ id: node.group.id, label: `${prefix}${node.group.name}` }); out.push(...flattenGroupOptions(node.children, depth + 1)); } return out; } type NpcEditModalProps = { open: boolean; initial: ProjectNpc | null; existingNames: string[]; project: Project | null; npcGroups: ProjectNpcGroup[]; onClose: () => void; onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>; onSave: (input: { name: string; filePath?: string; groupId?: NpcGroupId | null; binding?: NpcBinding; }) => Promise; }; export function NpcEditModal({ open, initial, existingNames, project, npcGroups, onClose, onPickImage, onSave, }: NpcEditModalProps) { const { t } = useEditorI18n(); const [name, setName] = useState(''); const [filePath, setFilePath] = useState(null); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); const [groupId, setGroupId] = useState(''); const [binding, setBinding] = useState(noneBinding()); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null); const groupOptions = useMemo(() => { const { roots } = buildNpcGroupForest(npcGroups, []); return flattenGroupOptions(roots); }, [npcGroups]); useEffect(() => { if (!open) return; setName(initial?.name ?? ''); setFilePath(null); setLocalPreviewUrl(null); setGroupId(initial?.groupId ?? ''); setBinding(initial?.binding ?? noneBinding()); setSaving(false); setError(null); }, [initial, open]); useEffect(() => { return () => { if (localPreviewUrl?.startsWith('blob:')) URL.revokeObjectURL(localPreviewUrl); }; }, [localPreviewUrl]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, open]); const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { setFilePath(path); setLocalPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return previewUrl; }); }; const drop = useFileDropZone({ onDropPaths: (paths) => { const picked = pickFirstMaterialImagePath(paths); if (!picked) return; setPreviewFromPathAndUrl(picked, ''); }, filterPaths: filterMaterialImagePaths, }); const trimmed = name.trim(); const nameOk = trimmed.length >= 1; const nameDup = nameOk && existingNames.some( (n) => normalizeName(n) === normalizeName(trimmed) && normalizeName(n) !== normalizeName(initial?.name ?? ''), ); const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId); const canSave = nameOk && !nameDup && hasImage && !saving; const previewSrc = localPreviewUrl ?? existingUrl; if (!open) return null; return createPortal( <>
{t('npcs.name')}
{!nameOk ?
{t('npcs.nameRequired')}
: null} {nameDup ?
{t('npcs.nameDup')}
: null}
{!initial && groupOptions.length > 0 ? (
{t('npcs.group')}