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 = { export type DetailImageProps = {
appRole?: UserRoles; appRole?: UserRoles;
src: string; src: string;
thumbnail?: string;
apiPath: string; apiPath: string;
refresh?: () => void; refresh?: () => void;
imageActions?: DetailImageButtonProps; imageActions?: DetailImageButtonProps;
@@ -465,7 +466,7 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
const expandImage = (event: any) => { const expandImage = (event: any) => {
cancelEvent(event); cancelEvent(event);
modals.open({ modals.open({
children: <ApiImage src={img} />, children: <ApiImage src={img} thumbnail={props.thumbnail} />,
withCloseButton: false withCloseButton: false
}); });
}; };
@@ -484,6 +485,7 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
<> <>
<ApiImage <ApiImage
src={img} src={img}
thumbnail={props.thumbnail}
mah={IMAGE_DIMENSION} mah={IMAGE_DIMENSION}
maw={IMAGE_DIMENSION} maw={IMAGE_DIMENSION}
onClick={expandImage} onClick={expandImage}
@@ -4,13 +4,14 @@
* Image caching is handled automagically by the browsers cache * Image caching is handled automagically by the browsers cache
*/ */
import { Image, type ImageProps, Skeleton, Stack } from '@mantine/core'; 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 { generateUrl } from '../../functions/urls';
import { useLocalState } from '../../states/LocalState'; import { useLocalState } from '../../states/LocalState';
interface ApiImageProps extends ImageProps { interface ApiImageProps extends ImageProps {
onClick?: (event: any) => void; onClick?: (event: any) => void;
thumbnail?: string;
} }
/** /**
@@ -19,14 +20,61 @@ interface ApiImageProps extends ImageProps {
export function ApiImage(props: Readonly<ApiImageProps>) { export function ApiImage(props: Readonly<ApiImageProps>) {
const { getHost } = useLocalState.getState(); const { getHost } = useLocalState.getState();
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const highResRef = useRef<HTMLImageElement>(null);
const imageUrl = useMemo(() => { const imageUrl = useMemo(() => {
return generateUrl(props.src, getHost()); return generateUrl(props.src, getHost());
}, [getHost, props.src]); }, [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 ( return (
<Stack> <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 ? ( {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} /> <Skeleton h={props?.h ?? props.w} w={props?.w ?? props.h} />
)} )}
@@ -17,6 +17,7 @@ interface PageDetailInterface {
badges?: ReactNode[]; badges?: ReactNode[];
breadcrumbs?: Breadcrumb[]; breadcrumbs?: Breadcrumb[];
lastCrumb?: Breadcrumb[]; lastCrumb?: Breadcrumb[];
thumbnailUrl?: string;
breadcrumbAction?: () => void; breadcrumbAction?: () => void;
actions?: ReactNode[]; actions?: ReactNode[];
editAction?: () => void; editAction?: () => void;
@@ -35,6 +36,7 @@ export function PageDetail({
subtitle, subtitle,
badges, badges,
imageUrl, imageUrl,
thumbnailUrl,
breadcrumbs, breadcrumbs,
lastCrumb: last_crumb, lastCrumb: last_crumb,
breadcrumbAction, breadcrumbAction,
@@ -108,6 +110,7 @@ export function PageDetail({
{imageUrl && ( {imageUrl && (
<ApiImage <ApiImage
src={imageUrl} src={imageUrl}
thumbnail={thumbnailUrl}
radius='sm' radius='sm'
miw={42} miw={42}
mah={42} mah={42}
@@ -714,6 +714,7 @@ export default function PartDetail() {
deleteFile: true deleteFile: true
}} }}
src={part.image} src={part.image}
thumbnail={part.thumbnail}
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)} apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
refresh={refreshInstance} refresh={refreshInstance}
pk={part.pk} pk={part.pk}
@@ -1225,6 +1226,7 @@ export default function PartDetail() {
} }
subtitle={part.description} subtitle={part.description}
imageUrl={part.image} imageUrl={part.image}
thumbnailUrl={part.thumbnail}
badges={badges} badges={badges}
breadcrumbs={ breadcrumbs={
user.hasViewRole(UserRoles.part_category) user.hasViewRole(UserRoles.part_category)