Skip to content

Commit 1dc23f0

Browse files
authored
fix: image load failed (AppFlowy-IO#60)
1 parent 874d748 commit 1dc23f0

File tree

7 files changed

+206
-71
lines changed

7 files changed

+206
-71
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
"coverage": "pnpm run test:unit && pnpm run test:components"
1919
},
2020
"dependencies": {
21-
"@appflowyinc/ai-chat": "0.0.14",
22-
"@appflowyinc/editor": "^0.1.5",
21+
"@appflowyinc/ai-chat": "0.0.15",
22+
"@appflowyinc/editor": "^0.1.6",
2323
"@atlaskit/primitives": "^5.5.3",
2424
"@emoji-mart/data": "^1.1.2",
2525
"@emoji-mart/react": "^1.1.1",

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 16 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,16 @@ import { YjsEditor } from '@/application/slate-yjs';
22
import { CustomEditor } from '@/application/slate-yjs/command';
33
import ImageResizer from '@/components/editor/components/blocks/image/ImageResizer';
44
import ImageToolbar from '@/components/editor/components/blocks/image/ImageToolbar';
5+
import Img from '@/components/editor/components/blocks/image/Img';
56
import { ImageBlockNode } from '@/components/editor/editor.type';
67
import { debounce } from 'lodash-es';
78
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8-
import { useTranslation } from 'react-i18next';
9-
import { Skeleton } from '@mui/material';
10-
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
119
import { useReadOnly, useSlateStatic } from 'slate-react';
1210
import { Element } from 'slate';
1311

1412
const MIN_WIDTH = 100;
1513

1614
function ImageRender({
17-
selected,
1815
node,
1916
showToolbar,
2017
localUrl,
@@ -26,57 +23,19 @@ function ImageRender({
2623
}) {
2724
const editor = useSlateStatic() as YjsEditor;
2825
const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element);
29-
30-
const [loading, setLoading] = useState(true);
31-
const [hasError, setHasError] = useState(false);
32-
3326
const imgRef = useRef<HTMLImageElement>(null);
27+
const [rendered, setRendered] = useState(false);
28+
3429
const { width: imageWidth } = useMemo(() => node.data || {}, [node.data]);
3530
const url = node.data.url || localUrl;
36-
const { t } = useTranslation();
37-
const blockId = node.blockId;
3831
const [initialWidth, setInitialWidth] = useState<number | null>(null);
3932
const [newWidth, setNewWidth] = useState<number | null>(imageWidth ?? null);
4033

4134
useEffect(() => {
42-
if (!loading && !hasError && initialWidth === null && imgRef.current) {
35+
if(rendered && initialWidth === null && imgRef.current) {
4336
setInitialWidth(imgRef.current.offsetWidth);
4437
}
45-
}, [hasError, initialWidth, loading]);
46-
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
47-
return {
48-
style: {
49-
width: loading || hasError ? '0' : newWidth ?? '100%',
50-
opacity: selected ? 0.8 : 1,
51-
height: hasError ? 0 : 'auto',
52-
},
53-
className: 'object-cover',
54-
ref: imgRef,
55-
src: url,
56-
draggable: false,
57-
onLoad: () => {
58-
setHasError(false);
59-
setLoading(false);
60-
},
61-
onError: () => {
62-
setHasError(true);
63-
setLoading(false);
64-
},
65-
};
66-
}, [url, newWidth, loading, hasError, selected]);
67-
68-
const renderErrorNode = useCallback(() => {
69-
return (
70-
<div
71-
className={
72-
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
73-
}
74-
>
75-
<ErrorOutline className={'text-function-error'}/>
76-
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
77-
</div>
78-
);
79-
}, [t]);
38+
}, [initialWidth, rendered]);
8039

