| 1 | import { |
| 2 | createProjectFaviconCache, |
| 3 | createProjectFaviconImageLoader, |
| 4 | PROJECT_FAVICON_MAX_DATA_URL_LENGTH, |
| 5 | PROJECT_FAVICON_THUMBNAIL_SIZE, |
| 6 | } from "@t3tools/client-runtime/project-favicon-cache"; |
| 7 | |
| 8 | const DATABASE_NAME = "t3code:project-favicons"; |
| 9 | const DATABASE_VERSION = 2; |
| 10 | const STORE_NAME = "images"; |
| 11 | let database: Promise<IDBDatabase> | undefined; |
| 12 | |
| 13 | function openDatabase() { |
| 14 | return (database ??= new Promise<IDBDatabase>((resolve, reject) => { |
| 15 | const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); |
| 16 | request.addEventListener("upgradeneeded", () => { |
| 17 | for (const name of request.result.objectStoreNames) { |
| 18 | if (name !== STORE_NAME) request.result.deleteObjectStore(name); |
| 19 | } |
| 20 | if (!request.result.objectStoreNames.contains(STORE_NAME)) { |
| 21 | request.result.createObjectStore(STORE_NAME); |
| 22 | } |
| 23 | }); |
| 24 | request.addEventListener("success", () => resolve(request.result)); |
| 25 | request.addEventListener("error", () => reject(request.error)); |
| 26 | request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); |
| 27 | })); |
| 28 | } |
| 29 | |
| 30 | function completed(transaction: IDBTransaction) { |
| 31 | return new Promise<void>((resolve, reject) => { |
| 32 | transaction.addEventListener("complete", () => resolve()); |
| 33 | transaction.addEventListener("abort", () => reject(transaction.error)); |
| 34 | transaction.addEventListener("error", () => reject(transaction.error)); |
| 35 | }); |
| 36 | } |
| 37 | |
| 38 | async function withStore<A>( |
| 39 | mode: IDBTransactionMode, |
| 40 | use: (store: IDBObjectStore) => IDBRequest<A> | void, |
| 41 | ) { |
| 42 | const transaction = (await openDatabase()).transaction(STORE_NAME, mode); |
| 43 | const request = use(transaction.objectStore(STORE_NAME)); |
| 44 | await completed(transaction); |
| 45 | return request?.result; |
| 46 | } |
| 47 | |
| 48 | /** Rasterizes a bitmap that is too large to inline, retrying at half size. */ |
| 49 | async function downscaleProjectFavicon( |
| 50 | image: { readonly mimeType: string; readonly bytes: Uint8Array<ArrayBuffer> }, |
| 51 | signal: AbortSignal, |
| 52 | ) { |
| 53 | const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); |
| 54 | try { |
| 55 | signal.throwIfAborted(); |
| 56 | const canvas = document.createElement("canvas"); |
| 57 | for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { |
| 58 | const scale = Math.min(1, size / bitmap.width, size / bitmap.height); |
| 59 | canvas.width = Math.max(1, Math.round(bitmap.width * scale)); |
| 60 | canvas.height = Math.max(1, Math.round(bitmap.height * scale)); |
| 61 | const context = canvas.getContext("2d"); |
| 62 | if (!context) throw new Error("Canvas is unavailable."); |
| 63 | context.clearRect(0, 0, canvas.width, canvas.height); |
| 64 | context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); |
| 65 | const dataUrl = canvas.toDataURL("image/webp", 0.85); |
| 66 | if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; |
| 67 | } |
| 68 | throw new Error("Project icon thumbnail exceeds the cache limit."); |
| 69 | } finally { |
| 70 | bitmap.close(); |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | export const projectFaviconCache = createProjectFaviconCache({ |
| 75 | storage: { |
| 76 | list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], |
| 77 | put: async (key, entry) => { |
| 78 | await withStore("readwrite", (store) => store.put(entry, key)); |
| 79 | }, |
| 80 | remove: async (key) => { |
| 81 | await withStore("readwrite", (store) => store.delete(key)); |
| 82 | }, |
| 83 | }, |
| 84 | load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), |
| 85 | }); |