c7bf7cf449
Stretch key action buttons, center empty audio/image pickers, and require confirmation before deleting a scene. Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
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,
|
||
);
|
||
}
|