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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user