fix: image load failed (#60)

This commit is contained in:
Kilu.He
2025-03-06 19:56:21 +08:00
committed by GitHub
parent 8ac85ebab5
commit a3eed75fb3
7 changed files with 206 additions and 71 deletions

View File

@@ -18,8 +18,8 @@
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/ai-chat": "0.0.14",
"@appflowyinc/editor": "^0.1.5",
"@appflowyinc/ai-chat": "0.0.15",
"@appflowyinc/editor": "^0.1.6",
"@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",

20
pnpm-lock.yaml generated
View File

@@ -2,11 +2,11 @@ lockfileVersion: '6.0'
dependencies:
'@appflowyinc/ai-chat':
specifier: 0.0.14
version: 0.0.14(@appflowyinc/editor@0.1.5)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)
specifier: 0.0.15
version: 0.0.15(@appflowyinc/editor@0.1.6)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)
'@appflowyinc/editor':
specifier: ^0.1.5
version: 0.1.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5)
specifier: ^0.1.6
version: 0.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5)
'@atlaskit/primitives':
specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@@ -535,10 +535,10 @@ packages:
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
dev: false
/@appflowyinc/ai-chat@0.0.14(@appflowyinc/editor@0.1.5)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0):
resolution: {integrity: sha512-5OqcuISj1MwWO5g+/Chy1kIeSjbbskmDlEqjHGgFeWl7W3IvgzEVIM7elU7u8LL8ZGxzGF6rZgHvgoTRn3A6cw==}
/@appflowyinc/ai-chat@0.0.15(@appflowyinc/editor@0.1.6)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0):
resolution: {integrity: sha512-vpF73487ARBFrxO7BywRsCWDEKSTyhT5SgPyZetBRsyVwLju6iYtFTyf7N7FdLsl1mWZnJUBcvi7YWdWEqno6A==}
peerDependencies:
'@appflowyinc/editor': ^0.1.5
'@appflowyinc/editor': ^0.1.6
axios: ^1.7.9
dompurify: ^3.2.4
i18next: ^22.4.10
@@ -547,7 +547,7 @@ packages:
react-dom: ^18.2.0
react-i18next: ^14.1.0
dependencies:
'@appflowyinc/editor': 0.1.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5)
'@appflowyinc/editor': 0.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5)
'@jest/globals': 29.7.0
'@radix-ui/react-avatar': 1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-label': 2.1.1(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
@@ -586,8 +586,8 @@ packages:
- ts-node
dev: false
/@appflowyinc/editor@0.1.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5):
resolution: {integrity: sha512-BpTv1pQpgUqGkNM6KgtUSrTO0AYiTy0UHqUMG5VlAlXS/KDYrS3T1/IIkNaPT876lUvndxujBl5hSX4QXo0agA==}
/@appflowyinc/editor@0.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5):
resolution: {integrity: sha512-IR+SfRM1E5QWTl4Q83GZ+XXSEYH/27nxj5Dy5Zfv697D7qirHENggU4lJBUXqRBXHSoKWB5wDJunF/t9HY+E9w==}
peerDependencies:
i18next: ^22.4.10
i18next-resources-to-backend: ^1.2.1

View File

