Files
ttrpg_manager/src/features/world/map/MapEditor.tsx
T
NilsBriggen 99c7657f96 UI overhaul "Living Codex" E: per-screen layouts
Ported all 9 screens to the prototype layout (visual only; all data wiring, text,
aria-labels, and testids preserved) via parallel agents + central verification:
- Dashboard: editorial hero, party roster w/ HP meters + avatars, quests, threats, rolls.
- Campaigns: cover-art deck cards + gilt rule + footer.
- Character sheet + roster: hero header, StatCoin abilities, prof rows; grimoire cards.
- Combat: glowing initiative rows (PC gilt / foe ember), condition badges, log.
- Dice: die-pool glyphs, adv/dis segmented toggle, crit/fumble stage, history.
- Compendium: Spectral stat blocks (ember headers, ability row).
- Settings: grouped icon-tile cards. Live Session: room-code card, seat grid, player board.
- Battle Map: carded list + tooled editor chrome (canvas untouched).

Regression fixes after the ports: dice die-buttons aria-label `d4` not `Roll 1d4`
(was colliding with the primary Roll button); map "Open player view" link uses an
ExternalLink icon (test updated, ↗ glyph removed); seat-claim card gets a stable
data-testid="seat-option" (redesign moved rounded-lg→rounded-xl).

223 unit + 34 e2e + 2 realtime green; tsc + eslint + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:37:00 +02:00

