690 lines
20 KiB
TypeScript
690 lines
20 KiB
TypeScript
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
|
import {
|
|
Check,
|
|
Eye,
|
|
EyeOff,
|
|
Import,
|
|
Library,
|
|
Minus,
|
|
Monitor,
|
|
Moon,
|
|
Plus,
|
|
Sun,
|
|
Users,
|
|
} from "lucide-react";
|
|
import React, {
|
|
type RefObject,
|
|
useCallback,
|
|
useDeferredValue,
|
|
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 { useThemeContext } from "../contexts/theme-context.js";
|
|
import { useLongPress } from "../hooks/use-long-press.js";
|
|
import { cn } from "../lib/utils.js";
|
|
import { D20Icon } from "./d20-icon.js";
|
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
|
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<HTMLInputElement | null>;
|
|
autoFocus?: boolean;
|
|
onManagePlayers?: () => void;
|
|
}
|
|
|
|
function creatureKey(r: SearchResult): string {
|
|
return `${r.source}:${r.name}`;
|
|
}
|
|
|
|
function AddModeSuggestions({
|
|
nameInput,
|
|
suggestions,
|
|
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;
|
|
}>) {
|
|
return (
|
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
|
<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"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={onDismiss}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
|
|
Esc
|
|
</kbd>
|
|
</button>
|
|
<div className="max-h-48 overflow-y-auto py-1">
|
|
{pcMatches.length > 0 && (
|
|
<>
|
|
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
|
|
Players
|
|
</div>
|
|
<ul>
|
|
{pcMatches.map((pc) => {
|
|
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
|
const pcColor = pc.color
|
|
? PLAYER_COLOR_HEX[pc.color]
|
|
: undefined;
|
|
return (
|
|
<li key={pc.id}>
|
|
<button
|
|
type="button"
|
|
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()}
|
|
onClick={() => {
|
|
onAddFromPlayerCharacter?.(pc);
|
|
onClear();
|
|
}}
|
|
>
|
|
{!!PcIcon && (
|
|
<PcIcon size={14} style={{ color: pcColor }} />
|
|
)}
|
|
<span className="flex-1 truncate">{pc.name}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
Player
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</>
|
|
)}
|
|
{suggestions.length > 0 && (
|
|
<ul>
|
|
{suggestions.map((result, i) => {
|
|
const key = creatureKey(result);
|
|
const isQueued =
|
|
queued !== null && creatureKey(queued.result) === key;
|
|
return (
|
|
<li key={key}>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
|
isQueued && "bg-accent/30",
|
|
!isQueued && i === suggestionIndex && "bg-accent/20",
|
|
!isQueued &&
|
|
i !== suggestionIndex &&
|
|
"hover:bg-hover-neutral-bg",
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onClickSuggestion(result)}
|
|
onMouseEnter={() => onSetSuggestionIndex(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
|
{isQueued ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (queued.count <= 1) {
|
|
onSetQueued(null);
|
|
} else {
|
|
onSetQueued({
|
|
...queued,
|
|
count: queued.count - 1,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<Minus className="h-3 w-3" />
|
|
</button>
|
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
|
|
{queued.count}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSetQueued({
|
|
...queued,
|
|
count: queued.count + 1,
|
|
});
|
|
}}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onConfirmQueued();
|
|
}}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
result.sourceDisplayName
|
|
)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const THEME_ICONS = {
|
|
system: Monitor,
|
|
light: Sun,
|
|
dark: Moon,
|
|
} as const;
|
|
|
|
const THEME_LABELS = {
|
|
system: "Theme: System",
|
|
light: "Theme: Light",
|
|
dark: "Theme: Dark",
|
|
} as const;
|
|
|
|
function buildOverflowItems(opts: {
|
|
onManagePlayers?: () => void;
|
|
onOpenSourceManager?: () => void;
|
|
bestiaryLoaded: boolean;
|
|
onBulkImport?: () => void;
|
|
bulkImportDisabled?: boolean;
|
|
themePreference?: "system" | "light" | "dark";
|
|
onCycleTheme?: () => void;
|
|
}): OverflowMenuItem[] {
|
|
const items: OverflowMenuItem[] = [];
|
|
if (opts.onManagePlayers) {
|
|
items.push({
|
|
icon: <Users className="h-4 w-4" />,
|
|
label: "Player Characters",
|
|
onClick: opts.onManagePlayers,
|
|
});
|
|
}
|
|
if (opts.onOpenSourceManager) {
|
|
items.push({
|
|
icon: <Library className="h-4 w-4" />,
|
|
label: "Manage Sources",
|
|
onClick: opts.onOpenSourceManager,
|
|
});
|
|
}
|
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
|
items.push({
|
|
icon: <Import className="h-4 w-4" />,
|
|
label: "Import All Sources",
|
|
onClick: opts.onBulkImport,
|
|
disabled: opts.bulkImportDisabled,
|
|
});
|
|
}
|
|
if (opts.onCycleTheme) {
|
|
const pref = opts.themePreference ?? "system";
|
|
const ThemeIcon = THEME_ICONS[pref];
|
|
items.push({
|
|
icon: <ThemeIcon className="h-4 w-4" />,
|
|
label: THEME_LABELS[pref],
|
|
onClick: opts.onCycleTheme,
|
|
keepOpen: true,
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
export function ActionBar({
|
|
inputRef,
|
|
autoFocus,
|
|
onManagePlayers,
|
|
}: Readonly<ActionBarProps>) {
|
|
const {
|
|
addCombatant,
|
|
addFromBestiary,
|
|
addFromPlayerCharacter,
|
|
hasCreatureCombatants,
|
|
canRollAllInitiative,
|
|
} = useEncounterContext();
|
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
|
useBestiaryContext();
|
|
const { characters: playerCharacters } = usePlayerCharactersContext();
|
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
|
useSidePanelContext();
|
|
const { preference: themePreference, cycleTheme } = useThemeContext();
|
|
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
|
const { state: bulkImportState } = useBulkImportContext();
|
|
|
|
const handleAddFromBestiary = useCallback(
|
|
(result: SearchResult) => {
|
|
const creatureId = addFromBestiary(result);
|
|
if (creatureId && panelView.mode === "closed") {
|
|
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({
|
|
onManagePlayers,
|
|
onOpenSourceManager: showSourceManager,
|
|
bestiaryLoaded,
|
|
onBulkImport: showBulkImport,
|
|
bulkImportDisabled: bulkImportState.status === "loading",
|
|
themePreference,
|
|
onCycleTheme: cycleTheme,
|
|
});
|
|
|
|
return (
|
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
|
<form
|
|
onSubmit={handleAdd}
|
|
className="relative flex flex-1 items-center gap-2"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="relative max-w-xs">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={nameInput}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
|
placeholder={
|
|
browseMode ? "Search stat blocks..." : "+ Add combatants"
|
|
}
|
|
className="pr-8"
|
|
autoFocus={autoFocus}
|
|
/>
|
|
{!!bestiaryLoaded && (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
|
browseMode && "text-accent",
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={toggleBrowseMode}
|
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
|
aria-label={
|
|
browseMode ? "Switch to add mode" : "Browse stat blocks"
|
|
}
|
|
>
|
|
{browseMode ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
{browseMode && deferredSuggestions.length > 0 && (
|
|
<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">
|
|
{deferredSuggestions.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={() => 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 && (
|
|
<AddModeSuggestions
|
|
nameInput={nameInput}
|
|
suggestions={deferredSuggestions}
|
|
pcMatches={deferredPcMatches}
|
|
suggestionIndex={suggestionIndex}
|
|
queued={queued}
|
|
onDismiss={dismissSuggestions}
|
|
onClear={clearInput}
|
|
onClickSuggestion={handleClickSuggestion}
|
|
onSetSuggestionIndex={setSuggestionIndex}
|
|
onSetQueued={setQueued}
|
|
onConfirmQueued={confirmQueued}
|
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={customInit}
|
|
onChange={(e) => setCustomInit(e.target.value)}
|
|
placeholder="Init"
|
|
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 && (
|
|
<Button type="submit">Add</Button>
|
|
)}
|
|
{!!hasCreatureCombatants && (
|
|
<>
|
|
<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} />}
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|