import { type CombatantId, type ConditionId, deriveHpStatus, } from "@initiative/domain"; import { Brain, X } from "lucide-react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../lib/utils"; import { AcShield } from "./ac-shield"; import { ConditionPicker } from "./condition-picker"; import { ConditionTags } from "./condition-tags"; import { D20Icon } from "./d20-icon"; import { HpAdjustPopover } from "./hp-adjust-popover"; import { ConfirmButton } from "./ui/confirm-button"; import { Input } from "./ui/input"; interface Combatant { readonly id: CombatantId; readonly name: string; readonly initiative?: number; readonly maxHp?: number; readonly currentHp?: number; readonly ac?: number; readonly conditions?: readonly ConditionId[]; readonly isConcentrating?: boolean; } interface CombatantRowProps { combatant: Combatant; isActive: boolean; onRename: (id: CombatantId, newName: string) => void; onSetInitiative: (id: CombatantId, value: number | undefined) => void; onRemove: (id: CombatantId) => void; onSetHp: (id: CombatantId, maxHp: number | undefined) => void; onAdjustHp: (id: CombatantId, delta: number) => void; onSetAc: (id: CombatantId, value: number | undefined) => void; onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void; onToggleConcentration: (id: CombatantId) => void; onShowStatBlock?: () => void; onRollInitiative?: (id: CombatantId) => void; } function EditableName({ name, combatantId, onRename, onShowStatBlock, }: { name: string; combatantId: CombatantId; onRename: (id: CombatantId, newName: string) => void; onShowStatBlock?: () => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(name); const inputRef = useRef(null); const clickTimerRef = useRef>(undefined); const longPressTimerRef = useRef>(undefined); const longPressTriggeredRef = useRef(false); const commit = useCallback(() => { const trimmed = draft.trim(); if (trimmed !== "" && trimmed !== name) { onRename(combatantId, trimmed); } setEditing(false); }, [draft, name, combatantId, onRename]); const startEditing = useCallback(() => { setDraft(name); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [name]); useEffect(() => { return () => { clearTimeout(clickTimerRef.current); clearTimeout(longPressTimerRef.current); }; }, []); const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (longPressTriggeredRef.current) { longPressTriggeredRef.current = false; return; } if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); clickTimerRef.current = undefined; startEditing(); } else { clickTimerRef.current = setTimeout(() => { clickTimerRef.current = undefined; onShowStatBlock?.(); }, 250); } }, [startEditing, onShowStatBlock], ); const handleTouchStart = useCallback(() => { longPressTriggeredRef.current = false; longPressTimerRef.current = setTimeout(() => { longPressTriggeredRef.current = true; startEditing(); }, 500); }, [startEditing]); const cancelLongPress = useCallback(() => { clearTimeout(longPressTimerRef.current); }, []); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } return ( <> ); } function MaxHpDisplay({ maxHp, onCommit, }: { maxHp: number | undefined; onCommit: (value: number | undefined) => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(maxHp?.toString() ?? ""); const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onCommit(undefined); } else { const n = Number.parseInt(draft, 10); if (!Number.isNaN(n) && n >= 1) { onCommit(n); } } setEditing(false); }, [draft, onCommit]); const startEditing = useCallback(() => { setDraft(maxHp?.toString() ?? ""); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [maxHp]); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } return ( ); } function ClickableHp({ currentHp, maxHp, onAdjust, dimmed, }: { currentHp: number | undefined; maxHp: number | undefined; onAdjust: (delta: number) => void; dimmed?: boolean; }) { const [popoverOpen, setPopoverOpen] = useState(false); const status = deriveHpStatus(currentHp, maxHp); if (maxHp === undefined) { return ( -- ); } return (
{popoverOpen && ( setPopoverOpen(false)} /> )}
); } function AcDisplay({ ac, onCommit, }: { ac: number | undefined; onCommit: (value: number | undefined) => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(ac?.toString() ?? ""); const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onCommit(undefined); } else { const n = Number.parseInt(draft, 10); if (!Number.isNaN(n) && n >= 0) { onCommit(n); } } setEditing(false); }, [draft, onCommit]); const startEditing = useCallback(() => { setDraft(ac?.toString() ?? ""); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [ac]); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } return ; } function InitiativeDisplay({ initiative, combatantId, dimmed, onSetInitiative, onRollInitiative, }: { initiative: number | undefined; combatantId: CombatantId; dimmed: boolean; onSetInitiative: (id: CombatantId, value: number | undefined) => void; onRollInitiative?: (id: CombatantId) => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(initiative?.toString() ?? ""); const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onSetInitiative(combatantId, undefined); } else { const n = Number.parseInt(draft, 10); if (!Number.isNaN(n)) { onSetInitiative(combatantId, n); } } setEditing(false); }, [draft, combatantId, onSetInitiative]); const startEditing = useCallback(() => { setDraft(initiative?.toString() ?? ""); setEditing(true); requestAnimationFrame(() => inputRef.current?.select()); }, [initiative]); if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }} /> ); } // Empty + bestiary creature → d20 roll button if (initiative === undefined && onRollInitiative) { return ( ); } // Has value → bold number, click to edit // Empty + manual → "--" placeholder, click to edit return ( ); } function rowBorderClass( isActive: boolean, isConcentrating: boolean | undefined, ): string { if (isActive) return "border-l-2 border-l-accent bg-accent/10"; if (isConcentrating) return "border-l-2 border-l-purple-400"; return "border-l-2 border-l-transparent"; } function concentrationIconClass( isConcentrating: boolean | undefined, dimmed: boolean, ): string { if (!isConcentrating) return "opacity-0 group-hover:opacity-50 text-muted-foreground"; return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400"; } function activateOnKeyDown( handler: () => void, ): (e: { key: string; preventDefault: () => void }) => void { return (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handler(); } }; } export function CombatantRow({ ref, combatant, isActive, onRename, onSetInitiative, onRemove, onSetHp, onAdjustHp, onSetAc, onToggleCondition, onToggleConcentration, onShowStatBlock, onRollInitiative, }: CombatantRowProps & { ref?: Ref }) { const { id, name, initiative, maxHp, currentHp } = combatant; const status = deriveHpStatus(currentHp, maxHp); const dimmed = status === "unconscious"; const [pickerOpen, setPickerOpen] = useState(false); const prevHpRef = useRef(currentHp); const [isPulsing, setIsPulsing] = useState(false); const pulseTimerRef = useRef>(undefined); useEffect(() => { const prevHp = prevHpRef.current; prevHpRef.current = currentHp; if ( prevHp !== undefined && currentHp !== undefined && currentHp < prevHp && combatant.isConcentrating ) { setIsPulsing(true); clearTimeout(pulseTimerRef.current); pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200); } }, [currentHp, combatant.isConcentrating]); useEffect(() => { if (!combatant.isConcentrating) { clearTimeout(pulseTimerRef.current); setIsPulsing(false); } }, [combatant.isConcentrating]); return ( /* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
{/* Concentration */} {/* Initiative */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} >
{/* Name + Conditions */}
onToggleCondition(id, conditionId)} onOpenPicker={() => setPickerOpen((prev) => !prev)} /> {pickerOpen && ( onToggleCondition(id, conditionId)} onClose={() => setPickerOpen(false)} /> )}
{/* AC */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > onSetAc(id, v)} />
{/* HP */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > onAdjustHp(id, delta)} dimmed={dimmed} /> {maxHp !== undefined && ( / )}
onSetHp(id, v)} />
{/* Actions */} } label="Remove combatant" onConfirm={() => onRemove(id)} className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity" />
); }