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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 10 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 16 additions & 58 deletions src/components/editor/components/blocks/image/ImageRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => {
Expand All @@ -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
Expand All @@ -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>
);
}
Expand Down
126 changes: 126 additions & 0 deletions src/components/editor/components/blocks/image/Img.tsx
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/components/editor/editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
@apply my-[4px];
}


.block-element {
&:has(.embed-block) {
//@apply mx-1;
Expand Down
50 changes: 50 additions & 0 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
@@ -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;

});
};
Loading