f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
194 lines
6.9 KiB
TypeScript
194 lines
6.9 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,
|
||
pickFirstMaterialImagePath,
|
||
useFileDropZone,
|
||
} from '../editor/fileDrop';
|
||
import editorStyles from '../editor/EditorApp.module.css';
|
||
import matStyles from '../editor/MaterialsModals.module.css';
|
||
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 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);
|
||
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="Закрыть" onClick={onClose} className={editorStyles.modalBackdrop} />
|
||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||
<div className={editorStyles.modalHeader}>
|
||
<div className={editorStyles.modalTitle}>
|
||
{initial ? 'Изменить токен' : 'Добавить токен'}
|
||
</div>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className={editorStyles.fieldGrid}>
|
||
<div className={editorStyles.fieldLabel}>Название</div>
|
||
<Input value={name} onChange={setName} placeholder="Название токена" />
|
||
{!nameOk ? <div className={editorStyles.fieldError}>Укажите название</div> : null}
|
||
{nameDup ? <div className={editorStyles.fieldError}>Такое название уже есть</div> : null}
|
||
</div>
|
||
|
||
<div className={editorStyles.fieldGrid}>
|
||
<div className={editorStyles.fieldLabel}>Изображение</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}>Отпустите, чтобы загрузить изображение</div>
|
||
) : null}
|
||
{previewSrc ? (
|
||
<img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} />
|
||
) : (
|
||
<div className={editorStyles.muted}>Перетащите изображение или выберите файл</div>
|
||
)}
|
||
<Button
|
||
onClick={() => {
|
||
void (async () => {
|
||
const res = await api.invoke(ipcChannels.tokens.pickImage, {});
|
||
if (res.canceled) return;
|
||
setPreviewFromPathAndUrl(res.filePath, res.previewDataUrl);
|
||
})();
|
||
}}
|
||
>
|
||
Выбрать изображение
|
||
</Button>
|
||
</div>
|
||
{!hasImage ? <div className={editorStyles.fieldError}>Нужно изображение</div> : null}
|
||
</div>
|
||
|
||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||
|
||
<div className={editorStyles.modalFooter}>
|
||
<Button onClick={onClose} disabled={saving}>
|
||
Отмена
|
||
</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 : 'Не удалось сохранить');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|