feat(tokens): app-local non-player tokens with session moves and UI polish
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>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user