Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/components/editor/components/blocks/image/ImageToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,19 +29,32 @@ 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,
}),
]);
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');
}
};

Expand Down
277 changes: 175 additions & 102 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckImageResult> => {
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<CheckImageResult> => {
// 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<Blob | null> => {
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);

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

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;
Expand All @@ -141,12 +138,88 @@ export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
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;
}
}

return null;
};

export const convertBlobToPng = async (blob: Blob): Promise<Blob> => {
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;
}
};
Loading
Loading