Files
DndGamePlayer/app/renderer/sceneEditor/TokenEditModal.tsx
T
Ivan Fontosh 586166cacd feat(audio): volume в проекте, DnD, параллельные плейлисты
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:09:26 +08:00

200 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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);
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="Закрыть" 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,
);
}