Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab9301b20 | ||
|
|
d653cfe489 | ||
|
|
228a2603e8 |
@@ -1,4 +1,4 @@
|
|||||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -10,19 +10,17 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, {
|
import React, { type RefObject, useCallback, useState } from "react";
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useDeferredValue,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
import {
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
creatureKey,
|
||||||
|
type QueuedCreature,
|
||||||
|
type SuggestionActions,
|
||||||
|
useActionBarState,
|
||||||
|
} from "../hooks/use-action-bar-state.js";
|
||||||
import { useLongPress } from "../hooks/use-long-press.js";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
@@ -32,11 +30,6 @@ import { Button } from "./ui/button.js";
|
|||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||||
|
|
||||||
interface QueuedCreature {
|
|
||||||
result: SearchResult;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionBarProps {
|
interface ActionBarProps {
|
||||||
inputRef?: RefObject<HTMLInputElement | null>;
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
@@ -44,8 +37,13 @@ interface ActionBarProps {
|
|||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
interface AddModeSuggestionsProps {
|
||||||
return `${r.source}:${r.name}`;
|
nameInput: string;
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
pcMatches: PlayerCharacter[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
queued: QueuedCreature | null;
|
||||||
|
actions: SuggestionActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddModeSuggestions({
|
function AddModeSuggestions({
|
||||||
@@ -54,34 +52,15 @@ function AddModeSuggestions({
|
|||||||
pcMatches,
|
pcMatches,
|
||||||
suggestionIndex,
|
suggestionIndex,
|
||||||
queued,
|
queued,
|
||||||
onDismiss,
|
actions,
|
||||||
onClickSuggestion,
|
}: Readonly<AddModeSuggestionsProps>) {
|
||||||
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;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onDismiss}
|
onClick={actions.dismiss}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||||
@@ -108,8 +87,8 @@ function AddModeSuggestions({
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFromPlayerCharacter?.(pc);
|
actions.addFromPlayerCharacter?.(pc);
|
||||||
onClear();
|
actions.clear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!!PcIcon && (
|
{!!PcIcon && (
|
||||||
@@ -145,8 +124,8 @@ function AddModeSuggestions({
|
|||||||
"hover:bg-hover-neutral-bg",
|
"hover:bg-hover-neutral-bg",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onClickSuggestion(result)}
|
onClick={() => actions.clickSuggestion(result)}
|
||||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||||
@@ -159,9 +138,9 @@ function AddModeSuggestions({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (queued.count <= 1) {
|
if (queued.count <= 1) {
|
||||||
onSetQueued(null);
|
actions.setQueued(null);
|
||||||
} else {
|
} else {
|
||||||
onSetQueued({
|
actions.setQueued({
|
||||||
...queued,
|
...queued,
|
||||||
count: queued.count - 1,
|
count: queued.count - 1,
|
||||||
});
|
});
|
||||||
@@ -179,7 +158,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSetQueued({
|
actions.setQueued({
|
||||||
...queued,
|
...queued,
|
||||||
count: queued.count + 1,
|
count: queued.count + 1,
|
||||||
});
|
});
|
||||||
@@ -193,7 +172,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onConfirmQueued();
|
actions.confirmQueued();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
@@ -214,6 +193,152 @@ function AddModeSuggestions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BrowseSuggestionsProps {
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
onSelect: (result: SearchResult) => void;
|
||||||
|
onHover: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowseSuggestions({
|
||||||
|
suggestions,
|
||||||
|
suggestionIndex,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: Readonly<BrowseSuggestionsProps>) {
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||||
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
|
{suggestions.map((result, i) => (
|
||||||
|
<li key={creatureKey(result)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||||
|
i === suggestionIndex
|
||||||
|
? "bg-accent/20 text-foreground"
|
||||||
|
: "text-foreground hover:bg-hover-neutral-bg",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelect(result)}
|
||||||
|
onMouseEnter={() => onHover(i)}
|
||||||
|
>
|
||||||
|
<span>{result.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{result.sourceDisplayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CustomStatFieldsProps>) {
|
||||||
|
return (
|
||||||
|
<div className="hidden items-center gap-2 sm:flex">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customInit}
|
||||||
|
onChange={(e) => onInitChange(e.target.value)}
|
||||||
|
placeholder="Init"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customAc}
|
||||||
|
onChange={(e) => onAcChange(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customMaxHp}
|
||||||
|
onChange={(e) => onMaxHpChange(e.target.value)}
|
||||||
|
placeholder="MaxHP"
|
||||||
|
className="w-18 text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
|
onClick={() => handleRollAllInitiative()}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...longPress}
|
||||||
|
disabled={!canRollAllInitiative}
|
||||||
|
title="Roll all initiative"
|
||||||
|
aria-label="Roll all initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
{!!menuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={menuPos}
|
||||||
|
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildOverflowItems(opts: {
|
function buildOverflowItems(opts: {
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
@@ -262,253 +387,33 @@ export function ActionBar({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: Readonly<ActionBarProps>) {
|
}: Readonly<ActionBarProps>) {
|
||||||
const {
|
const {
|
||||||
addCombatant,
|
nameInput,
|
||||||
addFromBestiary,
|
suggestions,
|
||||||
addFromPlayerCharacter,
|
pcMatches,
|
||||||
hasCreatureCombatants,
|
suggestionIndex,
|
||||||
canRollAllInitiative,
|
queued,
|
||||||
} = useEncounterContext();
|
customInit,
|
||||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
customAc,
|
||||||
useBestiaryContext();
|
customMaxHp,
|
||||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
browseMode,
|
||||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
bestiaryLoaded,
|
||||||
useSidePanelContext();
|
hasSuggestions,
|
||||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
suggestionActions,
|
||||||
|
handleNameChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBrowseKeyDown,
|
||||||
|
handleAdd,
|
||||||
|
handleBrowseSelect,
|
||||||
|
toggleBrowseMode,
|
||||||
|
setCustomInit,
|
||||||
|
setCustomAc,
|
||||||
|
setCustomMaxHp,
|
||||||
|
} = useActionBarState();
|
||||||
|
|
||||||
const { state: bulkImportState } = useBulkImportContext();
|
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<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 = () => {
|
|
||||||
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<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 = (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({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onOpenSourceManager: showSourceManager,
|
onOpenSourceManager: showSourceManager,
|
||||||
@@ -560,110 +465,40 @@ export function ActionBar({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{browseMode && deferredSuggestions.length > 0 && (
|
{!!browseMode && (
|
||||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
<BrowseSuggestions
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
suggestions={suggestions}
|
||||||
{deferredSuggestions.map((result, i) => (
|
suggestionIndex={suggestionIndex}
|
||||||
<li key={creatureKey(result)}>
|
onSelect={handleBrowseSelect}
|
||||||
<button
|
onHover={suggestionActions.setSuggestionIndex}
|
||||||
type="button"
|
/>
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
|
||||||
i === suggestionIndex
|
|
||||||
? "bg-accent/20 text-foreground"
|
|
||||||
: "text-foreground hover:bg-hover-neutral-bg",
|
|
||||||
)}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => handleBrowseSelect(result)}
|
|
||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
|
||||||
>
|
|
||||||
<span>{result.name}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{result.sourceDisplayName}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!browseMode && hasSuggestions && (
|
{!browseMode && hasSuggestions && (
|
||||||
<AddModeSuggestions
|
<AddModeSuggestions
|
||||||
nameInput={nameInput}
|
nameInput={nameInput}
|
||||||
suggestions={deferredSuggestions}
|
suggestions={suggestions}
|
||||||
pcMatches={deferredPcMatches}
|
pcMatches={pcMatches}
|
||||||
suggestionIndex={suggestionIndex}
|
suggestionIndex={suggestionIndex}
|
||||||
queued={queued}
|
queued={queued}
|
||||||
onDismiss={dismissSuggestions}
|
actions={suggestionActions}
|
||||||
onClear={clearInput}
|
|
||||||
onClickSuggestion={handleClickSuggestion}
|
|
||||||
onSetSuggestionIndex={setSuggestionIndex}
|
|
||||||
onSetQueued={setQueued}
|
|
||||||
onConfirmQueued={confirmQueued}
|
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<div className="hidden items-center gap-2 sm:flex">
|
<CustomStatFields
|
||||||
<Input
|
customInit={customInit}
|
||||||
type="text"
|
customAc={customAc}
|
||||||
inputMode="numeric"
|
customMaxHp={customMaxHp}
|
||||||
value={customInit}
|
onInitChange={setCustomInit}
|
||||||
onChange={(e) => setCustomInit(e.target.value)}
|
onAcChange={setCustomAc}
|
||||||
placeholder="Init"
|
onMaxHpChange={setCustomMaxHp}
|
||||||
className="w-16 text-center"
|
/>
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={customAc}
|
|
||||||
onChange={(e) => setCustomAc(e.target.value)}
|
|
||||||
placeholder="AC"
|
|
||||||
className="w-16 text-center"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={customMaxHp}
|
|
||||||
onChange={(e) => setCustomMaxHp(e.target.value)}
|
|
||||||
placeholder="MaxHP"
|
|
||||||
className="w-18 text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit">Add</Button>
|
<Button type="submit">Add</Button>
|
||||||
)}
|
)}
|
||||||
{!!hasCreatureCombatants && (
|
<RollAllButton />
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
|
||||||
onClick={() => handleRollAllInitiative()}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openRollAllMenu(e.clientX, e.clientY);
|
|
||||||
}}
|
|
||||||
{...rollAllLongPress}
|
|
||||||
disabled={!canRollAllInitiative}
|
|
||||||
title="Roll all initiative"
|
|
||||||
aria-label="Roll all initiative"
|
|
||||||
>
|
|
||||||
<D20Icon className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
{!!rollAllMenuPos && (
|
|
||||||
<RollModeMenu
|
|
||||||
position={rollAllMenuPos}
|
|
||||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
|
||||||
onClose={() => setRollAllMenuPos(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
CONDITION_DEFINITIONS,
|
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
ShieldMinus,
|
||||||
Siren,
|
Siren,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -41,6 +43,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Link,
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Moon,
|
||||||
};
|
};
|
||||||
@@ -56,6 +60,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
|||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
@@ -110,6 +115,7 @@ export function ConditionPicker({
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
|
const conditions = getConditionsForEdition(edition);
|
||||||
const active = new Set(activeConditions ?? []);
|
const active = new Set(activeConditions ?? []);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -122,7 +128,7 @@ export function ConditionPicker({
|
|||||||
: { visibility: "hidden" as const }
|
: { visibility: "hidden" as const }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{CONDITION_DEFINITIONS.map((def) => {
|
{conditions.map((def) => {
|
||||||
const Icon = ICON_MAP[def.iconName];
|
const Icon = ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = active.has(def.id);
|
const isActive = active.has(def.id);
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
|
ShieldMinus,
|
||||||
Siren,
|
Siren,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -40,6 +42,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Link,
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Moon,
|
||||||
};
|
};
|
||||||
@@ -55,6 +59,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
|||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConditionTagsProps {
|
interface ConditionTagsProps {
|
||||||
|
|||||||
299
apps/web/src/hooks/use-action-bar-state.ts
Normal file
299
apps/web/src/hooks/use-action-bar-state.ts
Normal file
@@ -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<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) => {
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
20
docs/adr/000-template.md
Normal file
20
docs/adr/000-template.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# ADR-NNN: [Title]
|
||||||
|
|
||||||
|
**Date**: YYYY-MM-DD
|
||||||
|
**Status**: accepted | superseded | deprecated
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
What is the problem or situation that motivates this decision?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
What did we decide, and why?
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
What other approaches were evaluated?
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
What are the trade-offs — both positive and negative?
|
||||||
45
docs/adr/001-errors-as-values.md
Normal file
45
docs/adr/001-errors-as-values.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ADR-001: Errors as Values, Not Exceptions
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect.
|
||||||
|
|
||||||
|
This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
All domain functions return `SuccessType | DomainError` unions. `DomainError` is a plain data object with a `kind` discriminant, a machine-readable `code`, and a human-readable `message`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DomainError {
|
||||||
|
readonly kind: "domain-error";
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Callers check results with the `isDomainError()` type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers).
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Thrown exceptions** — the JavaScript default. Simpler to write (`throw new Error(...)`) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and `catch` blocks lose type information about which errors are possible. Would also make domain functions impure.
|
||||||
|
|
||||||
|
**Result wrapper types** (e.g., `neverthrow`, `ts-results`) — formalizes the pattern with `.map()`, `.unwrap()`, `.match()` methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient.
|
||||||
|
|
||||||
|
**Validation libraries** (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields.
|
||||||
|
- Domain functions remain pure — they return data, never alter control flow.
|
||||||
|
- Error codes are stable, machine-readable identifiers that UI code can match on.
|
||||||
|
- Testing is straightforward: assert the return value, no try/catch in tests.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Every call site must check `isDomainError()` before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls.
|
||||||
|
- Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly.
|
||||||
|
- Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar.
|
||||||
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-002: Domain Events as Plain Data Objects
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Domain state transitions need to communicate what happened (not just the new state) so the UI layer can react — showing toasts, auto-scrolling, opening panels, etc. The project needs an event mechanism that stays consistent with the pure, deterministic domain core.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Domain events are plain data objects with a `type` string discriminant. They form a discriminated union (`DomainEvent`) of 18 event types. Events are returned alongside the new state from domain functions, not emitted through a pub/sub system:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example event
|
||||||
|
{ type: "TurnAdvanced", previousCombatantId: "abc", newCombatantId: "def", roundNumber: 2 }
|
||||||
|
|
||||||
|
// Domain function returns both state and events
|
||||||
|
function advanceTurn(encounter: Encounter): { encounter: Encounter; events: DomainEvent[] } | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
Events are consumed ephemerally by the UI layer and are not persisted.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Class-based events** (e.g., `class TurnAdvanced extends DomainEvent { ... }`) — common in OOP-style domain-driven design. Adds inheritance hierarchies, constructors, and `instanceof` checks. No benefit here: TypeScript's discriminated union narrowing (`switch (event.type)`) provides the same exhaustiveness checking without classes. Classes also can't be serialized/deserialized without custom logic.
|
||||||
|
|
||||||
|
**Event emitter / pub-sub** (Node `EventEmitter`, custom bus, RxJS) — events are broadcast and listeners subscribe. Decouples producers from consumers, but introduces implicit coupling (who's listening?), ordering concerns, and makes the domain impure (emitting is a side effect). Harder to test — you'd need to set up listeners and collect results instead of just asserting on a return value.
|
||||||
|
|
||||||
|
**Observable streams** (RxJS) — powerful for async event processing and composition. Massive overkill for this use case: events are synchronous, produced one batch at a time, and consumed immediately. Would add a significant dependency and conceptual overhead.
|
||||||
|
|
||||||
|
**No events** (just compare old and new state) — the UI could diff states to determine what changed. Works for simple cases, but can't express intent (did HP drop because of damage or because max HP was lowered?) and gets unwieldy as the state model grows.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Events are serializable (JSON-compatible). If the project ever adds undo/redo or event logging, no changes to the event format are needed.
|
||||||
|
- TypeScript's `switch (event.type)` provides exhaustiveness checking — the compiler warns if a new event type is added but not handled.
|
||||||
|
- No framework coupling. Events are just data; any consumer (React, a test, a CLI) can process them identically.
|
||||||
|
- Domain functions remain pure — events are returned, not emitted.
|
||||||
|
- Testing is trivial: assert that `result.events` contains the expected objects.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Events are currently consumed and discarded. There is no event log, replay, or undo capability. The architecture supports it, but it's not built.
|
||||||
|
- Adding a new event type requires updating the `DomainEvent` union, which touches a central file. This is intentional (forces explicit acknowledgment) but adds friction.
|
||||||
|
- No built-in mechanism for event handlers to communicate back (e.g., "veto this event"). Events are informational, not transactional.
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CONDITION_DEFINITIONS,
|
CONDITION_DEFINITIONS,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
} from "../conditions.js";
|
} from "../conditions.js";
|
||||||
|
|
||||||
function findCondition(id: string) {
|
function findCondition(id: string) {
|
||||||
@@ -25,13 +26,27 @@ describe("getConditionDescription", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("every condition has both descriptions", () => {
|
it("universal conditions have both descriptions", () => {
|
||||||
for (const def of CONDITION_DEFINITIONS) {
|
const universal = CONDITION_DEFINITIONS.filter(
|
||||||
|
(d) => d.edition === undefined,
|
||||||
|
);
|
||||||
|
expect(universal.length).toBeGreaterThan(0);
|
||||||
|
for (const def of universal) {
|
||||||
expect(def.description).toBeTruthy();
|
expect(def.description).toBeTruthy();
|
||||||
expect(def.description5e).toBeTruthy();
|
expect(def.description5e).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("edition-specific conditions have their edition description", () => {
|
||||||
|
const sapped = findCondition("sapped");
|
||||||
|
expect(sapped.description).toBeTruthy();
|
||||||
|
expect(sapped.edition).toBe("5.5e");
|
||||||
|
|
||||||
|
const slowed = findCondition("slowed");
|
||||||
|
expect(slowed.description).toBeTruthy();
|
||||||
|
expect(slowed.edition).toBe("5.5e");
|
||||||
|
});
|
||||||
|
|
||||||
it("conditions with identical rules share the same text", () => {
|
it("conditions with identical rules share the same text", () => {
|
||||||
const blinded = findCondition("blinded");
|
const blinded = findCondition("blinded");
|
||||||
expect(blinded.description).toBe(blinded.description5e);
|
expect(blinded.description).toBe(blinded.description5e);
|
||||||
@@ -42,3 +57,26 @@ describe("getConditionDescription", () => {
|
|||||||
expect(exhaustion.description).not.toBe(exhaustion.description5e);
|
expect(exhaustion.description).not.toBe(exhaustion.description5e);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getConditionsForEdition", () => {
|
||||||
|
it("includes sapped and slowed for 5.5e", () => {
|
||||||
|
const conditions = getConditionsForEdition("5.5e");
|
||||||
|
const ids = conditions.map((d) => d.id);
|
||||||
|
expect(ids).toContain("sapped");
|
||||||
|
expect(ids).toContain("slowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes sapped and slowed for 5e", () => {
|
||||||
|
const conditions = getConditionsForEdition("5e");
|
||||||
|
const ids = conditions.map((d) => d.id);
|
||||||
|
expect(ids).not.toContain("sapped");
|
||||||
|
expect(ids).not.toContain("slowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes universal conditions for both editions", () => {
|
||||||
|
const ids5e = getConditionsForEdition("5e").map((d) => d.id);
|
||||||
|
const ids55e = getConditionsForEdition("5.5e").map((d) => d.id);
|
||||||
|
expect(ids5e).toContain("blinded");
|
||||||
|
expect(ids55e).toContain("blinded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type ConditionId =
|
|||||||
| "poisoned"
|
| "poisoned"
|
||||||
| "prone"
|
| "prone"
|
||||||
| "restrained"
|
| "restrained"
|
||||||
|
| "sapped"
|
||||||
|
| "slowed"
|
||||||
| "stunned"
|
| "stunned"
|
||||||
| "unconscious";
|
| "unconscious";
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ export interface ConditionDefinition {
|
|||||||
readonly description5e: string;
|
readonly description5e: string;
|
||||||
readonly iconName: string;
|
readonly iconName: string;
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
|
/** When set, the condition only appears in this edition's picker. */
|
||||||
|
readonly edition?: RulesEdition;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConditionDescription(
|
export function getConditionDescription(
|
||||||
@@ -159,6 +163,26 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
iconName: "Link",
|
iconName: "Link",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "sapped",
|
||||||
|
label: "Sapped",
|
||||||
|
description:
|
||||||
|
"Disadvantage on next attack roll before the start of your next turn. (Weapon Mastery: Sap)",
|
||||||
|
description5e: "",
|
||||||
|
iconName: "ShieldMinus",
|
||||||
|
color: "amber",
|
||||||
|
edition: "5.5e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slowed",
|
||||||
|
label: "Slowed",
|
||||||
|
description:
|
||||||
|
"Speed reduced by 10 ft. until the start of your next turn. (Weapon Mastery: Slow)",
|
||||||
|
description5e: "",
|
||||||
|
iconName: "Snail",
|
||||||
|
color: "sky",
|
||||||
|
edition: "5.5e",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "stunned",
|
id: "stunned",
|
||||||
label: "Stunned",
|
label: "Stunned",
|
||||||
@@ -184,3 +208,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
||||||
CONDITION_DEFINITIONS.map((d) => d.id),
|
CONDITION_DEFINITIONS.map((d) => d.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function getConditionsForEdition(
|
||||||
|
edition: RulesEdition,
|
||||||
|
): readonly ConditionDefinition[] {
|
||||||
|
return CONDITION_DEFINITIONS.filter(
|
||||||
|
(d) => d.edition === undefined || d.edition === edition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
type ConditionDefinition,
|
type ConditionDefinition,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
type RulesEdition,
|
type RulesEdition,
|
||||||
VALID_CONDITION_IDS,
|
VALID_CONDITION_IDS,
|
||||||
} from "./conditions.js";
|
} from "./conditions.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user