a tool for shared writing and social publishing

handle dropping images onto canvas

+247
+233
components/Blocks/useHandleCanvasDrop.ts
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache, useEntity } from "src/replicache"; 3 + import { useEntitySetContext } from "components/EntitySetProvider"; 4 + import { v7 } from "uuid"; 5 + import { supabaseBrowserClient } from "supabase/browserClient"; 6 + import { localImages } from "src/utils/addImage"; 7 + import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 8 + 9 + // Helper function to load image dimensions and thumbhash 10 + const 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 + 72 + export 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 + };
+14
components/Canvas.tsx
··· 14 14 import { TooltipButton } from "./Buttons"; 15 15 import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; 16 16 import { AddSmall } from "./Icons/AddSmall"; 17 + import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 17 18 18 19 export function Canvas(props: { entityID: string; preview?: boolean }) { 19 20 let entity_set = useEntitySetContext(); ··· 69 70 let { rep } = useReplicache(); 70 71 let entity_set = useEntitySetContext(); 71 72 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 73 + let handleDrop = useHandleCanvasDrop(props.entityID); 74 + 72 75 return ( 73 76 <div 74 77 onClick={async (e) => { ··· 106 109 ); 107 110 } 108 111 }} 112 + onDragOver={ 113 + !props.preview && entity_set.permissions.write 114 + ? (e) => { 115 + e.preventDefault(); 116 + e.stopPropagation(); 117 + } 118 + : undefined 119 + } 120 + onDrop={ 121 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 122 + } 109 123 style={{ 110 124 minHeight: height + 512, 111 125 contain: "size layout paint",