diff --git a/src/components/editor/components/blocks/image/ImageToolbar.tsx b/src/components/editor/components/blocks/image/ImageToolbar.tsx index 0884e60d8..1f745bab2 100644 --- a/src/components/editor/components/blocks/image/ImageToolbar.tsx +++ b/src/components/editor/components/blocks/image/ImageToolbar.tsx @@ -15,7 +15,8 @@ import ActionButton from '@/components/editor/components/toolbar/selection-toolb import Align from '@/components/editor/components/toolbar/selection-toolbar/actions/Align'; import { ImageBlockNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; -import { fetchImageBlob } from '@/utils/image'; +import { convertBlobToPng, fetchImageBlob } from '@/utils/image'; +import { Log } from '@/utils/log'; function ImageToolbar({ node }: { node: ImageBlockNode }) { const editor = useSlateStatic() as YjsEditor; @@ -28,10 +29,19 @@ function ImageToolbar({ node }: { node: ImageBlockNode }) { }; const onCopyImage = async () => { - const blob = await fetchImageBlob(node.data.url || ''); + let blob = await fetchImageBlob(node.data.url || ''); if (blob) { try { + // Browser clipboard API often only supports PNG for images + if (blob.type !== 'image/png') { + try { + blob = await convertBlobToPng(blob); + } catch (conversionError) { + Log.warn('Failed to convert image to PNG, trying original format', conversionError); + } + } + await navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob, @@ -39,8 +49,12 @@ function ImageToolbar({ node }: { node: ImageBlockNode }) { ]); notify.success(t('document.plugins.image.copiedToPasteBoard')); } catch (error) { - notify.error('Failed to copy image'); + Log.error("Failed to write to clipboard:", error); + notify.error('Failed to write image to clipboard'); } + } else { + Log.error("Failed to fetch image blob for copying"); + notify.error('Failed to download the image'); } }; diff --git a/src/utils/image.ts b/src/utils/image.ts index 953d0343b..d2efe8cad 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -7,119 +7,103 @@ const resolveImageUrl = (url: string): string => { return url.startsWith('http') ? url : `${getConfigValue('APPFLOWY_BASE_URL', '')}${url}`; }; + +interface CheckImageResult { + ok: boolean; + status: number; + statusText: string; + error?: string; + validatedUrl?: string; +} + // Helper function to check image using Image() approach -const checkImageWithImageElement = ( - imageUrl: string, - resolve: (data: { - ok: boolean, - status: number, - statusText: string, - error?: string, - validatedUrl?: string, - }) => void -) => { - const img = new Image(); - - // Set a timeout to handle very slow loads - const timeoutId = setTimeout(() => { - resolve({ - ok: false, - status: 408, - statusText: 'Request Timeout', - error: 'Image loading timed out', - }); - }, 10000); // 10 second timeout - - img.onload = () => { - clearTimeout(timeoutId); - resolve({ - ok: true, - status: 200, - statusText: 'OK', - validatedUrl: imageUrl, - }); - }; - - img.onerror = () => { - clearTimeout(timeoutId); - resolve({ - ok: false, - status: 404, - statusText: 'Image Not Found', - error: 'Failed to load image', - }); - }; - - img.src = imageUrl; -}; +const validateImageLoad = (imageUrl: string): Promise => { + return new Promise((resolve) => { + const img = new Image(); + + // Set a timeout to handle very slow loads + const timeoutId = setTimeout(() => { + resolve({ + ok: false, + status: 408, + statusText: 'Request Timeout', + error: 'Image loading timed out', + }); + }, 10000); // 10 second timeout + + img.onload = () => { + clearTimeout(timeoutId); + resolve({ + ok: true, + status: 200, + statusText: 'OK', + validatedUrl: imageUrl, + }); + }; + + img.onerror = () => { + clearTimeout(timeoutId); + resolve({ + ok: false, + status: 404, + statusText: 'Image Not Found', + error: 'Failed to load image', + }); + }; -export const checkImage = async (url: string) => { - return new Promise((resolve: (data: { - ok: boolean, - status: number, - statusText: string, - error?: string, - validatedUrl?: string, - }) => void) => { - // If it's an AppFlowy file storage URL, try authenticated fetch first - if (isAppFlowyFileStorageUrl(url)) { - const token = getTokenParsed(); - - if (!token) { - // Allow browser to load publicly-accessible URLs without authentication - // Fall through to Image() approach with resolved URL - const resolvedUrl = resolveImageUrl(url); - - checkImageWithImageElement(resolvedUrl, resolve); - return; - } + img.src = imageUrl; + }); +}; - const fullUrl = resolveImageUrl(url); +export const checkImage = async (url: string): Promise => { + // If it's an AppFlowy file storage URL, try authenticated fetch first + if (isAppFlowyFileStorageUrl(url)) { + const token = getTokenParsed(); + const fullUrl = resolveImageUrl(url); - fetch(fullUrl, { - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - }) - .then((response) => { - console.debug("fetchImageBlob response", response); - if (response.ok) { - // Convert to blob URL for use in img tag - return response.blob().then((blob) => { - const blobUrl = URL.createObjectURL(blob); - - resolve({ - ok: true, - status: 200, - statusText: 'OK', - validatedUrl: blobUrl, - }); - }); - } else { - console.error('Authenticated image fetch failed', response.status, response.statusText); - // If authenticated fetch fails, fall back to Image() approach - // This allows publicly-accessible URLs to still work - checkImageWithImageElement(fullUrl, resolve); - } - }) - .catch((error) => { - console.error('Failed to fetch authenticated image', error); - // If fetch throws an error (CORS, network, etc.), fall back to Image() approach - checkImageWithImageElement(fullUrl, resolve); + if (token) { + try { + const response = await fetch(fullUrl, { + headers: { + Authorization: `Bearer ${token.access_token}`, + }, }); - return; + + if (response.ok) { + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + return { + ok: true, + status: 200, + statusText: 'OK', + validatedUrl: blobUrl, + }; + } + + console.error('Authenticated image fetch failed', response.status, response.statusText); + } catch (error) { + console.error('Failed to fetch authenticated image', error); + } } - // For non-AppFlowy URLs, use the original Image() approach - checkImageWithImageElement(url, resolve); - }); + // Fallback for no token or failed fetch + return validateImageLoad(fullUrl); + } + + // For non-AppFlowy URLs, use the original Image() approach + return validateImageLoad(url); }; export const fetchImageBlob = async (url: string): Promise => { if (isAppFlowyFileStorageUrl(url)) { + console.debug("fetch appflowy image blob", url); const token = getTokenParsed(); - if (!token) return null; + if (!token) { + console.error('No authentication token available for image fetch'); + return null; + } const fullUrl = resolveImageUrl(url); @@ -130,8 +114,21 @@ export const fetchImageBlob = async (url: string): Promise => { }, }); + console.debug("fetch image blob response", response); + if (response.ok) { - return await response.blob(); + const blob = await response.blob(); + + // If the blob type is generic or missing, try to infer from URL + if ((!blob.type || blob.type === 'application/octet-stream') && url) { + const inferredType = getMimeTypeFromUrl(url); + + if (inferredType) { + return blob.slice(0, blob.size, inferredType); + } + } + + return blob; } } catch (error) { return null; @@ -141,7 +138,18 @@ export const fetchImageBlob = async (url: string): Promise => { const response = await fetch(url); if (response.ok) { - return await response.blob(); + const blob = await response.blob(); + + // If the blob type is generic or missing, try to infer from URL + if ((!blob.type || blob.type === 'application/octet-stream') && url) { + const inferredType = getMimeTypeFromUrl(url); + + if (inferredType) { + return blob.slice(0, blob.size, inferredType); + } + } + + return blob; } } catch (error) { return null; @@ -149,4 +157,69 @@ export const fetchImageBlob = async (url: string): Promise => { } return null; +}; + +export const convertBlobToPng = async (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(blob); + + img.onload = () => { + const canvas = document.createElement('canvas'); + + canvas.width = img.width; + canvas.height = img.height; + + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Failed to get canvas context')); + return; + } + + ctx.drawImage(img, 0, 0); + canvas.toBlob((pngBlob) => { + if (pngBlob) { + resolve(pngBlob); + } else { + reject(new Error('Failed to convert to PNG')); + } + + URL.revokeObjectURL(url); + }, 'image/png'); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image for conversion')); + }; + + img.src = url; + }); +}; + +const getMimeTypeFromUrl = (url: string): string | null => { + // Handle data URLs + if (url.startsWith('data:')) { + return url.split(';')[0].split(':')[1]; + } + + const cleanUrl = url.split('?')[0]; + const ext = cleanUrl.split('.').pop()?.toLowerCase(); + + switch (ext) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + case 'webp': + return 'image/webp'; + case 'svg': + return 'image/svg+xml'; + default: + return null; + } }; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 58084b2b3..094393a62 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -100,9 +100,9 @@ export default defineConfig({ }), isProd ? viteExternalsPlugin({ - react: 'React', - 'react-dom': 'ReactDOM', - }) + react: 'React', + 'react-dom': 'ReactDOM', + }) : undefined, svgr({ svgrOptions: { @@ -143,22 +143,22 @@ export default defineConfig({ // Enable istanbul for code coverage (active if isTest is true) isTest ? istanbul({ - cypress: true, - requireEnv: false, - include: ['src/**/*'], - exclude: ['**/__tests__/**/*', 'cypress/**/*', 'node_modules/**/*'], - }) + cypress: true, + requireEnv: false, + include: ['src/**/*'], + exclude: ['**/__tests__/**/*', 'cypress/**/*', 'node_modules/**/*'], + }) : undefined, process.env.ANALYZE_MODE ? visualizer({ - emitFile: true, - }) + emitFile: true, + }) : undefined, process.env.ANALYZE_MODE ? totalBundleSize({ - fileNameRegex: /\.(js|css)$/, - calculateGzip: false, - }) + fileNameRegex: /\.(js|css)$/, + calculateGzip: false, + }) : undefined, ], // prevent vite from obscuring rust errors @@ -180,7 +180,8 @@ export default defineConfig({ sourcemap: true, minifyIdentifiers: false, // Disable identifier minification in development minifySyntax: false, // Disable syntax minification in development - drop: !isDev ? ['console', 'debugger'] : [], + drop: !isDev ? ['debugger'] : [], + pure: !isDev ? ['console.log', 'console.debug'] : [], }, build: { target: `esnext`, @@ -214,7 +215,7 @@ export default defineConfig({ } }, }, - } + } : {}, }, resolve: {