Show image loading placeholder only when visible
The MUI spinner impacts the cpu usage. While only a few of them don't matter, rendering multiple of them can impact the cpu usage. This can be a problem in the reader in case a chapter with a lot of chapters is rendered and a continuous reading mode is being used. In such a case the cpu usage stays increased until all pages have been loaded
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, forwardRef, ForwardedRef } from 'react';
|
import { useState, useEffect, forwardRef, ForwardedRef, useCallback, useRef } from 'react';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
@@ -19,6 +19,7 @@ import { SxProps, Theme } from '@mui/material/styles';
|
|||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
import { Priority } from '@/lib/Queue.ts';
|
import { Priority } from '@/lib/Queue.ts';
|
||||||
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
|
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
|
||||||
|
import { useIntersectionObserver } from '@/modules/core/hooks/useIntersectionObserver.tsx';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
shouldLoad?: boolean;
|
shouldLoad?: boolean;
|
||||||
@@ -42,170 +43,183 @@ interface IProps {
|
|||||||
retryKeyPrefix?: string;
|
retryKeyPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTMLImageElement | null>) => {
|
export const SpinnerImage = forwardRef(
|
||||||
const {
|
(props: IProps, imgRef: ForwardedRef<HTMLImageElement | HTMLDivElement | null>) => {
|
||||||
shouldLoad = true,
|
const {
|
||||||
shouldDecode,
|
shouldLoad = true,
|
||||||
useFetchApi,
|
|
||||||
disableCors,
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
onLoad,
|
|
||||||
onError,
|
|
||||||
spinnerStyle: { small, ...spinnerStyle } = {},
|
|
||||||
imgStyle,
|
|
||||||
hideImgStyle,
|
|
||||||
priority,
|
|
||||||
retryKeyPrefix,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const showMissingImageIcon = !src.length;
|
|
||||||
|
|
||||||
const [imageSourceUrl, setImageSourceUrl] = useState('');
|
|
||||||
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean | undefined>(undefined);
|
|
||||||
const [hasError, setHasError] = useState(false);
|
|
||||||
|
|
||||||
const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
|
|
||||||
setIsLoading(loading);
|
|
||||||
setHasError(error);
|
|
||||||
|
|
||||||
if (error && !loading && !aborted) {
|
|
||||||
onError?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loading && !error && !aborted) {
|
|
||||||
onLoad?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showMissingImageIcon || !shouldLoad) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageRequest = requestManager.requestImage(src, {
|
|
||||||
priority,
|
|
||||||
shouldDecode,
|
shouldDecode,
|
||||||
useFetchApi,
|
useFetchApi,
|
||||||
disableCors,
|
disableCors,
|
||||||
});
|
src,
|
||||||
let cacheTimeout: NodeJS.Timeout;
|
alt,
|
||||||
|
onLoad,
|
||||||
|
onError,
|
||||||
|
spinnerStyle: { small, ...spinnerStyle } = {},
|
||||||
|
imgStyle,
|
||||||
|
hideImgStyle,
|
||||||
|
priority,
|
||||||
|
retryKeyPrefix,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const fetchImage = async () => {
|
const { t } = useTranslation();
|
||||||
try {
|
|
||||||
const updateImage = async () => {
|
|
||||||
const image = await imageRequest.response;
|
|
||||||
|
|
||||||
updateImageState(false);
|
const loadingIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||||
setImageSourceUrl(image);
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkCache = await Promise.race([
|
const showMissingImageIcon = !src.length;
|
||||||
imageRequest.response,
|
|
||||||
new Promise((resolve) => {
|
|
||||||
cacheTimeout = setTimeout(resolve, 50);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const isImageCached = !!checkCache;
|
|
||||||
|
|
||||||
if (isImageCached) {
|
const [imageSourceUrl, setImageSourceUrl] = useState('');
|
||||||
await updateImage();
|
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
|
||||||
return;
|
const [isLoading, setIsLoading] = useState<boolean | undefined>(undefined);
|
||||||
}
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
updateImageState(true);
|
const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
|
||||||
await updateImage();
|
setIsLoading(loading);
|
||||||
} catch (e) {
|
setHasError(error);
|
||||||
const wasAborted =
|
|
||||||
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
|
if (error && !loading && !aborted) {
|
||||||
updateImageState(false, !wasAborted, wasAborted);
|
onError?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && !error && !aborted) {
|
||||||
|
onLoad?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchImage().catch(() => {});
|
useIntersectionObserver(
|
||||||
|
loadingIndicatorRef,
|
||||||
|
useCallback((entries) => setIsVisible(entries[0].isIntersecting), []),
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
imageRequest.cleanup();
|
if (showMissingImageIcon || !shouldLoad) {
|
||||||
clearTimeout(cacheTimeout);
|
return () => {};
|
||||||
imageRequest.abortRequest(new Error('Component was unmounted'));
|
}
|
||||||
};
|
|
||||||
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
|
|
||||||
|
|
||||||
return (
|
const imageRequest = requestManager.requestImage(src, {
|
||||||
<>
|
priority,
|
||||||
{showMissingImageIcon ? (
|
shouldDecode,
|
||||||
<Stack
|
useFetchApi,
|
||||||
ref={imgRef}
|
disableCors,
|
||||||
sx={{
|
});
|
||||||
height: '100%',
|
let cacheTimeout: NodeJS.Timeout;
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
const fetchImage = async () => {
|
||||||
background: (theme) => theme.palette.background.default,
|
try {
|
||||||
...spinnerStyle,
|
const updateImage = async () => {
|
||||||
}}
|
const image = await imageRequest.response;
|
||||||
>
|
|
||||||
<ImageIcon fontSize="large" />
|
updateImageState(false);
|
||||||
</Stack>
|
setImageSourceUrl(image);
|
||||||
) : (
|
};
|
||||||
<Box
|
|
||||||
component="img"
|
const checkCache = await Promise.race([
|
||||||
key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`}
|
imageRequest.response,
|
||||||
sx={[
|
new Promise((resolve) => {
|
||||||
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
|
cacheTimeout = setTimeout(resolve, 50);
|
||||||
applyStyles(!imageSourceUrl || isLoading || hasError, {
|
|
||||||
...hideImgStyle,
|
|
||||||
...applyStyles(!hideImgStyle, {
|
|
||||||
display: 'none',
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
]}
|
]);
|
||||||
ref={imgRef}
|
const isImageCached = !!checkCache;
|
||||||
crossOrigin={disableCors ? undefined : 'anonymous'}
|
|
||||||
src={imageSourceUrl}
|
|
||||||
alt={alt}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isLoading || (src && !imageSourceUrl) || hasError) && (
|
if (isImageCached) {
|
||||||
<Stack
|
await updateImage();
|
||||||
sx={{
|
return;
|
||||||
height: '100%',
|
}
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
updateImageState(true);
|
||||||
...spinnerStyle,
|
await updateImage();
|
||||||
}}
|
} catch (e) {
|
||||||
>
|
const wasAborted =
|
||||||
|
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
|
||||||
|
updateImageState(false, !wasAborted, wasAborted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchImage().catch(() => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
imageRequest.cleanup();
|
||||||
|
clearTimeout(cacheTimeout);
|
||||||
|
imageRequest.abortRequest(new Error('Component was unmounted'));
|
||||||
|
};
|
||||||
|
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showMissingImageIcon ? (
|
||||||
<Stack
|
<Stack
|
||||||
|
ref={imgRef}
|
||||||
sx={{
|
sx={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
background: (theme) => theme.palette.background.default,
|
||||||
|
...spinnerStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(isLoading || (src && !imageSourceUrl && !hasError)) && <CircularProgress thickness={5} />}
|
<ImageIcon fontSize="large" />
|
||||||
{hasError && isLoading === false && (
|
|
||||||
<>
|
|
||||||
<BrokenImageIcon />
|
|
||||||
<Button
|
|
||||||
startIcon={!small && <RefreshIcon />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
setImgLoadRetryKey((prevState) => (prevState + 1) % 100);
|
|
||||||
}}
|
|
||||||
size={small ? 'small' : 'large'}
|
|
||||||
>
|
|
||||||
{small ? <RefreshIcon /> : t('global.button.retry')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
) : (
|
||||||
)}
|
<Box
|
||||||
</>
|
component="img"
|
||||||
);
|
key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`}
|
||||||
});
|
sx={[
|
||||||
|
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
|
||||||
|
applyStyles(!imageSourceUrl || isLoading || hasError, {
|
||||||
|
...hideImgStyle,
|
||||||
|
...applyStyles(!hideImgStyle, {
|
||||||
|
display: 'none',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
ref={imgRef}
|
||||||
|
crossOrigin={disableCors ? undefined : 'anonymous'}
|
||||||
|
src={imageSourceUrl}
|
||||||
|
alt={alt}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isLoading || (src && !imageSourceUrl) || hasError) && (
|
||||||
|
<Stack
|
||||||
|
ref={loadingIndicatorRef}
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
...spinnerStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVisible && (isLoading || (src && !imageSourceUrl && !hasError)) && (
|
||||||
|
<CircularProgress thickness={5} />
|
||||||
|
)}
|
||||||
|
{hasError && isLoading === false && (
|
||||||
|
<>
|
||||||
|
<BrokenImageIcon />
|
||||||
|
<Button
|
||||||
|
startIcon={!small && <RefreshIcon />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setImgLoadRetryKey((prevState) => (prevState + 1) % 100);
|
||||||
|
}}
|
||||||
|
size={small ? 'small' : 'large'}
|
||||||
|
>
|
||||||
|
{small ? <RefreshIcon /> : t('global.button.retry')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user