a tool for shared writing and social publishing
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};