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:
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
50
frontend/src/components/BlurhashCanvas.tsx
Normal file
50
frontend/src/components/BlurhashCanvas.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user