diff --git a/package.json b/package.json index 053b9d6b..03e96b55 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 233f0910..b06f45cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/editor/components/blocks/image/ImageRender.tsx b/src/components/editor/components/blocks/image/ImageRender.tsx index fed4d596..f7173aa7 100644 --- a/src/components/editor/components/blocks/image/ImageRender.tsx +++ b/src/components/editor/components/blocks/image/ImageRender.tsx @@ -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(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(null); const [newWidth, setNewWidth] = useState(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 = 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 ( -
- -
{t('editor.imageLoadFailed')}
-
- ); - }, [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 (
- {`image-${blockId}`} { + setRendered(true); + }} /> + {!readOnly && initialWidth && ( <> )} - {showToolbar && } - {hasError ? renderErrorNode() : loading ? : null} + {showToolbar && }
); } diff --git a/src/components/editor/components/blocks/image/Img.tsx b/src/components/editor/components/blocks/image/Img.tsx new file mode 100644 index 00000000..4f7d91af --- /dev/null +++ b/src/components/editor/components/blocks/image/Img.tsx @@ -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, + onLoad?: () => void; + width: number | string; +}) { + const { t } = useTranslation(); + const [localUrl, setLocalUrl] = useState(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 = 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 ( + <> + {''} { + setLoading(false); + setImgError(null); + }} + draggable={false} + style={{ + visibility: imgError ? 'hidden' : 'visible', + width, + }} + className={'object-cover h-full bg-cover bg-center'} + /> + {loading ? ( +
+ +
+ ) : imgError ? ( +
+ +
{t('editor.imageLoadFailed')}
+
+ ) : null} + + ); +} + +export default Img; \ No newline at end of file diff --git a/src/components/editor/components/blocks/simple-table/simple-table.scss b/src/components/editor/components/blocks/simple-table/simple-table.scss index c6b9d2db..0c924766 100644 --- a/src/components/editor/components/blocks/simple-table/simple-table.scss +++ b/src/components/editor/components/blocks/simple-table/simple-table.scss @@ -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; } } } diff --git a/src/components/editor/editor.scss b/src/components/editor/editor.scss index 7b2008c8..5223a7e5 100644 --- a/src/components/editor/editor.scss +++ b/src/components/editor/editor.scss @@ -4,6 +4,7 @@ @apply my-[4px]; } + .block-element { &:has(.embed-block) { //@apply mx-1; diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 00000000..abf4ee23 --- /dev/null +++ b/src/utils/image.ts @@ -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; + + }); +}; \ No newline at end of file