Files
DndGamePlayer/app/renderer/npcs/NpcEditModal.tsx
T
Ivan Fontosh c7bf7cf449 fix(ui): polish editor layouts and confirm scene delete
Stretch key action buttons, center empty audio/image pickers, and require confirmation before deleting a scene.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:35:07 +08:00

281 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof buildNpcGroupForest>['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<void>;
};
export function NpcEditModal({
open,
initial,
existingNames,
project,
npcGroups,
onClose,
onPickImage,
onSave,
}: NpcEditModalProps) {
const { t } = useEditorI18n();
const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalClose}
>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.name')}</div>
<Input value={name} onChange={setName} placeholder={t('npcs.namePlaceholder')} />
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.nameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
</div>
{!initial && groupOptions.length > 0 ? (
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
<Select
value={groupId}
ariaLabel={t('npcs.group')}
onChange={(next) => setGroupId(next as NpcGroupId | '')}
options={[
{ value: '', label: t('npcs.ungrouped') },
...groupOptions.map((g) => ({ value: g.id, label: g.label })),
]}
/>
</div>
) : null}
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
<div
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
onDragEnter={drop.onDragEnter}
onDragLeave={drop.onDragLeave}
onDragOver={drop.onDragOver}
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
const file = files[i];
if (file) {
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
return;
}
setPreviewFromPathAndUrl(entry.path, '');
return;
}
}}
>
{drop.dragOver ? <div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
{previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : (
<div className={matStyles.imageDropEmpty}>
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
<Button
onClick={() => {
void (async () => {
const picked = await onPickImage();
if (!picked) return;
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
})();
}}
>
{t('npcs.chooseAvatar')}
</Button>
</div>
)}
{previewSrc ? (
<Button
onClick={() => {
void (async () => {
const picked = await onPickImage();
if (!picked) return;
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
})();
}}
>
{t('npcs.chooseAvatar')}
</Button>
) : null}
</div>
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div>
{project && !initial ? (
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
) : null}
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onSave({
name: trimmed,
...(filePath ? { filePath } : {}),
...(!initial ? { groupId: groupId || null, binding } : {}),
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}