@@ -2,19 +2,16 @@ import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import ImageResizer from '@/components/editor/components/blocks/image/ImageResizer';
import ImageToolbar from '@/components/editor/components/blocks/image/ImageToolbar';
import Img from '@/components/editor/components/blocks/image/Img';
import { ImageBlockNode } from '@/components/editor/editor.type';
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Skeleton } from '@mui/material';
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
import { useReadOnly, useSlateStatic } from 'slate-react';
import { Element } from 'slate';
const MIN_WIDTH = 100;
function ImageRender({
selected,
node,
showToolbar,
localUrl,
@@ -26,57 +23,19 @@ function ImageRender({
}) {
const editor = useSlateStatic() as YjsEditor;
const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const [rendered, setRendered] = useState(false);
const { width: imageWidth } = useMemo(() => node.data || {}, [node.data]);
const url = node.data.url || localUrl;
const { t } = useTranslation();
const blockId = node.blockId;
const [initialWidth, setInitialWidth] = useState<number | null>(null);
const [newWidth, setNewWidth] = useState<number | null>(imageWidth ?? null);
useEffect(() => {
if (!loading && !hasError && initialWidth === null && imgRef.current) {
if(rendered && initialWidth === null && imgRef.current) {
setInitialWidth(imgRef.current.offsetWidth);
}
}, [hasError, initialWidth, loading]);
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
return {
style: {
width: loading || hasError ? '0' : newWidth ?? '100%',
opacity: selected ? 0.8 : 1,
height: hasError ? 0 : 'auto',
},
className: 'object-cover',
ref: imgRef,
src: url,
draggable: false,
onLoad: () => {
setHasError(false);
setLoading(false);
},
onError: () => {
setHasError(true);
setLoading(false);
},
};
}, [url, newWidth, loading, hasError, selected]);
const renderErrorNode = useCallback(() => {
return (
<div
className={
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
}
>
<ErrorOutline className={'text-function-error'}/>
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
</div>
);
}, [t]);
}, [initialWidth, rendered]);
const debounceSubmitWidth = useMemo(() => {
return debounce((newWidth: number) => {
@@ -94,20 +53,24 @@ function ImageRender({
[debounceSubmitWidth],
);
if (!url) return null;
if(!url) return null;
return (
<div
style={{
minWidth: MIN_WIDTH,
width: loading || hasError ? '100%' : 'fit-content',
}}
className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`}
className={`image-render relative min-h-[48px] ${!rendered ? 'w-full' : 'w-fit'}`}
>
<img
loading={'lazy'} {...imageProps}
alt={`image-${blockId}`}
<Img
width={rendered ? (newWidth ?? '100%') : 0}
imgRef={imgRef}
url={url}
onLoad={() => {
setRendered(true);
}}
/>
{!readOnly && initialWidth && (
<>
<ImageResizer
@@ -123,12 +86,7 @@ function ImageRender({
/>
</>
)}
{showToolbar && <ImageToolbar node={node}/>}
{hasError ? renderErrorNode() : loading ? <Skeleton
variant="rounded"
width={'100%'}
height={200}
/> : null}
{showToolbar && <ImageToolbar node={node} />}
</div>
);
}

View File

@@ -0,0 +1,126 @@
import React, { useState, useCallback, useEffect } from 'react';
import { checkImage } from '@/utils/image';
import LoadingDots from '@/components/_shared/LoadingDots';
import { useTranslation } from 'react-i18next';
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
function Img({ onLoad, imgRef, url, width }: {
url: string,
imgRef?: React.RefObject<HTMLImageElement>,
onLoad?: () => void;
width: number | string;
}) {
const { t } = useTranslation();
const [localUrl, setLocalUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [imgError, setImgError] = useState<{
ok: boolean;
status: number;
statusText: string;
} | null>(null);
const handleCheckImage = useCallback(async(url: string) => {
setLoading(true);
// Configuration for polling
const maxAttempts = 5; // Maximum number of polling attempts
const pollingInterval = 6000; // Time between attempts in milliseconds (6 seconds)
const timeoutDuration = 30000; // Maximum time to poll in milliseconds (30 seconds)
let attempts = 0;
const startTime = Date.now();
const attemptCheck: () => Promise<boolean> = async() => {
try {
const result = await checkImage(url);
// Success case
if(result.ok) {
setImgError(null);
setLoading(false);
setLocalUrl(result.validatedUrl || url);
setTimeout(() => {
if(onLoad) {
onLoad();
}
}, 500);
return true;
}
// Error case but continue polling if within limits
setImgError(result);
// Check if we've exceeded our timeout or max attempts
attempts++;
const elapsedTime = Date.now() - startTime;
if(attempts >= maxAttempts || elapsedTime >= timeoutDuration) {
setLoading(false); // Stop loading after max attempts or timeout
return false;
}
await new Promise(resolve => setTimeout(resolve, pollingInterval));
return await attemptCheck();
// eslint-disable-next-line
} catch(e) {
setImgError({ ok: false, status: 404, statusText: 'Image Not Found' });
// Check if we should stop trying
attempts++;
const elapsedTime = Date.now() - startTime;
if(attempts >= maxAttempts || elapsedTime >= timeoutDuration) {
setLoading(false);
return false;
}
// Continue polling after interval
await new Promise(resolve => setTimeout(resolve, pollingInterval));
return await attemptCheck();
}
};
void attemptCheck();
// eslint-disable-next-line
}, []);
useEffect(() => {
void handleCheckImage(url);
}, [handleCheckImage, url]);
return (
<>
<img
ref={imgRef}
src={localUrl || url}
alt={''}
onLoad={() => {
setLoading(false);
setImgError(null);
}}
draggable={false}
style={{
visibility: imgError ? 'hidden' : 'visible',
width,
}}
className={'object-cover h-full bg-cover bg-center'}
/>
{loading ? (
<div className={'absolute bg-bg-body flex items-center inset-0 justify-center w-full h-full'}>
<LoadingDots />
</div>
) : imgError ? (
<div
className={
'flex h-[48px] top-0 absolute bg-bg-body w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
}
>
<ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
</div>
) : null}
</>
);
}
export default Img;

View File

@@ -27,7 +27,7 @@
@apply border-r border-b border-line-divider overflow-hidden whitespace-pre-wrap;
.cell-children {
.block-element {
@apply m-0;
margin: 0 !important;
}
}
}

View File

@@ -4,6 +4,7 @@
@apply my-[4px];
}
.block-element {
&:has(.embed-block) {
//@apply mx-1;

50
src/utils/image.ts Normal file
View File

@@ -0,0 +1,50 @@
export const checkImage = async(url: string) => {
return new Promise((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);
// Add cache-busting parameter to prevent browser caching
// which can sometimes hide image loading issues
const cacheBuster = `?cb=${Date.now()}`;
resolve({
ok: true,
status: 200,
statusText: 'OK',
validatedUrl: url + cacheBuster,
});
};
img.onerror = () => {
clearTimeout(timeoutId);
resolve({
ok: false,
status: 404,
statusText: 'Image Not Found',
error: 'Failed to load image',
});
};
const cacheBuster = `?cb=${Date.now()}`;
img.src = url + cacheBuster;
});
};