diff --git a/client/src/features/reading-history/__tests__/reading-history-lib.spec.ts b/client/src/features/reading-history/__tests__/reading-history-lib.spec.ts index 836279089..04b19a162 100644 --- a/client/src/features/reading-history/__tests__/reading-history-lib.spec.ts +++ b/client/src/features/reading-history/__tests__/reading-history-lib.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { ReadingHistoryItem } from '@bookorbit/types' import { filtersFromQuery, queryFromFilters } from '../composables/useReadingHistoryFilters' -import { exportFileName, toCsv, toMarkdown } from '../lib/history-export' +import { canvasToBlob, exportFileName, toCsv, toMarkdown } from '../lib/history-export' import { readSpanDays } from '../lib/history-format' import { historyRouteForChartClick } from '../lib/chart-month-link' import { fitTiles, withAlpha } from '../lib/share-poster' @@ -117,6 +117,24 @@ describe('reading history export', () => { expect(exportFileName('Q3 2026', 'md')).toBe('reading-history-q3-2026.md') expect(exportFileName('All time', 'csv')).toBe('reading-history-all-time.csv') }) + + it('waits for the canvas export callback when toBlob returns undefined', async () => { + const blob = new Blob(['poster'], { type: 'image/png' }) + const canvas = { + toBlob(callback: BlobCallback) { + queueMicrotask(() => callback(blob)) + }, + } as HTMLCanvasElement + + await expect(canvasToBlob(canvas, 'image/png')).resolves.toBe(blob) + }) + + it('rejects when the canvas is unavailable or produces no data', async () => { + const canvas = { toBlob: (callback: BlobCallback) => callback(null) } as HTMLCanvasElement + + await expect(canvasToBlob(null, 'image/png')).rejects.toThrow('Canvas is unavailable') + await expect(canvasToBlob(canvas, 'image/png')).rejects.toThrow('Canvas export returned no data') + }) }) describe('reading history helpers', () => { diff --git a/client/src/features/reading-history/components/ShareImageDialog.vue b/client/src/features/reading-history/components/ShareImageDialog.vue index 72793e096..ab0fafa97 100644 --- a/client/src/features/reading-history/components/ShareImageDialog.vue +++ b/client/src/features/reading-history/components/ShareImageDialog.vue @@ -13,7 +13,7 @@ import { useAuth } from '@/features/auth/composables/useAuth' import { useCoverVersions } from '@/features/book/composables/useCoverVersions' import { fetchReadingHistoryPage, fetchReadingHistorySummary } from '../api/reading-history.api' import { formatHours, formatMonthName } from '../lib/history-format' -import { saveBlob } from '../lib/history-export' +import { canvasToBlob, saveBlob } from '../lib/history-export' import { POSTER_SIZES, drawPoster, type PosterLook, type PosterShape, type PosterTile } from '../lib/share-poster' type SharePeriod = 'year' | 'quarter' | 'month' @@ -240,10 +240,7 @@ watch(books, (list) => { watch([books, shape, look, showTitles, showRereads, showRatings, imageVersion, canvas, loading], () => void nextTick(scheduleDraw)) function posterBlob(): Promise { - return new Promise( - (resolve, reject) => - canvas.value?.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('empty'))), 'image/png') ?? reject(new Error('no canvas')), - ) + return canvasToBlob(canvas.value, 'image/png') } function fileName() { diff --git a/client/src/features/reading-history/lib/history-export.ts b/client/src/features/reading-history/lib/history-export.ts index 3b04c8379..d60689923 100644 --- a/client/src/features/reading-history/lib/history-export.ts +++ b/client/src/features/reading-history/lib/history-export.ts @@ -123,3 +123,11 @@ export function saveBlob(blob: Blob, fileName: string) { link.remove() setTimeout(() => URL.revokeObjectURL(url), 30_000) } + +export function canvasToBlob(canvas: HTMLCanvasElement | null, type: string): Promise { + if (!canvas) return Promise.reject(new Error('Canvas is unavailable')) + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Canvas export returned no data'))), type) + }) +}