mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-12-01 03:47:55 +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"
|
"coverage": "pnpm run test:unit && pnpm run test:components"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@appflowyinc/ai-chat": "0.0.14",
|
"@appflowyinc/ai-chat": "0.0.15",
|
||||||
"@appflowyinc/editor": "^0.1.5",
|
"@appflowyinc/editor": "^0.1.6",
|
||||||
"@atlaskit/primitives": "^5.5.3",
|
"@atlaskit/primitives": "^5.5.3",
|
||||||
"@emoji-mart/data": "^1.1.2",
|
"@emoji-mart/data": "^1.1.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
|||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -2,11 +2,11 @@ lockfileVersion: '6.0'
|
|||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@appflowyinc/ai-chat':
|
'@appflowyinc/ai-chat':
|
||||||
specifier: 0.0.14
|
specifier: 0.0.15
|
||||||
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)
|
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':
|
'@appflowyinc/editor':
|
||||||
specifier: ^0.1.5
|
specifier: ^0.1.6
|
||||||
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)
|
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':
|
'@atlaskit/primitives':
|
||||||
specifier: ^5.5.3
|
specifier: ^5.5.3
|
||||||
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
|
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==}
|
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
|
||||||
dev: false
|
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):
|
/@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-5OqcuISj1MwWO5g+/Chy1kIeSjbbskmDlEqjHGgFeWl7W3IvgzEVIM7elU7u8LL8ZGxzGF6rZgHvgoTRn3A6cw==}
|
resolution: {integrity: sha512-vpF73487ARBFrxO7BywRsCWDEKSTyhT5SgPyZetBRsyVwLju6iYtFTyf7N7FdLsl1mWZnJUBcvi7YWdWEqno6A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@appflowyinc/editor': ^0.1.5
|
'@appflowyinc/editor': ^0.1.6
|
||||||
axios: ^1.7.9
|
axios: ^1.7.9
|
||||||
dompurify: ^3.2.4
|
dompurify: ^3.2.4
|
||||||
i18next: ^22.4.10
|
i18next: ^22.4.10
|
||||||
@@ -547,7 +547,7 @@ packages:
|
|||||||
react-dom: ^18.2.0
|
react-dom: ^18.2.0
|
||||||
react-i18next: ^14.1.0
|
react-i18next: ^14.1.0
|
||||||
dependencies:
|
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
|
'@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-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)
|
'@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
|
- ts-node
|
||||||
dev: false
|
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):
|
/@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-BpTv1pQpgUqGkNM6KgtUSrTO0AYiTy0UHqUMG5VlAlXS/KDYrS3T1/IIkNaPT876lUvndxujBl5hSX4QXo0agA==}
|
resolution: {integrity: sha512-IR+SfRM1E5QWTl4Q83GZ+XXSEYH/27nxj5Dy5Zfv697D7qirHENggU4lJBUXqRBXHSoKWB5wDJunF/t9HY+E9w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
i18next: ^22.4.10
|
i18next: ^22.4.10
|
||||||
i18next-resources-to-backend: ^1.2.1
|
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 { CustomEditor } from '@/application/slate-yjs/command';
|
||||||
import ImageResizer from '@/components/editor/components/blocks/image/ImageResizer';
|
import ImageResizer from '@/components/editor/components/blocks/image/ImageResizer';
|
||||||
import ImageToolbar from '@/components/editor/components/blocks/image/ImageToolbar';
|
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 { ImageBlockNode } from '@/components/editor/editor.type';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { useReadOnly, useSlateStatic } from 'slate-react';
|
||||||
import { Element } from 'slate';
|
import { Element } from 'slate';
|
||||||
|
|
||||||
const MIN_WIDTH = 100;
|
const MIN_WIDTH = 100;
|
||||||
|
|
||||||
function ImageRender({
|
function ImageRender({
|
||||||
selected,
|
|
||||||
node,
|
node,
|
||||||
showToolbar,
|
showToolbar,
|
||||||
localUrl,
|
localUrl,
|
||||||
@@ -26,57 +23,19 @@ function ImageRender({
|
|||||||
}) {
|
}) {
|
||||||
const editor = useSlateStatic() as YjsEditor;
|
const editor = useSlateStatic() as YjsEditor;
|
||||||
const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element);
|
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 imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const [rendered, setRendered] = useState(false);
|
||||||
|
|
||||||
const { width: imageWidth } = useMemo(() => node.data || {}, [node.data]);
|
const { width: imageWidth } = useMemo(() => node.data || {}, [node.data]);
|
||||||
const url = node.data.url || localUrl;
|
const url = node.data.url || localUrl;
|
||||||
const { t } = useTranslation();
|
|
||||||
const blockId = node.blockId;
|
|
||||||
const [initialWidth, setInitialWidth] = useState<number | null>(null);
|
const [initialWidth, setInitialWidth] = useState<number | null>(null);
|
||||||
const [newWidth, setNewWidth] = useState<number | null>(imageWidth ?? null);
|
const [newWidth, setNewWidth] = useState<number | null>(imageWidth ?? null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !hasError && initialWidth === null && imgRef.current) {
|
if(rendered && initialWidth === null && imgRef.current) {
|
||||||
setInitialWidth(imgRef.current.offsetWidth);
|
setInitialWidth(imgRef.current.offsetWidth);
|
||||||
}
|
}
|
||||||
}, [hasError, initialWidth, loading]);
|
}, [initialWidth, rendered]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const debounceSubmitWidth = useMemo(() => {
|
const debounceSubmitWidth = useMemo(() => {
|
||||||
return debounce((newWidth: number) => {
|
return debounce((newWidth: number) => {
|
||||||
@@ -100,14 +59,18 @@ function ImageRender({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minWidth: MIN_WIDTH,
|
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
|
<Img
|
||||||
loading={'lazy'} {...imageProps}
|
width={rendered ? (newWidth ?? '100%') : 0}
|
||||||
alt={`image-${blockId}`}
|
imgRef={imgRef}
|
||||||
|
url={url}
|
||||||
|
onLoad={() => {
|
||||||
|
setRendered(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!readOnly && initialWidth && (
|
{!readOnly && initialWidth && (
|
||||||
<>
|
<>
|
||||||
<ImageResizer
|
<ImageResizer
|
||||||
@@ -124,11 +87,6 @@ function ImageRender({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showToolbar && <ImageToolbar node={node} />}
|
{showToolbar && <ImageToolbar node={node} />}
|
||||||
{hasError ? renderErrorNode() : loading ? <Skeleton
|
|
||||||
variant="rounded"
|
|
||||||
width={'100%'}
|
|
||||||
height={200}
|
|
||||||
/> : null}
|
|
||||||
</div>
|
</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;
|
@apply border-r border-b border-line-divider overflow-hidden whitespace-pre-wrap;
|
||||||
.cell-children {
|
.cell-children {
|
||||||
.block-element {
|
.block-element {
|
||||||
@apply m-0;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@apply my-[4px];
|
@apply my-[4px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.block-element {
|
.block-element {
|
||||||
&:has(.embed-block) {
|
&:has(.embed-block) {
|
||||||
//@apply mx-1;
|
//@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