Files
initiative/apps/web/src/hooks/use-action-bar-state.ts
Lukas 80dd68752e Refactor useEncounter from useState to useReducer
Replaces 18 useCallback wrappers with a typed action union and
encounterReducer. Undo/redo wrapping is now systematic per-case in
the reducer instead of ad-hoc per operation. Complex cases (undo/redo,
bestiary add, player character add) are extracted into helper functions.

The stat block auto-show on bestiary add now uses lastCreatureId from
reducer state instead of the synchronous return value, with a useEffect
in use-action-bar-state to react to changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:41:40 +01:00

324 lines
8.4 KiB
TypeScript

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<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const deferredSuggestions = useDeferredValue(suggestions);
const deferredPcMatches = useDeferredValue(pcMatches);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(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<HTMLFormElement>) => {
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;
}