feat(ui): add BlurHashCanvas placeholder component

This commit is contained in:
Deluan 2026-07-23 20:45:04 -04:00
parent 9f2b78ab2f
commit 6ae855256c
4 changed files with 92 additions and 0 deletions

7
ui/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
"blurhash": "^2.0.5",
"clsx": "^2.1.1",
"connected-react-router": "^6.9.3",
"deepmerge": "^4.3.1",
@ -4431,6 +4432,12 @@
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==",
"license": "MIT"
},
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
"license": "MIT"
},
"node_modules/boxen": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz",

View File

@ -24,6 +24,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
"blurhash": "^2.0.5",
"clsx": "^2.1.1",
"connected-react-router": "^6.9.3",
"deepmerge": "^4.3.1",

View File

@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react'
import PropTypes from 'prop-types'
import { decode } from 'blurhash'
// A blurhash carries no detail beyond a few dozen pixels; CSS upscales the canvas.
const DECODE_SIZE = 32
export const BlurHashCanvas = ({ hash, className }) => {
const canvasRef = useRef(null)
useEffect(() => {
if (!hash || !canvasRef.current) {
return
}
try {
const pixels = decode(hash, DECODE_SIZE, DECODE_SIZE)
const ctx = canvasRef.current.getContext('2d')
if (!ctx) {
return
}
const imageData = ctx.createImageData(DECODE_SIZE, DECODE_SIZE)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
} catch {
// A malformed hash simply leaves the canvas transparent.
}
}, [hash])
if (!hash) {
return null
}
return (
<canvas
ref={canvasRef}
width={DECODE_SIZE}
height={DECODE_SIZE}
className={className}
aria-hidden="true"
/>
)
}
BlurHashCanvas.propTypes = {
hash: PropTypes.string,
className: PropTypes.string,
}

View File

@ -0,0 +1,38 @@
import { render } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { BlurHashCanvas } from './BlurHashCanvas'
describe('BlurHashCanvas', () => {
// jsdom has no real 2D context; stub it so getContext doesn't hit its noisy "not implemented" path.
let getContextSpy
beforeEach(() => {
getContextSpy = vi
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue({
createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
putImageData: () => {},
})
})
afterEach(() => {
getContextSpy.mockRestore()
})
it('renders nothing without a hash', () => {
const { container } = render(<BlurHashCanvas hash="" />)
expect(container.querySelector('canvas')).toBeNull()
})
it('renders a canvas for a valid hash', () => {
const { container } = render(
<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" />,
)
expect(container.querySelector('canvas')).not.toBeNull()
})
it('renders a canvas without throwing on a malformed hash', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { container } = render(<BlurHashCanvas hash="!!!not-a-blurhash!!!" />)
expect(container.querySelector('canvas')).not.toBeNull()
spy.mockRestore()
})
})