import { PureComponent } from 'react'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import Box from '@mui/material/Box'; import ButtonBase from '@mui/material/ButtonBase'; import Typography from '@mui/material/Typography'; import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles'; import { formatPrice } from '../../utils/price'; import { articleSku } from '../article/articleRoute'; import { getThumbHashDataUrl, getCachedImage, isImageLoaded, markImageLoaded, } from '../article/ThumbHashImage'; import { BrandLabel } from '../brand'; import { thumb } from '../../api'; const MIN_EDGE_STRETCH = 10; function drawContainedWithStretchedEdges( ctx, source, srcW, srcH, dstW, dstH, minStretch = MIN_EDGE_STRETCH, ) { ctx.clearRect(0, 0, dstW, dstH); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; const stretch = Math.min(minStretch, Math.max(0, Math.floor(Math.min(dstW, dstH) / 4))); const innerW = dstW - stretch * 2; const innerH = dstH - stretch * 2; const scale = Math.min(innerW / srcW, innerH / srcH); const fitW = srcW * scale; const fitH = srcH * scale; const offsetX = (dstW - fitW) / 2; const offsetY = (dstH - fitH) / 2; const leftW = Math.ceil(offsetX); const rightX = Math.floor(offsetX + fitW); const rightW = dstW - rightX; const topH = Math.ceil(offsetY); const botY = Math.floor(offsetY + fitH); const botH = dstH - botY; // Edge fill: a strongly blurred layer, crossfaded smoothly into a lightly // blurred layer near the image (masked by a blurred rect, no visible seams). // Rects overdraw past the canvas and under the center so blur never fades // into transparency. const maxBlur = Math.max(2, stretch * 0.6); const drawEdges = (c, blur) => { const pad = Math.ceil(blur * 3); c.filter = `blur(${blur}px)`; // 1. Four corners (1x1 source border pixels stretched) c.drawImage(source, 0, 0, 1, 1, -pad, -pad, leftW + pad * 2, topH + pad * 2); c.drawImage(source, srcW - 1, 0, 1, 1, rightX - pad, -pad, rightW + pad * 2, topH + pad * 2); c.drawImage(source, 0, srcH - 1, 1, 1, -pad, botY - pad, leftW + pad * 2, botH + pad * 2); c.drawImage( source, srcW - 1, srcH - 1, 1, 1, rightX - pad, botY - pad, rightW + pad * 2, botH + pad * 2, ); // 2. Four edge bars (stretched 1px border rows/columns) c.drawImage(source, 0, 0, srcW, 1, offsetX, -pad, fitW, topH + pad * 2); c.drawImage(source, 0, srcH - 1, srcW, 1, offsetX, botY - pad, fitW, botH + pad * 2); c.drawImage(source, 0, 0, 1, srcH, -pad, offsetY, leftW + pad * 2, fitH); c.drawImage(source, srcW - 1, 0, 1, srcH, rightX - pad, offsetY, rightW + pad * 2, fitH); c.filter = 'none'; }; drawEdges(ctx, maxBlur); const light = document.createElement('canvas'); light.width = dstW; light.height = dstH; const lctx = light.getContext('2d'); if (lctx) { drawEdges(lctx, Math.max(0.5, maxBlur * 0.15)); // Mask: blurred rect around the image -> opaque near it, fading outward. const grow = maxBlur * 0.5; lctx.globalCompositeOperation = 'destination-in'; lctx.filter = `blur(${maxBlur}px)`; lctx.fillStyle = '#000'; lctx.fillRect( offsetX - grow, offsetY - grow, fitW + grow * 2, fitH + grow * 2, ); lctx.filter = 'none'; ctx.drawImage(light, 0, 0); } // 3. Center image (aspect ratio preserved / contained) ctx.drawImage(source, 0, 0, srcW, srcH, offsetX, offsetY, fitW, fitH); } class ArticleTileBackdrop extends PureComponent { canvasRef = null; img = null; observer = null; constructor(props) { super(props); this.thumbUrl = getThumbHashDataUrl(props.thumbHash); const fullHash = props.fullHash; if (fullHash) { const cached = getCachedImage(fullHash); if (cached) { this.img = cached; } else { const img = new Image(); img.onload = () => { markImageLoaded(fullHash, img); if (this.img === img) { this.paint(); } }; img.src = thumb.url(fullHash); this.img = img; if (img.complete && img.naturalWidth) { markImageLoaded(fullHash, img); } } } } 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(); } } componentWillUnmount() { this.observer?.disconnect(); this.img = null; } loadImage() { const { fullHash } = this.props; if (!fullHash) return; const cached = getCachedImage(fullHash); if (cached) { this.img = cached; this.paint(); return; } if (this.img && this.img.src === thumb.url(fullHash)) { if (this.img.complete && this.img.naturalWidth) { markImageLoaded(fullHash, this.img); this.paint(); } return; } const img = new Image(); img.onload = () => { markImageLoaded(fullHash, img); if (this.img === img) { this.paint(); } }; img.src = thumb.url(fullHash); this.img = img; if (img.complete && img.naturalWidth) { markImageLoaded(fullHash, img); this.paint(); } } setCanvas = (canvas) => { this.canvasRef = canvas; if (canvas && typeof ResizeObserver !== 'undefined') { this.observer?.disconnect(); this.observer = new ResizeObserver(() => this.paint()); this.observer.observe(canvas); } this.paint(); }; paint = () => { const canvas = this.canvasRef; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const dstW = Math.round(rect.width) || canvas.clientWidth || TILE_SIZE; const dstH = Math.round(rect.height) || canvas.clientHeight || TILE_SIZE; if (!dstW || !dstH) return; if (canvas.width !== dstW) canvas.width = dstW; if (canvas.height !== dstH) canvas.height = dstH; const ctx = canvas.getContext('2d'); if (!ctx) return; const img = (this.img && this.img.complete && this.img.naturalWidth) ? this.img : getCachedImage(this.props.fullHash); if (img && img.complete && img.naturalWidth && img.naturalHeight) { this.img = img; drawContainedWithStretchedEdges( ctx, img, img.naturalWidth, img.naturalHeight, dstW, dstH, ); } }; render() { const { fullHash } = this.props; const showThumb = !isImageLoaded(fullHash) && Boolean(this.thumbUrl); return ( {showThumb ? ( ) : null} ); } } export class ArticleTile extends Tile { hasPhoto() { const { article } = this.props; return article?.thumbHash != null || article?.fullHash != null; } renderIcon() { return ; } renderSecondary() { const { article } = this.props; if (!article) return null; const sku = articleSku(article); const brand = article.brand; return ( <> {brand?.name ? ( ) : null} {sku ? ( {sku} ) : null} {article.net != null && ( {formatPrice(article.net)} )} ); } renderExtra() { const { article } = this.props; if (!article) return null; const isParent = Boolean(article.isParent || article.is_parent); const childCount = Number(article.childCount) || 0; if (!isParent && childCount <= 0) return null; return ( {childCount} ); } render() { const hasPhoto = this.hasPhoto(); const { article, label, sx, disabled } = this.props; const sku = articleSku(article); return ( {hasPhoto ? ( ) : ( {this.renderIcon()} )} {this.renderExtra()} {article?.brand?.name ? ( ) : null} {label ? ( {label} ) : null} {sku ? ( {sku} ) : null} {article.net != null && ( {formatPrice(article.net)} )} ); } }