a tool for shared writing and social publishing
at main 7.0 kB view raw
1import { useCallback } from "react"; 2import { useReplicache, useEntity } from "src/replicache"; 3import { useEntitySetContext } from "components/EntitySetProvider"; 4import { v7 } from "uuid"; 5import { supabaseBrowserClient } from "supabase/browserClient"; 6import { localImages } from "src/utils/addImage"; 7import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 8 9// Helper function to load image dimensions and thumbhash 10const processImage = async ( 11 file: File, 12): Promise<{ 13 width: number; 14 height: number; 15 thumbhash: string; 16}> => { 17 // Load image to get dimensions 18 const img = new Image(); 19 const url = URL.createObjectURL(file); 20 21 const dimensions = await new Promise<{ width: number; height: number }>( 22 (resolve, reject) => { 23 img.onload = () => { 24 resolve({ width: img.width, height: img.height }); 25 }; 26 img.onerror = reject; 27 img.src = url; 28 }, 29 ); 30 31 // Generate thumbhash 32 const arrayBuffer = await file.arrayBuffer(); 33 const blob = new Blob([arrayBuffer], { type: file.type }); 34 const imageBitmap = await createImageBitmap(blob); 35 36 const canvas = document.createElement("canvas"); 37 const context = canvas.getContext("2d") as CanvasRenderingContext2D; 38 const maxDimension = 100; 39 let width = imageBitmap.width; 40 let height = imageBitmap.height; 41 42 if (width > height) { 43 if (width > maxDimension) { 44 height *= maxDimension / width; 45 width = maxDimension; 46 } 47 } else { 48 if (height > maxDimension) { 49 width *= maxDimension / height; 50 height = maxDimension; 51 } 52 } 53 54 canvas.width = width; 55 canvas.height = height; 56 context.drawImage(imageBitmap, 0, 0, width, height); 57 58 const imageData = context.getImageData(0, 0, width, height); 59 const thumbhash = thumbHashToDataURL( 60 rgbaToThumbHash(imageData.width, imageData.height, imageData.data), 61 ); 62 63 URL.revokeObjectURL(url); 64 65 return { 66 width: dimensions.width, 67 height: dimensions.height, 68 thumbhash, 69 }; 70}; 71 72export const useHandleCanvasDrop = (entityID: string) => { 73 let { rep } = useReplicache(); 74 let entity_set = useEntitySetContext(); 75 let blocks = useEntity(entityID, "canvas/block"); 76 77 return useCallback( 78 async (e: React.DragEvent) => { 79 e.preventDefault(); 80 e.stopPropagation(); 81 82 if (!rep) return; 83 84 const files = e.dataTransfer.files; 85 if (!files || files.length === 0) return; 86 87 // Filter for image files only 88 const imageFiles = Array.from(files).filter((file) => 89 file.type.startsWith("image/"), 90 ); 91 92 if (imageFiles.length === 0) return; 93 94 const parentRect = e.currentTarget.getBoundingClientRect(); 95 const dropX = Math.max(e.clientX - parentRect.left, 0); 96 const dropY = Math.max(e.clientY - parentRect.top, 0); 97 98 const SPACING = 0; 99 const DEFAULT_WIDTH = 360; 100 101 // Process all images to get dimensions and thumbhashes 102 const processedImages = await Promise.all( 103 imageFiles.map((file) => processImage(file)), 104 ); 105 106 // Calculate grid dimensions based on image count 107 const COLUMNS = Math.ceil(Math.sqrt(imageFiles.length)); 108 109 // Calculate the width and height for each column and row 110 const colWidths: number[] = []; 111 const rowHeights: number[] = []; 112 113 for (let i = 0; i < imageFiles.length; i++) { 114 const col = i % COLUMNS; 115 const row = Math.floor(i / COLUMNS); 116 const dims = processedImages[i]; 117 118 // Scale image to fit within DEFAULT_WIDTH while maintaining aspect ratio 119 const scale = DEFAULT_WIDTH / dims.width; 120 const scaledWidth = DEFAULT_WIDTH; 121 const scaledHeight = dims.height * scale; 122 123 // Track max width for each column and max height for each row 124 colWidths[col] = Math.max(colWidths[col] || 0, scaledWidth); 125 rowHeights[row] = Math.max(rowHeights[row] || 0, scaledHeight); 126 } 127 128 const client = supabaseBrowserClient(); 129 const cache = await caches.open("minilink-user-assets"); 130 131 // Calculate positions and prepare data for all images 132 const imageBlocks = imageFiles.map((file, index) => { 133 const entity = v7(); 134 const fileID = v7(); 135 const row = Math.floor(index / COLUMNS); 136 const col = index % COLUMNS; 137 138 // Calculate x position by summing all previous column widths 139 let x = dropX; 140 for (let c = 0; c < col; c++) { 141 x += colWidths[c] + SPACING; 142 } 143 144 // Calculate y position by summing all previous row heights 145 let y = dropY; 146 for (let r = 0; r < row; r++) { 147 y += rowHeights[r] + SPACING; 148 } 149 150 const url = client.storage 151 .from("minilink-user-assets") 152 .getPublicUrl(fileID).data.publicUrl; 153 154 return { 155 file, 156 entity, 157 fileID, 158 url, 159 position: { x, y }, 160 dimensions: processedImages[index], 161 }; 162 }); 163 164 // Create all blocks with image facts 165 for (const block of imageBlocks) { 166 // Add to cache for immediate display 167 await cache.put( 168 new URL(block.url + "?local"), 169 new Response(block.file, { 170 headers: { 171 "Content-Type": block.file.type, 172 "Content-Length": block.file.size.toString(), 173 }, 174 }), 175 ); 176 localImages.set(block.url, true); 177 178 // Create canvas block 179 await rep.mutate.addCanvasBlock({ 180 newEntityID: block.entity, 181 parent: entityID, 182 position: block.position, 183 factID: v7(), 184 type: "image", 185 permission_set: entity_set.set, 186 }); 187 188 // Add image fact with local version for immediate display 189 if (navigator.serviceWorker) { 190 await rep.mutate.assertFact({ 191 entity: block.entity, 192 attribute: "block/image", 193 data: { 194 fallback: block.dimensions.thumbhash, 195 type: "image", 196 local: rep.clientID, 197 src: block.url, 198 height: block.dimensions.height, 199 width: block.dimensions.width, 200 }, 201 }); 202 } 203 } 204 205 // Upload all files to storage in parallel 206 await Promise.all( 207 imageBlocks.map(async (block) => { 208 await client.storage 209 .from("minilink-user-assets") 210 .upload(block.fileID, block.file, { 211 cacheControl: "public, max-age=31560000, immutable", 212 }); 213 214 // Update fact with final version 215 await rep.mutate.assertFact({ 216 entity: block.entity, 217 attribute: "block/image", 218 data: { 219 fallback: block.dimensions.thumbhash, 220 type: "image", 221 src: block.url, 222 height: block.dimensions.height, 223 width: block.dimensions.width, 224 }, 225 }); 226 }), 227 ); 228 229 return true; 230 }, 231 [rep, entityID, entity_set.set, blocks], 232 ); 233};