import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal, flushSync } from 'react-dom'; import { ipcChannels } from '../../shared/ipc/contracts'; import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types'; import { DEFAULT_PLAYER_IMAGE_OFFSET, DEFAULT_PLAYER_IMAGE_SCALE, DEFAULT_PLAYER_RING_COLOR, } from '../../shared/types/appPlayers'; import { getDndApi } from '../shared/dndApi'; import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView'; import { useAppPlayers } from '../shared/playerToken/useAppPlayers'; import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl'; import { Button, Input } from '../shared/ui/controls'; import styles from './EditorApp.module.css'; import { filterMaterialImagePaths, getDroppedFileEntries, pickFirstMaterialImagePath, useFileDropZone, } from './fileDrop'; import { useEditorI18n } from './i18n/EditorI18nContext'; import playerStyles from './PlayersModals.module.css'; const PLAYER_DND_MIME = 'application/x-dnd-player-id'; function PlayerRow({ player, selected, onSelect, }: { player: AppPlayer; selected: boolean; onSelect: () => void; }) { const url = usePlayerImageUrl(player.id); return ( ); } function TeamSection({ team, players, selectedId, onSelect, onAssign, onEdit, onDelete, }: { team: AppPlayerTeam | null; players: AppPlayer[]; selectedId: PlayerId | null; onSelect: (id: PlayerId) => void; onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void; onEdit?: () => void; onDelete?: () => void; }) { const { t } = useEditorI18n(); return (
{ if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault(); }} onDrop={(e) => { e.preventDefault(); const id = e.dataTransfer.getData(PLAYER_DND_MIME); if (id) onAssign(id as PlayerId, team?.id ?? null); }} >
{team ? : null} {team?.name ?? t('players.ungrouped')} {team ? (
) : null}
{players.map((player) => ( onSelect(player.id)} /> ))} {players.length === 0 ?
{t('players.dropHere')}
: null}
); } export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) { const { t } = useEditorI18n(); const api = getDndApi(); const { players, teams } = useAppPlayers(); const [query, setQuery] = useState(''); const [selectedId, setSelectedId] = useState(null); const [name, setName] = useState(''); const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR); const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET); const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE); const [filePath, setFilePath] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [adding, setAdding] = useState(false); const [saving, setSaving] = useState(false); const [progress, setProgress] = useState({ percent: 0, detail: '' }); /** Electron не поддерживает window.prompt — форма команды в сайдбаре. */ const [teamForm, setTeamForm] = useState<{ id: PlayerTeamId | null; name: string; color: string; } | null>(null); const [pendingDeletePlayer, setPendingDeletePlayer] = useState(null); const [pendingDeleteTeam, setPendingDeleteTeam] = useState(null); const appearanceTimerRef = useRef | null>(null); const selected = players.find((p) => p.id === selectedId) ?? null; const selectedUrl = usePlayerImageUrl(selected?.id); const resetModalState = useCallback(() => { if (appearanceTimerRef.current) { clearTimeout(appearanceTimerRef.current); appearanceTimerRef.current = null; } setQuery(''); setSelectedId(null); setName(''); setRingColor(DEFAULT_PLAYER_RING_COLOR); setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET }); setImageScale(DEFAULT_PLAYER_IMAGE_SCALE); setFilePath(null); setPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return null; }); setAdding(false); setSaving(false); setProgress({ percent: 0, detail: '' }); setTeamForm(null); setPendingDeletePlayer(null); setPendingDeleteTeam(null); }, []); const handleClose = useCallback(() => { if (saving) return; resetModalState(); onClose(); }, [onClose, resetModalState, saving]); useEffect(() => { if (!open) return; setSelectedId((current) => current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null), ); }, [open, players]); // Синхронизировать форму только при смене выбранного игрока — не при каждом // players.stateChanged (иначе сбрасывается несохранённый zoom/pan). useEffect(() => { if (adding) return; if (!selectedId) return; const p = players.find((item) => item.id === selectedId); if (!p) return; setName(p.name); setRingColor(p.ringColor); setImageOffset(p.imageOffset); setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE); setFilePath(null); setPreviewUrl(null); // eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding }, [adding, selectedId]); useEffect(() => { if (!open) return; return api.on(ipcChannels.players.upsertProgress, (event) => { setProgress({ percent: Math.max(0, Math.min(100, Math.round(event.percent))), detail: event.detail?.trim() ?? t('players.savingWait'), }); }); }, [api, open, t]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape' || saving) return; if (pendingDeletePlayer) { setPendingDeletePlayer(null); return; } if (pendingDeleteTeam) { setPendingDeleteTeam(null); return; } if (teamForm) { setTeamForm(null); return; } handleClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players; }, [players, query]); const beginAdd = () => { setAdding(true); setSelectedId(null); setName(''); setRingColor(DEFAULT_PLAYER_RING_COLOR); setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET }); setImageScale(DEFAULT_PLAYER_IMAGE_SCALE); setFilePath(null); setPreviewUrl(null); }; const setPreviewFromPathAndUrl = (path: string, url: string) => { setFilePath(path); setPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return url || null; }); }; const pickImage = async () => { const result = await api.invoke(ipcChannels.players.pickImage, {}); if (result.canceled) return; setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl); }; const applyDroppedImage = (path: string, previewFromFile: string) => { if (!adding && !selected) beginAdd(); setPreviewFromPathAndUrl(path, previewFromFile); }; const drop = useFileDropZone({ disabled: saving, filterPaths: filterMaterialImagePaths, onDropPaths: (paths) => { const picked = pickFirstMaterialImagePath(paths); if (!picked) return; applyDroppedImage(picked, ''); }, }); const appearanceLatestRef = useRef({ selected, adding, saving, ringColor, imageOffset, imageScale, }); appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale }; const persistAppearance = (patch: { ringColor?: string; imageOffset?: typeof imageOffset; imageScale?: number; }) => { const snap = appearanceLatestRef.current; if (!snap.selected || snap.adding || snap.saving) return; const payload = { id: snap.selected.id, teamId: snap.selected.teamId, name: snap.selected.name, ringColor: patch.ringColor ?? snap.ringColor, imageOffset: patch.imageOffset ?? snap.imageOffset, imageScale: patch.imageScale ?? snap.imageScale, }; if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current); appearanceTimerRef.current = setTimeout(() => { appearanceTimerRef.current = null; void api.invoke(ipcChannels.players.upsert, payload); }, 180); }; useEffect(() => { return () => { if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current); }; }, []); const save = async () => { const trimmed = name.trim(); if (!trimmed || (selected === null && filePath === null)) return; if (appearanceTimerRef.current) { clearTimeout(appearanceTimerRef.current); appearanceTimerRef.current = null; } flushSync(() => { setSaving(true); setProgress({ percent: 0, detail: t('players.savingWait') }); }); try { const result = await api.invoke(ipcChannels.players.upsert, { ...(selected ? { id: selected.id, teamId: selected.teamId } : {}), name: trimmed, ...(filePath ? { filePath } : {}), ringColor, imageOffset, imageScale, }); setAdding(false); setSelectedId(result.player.id); } finally { setSaving(false); } }; if (!open) return null; const activeUrl = previewUrl ?? selectedUrl; return createPortal( <>
{t('players.managerTitle')}
{ 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]; applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : ''); return; } }} > {drop.dragOver ?
{t('players.dropHint')}
: null} {adding || selected ? ( <> { setImageOffset(next); persistAppearance({ imageOffset: next }); }} onImageScaleChange={(next) => { setImageScale(next); persistAppearance({ imageScale: next }); }} sizePx={220} />
{selected ? ( ) : null}
) : (
{t('players.selectPrompt')}
)}
{pendingDeletePlayer ? createPortal( <>
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
, document.body, ) : null} {pendingDeleteTeam ? createPortal( <>
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
, document.body, ) : null} {saving ? (
{t('players.savingTitle')}
{progress.detail}
{progress.percent}%
) : null} , document.body, ); }