8140
const debounceSubmitWidth = useMemo(() => {
8241
return debounce((newWidth: number) => {
@@ -94,20 +53,24 @@ function ImageRender({
9453
[debounceSubmitWidth],
9554
);
9655

97-
if (!url) return null;
56+
if(!url) return null;
9857

9958
return (
10059
<div
10160
style={{
10261
minWidth: MIN_WIDTH,
103-
width: loading || hasError ? '100%' : 'fit-content',
10462
}}
105-
className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`}
63+
className={`image-render relative min-h-[48px] ${!rendered ? 'w-full' : 'w-fit'}`}
10664
>
107-
<img
108-
loading={'lazy'} {...imageProps}
109-
alt={`image-${blockId}`}
65+
<Img
66+
width={rendered ? (newWidth ?? '100%') : 0}
67+
imgRef={imgRef}
68+
url={url}
69+
onLoad={() => {
70+
setRendered(true);
71+
}}
11072
/>
73+
11174
{!readOnly && initialWidth && (
11275
<>
11376
<ImageResizer
@@ -123,12 +86,7 @@ function ImageRender({
12386
/>
12487
</>
12588
)}
126-
{showToolbar && <ImageToolbar node={node}/>}
127-
{hasError ? renderErrorNode() : loading ? <Skeleton
128-
variant="rounded"
129-
width={'100%'}
130-
height={200}
131-
/> : null}
89+
{showToolbar && <ImageToolbar node={node} />}
13290
</div>
13391
);
13492
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React, { useState, useCallback, useEffect } from 'react';
2+
import { checkImage } from '@/utils/image';
3+
import LoadingDots from '@/components/_shared/LoadingDots';
4+
import { useTranslation } from 'react-i18next';
5+
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
6+
7+
function Img({ onLoad, imgRef, url, width }: {
8+
url: string,
9+
imgRef?: React.RefObject<HTMLImageElement>,
10+
onLoad?: () => void;
11+
width: number | string;
12+
}) {
13+
const { t } = useTranslation();
14+
const [localUrl, setLocalUrl] = useState<string | null>(null);
15+
const [loading, setLoading] = useState(true);
16+
const [imgError, setImgError] = useState<{
17+
ok: boolean;
18+
status: number;
19+
statusText: string;
20+
} | null>(null);
21+
22+
const handleCheckImage = useCallback(async(url: string) => {
23+
setLoading(true);
24+
25+
// Configuration for polling
26+
const maxAttempts = 5; // Maximum number of polling attempts
27+
const pollingInterval = 6000; // Time between attempts in milliseconds (6 seconds)
28+
const timeoutDuration = 30000; // Maximum time to poll in milliseconds (30 seconds)
29+
30+
let attempts = 0;
31+
const startTime = Date.now();
32+
33+
const attemptCheck: () => Promise<boolean> = async() => {
34+
try {
35+
const result = await checkImage(url);
36+
37+
// Success case
38+
if(result.ok) {
39+
setImgError(null);
40+
setLoading(false);
41+
setLocalUrl(result.validatedUrl || url);
42+
setTimeout(() => {
43+
if(onLoad) {
44+
onLoad();
45+
}
46+
}, 500);
47+
48+
return true;
49+
}
50+
51+
// Error case but continue polling if within limits
52+
setImgError(result);
53+
54+
// Check if we've exceeded our timeout or max attempts
55+
attempts++;
56+
const elapsedTime = Date.now() - startTime;
57+
58+
if(attempts >= maxAttempts || elapsedTime >= timeoutDuration) {
59+
setLoading(false); // Stop loading after max attempts or timeout
60+
return false;
61+
}
62+
63+
await new Promise(resolve => setTimeout(resolve, pollingInterval));
64+
return await attemptCheck();
65+
// eslint-disable-next-line
66+
} catch(e) {
67+
setImgError({ ok: false, status: 404, statusText: 'Image Not Found' });
68+
// Check if we should stop trying
69+
attempts++;
70+
const elapsedTime = Date.now() - startTime;
71+
72+
if(attempts >= maxAttempts || elapsedTime >= timeoutDuration) {
73+
setLoading(false);
74+
return false;
75+
}
76+
77+
// Continue polling after interval
78+
await new Promise(resolve => setTimeout(resolve, pollingInterval));
79+
return await attemptCheck();
80+
}
81+
};
82+
83+
void attemptCheck();
84+
// eslint-disable-next-line
85+
}, []);
86+
87+
useEffect(() => {
88+
void handleCheckImage(url);
89+
}, [handleCheckImage, url]);
90+
91+
return (
92+
<>
93+
<img
94+
ref={imgRef}
95+
src={localUrl || url}
96+
alt={''}
97+
onLoad={() => {
98+
setLoading(false);
99+
setImgError(null);
100+
}}
101+
draggable={false}
102+
style={{
103+
visibility: imgError ? 'hidden' : 'visible',
104+
width,
105+
}}
106+
className={'object-cover h-full bg-cover bg-center'}
107+
/>
108+
{loading ? (
109+
<div className={'absolute bg-bg-body flex items-center inset-0 justify-center w-full h-full'}>
110+
<LoadingDots />
111+
</div>
112+
) : imgError ? (
113+
<div
114+
className={
115+
'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'
116+
}
117+
>
118+
<ErrorOutline className={'text-function-error'} />
119+
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
120+
</div>
121+
) : null}
122+
</>
123+
);
124+
}
125+
126+
export default Img;

src/components/editor/components/blocks/simple-table/simple-table.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
@apply border-r border-b border-line-divider overflow-hidden whitespace-pre-wrap;
2828
.cell-children {
2929
.block-element {
30-
@apply m-0;
30+
margin: 0 !important;
3131
}
3232
}
3333
}

src/components/editor/editor.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
@apply my-[4px];
55
}
66

7+
78
.block-element {
89
&:has(.embed-block) {
910
//@apply mx-1;

src/utils/image.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export const checkImage = async(url: string) => {
2+
return new Promise((resolve: (data: {
3+
ok: boolean,
4+
status: number,
5+
statusText: string,
6+
error?: string,
7+
validatedUrl?: string,
8+
}) => void) => {
9+
const img = new Image();
10+
11+
// Set a timeout to handle very slow loads
12+
const timeoutId = setTimeout(() => {
13+
resolve({
14+
ok: false,
15+
status: 408,
16+
statusText: 'Request Timeout',
17+
error: 'Image loading timed out',
18+
});
19+
}, 10000); // 10 second timeout
20+
21+
img.onload = () => {
22+
clearTimeout(timeoutId);
23+
// Add cache-busting parameter to prevent browser caching
24+
// which can sometimes hide image loading issues
25+
const cacheBuster = `?cb=${Date.now()}`;
26+
27+
resolve({
28+
ok: true,
29+
status: 200,
30+
statusText: 'OK',
31+
validatedUrl: url + cacheBuster,
32+
});
33+
};
34+
35+
img.onerror = () => {
36+
clearTimeout(timeoutId);
37+
resolve({
38+
ok: false,
39+
status: 404,
40+
statusText: 'Image Not Found',
41+
error: 'Failed to load image',
42+
});
43+
};
44+
45+
const cacheBuster = `?cb=${Date.now()}`;
46+
47+
img.src = url + cacheBuster;
48+
49+
});
50+
};

0 commit comments

Comments
 (0)