Files
Ivan Fontosh 101f595bac feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:35 +08:00

666 lines
23 KiB
TypeScript
Raw Permalink 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, { 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 (
<button
type="button"
data-testid={`player-row-${player.id}`}
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={onSelect}
>
<PlayerTokenView
name={player.name}
imageUrl={url}
ringColor={player.ringColor}
imageOffset={player.imageOffset}
imageScale={player.imageScale}
sizePx={48}
hideName
/>
<span>{player.name}</span>
</button>
);
}
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 (
<section
className={playerStyles.team}
onDragOver={(e) => {
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);
}}
>
<div className={playerStyles.teamHeader}>
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
{team ? (
<details className={playerStyles.teamMenu}>
<summary aria-label={t('players.teamMenu')}></summary>
<div className={playerStyles.teamMenuPopup}>
<button type="button" onClick={onEdit}>
{t('common.edit')}
</button>
<button type="button" onClick={onDelete}>
{t('common.delete')}
</button>
</div>
</details>
) : null}
</div>
<div className={playerStyles.teamBody}>
{players.map((player) => (
<PlayerRow
key={player.id}
player={player}
selected={player.id === selectedId}
onSelect={() => onSelect(player.id)}
/>
))}
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
</div>
</section>
);
}
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<PlayerId | null>(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<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(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<AppPlayer | null>(null);
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | 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(
<>
<div className={styles.modalBackdrop} aria-hidden />
<div
role="dialog"
aria-modal="true"
data-testid="players-modal"
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={handleClose}
className={styles.modalClose}
disabled={saving}
>
×
</button>
</div>
<div className={playerStyles.body}>
<aside className={playerStyles.sidebar}>
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
<div className={playerStyles.sidebarActions}>
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
{t('players.add')}
</Button>
<Button
data-testid="players-add-team"
onClick={() =>
setTeamForm({
id: null,
name: '',
color: DEFAULT_PLAYER_RING_COLOR,
})
}
>
{t('players.addTeam')}
</Button>
</div>
{teamForm ? (
<div className={playerStyles.teamForm} data-testid="players-team-form">
<label className={playerStyles.teamFormField}>
<span>{t('players.teamName')}</span>
<Input
value={teamForm.name}
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
placeholder={t('players.teamName')}
autoFocus
/>
</label>
<label className={playerStyles.teamFormField}>
<span>{t('players.teamColor')}</span>
<input
type="color"
value={teamForm.color}
onChange={(e) =>
setTeamForm((prev) =>
prev ? { ...prev, color: e.currentTarget.value } : prev,
)
}
/>
</label>
<div className={playerStyles.teamFormActions}>
<Button
variant="primary"
disabled={!teamForm.name.trim()}
onClick={() => {
const trimmed = teamForm.name.trim();
if (!trimmed) return;
void api
.invoke(ipcChannels.players.upsertTeam, {
...(teamForm.id ? { id: teamForm.id } : {}),
name: trimmed,
color: teamForm.color,
})
.then(() => setTeamForm(null));
}}
>
{t('common.save')}
</Button>
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
</div>
</div>
) : null}
<div className={playerStyles.list}>
{teams.map((team) => (
<TeamSection
key={team.id}
team={team}
players={filtered.filter((p) => p.teamId === team.id)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
onEdit={() =>
setTeamForm({
id: team.id,
name: team.name,
color: team.color,
})
}
onDelete={() => setPendingDeleteTeam(team)}
/>
))}
<TeamSection
team={null}
players={filtered.filter((p) => p.teamId === null)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
/>
</div>
</aside>
<main
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
.filter(Boolean)
.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 (!pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i];
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
return;
}
}}
>
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
{adding || selected ? (
<>
<PlayerTokenView
data-testid="player-token-preview"
name={name}
imageUrl={activeUrl}
ringColor={ringColor}
imageOffset={imageOffset}
imageScale={imageScale}
panEnabled
onImageOffsetChange={(next) => {
setImageOffset(next);
persistAppearance({ imageOffset: next });
}}
onImageScaleChange={(next) => {
setImageScale(next);
persistAppearance({ imageScale: next });
}}
sizePx={220}
/>
<label className={playerStyles.field}>
<span>{t('players.name')}</span>
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
</label>
<label className={playerStyles.field}>
<span>{t('players.ringColor')}</span>
<input
type="color"
value={ringColor}
onChange={(e) => {
const next = e.currentTarget.value;
setRingColor(next);
persistAppearance({ ringColor: next });
}}
/>
</label>
<div className={playerStyles.actions}>
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
{t('players.chooseImage')}
</Button>
<Button
variant="primary"
data-testid="players-save"
disabled={!name.trim() || (!selected && !filePath) || saving}
onClick={() => void save()}
>
{saving ? t('common.saving') : t('common.save')}
</Button>
{selected ? (
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
) : null}
</div>
</>
) : (
<div className={styles.muted}>{t('players.selectPrompt')}</div>
)}
</main>
</div>
</div>
{pendingDeletePlayer
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeletePlayer(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="players-delete-confirm"
className={styles.modalDialog}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeletePlayer(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeletePlayer.id;
setPendingDeletePlayer(null);
void api.invoke(ipcChannels.players.delete, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteTeam
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeleteTeam(null)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeleteTeam(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteTeam.id;
setPendingDeleteTeam(null);
void api.invoke(ipcChannels.players.deleteTeam, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{saving ? (
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
</div>
<div className={styles.progressMeta}>
<div>{progress.detail}</div>
<div>{progress.percent}%</div>
</div>
</div>
</div>
) : null}
</>,
document.body,
);
}