mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-30 03:18:02 +08:00
fix: image load failed (#60)
This commit is contained in:
@@ -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
20
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
126
src/components/editor/components/blocks/image/Img.tsx
Normal file
126
src/components/editor/components/blocks/image/Img.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@apply my-[4px];
|
||||
}
|
||||
|
||||
|
||||
.block-element {
|
||||
&:has(.embed-block) {
|
||||
//@apply mx-1;
|
||||
|
||||
50
src/utils/image.ts
Normal file
50
src/utils/image.ts
Normal 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;
|
||||
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user