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:
2026-06-08 14:42:06 +02:00
parent ca3769eb6b
commit 1aff63f29c
18 changed files with 239 additions and 42 deletions
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { coverCrop } from './resize';
describe('coverCrop', () => {
it('crops a landscape image to a centered square', () => {
expect(coverCrop(200, 100)).toEqual({ sx: 50, sy: 0, s: 100 });
});
it('crops a portrait image to a centered square', () => {
expect(coverCrop(100, 200)).toEqual({ sx: 0, sy: 50, s: 100 });
});
it('leaves a square image unchanged', () => {
expect(coverCrop(120, 120)).toEqual({ sx: 0, sy: 0, s: 120 });
});
it('guards degenerate sizes', () => {
expect(coverCrop(0, 0)).toEqual({ sx: 0, sy: 0, s: 0 });
});
});
+51
View File
@@ -0,0 +1,51 @@
/** Center-crop + downscale helpers for character portraits / token icons.
* Kept tiny so portraits stay cheap in IndexedDB and over the wire. */
/** Source rect for a centered square crop of a WxH image. */
export function coverCrop(w: number, h: number): { sx: number; sy: number; s: number } {
const s = Math.max(0, Math.min(w, h));
return { sx: (w - s) / 2, sy: (h - s) / 2, s };
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
/** Read a File as a data URL. */
export function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Load an image (data URL or object URL), center-crop to a square and downscale
* to `size`px, returning a compact JPEG data URL. Falls back to the source on
* any failure so callers never end up with nothing.
*/
export async function squareThumbnail(src: string, size = 160, quality = 0.82): Promise<string> {
try {
const img = await loadImage(src);
const { sx, sy, s } = coverCrop(img.naturalWidth, img.naturalHeight);
if (s <= 0) return src;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return src;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size);
return canvas.toDataURL('image/jpeg', quality);
} catch {
return src;
}
}