feat: blurhash placeholder while image loads

- Add BlurhashCanvas component (decodes hash → canvas pixel data)
- DrawCanvas: expose onImageLoad callback prop
- DrawIt + GenerateIt: show blurhash layer until real image is ready,
  reset imageLoaded state on picture navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 08:09:09 +02:00
parent 7c983a7460
commit f4b082329e
6 changed files with 89 additions and 4 deletions

View File

@@ -8,6 +8,7 @@
"name": "content-mentor-frontend", "name": "content-mentor-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"blurhash": "^2.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0" "react-router-dom": "^6.26.0"
@@ -1223,6 +1224,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"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/browserslist": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"blurhash": "^2.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0" "react-router-dom": "^6.26.0"

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react'
import { decode } from 'blurhash'
interface Props {
hash: string
width?: number
height?: number
style?: React.CSSProperties
}
/**
* Rendert einen Blurhash-Hash als Canvas-Platzhalter.
* Wird als absolut positionierter Layer unter dem echten Bild gelegt
* und verschwindet sobald das echte Bild geladen ist.
*/
export default function BlurhashCanvas({ hash, width = 32, height = 32, style }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !hash) return
try {
const pixels = decode(hash, width, height)
const ctx = canvas.getContext('2d')
if (!ctx) return
const imageData = ctx.createImageData(width, height)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
} catch {
// Ungültiger Hash → kein Crash
}
}, [hash, width, height])
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
imageRendering: 'auto',
...style,
}}
/>
)
}

View File

@@ -22,11 +22,12 @@ interface Props {
selectedObjectId: string | null selectedObjectId: string | null
mode: 'rect' | 'polygon' mode: 'rect' | 'polygon'
onHasSelection: (has: boolean) => void onHasSelection: (has: boolean) => void
onImageLoad?: () => void
readOnly?: boolean readOnly?: boolean
} }
export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas( export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
{ imageSrc, objects, selectedObjectId, mode, onHasSelection, readOnly = false }, { imageSrc, objects, selectedObjectId, mode, onHasSelection, onImageLoad, readOnly = false },
ref ref
) { ) {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -278,6 +279,7 @@ export default forwardRef<DrawCanvasHandle, Props>(function DrawCanvas(
img.onload = () => { img.onload = () => {
imageCache.set(imageSrc, img) imageCache.set(imageSrc, img)
applyImage(img) applyImage(img)
onImageLoad?.()
} }
img.onerror = () => console.error('Fehler beim Laden des Bildes:', imageSrc) img.onerror = () => console.error('Fehler beim Laden des Bildes:', imageSrc)
img.src = imageSrc img.src = imageSrc

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas' import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import BlurhashCanvas from '../components/BlurhashCanvas'
import Topbar from '../components/Topbar' import Topbar from '../components/Topbar'
import { import {
getDbPictures, getDbPictures,
@@ -57,6 +58,7 @@ export default function DrawIt() {
const [finishing, setFinishing] = useState(false) const [finishing, setFinishing] = useState(false)
const [statusMsg, setStatusMsg] = useState('') const [statusMsg, setStatusMsg] = useState('')
const [statusError, setStatusError] = useState(false) const [statusError, setStatusError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const canvasRef = useRef<DrawCanvasHandle>(null) const canvasRef = useRef<DrawCanvasHandle>(null)
@@ -94,6 +96,7 @@ export default function DrawIt() {
if (!currentPicture || !token) { if (!currentPicture || !token) {
setObjects([]); setSelectedObjectId(null) setObjects([]); setSelectedObjectId(null)
setPictureWords([]); setPendingWords([]) setPictureWords([]); setPendingWords([])
setImageLoaded(false)
return return
} }
getDbObjects(currentPicture.id, token) getDbObjects(currentPicture.id, token)
@@ -315,8 +318,17 @@ export default function DrawIt() {
<main className="canvas-area"> <main className="canvas-area">
<div <div
className="canvas-frame" className="canvas-frame"
style={currentPicture ? { background: 'var(--surface-2)' } : undefined} style={{ position: 'relative', background: 'var(--surface-2)' }}
> >
{/* Blurhash-Platzhalter: sichtbar solange das echte Bild noch lädt */}
{currentPicture?.blurhash && !imageLoaded && (
<BlurhashCanvas
hash={currentPicture.blurhash}
width={32}
height={32}
style={{ zIndex: 1 }}
/>
)}
<DrawCanvas <DrawCanvas
ref={canvasRef} ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null} imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
@@ -324,6 +336,7 @@ export default function DrawIt() {
selectedObjectId={selectedObjectId} selectedObjectId={selectedObjectId}
mode={mode} mode={mode}
onHasSelection={handleHasSelection} onHasSelection={handleHasSelection}
onImageLoad={() => setImageLoaded(true)}
/> />
</div> </div>
</main> </main>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas' import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import BlurhashCanvas from '../components/BlurhashCanvas'
import Topbar from '../components/Topbar' import Topbar from '../components/Topbar'
import { import {
getDbPictures, getDbPictures,
@@ -285,6 +286,7 @@ export default function GenerateIt() {
const [currentIndex, setCurrentIndex] = useState(-1) const [currentIndex, setCurrentIndex] = useState(-1)
const [dbObjects, setDbObjects] = useState<DbObject[]>([]) const [dbObjects, setDbObjects] = useState<DbObject[]>([])
const [selectedObjId, setSelectedObjId] = useState<string | null>(null) const [selectedObjId, setSelectedObjId] = useState<string | null>(null)
const [imageLoaded, setImageLoaded] = useState(false)
const [pairs, setPairs] = useState<DbPair[]>([]) const [pairs, setPairs] = useState<DbPair[]>([])
const [pairsLoading, setPairsLoading] = useState(false) const [pairsLoading, setPairsLoading] = useState(false)
@@ -310,7 +312,7 @@ export default function GenerateIt() {
// Load db_objects when picture changes // Load db_objects when picture changes
useEffect(() => { useEffect(() => {
if (!currentPicture || !token) { if (!currentPicture || !token) {
setDbObjects([]); setSelectedObjId(null); setPairs([]) setDbObjects([]); setSelectedObjId(null); setPairs([]); setImageLoaded(false)
return return
} }
getDbObjects(currentPicture.id, token) getDbObjects(currentPicture.id, token)
@@ -404,7 +406,16 @@ export default function GenerateIt() {
{/* Center: Canvas */} {/* Center: Canvas */}
<main className="canvas-area"> <main className="canvas-area">
<div className="canvas-frame"> <div className="canvas-frame" style={{ position: 'relative', background: 'var(--surface-2)' }}>
{/* Blurhash-Platzhalter: sichtbar solange das echte Bild noch lädt */}
{currentPicture?.blurhash && !imageLoaded && (
<BlurhashCanvas
hash={currentPicture.blurhash}
width={32}
height={32}
style={{ zIndex: 1 }}
/>
)}
<DrawCanvas <DrawCanvas
ref={canvasRef} ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null} imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.picture, token) : null}
@@ -412,6 +423,7 @@ export default function GenerateIt() {
selectedObjectId={selectedObjId} selectedObjectId={selectedObjId}
mode="rect" mode="rect"
onHasSelection={() => {}} onHasSelection={() => {}}
onImageLoad={() => setImageLoaded(true)}
readOnly readOnly
/> />
</div> </div>