import type { CreatureId, PlayerCharacter } from "@initiative/domain"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, } from "react"; import type { SearchResult } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js"; import { usePlayerCharactersContext } from "../contexts/player-characters-context.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js"; export interface QueuedCreature { result: SearchResult; count: number; } export interface SuggestionActions { dismiss: () => void; clear: () => void; clickSuggestion: (result: SearchResult) => void; setSuggestionIndex: (i: number) => void; setQueued: (q: QueuedCreature | null) => void; confirmQueued: () => void; addFromPlayerCharacter?: (pc: PlayerCharacter) => void; } export function creatureKey(r: SearchResult): string { return `${r.source}:${r.name}`; } export function useActionBarState() { const { addCombatant, addFromBestiary, addMultipleFromBestiary, addFromPlayerCharacter, lastCreatureId, } = useEncounterContext(); const { search: bestiarySearch, isLoaded: bestiaryLoaded } = useBestiaryContext(); const { characters: playerCharacters } = usePlayerCharactersContext(); const { showBulkImport, showSourceManager, showCreature, panelView } = useSidePanelContext(); // Auto-show stat block when a bestiary creature is added on desktop const prevCreatureIdRef = useRef(lastCreatureId); useEffect(() => { if ( lastCreatureId && lastCreatureId !== prevCreatureIdRef.current && panelView.mode === "closed" && globalThis.matchMedia("(min-width: 1024px)").matches ) { showCreature(lastCreatureId); } prevCreatureIdRef.current = lastCreatureId; }, [lastCreatureId, panelView.mode, showCreature]); const [nameInput, setNameInput] = useState(""); const [suggestions, setSuggestions] = useState([]); const [pcMatches, setPcMatches] = useState([]); const deferredSuggestions = useDeferredValue(suggestions); const deferredPcMatches = useDeferredValue(pcMatches); const [suggestionIndex, setSuggestionIndex] = useState(-1); const [queued, setQueued] = useState(null); const [customInit, setCustomInit] = useState(""); const [customAc, setCustomAc] = useState(""); const [customMaxHp, setCustomMaxHp] = useState(""); const [browseMode, setBrowseMode] = useState(false); const clearCustomFields = () => { setCustomInit(""); setCustomAc(""); setCustomMaxHp(""); }; const clearInput = useCallback(() => { setNameInput(""); setSuggestions([]); setPcMatches([]); setQueued(null); setSuggestionIndex(-1); }, []); const dismissSuggestions = useCallback(() => { setSuggestions([]); setPcMatches([]); setQueued(null); setSuggestionIndex(-1); }, []); const handleAddFromBestiary = useCallback( (result: SearchResult) => { addFromBestiary(result); }, [addFromBestiary], ); const handleViewStatBlock = useCallback( (result: SearchResult) => { const slug = result.name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/(^-|-$)/g, ""); const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; showCreature(cId); }, [showCreature], ); const confirmQueued = useCallback(() => { if (!queued) return; if (queued.count === 1) { handleAddFromBestiary(queued.result); } else { addMultipleFromBestiary(queued.result, queued.count); } clearInput(); }, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]); const parseNum = (v: string): number | undefined => { if (v.trim() === "") return undefined; const n = Number(v); return Number.isNaN(n) ? undefined : n; }; const handleAdd = (e: React.SubmitEvent) => { e.preventDefault(); if (browseMode) return; if (queued) { confirmQueued(); return; } if (nameInput.trim() === "") return; const opts: { initiative?: number; ac?: number; maxHp?: number } = {}; const init = parseNum(customInit); const ac = parseNum(customAc); const maxHp = parseNum(customMaxHp); if (init !== undefined) opts.initiative = init; if (ac !== undefined) opts.ac = ac; if (maxHp !== undefined) opts.maxHp = maxHp; addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined); setNameInput(""); setSuggestions([]); setPcMatches([]); clearCustomFields(); }; const handleBrowseSearch = (value: string) => { setSuggestions(value.length >= 2 ? bestiarySearch(value) : []); }; const handleAddSearch = (value: string) => { let newSuggestions: SearchResult[] = []; let newPcMatches: PlayerCharacter[] = []; if (value.length >= 2) { newSuggestions = bestiarySearch(value); setSuggestions(newSuggestions); if (playerCharacters && playerCharacters.length > 0) { const lower = value.toLowerCase(); newPcMatches = playerCharacters.filter((pc) => pc.name.toLowerCase().includes(lower), ); } setPcMatches(newPcMatches); } else { setSuggestions([]); setPcMatches([]); } if (newSuggestions.length > 0 || newPcMatches.length > 0) { clearCustomFields(); } if (queued) { const qKey = creatureKey(queued.result); const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey); if (!stillVisible) { setQueued(null); } } }; const handleNameChange = (value: string) => { setNameInput(value); setSuggestionIndex(-1); if (browseMode) { handleBrowseSearch(value); } else { handleAddSearch(value); } }; const handleClickSuggestion = useCallback((result: SearchResult) => { const key = creatureKey(result); setQueued((prev) => { if (prev && creatureKey(prev.result) === key) { return { ...prev, count: prev.count + 1 }; } return { result, count: 1 }; }); }, []); const handleEnter = () => { if (queued) { confirmQueued(); } else if (suggestionIndex >= 0) { handleClickSuggestion(suggestions[suggestionIndex]); } }; const hasSuggestions = deferredSuggestions.length > 0 || deferredPcMatches.length > 0; const handleKeyDown = (e: React.KeyboardEvent) => { if (!hasSuggestions) return; if (e.key === "ArrowDown") { e.preventDefault(); setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); } else if (e.key === "Enter") { e.preventDefault(); handleEnter(); } else if (e.key === "Escape") { dismissSuggestions(); } }; const handleBrowseKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { setBrowseMode(false); clearInput(); return; } if (suggestions.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); } else if (e.key === "Enter" && suggestionIndex >= 0) { e.preventDefault(); handleViewStatBlock(suggestions[suggestionIndex]); setBrowseMode(false); clearInput(); } }; const handleBrowseSelect = (result: SearchResult) => { handleViewStatBlock(result); setBrowseMode(false); clearInput(); }; const toggleBrowseMode = () => { setBrowseMode((prev) => { const next = !prev; setSuggestionIndex(-1); setQueued(null); if (next) { handleBrowseSearch(nameInput); } else { handleAddSearch(nameInput); } return next; }); clearCustomFields(); }; const suggestionActions: SuggestionActions = useMemo( () => ({ dismiss: dismissSuggestions, clear: clearInput, clickSuggestion: handleClickSuggestion, setSuggestionIndex, setQueued, confirmQueued, addFromPlayerCharacter, }), [ dismissSuggestions, clearInput, handleClickSuggestion, confirmQueued, addFromPlayerCharacter, ], ); return { // State nameInput, suggestions: deferredSuggestions, pcMatches: deferredPcMatches, suggestionIndex, queued, customInit, customAc, customMaxHp, browseMode, bestiaryLoaded, hasSuggestions, showBulkImport, showSourceManager, // Actions suggestionActions, handleNameChange, handleKeyDown, handleBrowseKeyDown, handleAdd, handleBrowseSelect, toggleBrowseMode, setCustomInit, setCustomAc, setCustomMaxHp, } as const; }