408 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useState } from 'react';
import {
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
ChevronRight, type LucideIcon,
} from 'lucide-react';
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
import { nextTurn, previousTurn, applyDamage, applyHealing, updateCombatant, logEvent } from '@/lib/combat/engine';
import { newId } from '@/lib/ids';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import {
brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell,
blockingSegments, computeVisibleCells,
type Point,
} from '@/lib/map';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas';
import { TokenPalette, type TokenSpec } from './TokenPalette';
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping' | 'walls';
const TOOL_ICON: Record<Tool, LucideIcon> = {
move: Move, reveal: ScanEye, hide: EyeOff, measure: Ruler,
aoe: Cloud, draw: PenTool, ping: Crosshair, walls: Spline,
};
/** Distance from point p to segment a-b (image px). */
function distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x, dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
const cx = a.x + t * dx, cy = a.y + t * dy;
return Math.hypot(p.x - cx, p.y - cy);
}
type FogShape = 'brush' | 'rect' | 'poly';
type AoeShape = 'circle' | 'cone' | 'line' | 'square';
type DrawKind = MapDrawing['kind'];
export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) {
const [m, setM] = useState<BattleMap>(map);
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
const [tool, setTool] = useState<Tool>('move');
const [fogShape, setFogShape] = useState<FogShape>('brush');
const [brush, setBrush] = useState(0);
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
const [aoeFeet, setAoeFeet] = useState(20);
const [drawKind, setDrawKind] = useState<DrawKind>('freehand');
const [drawColor, setDrawColor] = useState('#d4af37');
const [gmDraw, setGmDraw] = useState(true);
const [dims, setDims] = useState({ cols: 0, rows: 0 });
const [editToken, setEditToken] = useState<string | null>(null);
// Collapsed by default on small screens so the map gets the width.
const [showPalette, setShowPalette] = useState(() => typeof window === 'undefined' || window.innerWidth >= 880);
// transient interaction state
const [overlay, setOverlay] = useState<Overlay>({});
const anchor = useRef<Point | null>(null);
const [poly, setPoly] = useState<Point[]>([]);
const draftPts = useRef<Point[]>([]);
const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e';
const activeEncounter = useMemo(() => encounters.find((e) => e.status === 'active') ?? null, [encounters]);
const activeCombatant = activeEncounter ? activeEncounter.combatants[activeEncounter.turnIndex] : undefined;
const activeTokenId = activeCombatant ? m.tokens.find((t) => t.combatantId === activeCombatant.id)?.id : undefined;
const damageCombatant = (id: string, amt: number) => {
if (!activeEncounter) return;
void encountersRepo.mutate(activeEncounter.id, (e) => {
const cb = e.combatants.find((c) => c.id === id);
if (!cb) return e;
const updated = amt < 0 ? applyDamage(cb, -amt) : applyHealing(cb, amt);
return logEvent(updateCombatant(e, id, { hp: updated.hp }), `${cb.name} ${amt < 0 ? `takes ${-amt} damage` : `heals ${amt}`}`);
});
};
// Live HP/conditions: prefer an active-encounter combatant, then a linked
// character, then the token's denormalized snapshot.
const canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => {
const linkedChar = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
const linkedCb = t.combatantId && activeEncounter ? activeEncounter.combatants.find((c) => c.id === t.combatantId) : undefined;
const image = t.image ?? linkedChar?.portrait;
return {
id: t.id, label: t.label || (linkedChar?.name ?? linkedCb?.name ?? ''), color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind,
...(image ? { image } : {}),
hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp,
conditions: linkedCb?.conditions ?? t.conditions,
};
}), [m.tokens, characters, activeEncounter]);
const commitReveal = (cells: string[], reveal: boolean) =>
update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
const onPointer = (phase: 'down' | 'move' | 'up' | 'hover', p: { point: Point; col: number; row: number }) => {
// Live previews when no button is held.
if (phase === 'hover') {
if (tool === 'reveal' || tool === 'hide') {
if (fogShape === 'brush') setOverlay({ brush: { col: p.col, row: p.row, size: brush } });
else if (fogShape === 'poly') setOverlay({ poly: { points: poly, cursor: p.point }, ...(poly.length >= 2 ? { cells: polygonCells([...poly, p.point], gridSpec()) } : {}) });
} else if (tool === 'aoe' && (aoeShape === 'circle' || aoeShape === 'square')) {
setOverlay({ cells: aoeShape === 'circle' ? circleCells(p.point, aoeFeet, gridSpec()) : squareCells(p.point, aoeFeet, gridSpec()) });
} else if (tool === 'walls') {
setOverlay({ poly: { points: poly, cursor: p.point } });
}
return;
}
if (tool === 'walls') {
if (phase === 'down') {
if (toggleDoorNear(p.point)) return; // click a door to open/close it
const next = [...poly, p.point];
setPoly(next);
setOverlay({ poly: { points: next, cursor: p.point } });
}
return;
}
const reveal = tool === 'reveal';
if (tool === 'reveal' || tool === 'hide') {
if (fogShape === 'brush') {
if (phase !== 'up') { commitReveal(brushCells(p.col, p.row, brush, dims.cols, dims.rows), reveal); setOverlay({ brush: { col: p.col, row: p.row, size: brush } }); }
} else if (fogShape === 'rect') {
if (phase === 'down') anchor.current = p.point;
else if (anchor.current) {
const a = worldToCell(anchor.current, m.gridSize);
const cells = rectCells(a, { col: p.col, row: p.row }, dims.cols, dims.rows);
if (phase === 'move') setOverlay({ cells });
else { commitReveal(cells, reveal); anchor.current = null; setOverlay({}); }
}
} else if (fogShape === 'poly') {
if (phase === 'down') { const next = [...poly, p.point]; setPoly(next); setOverlay({ poly: { points: next, cursor: p.point }, ...(next.length >= 3 ? { cells: polygonCells(next, gridSpec()) } : {}) }); }
}
return;
}
if (tool === 'measure') {
if (phase === 'down') anchor.current = p.point;
else if (anchor.current) {
const a = worldToCell(anchor.current, m.gridSize);
const cells = gridDistance(a, { col: p.col, row: p.row }, distMode);
setOverlay({ line: { a: anchor.current, b: p.point }, label: { at: p.point, text: `${toFeet(cells, m.gridUnit.feet)} ft` } });
if (phase === 'up') anchor.current = null;
}
return;
}
if (tool === 'aoe') {
if (phase === 'down') anchor.current = p.point;
const origin = anchor.current ?? p.point;
let cells: string[] = [];
if (aoeShape === 'circle') cells = circleCells(p.point, aoeFeet, gridSpec());
else if (aoeShape === 'square') cells = squareCells(p.point, aoeFeet, gridSpec());
else if (aoeShape === 'cone') cells = coneCells(origin, p.point, aoeFeet, 53, gridSpec());
else cells = lineCells(origin, p.point, 5, gridSpec());
setOverlay({ cells });
if (phase === 'up' && aoeShape !== 'circle' && aoeShape !== 'square') anchor.current = null;
return;
}
if (tool === 'draw') {
if (drawKind === 'text') {
if (phase === 'down') {
const text = window.prompt('Label text:')?.trim();
if (text) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [p.point], color: drawColor, width: 4, text, gmOnly: gmDraw }] });
}
return;
}
if (phase === 'down') { draftPts.current = [p.point]; }
else if (phase === 'move') {
draftPts.current = drawKind === 'freehand' ? [...draftPts.current, p.point] : [draftPts.current[0]!, p.point];
setOverlay({ draft: { id: 'draft', kind: drawKind, points: draftPts.current, color: drawColor, width: 3 } });
} else {
if (draftPts.current.length >= 2) update({ drawings: [...m.drawings, { id: newId(), kind: drawKind, points: draftPts.current, color: drawColor, width: 3, gmOnly: gmDraw }] });
draftPts.current = []; setOverlay({});
}
return;
}
if (tool === 'ping' && phase === 'down') {
const at = p.point;
setOverlay({ cells: [`${p.col},${p.row}`] });
window.setTimeout(() => setOverlay((o) => (o.cells?.[0] === `${p.col},${p.row}` ? {} : o)), 1500);
void at;
}
};
const gridSpec = () => ({ gridSize: m.gridSize, feetPerCell: m.gridUnit.feet, cols: dims.cols, rows: dims.rows });
const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); };
// --- walls & dynamic vision ---
const finishWall = () => { if (poly.length >= 2) update({ walls: [...m.walls, { id: newId(), points: poly }] }); setPoly([]); setOverlay({}); };
const toggleDoorNear = (p: Point): boolean => {
const thresh = m.gridSize * 0.5;
let bestI = -1, best = thresh;
m.doors.forEach((d, i) => { const dist = distToSegment(p, d.a, d.b); if (dist < best) { best = dist; bestI = i; } });
if (bestI < 0) return false;
update({ doors: m.doors.map((d, i) => (i === bestI ? { ...d, open: !d.open } : d)) });
return true;
};
/** Cells visible to the party (pc tokens), honouring walls + closed doors. */
const visionReveal = (tokens: MapToken[], revealed: string[]): string[] => {
const viewers = tokens.filter((t) => t.kind === 'pc').map((t) => ({ x: (t.col + t.size / 2) * m.gridSize, y: (t.row + t.size / 2) * m.gridSize }));
if (viewers.length === 0 || dims.cols === 0) return revealed;
const segments = blockingSegments({ walls: m.walls, doors: m.doors });
const radiusPx = m.sightRadiusFeet > 0 ? (m.sightRadiusFeet / m.gridUnit.feet) * m.gridSize : 0;
const vis = computeVisibleCells({ viewers, segments, gridSize: m.gridSize, cols: dims.cols, rows: dims.rows, radiusPx });
return applyReveal(revealed, [...vis]);
};
const revealFromParty = () => update({ fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) });
// Clear transient hover previews when the cursor leaves, but keep an in-progress polygon.
const onLeave = () => setOverlay(poly.length ? { poly: { points: poly } } : {});
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
const moveToken = (id: string, col: number, row: number) => setM((prev) => {
const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t));
const revealed = prev.dynamicVision ? visionReveal(tokens, prev.revealed) : prev.revealed;
const next = { ...prev, tokens, revealed };
save(next);
return next;
});
const removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
const onTokenIcon = async (id: string, file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 160);
patchToken(id, { image: thumb });
};
const token = m.tokens.find((t) => t.id === editToken) ?? null;
const tokenLinkedChar = token?.characterId ? characters.find((c) => c.id === token.characterId) : undefined;
return (
<div>
<div className="grid gap-2" style={{ gridTemplateColumns: showPalette ? '210px minmax(0, 1fr)' : 'minmax(0, 1fr)' }}>
{showPalette && (
<TokenPalette characters={characters} encounters={encounters} existingTokens={m.tokens}
onPlace={addTokenSpec} onPlaceMany={addTokenSpecs} onClose={() => setShowPalette(false)} />
)}
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2 text-sm paper-grain">
{!showPalette && (
<Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>
Tokens <ChevronRight size={14} aria-hidden />
</Button>
)}
<Input className="h-8 max-w-36 font-display" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
<span className="mx-1 h-5 w-px bg-line" aria-hidden />
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => {
const ToolIcon = TOOL_ICON[t];
return (
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">
<ToolIcon size={15} aria-hidden />
{t}
</Button>
);
})}
</div>
{/* Contextual sub-toolbar */}
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel-2 p-2 text-xs text-muted">
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
<label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label>
<label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
<label className="flex items-center gap-1">Feet/cell<NumberField className="w-14" value={m.gridUnit.feet} min={1} max={100} onChange={(feet) => update({ gridUnit: { feet } })} aria-label="Feet per cell" /></label>
{(tool === 'reveal' || tool === 'hide') && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
<Button key={s} size="sm" variant={fogShape === s ? 'primary' : 'ghost'} onClick={() => { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}</Button>
))}
{fogShape === 'brush' && <label className="flex items-center gap-1">Size<NumberField className="w-12" value={brush} min={0} max={5} onChange={setBrush} aria-label="Brush size" /></label>}
{fogShape === 'poly' && <Button size="sm" variant="secondary" disabled={poly.length < 3} onClick={finishPoly}>Finish polygon ({poly.length})</Button>}
<Button size="sm" variant="ghost" onClick={() => update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all</Button>
<Button size="sm" variant="ghost" onClick={() => update({ revealed: hideAll() })}>Hide all</Button>
</>
)}
{tool === 'aoe' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
<Button key={s} size="sm" variant={aoeShape === s ? 'primary' : 'ghost'} onClick={() => { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}</Button>
))}
<label className="flex items-center gap-1">Feet<NumberField className="w-14" value={aoeFeet} min={5} max={120} onChange={setAoeFeet} aria-label="AoE size feet" /></label>
<Button size="sm" variant="ghost" onClick={() => setOverlay({})}>Clear</Button>
</>
)}
{tool === 'draw' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
<Select className="h-8 w-28" value={drawKind} onChange={(e) => setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
{(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => <option key={k} value={k}>{k}</option>)}
</Select>
<input type="color" value={drawColor} onChange={(e) => setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
<label className="flex items-center gap-1"><input type="checkbox" checked={gmDraw} onChange={(e) => setGmDraw(e.target.checked)} /> GM only</label>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: m.drawings.slice(0, -1) })}>Undo draw</Button>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: [] })}>Clear drawings</Button>
</>
)}
{tool === 'walls' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
<span className="text-[11px]">Click to add wall points; click a door to open/close.</span>
<Button size="sm" variant="secondary" disabled={poly.length < 2} onClick={finishWall}>Finish wall ({poly.length})</Button>
<Button size="sm" variant="ghost" disabled={m.walls.length === 0} onClick={() => update({ walls: [] })}>Clear walls</Button>
<label className="flex items-center gap-1">Sight ft<NumberField className="w-14" value={m.sightRadiusFeet} min={0} max={500} onChange={(v) => update({ sightRadiusFeet: v })} aria-label="Sight radius feet" /></label>
<Button size="sm" variant="secondary" onClick={revealFromParty}>Reveal from party</Button>
</>
)}
<span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" variant="secondary" onClick={addToken}>
<CirclePlus size={14} aria-hidden />
+ Token
</Button>
</div>
{activeEncounter && (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
<span className="font-display font-semibold text-ink"> {activeCombatant?.name ?? '—'}</span>
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}> Prev</Button>
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn </Button>
{activeTokenId ? <span className="text-xs text-muted"> their token glows on the map</span> : <span className="text-xs text-muted"> place their token from the palette</span>}
</div>
)}
<MapCanvas
viewportHeight="calc(100vh - 17rem)"
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings, walls: m.walls, doors: m.doors }}
overlay={overlay}
tokensDraggable={tool === 'move'}
activeTokenId={activeTokenId}
{...(tool !== 'move' ? { onPointer, onLeave } : {})}
onTokenMove={moveToken}
onTokenClick={(id) => setEditToken(id)}
onReady={setDims}
/>
</div>
</div>
<p className="mt-2 text-xs text-muted">
Place party/foe tokens from the left palette. Drag to move (Move tool); scroll to zoom, drag empty space (or middle/right-drag) to pan.
Use <strong>Reveal/Hide</strong> for fog, <strong>Measure</strong>/<strong>AoE</strong> for ranges, <strong>Draw</strong> for annotations, then Show to players.
</p>
{token && (
<Modal open onClose={() => setEditToken(null)} title="Token"
footer={<><Button variant="ghost" onClick={() => setEditToken(null)}>Done</Button><Button variant="danger" onClick={() => { removeToken(token.id); setEditToken(null); }}>Delete</Button></>}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="block text-xs text-muted">Label<Input value={token.label} onChange={(e) => patchToken(token.id, { label: e.target.value })} /></label>
<label className="block text-xs text-muted">Size<Select value={token.size} onChange={(e) => patchToken(token.id, { size: Number(e.target.value) })}>{[1, 2, 3, 4].map((s) => <option key={s} value={s}>{s}×{s}</option>)}</Select></label>
<label className="block text-xs text-muted">Kind<Select value={token.kind} onChange={(e) => patchToken(token.id, { kind: e.target.value as MapToken['kind'] })}>{['pc', 'npc', 'monster', 'object'].map((k) => <option key={k} value={k}>{k}</option>)}</Select></label>
<label className="block text-xs text-muted">Link character<Select value={token.characterId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { characterId: e.target.value } : { characterId: undefined })}><option value=""> none </option>{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
{activeEncounter && (
<label className="block text-xs text-muted">Link combatant<Select value={token.combatantId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { combatantId: e.target.value } : { combatantId: undefined })}><option value=""> none </option>{activeEncounter.combatants.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Icon</span>
{token.image ?? tokenLinkedChar?.portrait
? <img src={token.image ?? tokenLinkedChar?.portrait} alt="" className="h-10 w-10 rounded-full border border-line object-cover" />
: <span className="grid h-10 w-10 place-items-center rounded-full border border-line text-[10px] text-muted">none</span>}
<label className="cursor-pointer rounded-md border border-line px-2 py-1 text-xs text-ink hover:border-accent">
Upload
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onTokenIcon(token.id, f); e.target.value = ''; }} />
</label>
{token.image && <Button size="sm" variant="ghost" onClick={() => patchToken(token.id, { image: undefined })}>Clear</Button>}
{!token.image && tokenLinkedChar?.portrait && <span className="text-[10px] text-muted">using {tokenLinkedChar.name}'s portrait</span>}
</div>
<div className="flex flex-wrap items-center gap-1">
{TOKEN_COLORS.map((c) => <button key={c} aria-label={`color ${c}`} onClick={() => patchToken(token.id, { color: c })} className={cn('h-6 w-6 rounded-full border', token.color === c ? 'border-accent' : 'border-line')} style={{ background: c }} />)}
</div>
<label className="flex items-center gap-2 text-sm text-ink"><input type="checkbox" checked={token.gmOnly} onChange={(e) => patchToken(token.id, { gmOnly: e.target.checked })} /> Hidden from players (GM-only)</label>
{token.combatantId && activeEncounter?.combatants.some((c) => c.id === token.combatantId) && (() => {
const cb = activeEncounter.combatants.find((c) => c.id === token.combatantId)!;
return (
<div className="rounded-md border border-line bg-surface p-2">
<div className="mb-1 flex items-center justify-between text-xs"><span className="text-muted">Combat HP (syncs to the tracker)</span><span className="tabular-nums text-ink">{cb.hp.current}/{cb.hp.max}</span></div>
<div className="flex flex-wrap gap-1">
<Button size="sm" variant="danger" onClick={() => damageCombatant(token.combatantId!, -5)}>5</Button>
<Button size="sm" variant="secondary" onClick={() => damageCombatant(token.combatantId!, -1)}>1</Button>
<Button size="sm" variant="secondary" onClick={() => damageCombatant(token.combatantId!, 1)}>+1</Button>
<Button size="sm" variant="primary" onClick={() => damageCombatant(token.combatantId!, 5)}>+5</Button>
</div>
</div>
);
})()}
</div>
</Modal>
)}
</div>
);
}