import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels } from '../../shared/ipc/contracts'; import { INITIATIVE_CONDITION_IDS, type InitiativeConditionId, type InitiativeEntry, } from '../../shared/types/initiative'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; import { InitiativeConditionChips } from '../shared/initiative/InitiativeConditionChips'; import { useInitiativeState } from '../shared/initiative/useInitiativeState'; import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import { Button, Input } from '../shared/ui/controls'; import styles from './InitiativeApp.module.css'; const DND_MIME = 'application/x-dnd-initiative-entry-id'; const CONDITION_MENU_WIDTH = 200; /** Компактная высота меню; фактическая не больше доступного места на экране. */ const CONDITION_MENU_MAX_H = 220; const CONDITION_MENU_GAP = 6; const CONDITION_MENU_EDGE = 8; function EntryAvatar({ entry }: { entry: InitiativeEntry }) { if (entry.kind === 'manual') { return (
{entry.letter}
); } if (entry.kind === 'npc') { return ; } return ; } function NpcAvatar({ assetId, name }: { assetId: string | null; name: string }) { const url = useAssetUrl(assetId as import('../../shared/types').AssetId | null); if (url) { return (
); } const letter = (name.trim()[0] ?? '?').toUpperCase(); return (
{letter}
); } function PlayerAvatar({ playerId, name }: { playerId: string; name: string }) { const url = usePlayerImageUrl(playerId as import('../../shared/types').PlayerId); if (url) { return (
); } const letter = (name.trim()[0] ?? '?').toUpperCase(); return (
{letter}
); } function QueueSlot({ entry, active, conditions, onSelect, onRemove, onDragStart, onDragOver, onDrop, conditionsOpen, onToggleConditions, onAddCondition, onRemoveCondition, }: { entry: InitiativeEntry; active: boolean; conditions: readonly InitiativeConditionId[]; onSelect: () => void; onRemove: () => void; onDragStart: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void; conditionsOpen: boolean; onToggleConditions: () => void; onAddCondition: (id: InitiativeConditionId) => void; onRemoveCondition: (id: InitiativeConditionId) => void; }) { const { t } = useEditorI18n(); const available = INITIATIVE_CONDITION_IDS.filter((id) => !conditions.includes(id)); const addBtnRef = useRef(null); const [menuPos, setMenuPos] = useState<{ top: number; left: number; maxHeight: number } | null>( null, ); useLayoutEffect(() => { if (!conditionsOpen) { setMenuPos(null); return; } const btn = addBtnRef.current; if (!btn) return; const place = () => { const r = btn.getBoundingClientRect(); const left = Math.max( CONDITION_MENU_EDGE, Math.min( window.innerWidth - CONDITION_MENU_WIDTH - CONDITION_MENU_EDGE, r.left, ), ); const spaceBelow = window.innerHeight - r.bottom - CONDITION_MENU_GAP - CONDITION_MENU_EDGE; const spaceAbove = r.top - CONDITION_MENU_GAP - CONDITION_MENU_EDGE; // Предпочитаем под кнопкой; если снизу мало места — над ней. const openBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove; const available = Math.max(80, openBelow ? spaceBelow : spaceAbove); const maxHeight = Math.min(CONDITION_MENU_MAX_H, available); const top = openBelow ? r.bottom + CONDITION_MENU_GAP : Math.max(CONDITION_MENU_EDGE, r.top - CONDITION_MENU_GAP - maxHeight); setMenuPos({ top, left, maxHeight }); }; place(); window.addEventListener('resize', place); window.addEventListener('scroll', place, true); return () => { window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); }; }, [conditionsOpen]); useEffect(() => { if (!conditionsOpen) return; const onDown = (e: MouseEvent) => { const target = e.target as Node | null; if (addBtnRef.current?.contains(target)) return; const menu = document.getElementById(`initiative-condition-menu-${entry.id}`); if (menu?.contains(target)) return; onToggleConditions(); }; document.addEventListener('mousedown', onDown); return () => document.removeEventListener('mousedown', onDown); }, [conditionsOpen, entry.id, onToggleConditions]); return (
{entry.name}
{conditionsOpen && menuPos ? createPortal(
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > {available.length === 0 ? (
{t('initiative.conditionsEmpty')}
) : ( available.map((id) => ( )) )}
, document.body, ) : null}
); } export function InitiativeApp() { const { t } = useEditorI18n(); const api = getDndApi(); const [state, initiativeApi] = useInitiativeState(); const [manualName, setManualName] = useState(''); const [conditionsFor, setConditionsFor] = useState(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { void api.invoke(ipcChannels.windows.closeInitiative, {}); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [api]); const orderEntries = useMemo(() => { if (!state) return []; return state.order.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[]; }, [state]); const reserveEntries = useMemo(() => { if (!state) return []; return state.reserve.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[]; }, [state]); if (!state) { return
; } const reorderDrop = (targetId: string, e: React.DragEvent) => { e.preventDefault(); const fromId = e.dataTransfer.getData(DND_MIME); if (!fromId || fromId === targetId) return; const next = [...state.order]; const fromIdx = next.indexOf(fromId); const toIdx = next.indexOf(targetId); if (fromIdx < 0 || toIdx < 0) return; next.splice(fromIdx, 1); next.splice(toIdx, 0, fromId); void initiativeApi.dispatch({ kind: 'reorder', order: next }); }; const addManual = () => { const name = manualName.trim(); if (!name) return; void initiativeApi.dispatch({ kind: 'addManual', name }); setManualName(''); }; return (
{ if (e.key === 'Enter') { e.preventDefault(); addManual(); } }} />
{t('initiative.queue')}
{orderEntries.length === 0 ? (
{t('initiative.queueEmpty')}
) : ( orderEntries.map((entry) => ( void initiativeApi.dispatch({ kind: 'setActive', id: entry.id })} onRemove={() => void initiativeApi.dispatch({ kind: 'moveToReserve', id: entry.id })} onDragStart={(e) => { e.dataTransfer.setData(DND_MIME, entry.id); e.dataTransfer.effectAllowed = 'move'; }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} onDrop={(e) => reorderDrop(entry.id, e)} conditionsOpen={conditionsFor === entry.id} onToggleConditions={() => setConditionsFor((cur) => (cur === entry.id ? null : entry.id)) } onAddCondition={(conditionId) => { void initiativeApi.dispatch({ kind: 'addCondition', id: entry.id, conditionId }); setConditionsFor(null); }} onRemoveCondition={(conditionId) => { void initiativeApi.dispatch({ kind: 'removeCondition', id: entry.id, conditionId }); }} /> )) )}
{t('initiative.reserve')}
{reserveEntries.length === 0 ? (
{t('initiative.reserveEmpty')}
) : ( reserveEntries.map((entry) => (
{entry.name} {entry.kind === 'manual' ? ( ) : null}
)) )}
); }