4b1c1deda2
Persistent damage displayed as compact tags with damage type icon and formula (e.g., Flame + "2d6"). Supports fire, bleed, acid, cold, electricity, poison, and mental types. One instance per type, added via sub-picker in the condition picker. PF2e only, persists across reload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
277 lines
7.9 KiB
TypeScript
277 lines
7.9 KiB
TypeScript
import {
|
|
type ConditionEntry,
|
|
type ConditionId,
|
|
getConditionDescription,
|
|
getConditionsForEdition,
|
|
type PersistentDamageEntry,
|
|
type PersistentDamageType,
|
|
} from "@initiative/domain";
|
|
import { Check, Flame, Minus, Plus } from "lucide-react";
|
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
|
import { cn } from "../lib/utils";
|
|
import {
|
|
CONDITION_COLOR_CLASSES,
|
|
CONDITION_ICON_MAP,
|
|
} from "./condition-styles.js";
|
|
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
|
|
import { Tooltip } from "./ui/tooltip.js";
|
|
|
|
interface ConditionPickerProps {
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
activeConditions: readonly ConditionEntry[] | undefined;
|
|
activePersistentDamage?: readonly PersistentDamageEntry[];
|
|
onToggle: (conditionId: ConditionId) => void;
|
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
|
onAddPersistentDamage?: (
|
|
damageType: PersistentDamageType,
|
|
formula: string,
|
|
) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ConditionPicker({
|
|
anchorRef,
|
|
activeConditions,
|
|
activePersistentDamage,
|
|
onToggle,
|
|
onSetValue,
|
|
onAddPersistentDamage,
|
|
onClose,
|
|
}: Readonly<ConditionPickerProps>) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState<{
|
|
top: number;
|
|
left: number;
|
|
maxHeight: number;
|
|
} | null>(null);
|
|
|
|
const [editing, setEditing] = useState<{
|
|
id: ConditionId;
|
|
value: number;
|
|
} | null>(null);
|
|
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
|
|
|
useLayoutEffect(() => {
|
|
const anchor = anchorRef.current;
|
|
const el = ref.current;
|
|
if (!anchor || !el) return;
|
|
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const menuHeight = el.scrollHeight;
|
|
const pad = 8;
|
|
|
|
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
|
const spaceAbove = anchorRect.top - pad;
|
|
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
|
|
|
const top = openBelow
|
|
? anchorRect.bottom + 4
|
|
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
|
|
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
|
|
|
|
setPos({ top, left: anchorRect.left, maxHeight });
|
|
}, [anchorRef]);
|
|
|
|
useClickOutside(ref, onClose);
|
|
|
|
const { edition } = useRulesEditionContext();
|
|
const conditions = getConditionsForEdition(edition);
|
|
const activeMap = new Map(
|
|
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
|
);
|
|
const showPersistentDamageEntry =
|
|
edition === "pf2e" && !!onAddPersistentDamage;
|
|
const persistentDamageInsertIndex = showPersistentDamageEntry
|
|
? conditions.findIndex(
|
|
(d) => d.label.localeCompare("Persistent Damage") > 0,
|
|
)
|
|
: -1;
|
|
|
|
const persistentDamageEntry = showPersistentDamageEntry ? (
|
|
<React.Fragment key="persistent-damage">
|
|
<div
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
|
showPersistentDamage && "bg-card/50",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex flex-1 items-center gap-2"
|
|
onClick={() => setShowPersistentDamage((prev) => !prev)}
|
|
>
|
|
<Flame
|
|
size={14}
|
|
className={
|
|
showPersistentDamage ? "text-orange-400" : "text-muted-foreground"
|
|
}
|
|
/>
|
|
<span
|
|
className={
|
|
showPersistentDamage ? "text-foreground" : "text-muted-foreground"
|
|
}
|
|
>
|
|
Persistent Damage
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{!!showPersistentDamage && (
|
|
<PersistentDamagePicker
|
|
activeEntries={activePersistentDamage}
|
|
onAdd={onAddPersistentDamage}
|
|
onClose={() => setShowPersistentDamage(false)}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
) : null;
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={ref}
|
|
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
|
|
style={
|
|
pos
|
|
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
|
|
: { visibility: "hidden" as const }
|
|
}
|
|
>
|
|
{conditions.map((def, index) => {
|
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
|
if (!Icon) return null;
|
|
const isActive = activeMap.has(def.id);
|
|
const activeValue = activeMap.get(def.id);
|
|
const isEditing = editing?.id === def.id;
|
|
const colorClass =
|
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
|
|
|
const handleClick = () => {
|
|
if (def.valued && edition === "pf2e") {
|
|
const current = activeMap.get(def.id);
|
|
setEditing({
|
|
id: def.id,
|
|
value: current ?? 1,
|
|
});
|
|
} else {
|
|
onToggle(def.id);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<React.Fragment key={def.id}>
|
|
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
|
<Tooltip
|
|
content={getConditionDescription(def, edition)}
|
|
className="block"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
|
(isActive || isEditing) && "bg-card/50",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex flex-1 items-center gap-2"
|
|
onClick={handleClick}
|
|
>
|
|
<Icon
|
|
size={14}
|
|
className={
|
|
isActive || isEditing
|
|
? colorClass
|
|
: "text-muted-foreground"
|
|
}
|
|
/>
|
|
<span
|
|
className={
|
|
isActive || isEditing
|
|
? "text-foreground"
|
|
: "text-muted-foreground"
|
|
}
|
|
>
|
|
{def.label}
|
|
</span>
|
|
</button>
|
|
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
|
{activeValue}
|
|
</span>
|
|
)}
|
|
{isEditing && (
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
type="button"
|
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (editing.value > 1) {
|
|
setEditing({
|
|
...editing,
|
|
value: editing.value - 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 text-xs">
|
|
{editing.value}
|
|
</span>
|
|
{(() => {
|
|
const atMax =
|
|
def.maxValue !== undefined &&
|
|
editing.value >= def.maxValue;
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"rounded p-0.5",
|
|
atMax
|
|
? "cursor-not-allowed text-muted-foreground opacity-50"
|
|
: "text-foreground hover:bg-accent/40",
|
|
)}
|
|
disabled={atMax}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (!atMax) {
|
|
setEditing({
|
|
...editing,
|
|
value: editing.value + 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();
|
|
onSetValue(editing.id, editing.value);
|
|
setEditing(null);
|
|
}}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Tooltip>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
{persistentDamageInsertIndex === -1 && persistentDamageEntry}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|