a tool for shared writing and social publishing
at feature/email 116 lines 3.5 kB view raw
1import { Replicache } from "replicache"; 2import { ReplicacheMutators } from "../replicache"; 3import { supabaseBrowserClient } from "supabase/browserClient"; 4import type { FilterAttributes } from "src/replicache/attributes"; 5import { rgbaToDataURL, rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 6import { v7 } from "uuid"; 7 8export const localImages = new Map<string, boolean>(); 9export async function addImage( 10 file: File, 11 rep: Replicache<ReplicacheMutators>, 12 args: { 13 entityID: string; 14 attribute: keyof FilterAttributes<{ type: "image" }>; 15 }, 16) { 17 let client = supabaseBrowserClient(); 18 let cache = await caches.open("minilink-user-assets"); 19 let fileID = v7(); 20 let url = client.storage.from("minilink-user-assets").getPublicUrl(fileID) 21 .data.publicUrl; 22 // Re-encode through canvas to bake EXIF orientation into pixel data. 23 // iPhone photos have EXIF rotation metadata that browsers respect, but 24 // Supabase's image transformation pipeline strips without applying. 25 let { blob: uploadBlob, width, height } = await normalizeOrientation(file); 26 27 await cache.put( 28 new URL(url + "?local"), 29 new Response(uploadBlob, { 30 headers: { 31 "Content-Type": uploadBlob.type, 32 "Content-Length": uploadBlob.size.toString(), 33 }, 34 }), 35 ); 36 localImages.set(url, true); 37 38 let thumbhash = await getThumbHash(file); 39 if (navigator.serviceWorker) 40 await rep.mutate.assertFact({ 41 entity: args.entityID, 42 attribute: "block/image", 43 data: { 44 fallback: thumbhash, 45 type: "image", 46 local: rep.clientID, 47 src: url, 48 height, 49 width, 50 }, 51 }); 52 await client.storage.from("minilink-user-assets").upload(fileID, uploadBlob, { 53 cacheControl: "public, max-age=31560000, immutable", 54 }); 55 await rep.mutate.assertFact({ 56 entity: args.entityID, 57 attribute: args.attribute, 58 data: { 59 fallback: thumbhash, 60 type: "image", 61 src: url, 62 height, 63 width, 64 }, 65 }); 66} 67 68async function getThumbHash(file: File) { 69 const arrayBuffer = await file.arrayBuffer(); 70 const blob = new Blob([arrayBuffer], { type: file.type }); 71 const imageBitmap = await createImageBitmap(blob); 72 73 const canvas = document.createElement("canvas"); 74 const context = canvas.getContext("2d") as CanvasRenderingContext2D; 75 const maxDimension = 100; 76 let width = imageBitmap.width; 77 let height = imageBitmap.height; 78 79 if (width > height) { 80 if (width > maxDimension) { 81 height *= maxDimension / width; 82 width = maxDimension; 83 } 84 } else { 85 if (height > maxDimension) { 86 width *= maxDimension / height; 87 height = maxDimension; 88 } 89 } 90 91 canvas.width = width; 92 canvas.height = height; 93 context.drawImage(imageBitmap, 0, 0, width, height); 94 95 const imageData = context.getImageData(0, 0, width, height); 96 const thumbHash = thumbHashToDataURL( 97 rgbaToThumbHash(imageData.width, imageData.height, imageData.data), 98 ); 99 return thumbHash; 100} 101 102async function normalizeOrientation( 103 file: File, 104): Promise<{ blob: Blob; width: number; height: number }> { 105 let bitmap = await createImageBitmap(file); 106 let canvas = document.createElement("canvas"); 107 canvas.width = bitmap.width; 108 canvas.height = bitmap.height; 109 let ctx = canvas.getContext("2d")!; 110 ctx.drawImage(bitmap, 0, 0); 111 bitmap.close(); 112 let blob = await new Promise<Blob>((resolve) => 113 canvas.toBlob((b) => resolve(b!), "image/webp", 0.92), 114 ); 115 return { blob, width: canvas.width, height: canvas.height }; 116}