Phase 2: character portraits → token icons (local + realtime)
- character.portrait + mapToken.image (data URLs, Dexie v10, additive). - src/lib/img/resize.ts: center-crop + downscale helper (160px tokens / 256px portraits) keeping IndexedDB + wire payloads small. - Portrait uploader on the character sheet; token icon uploader in the map token modal (falls back to the linked character's portrait). Palette PC entries show the portrait thumbnail. - Tokens render the image clipped to the circle (HP ring + condition badge on top). - Realtime: generalized the image channel to many images (map bg + token/portrait icons), deduped by content and resent on reconnect. Player tokens/party carry an imageId resolved from the session image cache; party panel shows portraits. - Also: fix the encounter-picker dropdown text being clipped (drop fixed height). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ export interface CanvasToken {
|
||||
row: number;
|
||||
size: number;
|
||||
kind: 'pc' | 'npc' | 'monster' | 'object';
|
||||
/** resolved icon data URL (token image or linked character portrait) */
|
||||
image?: string | undefined;
|
||||
hp?: { current: number; max: number; temp: number } | undefined;
|
||||
conditions: { name: string; value?: number | undefined }[];
|
||||
}
|
||||
@@ -412,6 +414,10 @@ function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{token.image && (
|
||||
<img src={token.image} alt="" draggable={false}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full rounded-full object-cover" />
|
||||
)}
|
||||
{frac !== null && (
|
||||
<svg className="pointer-events-none absolute inset-0" viewBox="0 0 36 36" aria-hidden>
|
||||
<circle cx="18" cy="18" r="17" fill="none" stroke="rgba(0,0,0,0.35)" strokeWidth="2" />
|
||||
@@ -419,7 +425,7 @@ function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick
|
||||
strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="z-10 truncate px-0.5">{token.label}</span>
|
||||
{!token.image && <span className="z-10 truncate px-0.5">{token.label}</span>}
|
||||
{token.conditions.length > 0 && (
|
||||
<span className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger text-[9px] text-white" title={token.conditions.map((c) => c.name).join(', ')}>
|
||||
{token.conditions.length}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
||||
import { mapsRepo } from '@/lib/db/repositories';
|
||||
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,
|
||||
@@ -57,9 +58,11 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
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,
|
||||
};
|
||||
@@ -156,7 +159,12 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
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 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>
|
||||
@@ -252,6 +260,18 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
<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>
|
||||
|
||||
@@ -55,7 +55,9 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
|
||||
{pcs.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button onClick={() => placePc(c)} className="flex w-full items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-accent">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR.pc }} />
|
||||
{c.portrait
|
||||
? <img src={c.portrait} alt="" className="h-5 w-5 shrink-0 rounded-full object-cover" />
|
||||
: <span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR.pc }} />}
|
||||
<span className="flex-1 truncate text-ink">{c.name}</span>
|
||||
<span className="text-[10px] uppercase text-muted">{hasPc(c.id) ? 'placed' : `L${c.level}`}</span>
|
||||
</button>
|
||||
@@ -77,7 +79,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
|
||||
})))}>+ All</Button>
|
||||
</div>
|
||||
{withCombatants.length > 1 ? (
|
||||
<Select className="mb-1 h-7 w-full text-xs" value={selectedEnc.id} onChange={(e) => setEncId(e.target.value)} aria-label="Encounter">
|
||||
<Select className="mb-1 w-full px-2 py-1 pr-7 text-xs leading-tight" value={selectedEnc.id} onChange={(e) => setEncId(e.target.value)} aria-label="Encounter">
|
||||
{withCombatants.map((e) => (
|
||||
<option key={e.id} value={e.id}>{e.status === 'active' ? '● ' : ''}{e.name} ({e.combatants.length})</option>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user