Use a canvas on the front to draw blurhash

This commit is contained in:
Zoe Roux 2023-11-08 15:48:29 +01:00
parent 1a92094eaf
commit 9409197766
2 changed files with 104 additions and 21 deletions

View File

@ -19,7 +19,17 @@
*/ */
import { decode } from "blurhash"; import { decode } from "blurhash";
import { ReactElement } from "react"; import {
HTMLAttributes,
ReactElement,
createElement,
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useYoshiki } from "yoshiki"; import { useYoshiki } from "yoshiki";
import { Stylable, nativeStyleToCss } from "yoshiki/native"; import { Stylable, nativeStyleToCss } from "yoshiki/native";
import { StyleList, processStyleList } from "yoshiki/src/type"; import { StyleList, processStyleList } from "yoshiki/src/type";
@ -191,28 +201,102 @@ function generatePng(width: number, height: number, rgbaString: string) {
return pngString; return pngString;
} }
export const BlurhashContainer = ({ const BlurhashCanvas = forwardRef<
blurhash, HTMLCanvasElement,
blurhashUrl, {
children, blurhash: string;
...props } & HTMLAttributes<HTMLCanvasElement>
}: { >(function BlurhashCanvas({ blurhash, ...props }, forwardedRef) {
blurhash?: string; const ref = useRef<HTMLCanvasElement>(null);
blurhashUrl?: string; const { css } = useYoshiki();
children?: ReactElement | ReactElement[];
}) => { useImperativeHandle(forwardedRef, () => ref.current!, []);
useLayoutEffect(() => {
if (!ref.current) return;
const pixels = decode(blurhash, 32, 32);
const ctx = ref.current.getContext("2d");
if (!ctx) return;
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}, [blurhash]);
return (
<canvas
ref={ref}
width={32}
height={32}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
},
props,
)}
/>
);
});
const BlurhashDiv = forwardRef<
HTMLDivElement,
{ blurhash: string } & HTMLAttributes<HTMLDivElement>
>(function BlurhashDiv({ blurhash, ...props }, ref) {
const { css } = useYoshiki(); const { css } = useYoshiki();
return ( return (
<div <div
ref={ref}
style={{ style={{
// Use a blurhash here to nicely fade the NextImage when it is loaded completly // Use a blurhash here to nicely fade the NextImage when it is loaded completly
// (this prevents loading the image line by line which is ugly and buggy on firefox) // (this prevents loading the image line by line which is ugly and buggy on firefox)
backgroundImage: `url(${blurhashUrl ?? blurHashToDataURL(blurhash)})`, backgroundImage: `url(${blurHashToDataURL(blurhash)})`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
backgroundPosition: "50% 50%", backgroundPosition: "50% 50%",
}} }}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
},
props,
)}
/>
);
});
export const BlurhashContainer = ({
blurhash,
children,
...props
}: {
blurhash: string;
children?: ReactElement | ReactElement[];
}) => {
const { css } = useYoshiki();
const ref = useRef<HTMLCanvasElement & HTMLDivElement>(null);
const [renderType, setRenderType] = useState<"ssr" | "hydratation" | "client">(
typeof window === "undefined" ? "ssr" : "hydratation",
);
useLayoutEffect(() => {
// If the html is empty, it was not SSRed.
if (ref.current?.innerHTML === '') setRenderType("client");
}, []);
return (
<div
{...css( {...css(
{ {
// To reproduce view's behavior // To reproduce view's behavior
@ -223,6 +307,11 @@ export const BlurhashContainer = ({
nativeStyleToCss(props), nativeStyleToCss(props),
)} )}
> >
{renderType === "ssr" && <BlurhashDiv ref={ref} blurhash={blurhash} />}
{renderType === "client" && <BlurhashCanvas ref={ref} blurhash={blurhash} />}
{renderType === "hydratation" && (
<div ref={ref} dangerouslySetInnerHTML={{ __html: "" }} suppressHydrationWarning />
)}
{children} {children}
</div> </div>
); );

View File

@ -37,13 +37,9 @@ export const Image = ({
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => { }: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const [state, setState] = useState<"loading" | "errored" | "finished">( const [state, setState] = useState<"loading" | "errored" | "finished">(
src ? "finished" : "errored", src ? (typeof window === "undefined" ? "finished" : "loading") : "errored",
); );
useLayoutEffect(() => {
setState("loading");
}, []);
const border = { borderRadius: 6 } satisfies ViewStyle; const border = { borderRadius: 6 } satisfies ViewStyle;
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />; if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
@ -55,9 +51,8 @@ export const Image = ({
); );
} }
const blurhashUrl = blurHashToDataURL(src.blurhash);
return ( return (
<BlurhashContainer blurhashUrl={blurhashUrl} {...css([layout, border], props)}> <BlurhashContainer blurhash={src.blurhash} {...css([layout, border], props)}>
<NextImage <NextImage
src={src[quality ?? "high"]} src={src[quality ?? "high"]}
priority={quality === "high"} priority={quality === "high"}
@ -68,12 +63,11 @@ export const Image = ({
opacity: state === "loading" ? 0 : 1, opacity: state === "loading" ? 0 : 1,
transition: "opacity .2s ease-out", transition: "opacity .2s ease-out",
}} }}
blurDataURL={blurhashUrl}
placeholder="blur"
// Don't use next's server to reprocess images, they are already optimized by kyoo. // Don't use next's server to reprocess images, they are already optimized by kyoo.
unoptimized={true} unoptimized={true}
onLoad={() => setState("finished")} onLoad={() => setState("finished")}
onError={() => setState("errored")} onError={() => setState("errored")}
suppressHydrationWarning
/> />
</BlurhashContainer> </BlurhashContainer>
); );