tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
handle dropping images onto canvas
awarm.space
4 months ago
d0502b58
c1ad6813
+247
2 changed files
expand all
collapse all
unified
split
components
Blocks
useHandleCanvasDrop.ts
Canvas.tsx
+233
components/Blocks/useHandleCanvasDrop.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
import { TooltipButton } from "./Buttons";
15
import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers";
16
import { AddSmall } from "./Icons/AddSmall";
0
17
18
export function Canvas(props: { entityID: string; preview?: boolean }) {
19
let entity_set = useEntitySetContext();
···
69
let { rep } = useReplicache();
70
let entity_set = useEntitySetContext();
71
let height = Math.max(...blocks.map((f) => f.data.position.y), 0);
0
0
72
return (
73
<div
74
onClick={async (e) => {
···
106
);
107
}
108
}}
0
0
0
0
0
0
0
0
0
0
0
109
style={{
110
minHeight: height + 512,
111
contain: "size layout paint",
···
14
import { TooltipButton } from "./Buttons";
15
import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers";
16
import { AddSmall } from "./Icons/AddSmall";
17
+
import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop";
18
19
export function Canvas(props: { entityID: string; preview?: boolean }) {
20
let entity_set = useEntitySetContext();
···
70
let { rep } = useReplicache();
71
let entity_set = useEntitySetContext();
72
let height = Math.max(...blocks.map((f) => f.data.position.y), 0);
73
+
let handleDrop = useHandleCanvasDrop(props.entityID);
74
+
75
return (
76
<div
77
onClick={async (e) => {
···
109
);
110
}
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
+
}
123
style={{
124
minHeight: height + 512,
125
contain: "size layout paint",