u
This commit is contained in:
@@ -1,6 +1,48 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { thumbHashToRGBA } from 'thumbhash';
|
||||
import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
|
||||
|
||||
const dataUrlCache = (globalThis.__THUMB_DATA_URL_CACHE__ ??= new Map());
|
||||
const loadedImageHashes = (globalThis.__LOADED_IMAGE_HASHES__ ??= new Set());
|
||||
export const imageCache = (globalThis.__IMAGE_CACHE__ ??= new Map());
|
||||
|
||||
export function isImageLoaded(fullHash) {
|
||||
if (!fullHash) return false;
|
||||
return loadedImageHashes.has(fullHash) || imageCache.has(fullHash);
|
||||
}
|
||||
|
||||
export function markImageLoaded(fullHash, img) {
|
||||
if (!fullHash) return;
|
||||
loadedImageHashes.add(fullHash);
|
||||
if (img) {
|
||||
imageCache.set(fullHash, img);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedImage(fullHash) {
|
||||
if (!fullHash) return null;
|
||||
const img = imageCache.get(fullHash);
|
||||
if (img && img.complete && img.naturalWidth) {
|
||||
return img;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getThumbHashDataUrl(thumbHash) {
|
||||
if (!thumbHash || typeof thumbHash !== 'string') return null;
|
||||
let url = dataUrlCache.get(thumbHash);
|
||||
if (!url) {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
|
||||
url = thumbHashToDataURL(bytes);
|
||||
dataUrlCache.set(thumbHash, url);
|
||||
} catch (err) {
|
||||
console.warn('[ThumbHashImage] thumbHashToDataURL failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function decodeThumbHash(thumbHash) {
|
||||
if (!thumbHash || typeof thumbHash !== 'string') return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
|
||||
import { Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { articleSku } from '../article/articleRoute';
|
||||
import { decodeThumbHash } from '../article/ThumbHashImage';
|
||||
import { getThumbHashDataUrl, getCachedImage, imageCache } from '../article/ThumbHashImage';
|
||||
import { BrandLabel } from '../brand';
|
||||
import { thumb } from '../../api';
|
||||
|
||||
@@ -100,12 +100,22 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
img = null;
|
||||
observer = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
|
||||
this.img = getCachedImage(props.fullHash);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadImage();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.thumbHash !== this.props.thumbHash) {
|
||||
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
|
||||
}
|
||||
if (prevProps.fullHash !== this.props.fullHash) {
|
||||
this.img = getCachedImage(this.props.fullHash);
|
||||
this.loadImage();
|
||||
}
|
||||
}
|
||||
@@ -118,8 +128,15 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
loadImage() {
|
||||
const { fullHash } = this.props;
|
||||
if (fullHash) {
|
||||
const cached = getCachedImage(fullHash);
|
||||
if (cached) {
|
||||
this.img = cached;
|
||||
this.paint();
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageCache.set(fullHash, img);
|
||||
if (this.img === img) {
|
||||
this.paint();
|
||||
}
|
||||
@@ -127,15 +144,18 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
img.src = thumb.url(fullHash);
|
||||
this.img = img;
|
||||
if (img.complete && img.naturalWidth) {
|
||||
imageCache.set(fullHash, img);
|
||||
this.paint();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.paint();
|
||||
}
|
||||
|
||||
setCanvas = (canvas) => {
|
||||
this.canvasRef = canvas;
|
||||
if (!this.img && this.props.fullHash) {
|
||||
this.img = getCachedImage(this.props.fullHash);
|
||||
}
|
||||
if (canvas && typeof ResizeObserver !== 'undefined') {
|
||||
this.observer?.disconnect();
|
||||
this.observer = new ResizeObserver(() => this.paint());
|
||||
@@ -157,8 +177,9 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = this.img;
|
||||
const img = this.img || getCachedImage(this.props.fullHash);
|
||||
if (img && img.complete && img.naturalWidth && img.naturalHeight) {
|
||||
this.img = img;
|
||||
drawContainedWithStretchedEdges(
|
||||
ctx,
|
||||
img,
|
||||
@@ -167,41 +188,49 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
dstW,
|
||||
dstH,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { thumbHash } = this.props;
|
||||
if (thumbHash) {
|
||||
const decoded = decodeThumbHash(thumbHash);
|
||||
if (decoded) {
|
||||
const { w, h, rgba } = decoded;
|
||||
const temp = document.createElement('canvas');
|
||||
temp.width = w;
|
||||
temp.height = h;
|
||||
temp.getContext('2d').putImageData(
|
||||
new ImageData(new Uint8ClampedArray(rgba), w, h),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
drawContainedWithStretchedEdges(ctx, temp, w, h, dstW, dstH);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const hasCachedImage = Boolean(this.img && this.img.complete && this.img.naturalWidth);
|
||||
return (
|
||||
<Box
|
||||
component="canvas"
|
||||
ref={this.setCanvas}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{!hasCachedImage && this.thumbUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={this.thumbUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Box
|
||||
component="canvas"
|
||||
ref={this.setCanvas}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import ThumbHashImage from '../article/ThumbHashImage';
|
||||
import { getThumbHashDataUrl, getCachedImage, imageCache } from '../article/ThumbHashImage';
|
||||
import { thumb } from '../../api';
|
||||
import { tileBaseSx } from 'dashgui/tiles';
|
||||
|
||||
@@ -195,36 +195,56 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
|
||||
* ThumbHash is shown immediately as a placeholder; the thumb fades in once loaded.
|
||||
*/
|
||||
class CategoryBackdrop extends PureComponent {
|
||||
state = { thumbLoaded: false };
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
|
||||
this.isCached = Boolean(getCachedImage(props.fullHash));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.thumbHash !== this.props.thumbHash) {
|
||||
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
|
||||
}
|
||||
if (prevProps.fullHash !== this.props.fullHash) {
|
||||
this.setState({ thumbLoaded: false });
|
||||
this.isCached = Boolean(getCachedImage(this.props.fullHash));
|
||||
this.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
handleThumbLoad = () => {
|
||||
this.setState({ thumbLoaded: true });
|
||||
};
|
||||
|
||||
handleThumbError = () => {
|
||||
this.setState({ thumbLoaded: false });
|
||||
handleLoad = (e) => {
|
||||
if (this.props.fullHash && e.target) {
|
||||
imageCache.set(this.props.fullHash, e.target);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { thumbHash, fullHash } = this.props;
|
||||
const { thumbLoaded } = this.state;
|
||||
if (!thumbHash && !fullHash) return null;
|
||||
const { fullHash } = this.props;
|
||||
if (!this.thumbUrl && !fullHash) return null;
|
||||
const isCached = this.isCached || Boolean(getCachedImage(fullHash));
|
||||
return (
|
||||
<Box sx={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden' }}>
|
||||
{thumbHash && !thumbLoaded ? <ThumbHashImage thumbHash={thumbHash} /> : null}
|
||||
{fullHash ? (
|
||||
{!isCached && this.thumbUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={thumb.url(fullHash)}
|
||||
src={this.thumbUrl}
|
||||
alt=""
|
||||
onLoad={this.handleThumbLoad}
|
||||
onError={this.handleThumbError}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{fullHash ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={thumb.url(fullHash)}
|
||||
alt=""
|
||||
onLoad={this.handleLoad}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -232,7 +252,6 @@ class CategoryBackdrop extends PureComponent {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user