060bbd471a
End-session buttons, Escape, and window close share one confirm. Help is a question-mark icon on the control panel and after the version. About opens directly. Scene editor follows the UI language. Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||
import type { AppToken } from '../../shared/types';
|
||
import {
|
||
filterMaterialImagePaths,
|
||
getDroppedFileEntries,
|
||
nameFromFilePath,
|
||
pickFirstMaterialImagePath,
|
||
useFileDropZone,
|
||
} from '../editor/fileDrop';
|
||
import editorStyles from '../editor/EditorApp.module.css';
|
||
import matStyles from '../editor/MaterialsModals.module.css';
|
||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||
import { getDndApi } from '../shared/dndApi';
|
||
import { Button, Input } from '../shared/ui/controls';
|
||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||
|
||
type Props = {
|
||
open: boolean;
|
||
initial: AppToken | null;
|
||
existingNames: string[];
|
||
onClose: () => void;
|
||
onSaved: (token: AppToken) => void;
|
||
};
|
||
|
||
function normalizeName(input: string): string {
|
||
return input.trim().toLowerCase();
|
||
}
|
||
|
||
export function TokenEditModal({ open, initial, existingNames, onClose, onSaved }: Props) {
|
||
const { t } = useEditorI18n();
|
||
const api = getDndApi();
|
||
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 = useTokenImageUrl(initial?.id ?? null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setName(initial?.name ?? '');
|
||
setFilePath(null);
|
||
setLocalPreviewUrl(null);
|
||
setSaving(false);
|
||
setError(null);
|
||
}, [open, initial]);
|
||
|
||
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);
|
||
setName((prev) => {
|
||
if (prev.trim()) return prev;
|
||
const fromFile = nameFromFilePath(path);
|
||
return fromFile || prev;
|
||
});
|
||
setLocalPreviewUrl((prev) => {
|
||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||
return previewUrl || null;
|
||
});
|
||
};
|
||
|
||
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);
|
||
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('sceneEditor.editToken') : t('sceneEditor.addToken')}
|
||
</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('sceneEditor.tokenName')}</div>
|
||
<Input value={name} onChange={setName} placeholder={t('sceneEditor.tokenNamePlaceholder')} />
|
||
{!nameOk ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameRequired')}</div> : null}
|
||
{nameDup ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameDuplicate')}</div> : null}
|
||
</div>
|
||
|
||
<div className={editorStyles.fieldGrid}>
|
||
<div className={editorStyles.fieldLabel}>{t('sceneEditor.image')}</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('sceneEditor.dropImage')}</div>
|
||
) : null}
|
||
{previewSrc ? (
|
||
<img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} />
|
||
) : (
|
||
<div className={editorStyles.muted}>{t('sceneEditor.dropOrPick')}</div>
|
||
)}
|
||
<Button
|
||
onClick={() => {
|
||
void (async () => {
|
||
const res = await api.invoke(ipcChannels.tokens.pickImage, {});
|
||
if (res.canceled) return;
|
||
setPreviewFromPathAndUrl(res.filePath, res.previewDataUrl);
|
||
})();
|
||
}}
|
||
>
|
||
{t('sceneEditor.chooseImage')}
|
||
</Button>
|
||
</div>
|
||
{!hasImage ? <div className={editorStyles.fieldError}>{t('sceneEditor.imageRequired')}</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 {
|
||
const { token } = await api.invoke(ipcChannels.tokens.upsert, {
|
||
id: initial?.id ?? null,
|
||
name: trimmed,
|
||
filePath: filePath ?? null,
|
||
});
|
||
onSaved(token);
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : t('sceneEditor.saveFailed'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{t('common.save')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|