Temp HP absorbs damage before current HP, cannot be healed, and does not stack (higher value wins). Displayed as cyan +N after current HP with a Shield button in the HP adjustment popover. Column space is reserved across all rows only when any combatant has temp HP. Concentration pulse fires on any damage, including damage fully absorbed by temp HP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
4.2 KiB
TypeScript
157 lines
4.2 KiB
TypeScript
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Input } from "./ui/input";
|
|
|
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
|
|
|
interface HpAdjustPopoverProps {
|
|
readonly onAdjust: (delta: number) => void;
|
|
readonly onSetTempHp: (value: number) => void;
|
|
readonly onClose: () => void;
|
|
}
|
|
|
|
export function HpAdjustPopover({
|
|
onAdjust,
|
|
onSetTempHp,
|
|
onClose,
|
|
}: HpAdjustPopoverProps) {
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const parent = el.parentElement;
|
|
if (!parent) return;
|
|
const trigger = parent.getBoundingClientRect();
|
|
const popover = el.getBoundingClientRect();
|
|
const vw = document.documentElement.clientWidth;
|
|
let left = trigger.left;
|
|
if (left + popover.width > vw) {
|
|
left = vw - popover.width - 8;
|
|
}
|
|
if (left < 8) {
|
|
left = 8;
|
|
}
|
|
setPos({ top: trigger.bottom + 4, left });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
requestAnimationFrame(() => inputRef.current?.focus());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [onClose]);
|
|
|
|
const parsedValue =
|
|
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
|
const isValid =
|
|
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
|
|
|
|
const applyDelta = useCallback(
|
|
(sign: -1 | 1) => {
|
|
if (inputValue === "") return;
|
|
const n = Number.parseInt(inputValue, 10);
|
|
if (Number.isNaN(n) || n <= 0) return;
|
|
onAdjust(sign * n);
|
|
onClose();
|
|
},
|
|
[inputValue, onAdjust, onClose],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter") {
|
|
if (e.shiftKey) {
|
|
applyDelta(1);
|
|
} else {
|
|
applyDelta(-1);
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
onClose();
|
|
}
|
|
},
|
|
[applyDelta, onClose],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
|
style={
|
|
pos
|
|
? { top: pos.top, left: pos.left }
|
|
: { visibility: "hidden" as const }
|
|
}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={inputValue}
|
|
placeholder="HP"
|
|
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
|
setInputValue(v);
|
|
}
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={!isValid}
|
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={() => applyDelta(-1)}
|
|
title="Apply damage"
|
|
aria-label="Apply damage"
|
|
>
|
|
<Sword size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!isValid}
|
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={() => applyDelta(1)}
|
|
title="Apply healing"
|
|
aria-label="Apply healing"
|
|
>
|
|
<Heart size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!isValid}
|
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={() => {
|
|
if (isValid && parsedValue) {
|
|
onSetTempHp(parsedValue);
|
|
onClose();
|
|
}
|
|
}}
|
|
title="Set temp HP"
|
|
aria-label="Set temp HP"
|
|
>
|
|
<ShieldPlus size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|