Fishies 0.8.1

This commit is contained in:
CRTkafa 2026-10-01 12:41:30 +07:00
commit 5821ce9f4d
76 changed files with 2198 additions and 0 deletions

71
src/FishiesImg.tsx Normal file
View file

@ -0,0 +1,71 @@
'use client'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ImgHTMLAttributes, type Ref } from 'react'
import { reveal } from './fx-image'
import './loader.css'
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
/** The one way Fishies puts a picture on screen: a drop-in `<img>` that shows the Fishies
* spinner on a glass placeholder in its own box until the file is fully downloaded and
* decoded, then fades it in. Same element, same attributes, same box as the bare `<img>` it
* replaces, so no surface's selectors or layout move. See fx-image.ts for the states.
*
* Give it a size (width/height, or a CSS box) wherever you can: that box is the placeholder,
* and it is what keeps the layout still while the picture is on its way. */
export default function FishiesImg({ ref, decoding, ...props }: ImgHTMLAttributes<HTMLImageElement> & { ref?: Ref<HTMLImageElement> }) {
const own = useRef<HTMLImageElement | null>(null)
const mounted = useRef(false)
const setRef = useCallback((node: HTMLImageElement | null) => {
own.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
}, [ref])
useIsomorphicLayoutEffect(() => {
const img = own.current
if (!img) return
// A new address waits again; the document's listener (FX_IMAGE_SCRIPT) or this effect
// reveals it. A picture already complete at first mount was in memory: no fade.
if (mounted.current) img.setAttribute('data-fx', 'wait')
const first = !mounted.current
mounted.current = true
reveal(img, first && img.complete)
const settle = () => reveal(img)
img.addEventListener('load', settle)
img.addEventListener('error', settle)
return () => { img.removeEventListener('load', settle); img.removeEventListener('error', settle) }
}, [props.src, props.srcSet])
return <img {...props} ref={setRef} decoding={decoding ?? 'async'} data-fx="wait" suppressHydrationWarning/>
}
/** A `<video>` with a poster: render it with `data-fx={poster ? 'wait' : undefined}` and
* `suppressHydrationWarning`, and pass its ref here. The same placeholder as FishiesImg holds
* its box until the poster is decoded (or the first frame arrives, whichever is first). */
export function useFxPoster(video: { current: HTMLVideoElement | null }, poster: string | null | undefined) {
const decoded = useDecodedUrl(poster)
useIsomorphicLayoutEffect(() => {
const element = video.current
if (!element || !poster) return
const show = () => { if (element.getAttribute('data-fx') === 'wait') element.setAttribute('data-fx', 'fade') }
if (decoded || element.readyState >= 2) { show(); return }
element.addEventListener('loadeddata', show)
return () => element.removeEventListener('loadeddata', show)
}, [video, poster, decoded])
}
/** For a picture drawn as a CSS background: the address once it is downloaded and decoded,
* `null` until then (show a FishiesSpinner meanwhile). */
export function useDecodedUrl(url: string | null | undefined): string | null {
const [ready, setReady] = useState<string | null>(null)
useEffect(() => {
if (!url) return
let live = true
const probe = new Image()
probe.decoding = 'async'
probe.src = url
const done = () => { if (live) setReady(url) }
;(typeof probe.decode === 'function' ? probe.decode() : new Promise(resolve => { probe.onload = resolve })).then(done, done)
return () => { live = false }
}, [url])
return url && ready === url ? url : null
}