Skip to content

Commit 682cb83

Browse files
authored
fix: copy image (#186)
* fix: copy image * fix: log
1 parent 6411f7b commit 682cb83

File tree

3 files changed

+208
-120
lines changed

3 files changed

+208
-120
lines changed

src/components/editor/components/blocks/image/ImageToolbar.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import ActionButton from '@/components/editor/components/toolbar/selection-toolb
1515
import Align from '@/components/editor/components/toolbar/selection-toolbar/actions/Align';
1616
import { ImageBlockNode } from '@/components/editor/editor.type';
1717
import { useEditorContext } from '@/components/editor/EditorContext';
18-
import { fetchImageBlob } from '@/utils/image';
18+
import { convertBlobToPng, fetchImageBlob } from '@/utils/image';
19+
import { Log } from '@/utils/log';
1920

2021
function ImageToolbar({ node }: { node: ImageBlockNode }) {
2122
const editor = useSlateStatic() as YjsEditor;
@@ -28,19 +29,32 @@ function ImageToolbar({ node }: { node: ImageBlockNode }) {
2829
};
2930

3031
const onCopyImage = async () => {
31-
const blob = await fetchImageBlob(node.data.url || '');
32+
let blob = await fetchImageBlob(node.data.url || '');
3233

3334
if (blob) {
3435
try {
36+
// Browser clipboard API often only supports PNG for images
37+
if (blob.type !== 'image/png') {
38+
try {
39+
blob = await convertBlobToPng(blob);
40+
} catch (conversionError) {
41+
Log.warn('Failed to convert image to PNG, trying original format', conversionError);
42+
}
43+
}
44+
3545
await navigator.clipboard.write([
3646
new ClipboardItem({
3747
[blob.type]: blob,
3848
}),
3949
]);
4050
notify.success(t('document.plugins.image.copiedToPasteBoard'));
4151
} catch (error) {
42-
notify.error('Failed to copy image');
52+
Log.error("Failed to write to clipboard:", error);
53+
notify.error('Failed to write image to clipboard');
4354
}
55+
} else {
56+
Log.error("Failed to fetch image blob for copying");
57+
notify.error('Failed to download the image');
4458
}
4559
};
4660

src/utils/image.ts

Lines changed: 175 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -7,119 +7,103 @@ const resolveImageUrl = (url: string): string => {
77
return url.startsWith('http') ? url : `${getConfigValue('APPFLOWY_BASE_URL', '')}${url}`;
88
};
99

10+
11+
interface CheckImageResult {
12+
ok: boolean;
13+
status: number;
14+
statusText: string;
15+
error?: string;
16+
validatedUrl?: string;
17+
}
18+
1019
// Helper function to check image using Image() approach
11-
const checkImageWithImageElement = (
12-
imageUrl: string,
13-
resolve: (data: {
14-
ok: boolean,
15-
status: number,
16-
statusText: string,
17-
error?: string,
18-
validatedUrl?: string,
19-
}) => void
20-
) => {
21-
const img = new Image();
22-
23-
// Set a timeout to handle very slow loads
24-
const timeoutId = setTimeout(() => {
25-
resolve({
26-
ok: false,
27-
status: 408,
28-
statusText: 'Request Timeout',
29-
error: 'Image loading timed out',
30-
});
31-
}, 10000); // 10 second timeout
32-
33-
img.onload = () => {
34-
clearTimeout(timeoutId);
35-
resolve({
36-
ok: true,
37-
status: 200,
38-
statusText: 'OK',
39-
validatedUrl: imageUrl,
40-
});
41-
};
42-
43-
img.onerror = () => {
44-
clearTimeout(timeoutId);
45-
resolve({
46-
ok: false,
47-
status: 404,
48-
statusText: 'Image Not Found',
49-
error: 'Failed to load image',
50-
});
51-
};
52-
53-
img.src = imageUrl;
54-
};
20+
const validateImageLoad = (imageUrl: string): Promise<CheckImageResult> => {
21+
return new Promise((resolve) => {
22+
const img = new Image();
23+
24+
// Set a timeout to handle very slow loads
25+
const timeoutId = setTimeout(() => {
26+
resolve({
27+
ok: false,
28+
status: 408,
29+
statusText: 'Request Timeout',
30+
error: 'Image loading timed out',
31+
});
32+
}, 10000); // 10 second timeout
33+
34+
img.onload = () => {
35+
clearTimeout(timeoutId);
36+
resolve({
37+
ok: true,
38+
status: 200,
39+
statusText: 'OK',
40+
validatedUrl: imageUrl,
41+
});
42+
};
43+
44+
img.onerror = () => {
45+
clearTimeout(timeoutId);
46+
resolve({
47+
ok: false,
48+
status: 404,
49+
statusText: 'Image Not Found',
50+
error: 'Failed to load image',
51+
});
52+
};
5553

56-
export const checkImage = async (url: string) => {
57-
return new Promise((resolve: (data: {
58-
ok: boolean,
59-
status: number,
60-
statusText: string,
61-
error?: string,
62-
validatedUrl?: string,
63-
}) => void) => {
64-
// If it's an AppFlowy file storage URL, try authenticated fetch first
65-
if (isAppFlowyFileStorageUrl(url)) {
66-
const token = getTokenParsed();
67-
68-
if (!token) {
69-
// Allow browser to load publicly-accessible URLs without authentication
70-
// Fall through to Image() approach with resolved URL
71-
const resolvedUrl = resolveImageUrl(url);
72-
73-
checkImageWithImageElement(resolvedUrl, resolve);
74-
return;
75-
}
54+
img.src = imageUrl;
55+
});
56+
};
7657

77-
const fullUrl = resolveImageUrl(url);
58+
export const checkImage = async (url: string): Promise<CheckImageResult> => {
59+
// If it's an AppFlowy file storage URL, try authenticated fetch first
60+
if (isAppFlowyFileStorageUrl(url)) {
61+
const token = getTokenParsed();
62+
const fullUrl = resolveImageUrl(url);
7863

79-
fetch(fullUrl, {
80-
headers: {
81-
Authorization: `Bearer ${token.access_token}`,
82-
},
83-
})
84-
.then((response) => {
85-
console.debug("fetchImageBlob response", response);
86-
if (response.ok) {
87-
// Convert to blob URL for use in img tag
88-
return response.blob().then((blob) => {
89-
const blobUrl = URL.createObjectURL(blob);
90-
91-
resolve({
92-
ok: true,
93-
status: 200,
94-
statusText: 'OK',
95-
validatedUrl: blobUrl,
96-
});
97-
});
98-
} else {
99-
console.error('Authenticated image fetch failed', response.status, response.statusText);
100-
// If authenticated fetch fails, fall back to Image() approach
101-
// This allows publicly-accessible URLs to still work
102-
checkImageWithImageElement(fullUrl, resolve);
103-
}
104-
})
105-
.catch((error) => {
106-
console.error('Failed to fetch authenticated image', error);
107-
// If fetch throws an error (CORS, network, etc.), fall back to Image() approach
108-
checkImageWithImageElement(fullUrl, resolve);
64+
if (token) {
65+
try {
66+
const response = await fetch(fullUrl, {
67+
headers: {
68+
Authorization: `Bearer ${token.access_token}`,
69+
},
10970
});
110-
return;
71+
72+
if (response.ok) {
73+
const blob = await response.blob();
74+
const blobUrl = URL.createObjectURL(blob);
75+
76+
return {
77+
ok: true,
78+
status: 200,
79+
statusText: 'OK',
80+
validatedUrl: blobUrl,
81+
};
82+
}
83+
84+
console.error('Authenticated image fetch failed', response.status, response.statusText);
85+
} catch (error) {
86+
console.error('Failed to fetch authenticated image', error);
87+
}
11188
}
11289

113-
// For non-AppFlowy URLs, use the original Image() approach
114-
checkImageWithImageElement(url, resolve);
115-
});
90+
// Fallback for no token or failed fetch
91+
return validateImageLoad(fullUrl);
92+
}
93+
94+
// For non-AppFlowy URLs, use the original Image() approach
95+
return validateImageLoad(url);
11696
};
11797

11898
export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
11999
if (isAppFlowyFileStorageUrl(url)) {
100+
console.debug("fetch appflowy image blob", url);
120101
const token = getTokenParsed();
121102

122-
if (!token) return null;
103+
if (!token) {
104+
console.error('No authentication token available for image fetch');
105+
return null;
106+
}
123107

124108
const fullUrl = resolveImageUrl(url);
125109

@@ -130,8 +114,21 @@ export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
130114
},
131115
});
132116

117+
console.debug("fetch image blob response", response);
118+
133119
if (response.ok) {
134-
return await response.blob();
120+
const blob = await response.blob();
121+
122+
// If the blob type is generic or missing, try to infer from URL
123+
if ((!blob.type || blob.type === 'application/octet-stream') && url) {
124+
const inferredType = getMimeTypeFromUrl(url);
125+
126+
if (inferredType) {
127+
return blob.slice(0, blob.size, inferredType);
128+
}
129+
}
130+
131+
return blob;
135132
}
136133
} catch (error) {
137134
return null;
@@ -141,12 +138,88 @@ export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
141138
const response = await fetch(url);
142139

143140
if (response.ok) {
144-
return await response.blob();
141+
const blob = await response.blob();
142+
143+
// If the blob type is generic or missing, try to infer from URL
144+
if ((!blob.type || blob.type === 'application/octet-stream') && url) {
145+
const inferredType = getMimeTypeFromUrl(url);
146+
147+
if (inferredType) {
148+
return blob.slice(0, blob.size, inferredType);
149+
}
150+
}
151+
152+
return blob;
145153
}
146154
} catch (error) {
147155
return null;
148156
}
149157
}
150158

151159
return null;
160+
};
161+
162+
export const convertBlobToPng = async (blob: Blob): Promise<Blob> => {
163+
return new Promise((resolve, reject) => {
164+
const img = new Image();
165+
const url = URL.createObjectURL(blob);
166+
167+
img.onload = () => {
168+
const canvas = document.createElement('canvas');
169+
170+
canvas.width = img.width;
171+
canvas.height = img.height;
172+
173+
const ctx = canvas.getContext('2d');
174+
175+
if (!ctx) {
176+
reject(new Error('Failed to get canvas context'));
177+
return;
178+
}
179+
180+
ctx.drawImage(img, 0, 0);
181+
canvas.toBlob((pngBlob) => {
182+
if (pngBlob) {
183+
resolve(pngBlob);
184+
} else {
185+
reject(new Error('Failed to convert to PNG'));
186+
}
187+
188+
URL.revokeObjectURL(url);
189+
}, 'image/png');
190+
};
191+
192+
img.onerror = () => {
193+
URL.revokeObjectURL(url);
194+
reject(new Error('Failed to load image for conversion'));
195+
};
196+
197+
img.src = url;
198+
});
199+
};
200+
201+
const getMimeTypeFromUrl = (url: string): string | null => {
202+
// Handle data URLs
203+
if (url.startsWith('data:')) {
204+
return url.split(';')[0].split(':')[1];
205+
}
206+
207+
const cleanUrl = url.split('?')[0];
208+
const ext = cleanUrl.split('.').pop()?.toLowerCase();
209+
210+
switch (ext) {
211+
case 'jpg':
212+
case 'jpeg':
213+
return 'image/jpeg';
214+
case 'png':
215+
return 'image/png';
216+
case 'gif':
217+
return 'image/gif';
218+
case 'webp':
219+
return 'image/webp';
220+
case 'svg':
221+
return 'image/svg+xml';
222+
default:
223+
return null;
224+
}
152225
};

0 commit comments

Comments
 (0)