diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 7f0e38d..95e7cfc 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -1,4 +1,4 @@ -import type { CreatureId, PlayerCharacter } from "@initiative/domain"; +import type { PlayerCharacter } from "@initiative/domain"; import { Check, Eye, @@ -10,19 +10,17 @@ import { Settings, Users, } from "lucide-react"; -import React, { - type RefObject, - useCallback, - useDeferredValue, - useState, -} from "react"; +import React, { type RefObject, useCallback, useState } from "react"; import type { SearchResult } from "../contexts/bestiary-context.js"; -import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useBulkImportContext } from "../contexts/bulk-import-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js"; import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js"; -import { usePlayerCharactersContext } from "../contexts/player-characters-context.js"; -import { useSidePanelContext } from "../contexts/side-panel-context.js"; +import { + creatureKey, + type QueuedCreature, + type SuggestionActions, + useActionBarState, +} from "../hooks/use-action-bar-state.js"; import { useLongPress } from "../hooks/use-long-press.js"; import { cn } from "../lib/utils.js"; import { D20Icon } from "./d20-icon.js"; @@ -32,11 +30,6 @@ import { Button } from "./ui/button.js"; import { Input } from "./ui/input.js"; import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js"; -interface QueuedCreature { - result: SearchResult; - count: number; -} - interface ActionBarProps { inputRef?: RefObject; autoFocus?: boolean; @@ -44,8 +37,13 @@ interface ActionBarProps { onOpenSettings?: () => void; } -function creatureKey(r: SearchResult): string { - return `${r.source}:${r.name}`; +interface AddModeSuggestionsProps { + nameInput: string; + suggestions: SearchResult[]; + pcMatches: PlayerCharacter[]; + suggestionIndex: number; + queued: QueuedCreature | null; + actions: SuggestionActions; } function AddModeSuggestions({ @@ -54,34 +52,15 @@ function AddModeSuggestions({ pcMatches, suggestionIndex, queued, - onDismiss, - onClickSuggestion, - onSetSuggestionIndex, - onSetQueued, - onConfirmQueued, - onAddFromPlayerCharacter, - onClear, -}: Readonly<{ - nameInput: string; - suggestions: SearchResult[]; - pcMatches: PlayerCharacter[]; - suggestionIndex: number; - queued: QueuedCreature | null; - onDismiss: () => void; - onClear: () => void; - onClickSuggestion: (result: SearchResult) => void; - onSetSuggestionIndex: (i: number) => void; - onSetQueued: (q: QueuedCreature | null) => void; - onConfirmQueued: () => void; - onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; -}>) { + actions, +}: Readonly) { return (
+ + ))} + +
+ ); +} + +interface CustomStatFieldsProps { + customInit: string; + customAc: string; + customMaxHp: string; + onInitChange: (v: string) => void; + onAcChange: (v: string) => void; + onMaxHpChange: (v: string) => void; +} + +function CustomStatFields({ + customInit, + customAc, + customMaxHp, + onInitChange, + onAcChange, + onMaxHpChange, +}: Readonly) { + return ( +
+ onInitChange(e.target.value)} + placeholder="Init" + className="w-16 text-center" + /> + onAcChange(e.target.value)} + placeholder="AC" + className="w-16 text-center" + /> + onMaxHpChange(e.target.value)} + placeholder="MaxHP" + className="w-18 text-center" + /> +
+ ); +} + +function RollAllButton() { + const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext(); + const { handleRollAllInitiative } = useInitiativeRollsContext(); + + const [menuPos, setMenuPos] = useState<{ + x: number; + y: number; + } | null>(null); + + const openMenu = useCallback((x: number, y: number) => { + setMenuPos({ x, y }); + }, []); + + const longPress = useLongPress( + useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + if (touch) openMenu(touch.clientX, touch.clientY); + }, + [openMenu], + ), + ); + + if (!hasCreatureCombatants) return null; + + return ( + <> + + {!!menuPos && ( + handleRollAllInitiative(mode)} + onClose={() => setMenuPos(null)} + /> + )} + + ); +} + function buildOverflowItems(opts: { onManagePlayers?: () => void; onOpenSourceManager?: () => void; @@ -262,253 +387,33 @@ export function ActionBar({ onOpenSettings, }: Readonly) { const { - addCombatant, - addFromBestiary, - addFromPlayerCharacter, - hasCreatureCombatants, - canRollAllInitiative, - } = useEncounterContext(); - const { search: bestiarySearch, isLoaded: bestiaryLoaded } = - useBestiaryContext(); - const { characters: playerCharacters } = usePlayerCharactersContext(); - const { showBulkImport, showSourceManager, showCreature, panelView } = - useSidePanelContext(); - const { handleRollAllInitiative } = useInitiativeRollsContext(); + nameInput, + suggestions, + pcMatches, + suggestionIndex, + queued, + customInit, + customAc, + customMaxHp, + browseMode, + bestiaryLoaded, + hasSuggestions, + showBulkImport, + showSourceManager, + suggestionActions, + handleNameChange, + handleKeyDown, + handleBrowseKeyDown, + handleAdd, + handleBrowseSelect, + toggleBrowseMode, + setCustomInit, + setCustomAc, + setCustomMaxHp, + } = useActionBarState(); + const { state: bulkImportState } = useBulkImportContext(); - const handleAddFromBestiary = useCallback( - (result: SearchResult) => { - const creatureId = addFromBestiary(result); - const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches; - if (creatureId && panelView.mode === "closed" && isDesktop) { - showCreature(creatureId); - } - }, - [addFromBestiary, panelView.mode, showCreature], - ); - - 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 [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 = () => { - setNameInput(""); - setSuggestions([]); - setPcMatches([]); - setQueued(null); - setSuggestionIndex(-1); - }; - - const dismissSuggestions = () => { - setSuggestions([]); - setPcMatches([]); - setQueued(null); - setSuggestionIndex(-1); - }; - - const confirmQueued = () => { - if (!queued) return; - for (let i = 0; i < queued.count; i++) { - handleAddFromBestiary(queued.result); - } - 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 = (result: SearchResult) => { - const key = creatureKey(result); - if (queued && creatureKey(queued.result) === key) { - setQueued({ ...queued, count: queued.count + 1 }); - } else { - setQueued({ 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 [rollAllMenuPos, setRollAllMenuPos] = useState<{ - x: number; - y: number; - } | null>(null); - - const openRollAllMenu = useCallback((x: number, y: number) => { - setRollAllMenuPos({ x, y }); - }, []); - - const rollAllLongPress = useLongPress( - useCallback( - (e: React.TouchEvent) => { - const touch = e.touches[0]; - if (touch) openRollAllMenu(touch.clientX, touch.clientY); - }, - [openRollAllMenu], - ), - ); - const overflowItems = buildOverflowItems({ onManagePlayers, onOpenSourceManager: showSourceManager, @@ -560,110 +465,40 @@ export function ActionBar({ )} )} - {browseMode && deferredSuggestions.length > 0 && ( -
-
    - {deferredSuggestions.map((result, i) => ( -
  • - -
  • - ))} -
-
+ {!!browseMode && ( + )} {!browseMode && hasSuggestions && ( )} {!browseMode && nameInput.length >= 2 && !hasSuggestions && ( -
- setCustomInit(e.target.value)} - placeholder="Init" - className="w-16 text-center" - /> - setCustomAc(e.target.value)} - placeholder="AC" - className="w-16 text-center" - /> - setCustomMaxHp(e.target.value)} - placeholder="MaxHP" - className="w-18 text-center" - /> -
+ )} {!browseMode && nameInput.length >= 2 && !hasSuggestions && ( )} - {!!hasCreatureCombatants && ( - <> - - {!!rollAllMenuPos && ( - handleRollAllInitiative(mode)} - onClose={() => setRollAllMenuPos(null)} - /> - )} - - )} + {overflowItems.length > 0 && } diff --git a/apps/web/src/hooks/use-action-bar-state.ts b/apps/web/src/hooks/use-action-bar-state.ts new file mode 100644 index 0000000..0dc6a5c --- /dev/null +++ b/apps/web/src/hooks/use-action-bar-state.ts @@ -0,0 +1,299 @@ +import type { CreatureId, PlayerCharacter } from "@initiative/domain"; +import { useCallback, useDeferredValue, useMemo, 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, addFromPlayerCharacter } = + useEncounterContext(); + const { search: bestiarySearch, isLoaded: bestiaryLoaded } = + useBestiaryContext(); + const { characters: playerCharacters } = usePlayerCharactersContext(); + const { showBulkImport, showSourceManager, showCreature, panelView } = + useSidePanelContext(); + + 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) => { + const creatureId = addFromBestiary(result); + const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches; + if (creatureId && panelView.mode === "closed" && isDesktop) { + showCreature(creatureId); + } + }, + [addFromBestiary, panelView.mode, showCreature], + ); + + 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; + for (let i = 0; i < queued.count; i++) { + handleAddFromBestiary(queued.result); + } + clearInput(); + }, [queued, handleAddFromBestiary, 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; +}