trans3 / ui /components /Image.tsx
Mayo
refactor(ui): menu, settings, welcome, navigator + error/loading chrome
b44b9d7 unverified
Raw
History Blame Contribute Delete
5.71 kB
'use client'
import type { CSSProperties } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cancelObjectUrlRevoke, convertToBlob, revokeObjectUrlLater } from '@/lib/io/blobConvert'
type ImageProps = {
data?: Uint8Array
visible?: boolean
opacity?: number
transition?: boolean
dataKey?: string | number
} & Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'>
const FADE_DURATION_MS = 180
// Cross-fade between successive image buffers to avoid UI flicker when
// swapping inpaint results.
export function Image({
data,
visible = true,
opacity = 1,
transition = true,
dataKey,
style,
alt = '',
...props
}: ImageProps) {
const dataDep = dataKey ?? data
// Simple path without transitions (used for static base image to avoid extra paints)
const [plainSrc, setPlainSrc] = useState<string | null>(null)
const plainSrcRef = useRef<string | null>(null)
useEffect(() => {
if (!transition) {
if (!dataDep || !data) {
revokeObjectUrlLater(plainSrcRef.current)
plainSrcRef.current = null
setPlainSrc(null)
return
}
let cancelled = false
convertToBlob(data).then((blob) => {
if (cancelled) return
const prev = plainSrcRef.current
const url = URL.createObjectURL(blob)
cancelObjectUrlRevoke(url)
plainSrcRef.current = url
setPlainSrc(url)
revokeObjectUrlLater(prev)
})
return () => {
cancelled = true
}
}
setPlainSrc(null)
return
}, [data, dataDep, transition])
if (!transition) {
if (!visible || !plainSrc) return null
return (
<img
{...props}
alt={alt}
src={plainSrc}
draggable={false}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
userSelect: 'none',
width: '100%',
height: '100%',
objectFit: 'contain',
...style,
opacity,
}}
/>
)
}
const [currentSrc, setCurrentSrc] = useState<string | null>(null)
const [nextSrc, setNextSrc] = useState<string | null>(null)
const [crossfade, setCrossfade] = useState(false)
const currentSrcRef = useRef<string | null>(null)
const nextSrcRef = useRef<string | null>(null)
const cleanupUrl = useCallback((url: string | null) => {
revokeObjectUrlLater(url)
}, [])
useEffect(() => {
currentSrcRef.current = currentSrc
}, [currentSrc])
useEffect(() => {
nextSrcRef.current = nextSrc
}, [nextSrc])
useEffect(() => {
return () => {
cleanupUrl(currentSrcRef.current)
cleanupUrl(nextSrcRef.current)
}
}, [cleanupUrl])
const promoteNext = useCallback(() => {
const incoming = nextSrcRef.current
if (!incoming) return
const outgoing = currentSrcRef.current
currentSrcRef.current = incoming
setCurrentSrc(incoming)
setNextSrc(null)
setCrossfade(false)
if (outgoing && outgoing !== incoming) {
cleanupUrl(outgoing)
}
}, [cleanupUrl])
useEffect(() => {
if (!dataDep || !data) {
cleanupUrl(currentSrcRef.current)
cleanupUrl(nextSrcRef.current)
currentSrcRef.current = null
nextSrcRef.current = null
setCurrentSrc(null)
setNextSrc(null)
setCrossfade(false)
return
}
let cancelled = false
convertToBlob(data).then((blob) => {
if (cancelled) return
const objectUrl = URL.createObjectURL(blob)
cancelObjectUrlRevoke(objectUrl)
const preload = new window.Image()
preload.onload = () => {
if (cancelled) {
cleanupUrl(objectUrl)
return
}
// First image, render immediately
if (!currentSrcRef.current) {
currentSrcRef.current = objectUrl
setCurrentSrc(objectUrl)
return
}
// Subsequent images: queue and cross-fade
setNextSrc((prev) => {
if (prev && prev !== currentSrcRef.current) {
cleanupUrl(prev)
}
return objectUrl
})
setCrossfade(false)
requestAnimationFrame(() => {
requestAnimationFrame(() => setCrossfade(true))
})
}
preload.src = objectUrl
})
return () => {
cancelled = true
}
}, [data, dataDep, cleanupUrl])
useEffect(() => {
if (!nextSrc || !crossfade) return
const timeout = window.setTimeout(
promoteNext,
FADE_DURATION_MS + 50, // safety fallback in case transitionend doesn't fire
)
return () => window.clearTimeout(timeout)
}, [nextSrc, crossfade, promoteNext])
if (!visible || (!currentSrc && !nextSrc)) return null
const baseStyle: CSSProperties = {
position: 'absolute',
inset: 0,
pointerEvents: 'none',
userSelect: 'none',
width: '100%',
height: '100%',
objectFit: 'contain',
...style,
}
return (
<>
{currentSrc && (
<img
{...props}
alt={alt}
src={currentSrc}
draggable={false}
style={{
...baseStyle,
opacity: nextSrc ? (crossfade ? 0 : opacity) : opacity,
transition: nextSrc && crossfade ? `opacity ${FADE_DURATION_MS}ms ease` : undefined,
}}
/>
)}
{nextSrc && (
<img
{...props}
alt={alt}
src={nextSrc}
draggable={false}
onTransitionEnd={promoteNext}
style={{
...baseStyle,
opacity: crossfade ? opacity : 0,
transition: `opacity ${FADE_DURATION_MS}ms ease`,
}}
/>
)}
</>
)
}