2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-28 13:54:25 +00:00

[UI] Smooth image loading (#11815)

* Smooth image loading

* Add "thumbnail" prop

* Hide thumbnail once loaded
This commit is contained in:
Oliver
2026-04-27 18:17:25 +10:00
committed by GitHub
parent 9763ce01ae
commit 27ebfeba1c
4 changed files with 58 additions and 3 deletions
@@ -42,6 +42,7 @@ import { StylishText } from '../items/StylishText';
export type DetailImageProps = {
appRole?: UserRoles;
src: string;
thumbnail?: string;
apiPath: string;
refresh?: () => void;
imageActions?: DetailImageButtonProps;
@@ -465,7 +466,7 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
const expandImage = (event: any) => {
cancelEvent(event);
modals.open({
children: <ApiImage src={img} />,
children: <ApiImage src={img} thumbnail={props.thumbnail} />,
withCloseButton: false
});
};
@@ -484,6 +485,7 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
<>
<ApiImage
src={img}
thumbnail={props.thumbnail}
mah={IMAGE_DIMENSION}
maw={IMAGE_DIMENSION}
onClick={expandImage}
@@ -4,13 +4,14 @@
* Image caching is handled automagically by the browsers cache
*/
import { Image, type ImageProps, Skeleton, Stack } from '@mantine/core';
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { generateUrl } from '../../functions/urls';
import { useLocalState } from '../../states/LocalState';
interface ApiImageProps extends ImageProps {
onClick?: (event: any) => void;
thumbnail?: string;
}
/**
@@ -19,14 +20,61 @@ interface ApiImageProps extends ImageProps {
export function ApiImage(props: Readonly<ApiImageProps>) {
const { getHost } = useLocalState.getState();
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const highResRef = useRef<HTMLImageElement>(null);
const imageUrl = useMemo(() => {
return generateUrl(props.src, getHost());
}, [getHost, props.src]);
const thumbnailUrl = useMemo(() => {
if (props.thumbnail) {
return generateUrl(props.thumbnail, getHost());
} else {
return null;
}
}, [getHost, props.thumbnail]);
// Hook for progressive loading of the high-res image
useEffect(() => {
setIsLoaded(false);
const img = new window.Image();
img.src = imageUrl;
img.onload = () => setIsLoaded(true);
return () => {
img.onload = null;
};
}, [imageUrl]);
return (
<Stack>
{thumbnailUrl && !isLoaded && (
<Image
{...props}
src={thumbnailUrl}
fit='contain'
style={{
position: 'absolute',
filter: 'blur(1px)',
transform: 'scale(0.95)',
opacity: isLoaded ? 0 : 1,
transition: 'opacity 0.2s ease'
}}
/>
)}
{imageUrl ? (
<Image {...props} src={imageUrl} fit='contain' />
<Image
ref={highResRef}
{...props}
src={imageUrl}
fit='contain'
style={{
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.2s ease'
}}
/>
) : (
<Skeleton h={props?.h ?? props.w} w={props?.w ?? props.h} />
)}
@@ -17,6 +17,7 @@ interface PageDetailInterface {
badges?: ReactNode[];
breadcrumbs?: Breadcrumb[];
lastCrumb?: Breadcrumb[];
thumbnailUrl?: string;
breadcrumbAction?: () => void;
actions?: ReactNode[];
editAction?: () => void;
@@ -35,6 +36,7 @@ export function PageDetail({
subtitle,
badges,
imageUrl,
thumbnailUrl,
breadcrumbs,
lastCrumb: last_crumb,
breadcrumbAction,
@@ -108,6 +110,7 @@ export function PageDetail({
{imageUrl && (
<ApiImage
src={imageUrl}
thumbnail={thumbnailUrl}
radius='sm'
miw={42}
mah={42}
@@ -714,6 +714,7 @@ export default function PartDetail() {
deleteFile: true
}}
src={part.image}
thumbnail={part.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance}
pk={part.pk}
@@ -1225,6 +1226,7 @@ export default function PartDetail() {
}
subtitle={part.description}
imageUrl={part.image}
thumbnailUrl={part.thumbnail}
badges={badges}
breadcrumbs={
user.hasViewRole(UserRoles.part_category)