Phase 8: session tooling — handouts to players

- GM "📣 Handout" composer in the header (title + body + optional image) sets
  uiStore.activeHandout; a clear button hides it.
- snapshot gains an optional handout {title, body, imageId}; buildSnapshot streams
  the handout image over the existing image channel (id "handout").
- Player view (local + networked) shows the handout as a prominent card above the
  boards. resizeToMax() keeps handout images compact.
- (Live scene/map switching already works via "Show to players".)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:48:23 +02:00
parent f923375ebb
commit 7f1ab8f094
10 changed files with 125 additions and 6 deletions
+20
View File
@@ -16,6 +16,26 @@ function loadImage(src: string): Promise<HTMLImageElement> {
});
}
/** Downscale an image to fit within maxDim (preserving aspect), as a JPEG data URL. */
export async function resizeToMax(src: string, maxDim = 1024, quality = 0.85): Promise<string> {
try {
const img = await loadImage(src);
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return src;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, w, h);
return canvas.toDataURL('image/jpeg', quality);
} catch {
return src;
}
}
/** Natural pixel size of an image data URL. */
export function imageSize(src: string): Promise<{ w: number; h: number }> {
return loadImage(src).then((img) => ({ w: img.naturalWidth, h: img.naturalHeight }));
+7
View File
@@ -56,6 +56,12 @@ export const playerMapSchema = z.object({
tokens: z.array(playerTokenSchema), drawings: z.array(playerDrawingSchema), hasImage: z.boolean(),
});
export const handoutSchema = z.object({
title: z.string(),
body: z.string(),
imageId: z.string().nullable(),
});
export const snapshotSchema = z.object({
campaignName: z.string(),
calendarDay: int.nullable(),
@@ -63,6 +69,7 @@ export const snapshotSchema = z.object({
encounter: playerEncounterSchema.nullable(),
map: playerMapSchema.nullable(),
mapImageId: z.string().nullable(),
handout: handoutSchema.nullable().optional(),
});
export type Snapshot = z.infer<typeof snapshotSchema>;
+9 -1
View File
@@ -24,8 +24,9 @@ export function buildSnapshot(input: {
calendar: Calendar | null;
activeEncounterId: string | null;
activeMapId: string | null;
handout?: { title: string; body: string; image?: string } | null;
}): SnapshotResult {
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId } = input;
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, handout } = input;
const sys = getSystem(campaign.system);
const images: Record<string, string> = {};
const byId = new Map(characters.map((c) => [c.id, c]));
@@ -70,6 +71,12 @@ export function buildSnapshot(input: {
if (activeMap?.image) images[activeMap.id] = activeMap.image;
let handoutOut: { title: string; body: string; imageId: string | null } | null = null;
if (handout) {
if (handout.image) images['handout'] = handout.image;
handoutOut = { title: handout.title, body: handout.body, imageId: handout.image ? 'handout' : null };
}
return {
snapshot: {
campaignName: campaign.name,
@@ -78,6 +85,7 @@ export function buildSnapshot(input: {
encounter: activeEnc ? toPlayerEncounter(activeEnc) : null,
map: projection,
mapImageId: activeMap && activeMap.image ? activeMap.id : null,
handout: handoutOut,
},
images,
};