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>
324 lines
8.4 KiB
TypeScript
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;
|
|
}
|