37ba855faf
Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section. Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import type { ProjectNpc } from '../../shared/types';
|
||
import { Button, Input } from '../shared/ui/controls';
|
||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||
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';
|
||
|
||
function normalizeName(input: string): string {
|
||
return input.trim().toLowerCase();
|
||
}
|
||
|
||
type NpcEditModalProps = {
|
||
open: boolean;
|
||
initial: ProjectNpc | null;
|
||
existingNames: string[];
|
||
onClose: () => void;
|
||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
|
||
};
|
||
|
||
export function NpcEditModal({
|
||
open,
|
||
initial,
|
||
existingNames,
|
||
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 [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setName(initial?.name ?? '');
|
||
setFilePath(null);
|
||
setLocalPreviewUrl(null);
|
||
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>
|
||
|
||
<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 (!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={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>
|
||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||
</div>
|
||
|
||
{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(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{t('common.save')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|