feat: make thumbhash work with image slider
Some checks failed
Deploy to SFTP Server / build (push) Has been cancelled

This commit is contained in:
max_richter 2024-04-06 23:40:31 +02:00
parent 68431e6b9c
commit 96c053d5ff
10 changed files with 126 additions and 68 deletions

View File

@ -70,8 +70,8 @@ picture.thumb::before {
position: absolute; position: absolute;
bottom: 10px; bottom: 10px;
right: 10px; right: 10px;
width: 30px; width: 20px;
height: 30px; height: 20px;
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
pointer-events: none; pointer-events: none;
@ -81,7 +81,6 @@ picture.thumb::before {
z-index: 2; z-index: 2;
} }
picture.thumb-loading::before { picture.thumb-loading::before {
opacity: 0.5; opacity: 0.5;
} }

View File

@ -49,6 +49,7 @@ const link = translatePath(`/${collection}/${slug.split("/")[0]}`);
<a href={link}> <a href={link}>
<Image <Image
hash hash
loader={false}
src={cover} src={cover}
alt={"cover for " + title} alt={"cover for " + title}
class="right-0 h-full object-cover object-center rounded-none border-l border-neutral" class="right-0 h-full object-cover object-center rounded-none border-l border-neutral"

View File

@ -1,40 +1,20 @@
--- ---
import type { ImageMetadata } from "astro"; import type { ImageMetadata } from "astro";
import { Picture as AstroImage } from "astro:assets"; import { Picture as AstroImage } from "astro:assets";
import { generateThumbHash } from "@helpers/image";
interface Props { interface Props {
src: ImageMetadata; src: ImageMetadata;
alt: string; alt: string;
class?: string; class?: string;
caption?: string; caption?: string;
hash?: boolean; hash?: boolean;
loader?: boolean;
maxWidth?: number; maxWidth?: number;
} }
import { rgbaToThumbHash } from "thumbhash";
const { src: image, hash = true, alt, maxWidth } = Astro.props; const { src: image, loader = true, hash = true, alt, maxWidth } = Astro.props;
let thumbhash = ""; let thumbhash = hash ? await generateThumbHash(image) : "";
const s = await import("sharp");
const sharp = s.default;
if (hash) {
const scaleFactor = 100 / Math.max(image.width, image.height);
const smallWidth = Math.floor(image.width * scaleFactor);
const smallHeight = Math.floor(image.height * scaleFactor);
//@ts-ignore
const smallImg = await sharp(image.fsPath)
.resize(smallWidth, smallHeight)
.withMetadata()
.raw()
.ensureAlpha()
.toBuffer();
const buffer = rgbaToThumbHash(smallWidth, smallHeight, smallImg);
thumbhash = Buffer.from(buffer).toString("base64");
}
const sizes = [ const sizes = [
{ {
@ -59,7 +39,9 @@ const sizes = [
src={image} src={image}
alt={alt} alt={alt}
data-thumbhash={thumbhash} data-thumbhash={thumbhash}
pictureAttributes={{ class: hash ? "block h-full relative" : "" }} pictureAttributes={{
class: `${hash ? "block h-full relative" : ""} ${loader ? "thumb" : ""}`,
}}
class={Astro.props.class} class={Astro.props.class}
widths={sizes.map((size) => size.width)} widths={sizes.map((size) => size.width)}
sizes={sizes sizes={sizes

View File

@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
let slot: HTMLDivElement; let slot: HTMLDivElement;
let images: HTMLImageElement[]; let images: (HTMLPictureElement | HTMLImageElement)[];
export let title: string; export let title: string;
@ -10,17 +8,24 @@
let height: number; let height: number;
let loaded = false; let loaded = false;
function hide(img: HTMLImageElement) { function hide(img: HTMLPictureElement) {
img.classList.remove("active"); img.classList.remove("active");
} }
function show(img: HTMLImageElement) { function show(img: HTMLPictureElement) {
img.classList.add("active"); img.classList.add("active");
if (img?.alt) altText = img.alt; const _img = img.querySelector("img");
if (!_img) return;
_img.addEventListener("load", () => {
img.classList.remove("thumb-loading");
_img.style.opacity = "1";
console.log("loaded");
});
if (_img?.alt) altText = _img.alt;
else altText = ""; else altText = "";
height = img.getBoundingClientRect().height; height = _img.getBoundingClientRect().height;
setTimeout(() => { setTimeout(() => {
height = img.getBoundingClientRect().height; height = _img.getBoundingClientRect().height;
}, 100); }, 100);
} }
@ -33,8 +38,8 @@
show(images[index]); show(images[index]);
} }
$: if (slot) { $: if (slot && !images?.length) {
images = Array.from(slot.querySelectorAll("img")); images = Array.from(slot.querySelectorAll("picture"));
if (images?.length) { if (images?.length) {
images.forEach(hide); images.forEach(hide);
show(images[index]); show(images[index]);
@ -52,23 +57,14 @@
<div <div
class="wrapper grid overflow-hidden rounded-xl border border-light" class="wrapper grid overflow-hidden rounded-xl border border-light"
class:title class:title
class:not-loaded={!loaded}
class:loaded class:loaded
style={`--height:${height}px`} style={`--height:${height}px`}
> >
{#if title} {#if title}
<div class="flex items-center p-x-4 bg"> <div class="flex items-center p-x-4 bg justify-between">
<h3>{title}</h3> <h3>{title}</h3>
</div>
{/if}
<div class="images" bind:this={slot}>
<slot />
</div>
<div class="p-2 flex place-content-between bg">
<p>{index + 1}/{images?.length}</p>
{#if altText}
<p class="text-right">{altText}</p>
{/if}
<div class="overflow-hidden rounded-md bg-light gap-2 flex p-1"> <div class="overflow-hidden rounded-md bg-light gap-2 flex p-1">
<button <button
class="flex-1 i-tabler-arrow-left" class="flex-1 i-tabler-arrow-left"
@ -82,6 +78,21 @@
/> />
</div> </div>
</div> </div>
{/if}
<div class="images" bind:this={slot}>
<slot />
</div>
<div class="p-2 flex place-content-between bg">
<p>
{#if images?.length}
{index + 1}/{images?.length}
{/if}
</p>
{#if altText}
<p class="flex-1 text-center">{altText}</p>
{/if}
</div>
</div> </div>
<style> <style>
@ -102,11 +113,15 @@
height: calc(var(--height) + 40px); height: calc(var(--height) + 40px);
} }
.not-loaded .images :global(picture):first-child {
display: block !important;
}
.wrapper.title.loaded { .wrapper.title.loaded {
height: calc(var(--height) + 80px); height: calc(var(--height) + 80px);
} }
.images :global(img) { .images :global(picture) {
border-radius: 0px; border-radius: 0px;
display: none; display: none;
} }

View File

@ -13,7 +13,13 @@ const t = useTranslations(Astro.url);
classes="googley-eye-target relative rounded-diag-md border border-neutral gradient grid grid-cols-[250px_1fr] h-[180px] mt-8" classes="googley-eye-target relative rounded-diag-md border border-neutral gradient grid grid-cols-[250px_1fr] h-[180px] mt-8"
> >
<div class="image"> <div class="image">
<Image src={MaxImg} alt="its mee" hash={false} maxWidth={700} /> <Image
src={MaxImg}
alt="its mee"
hash={false}
maxWidth={700}
loader={false}
/>
<div class="eye right"> <div class="eye right">
<GoogleyEye client:load /> <GoogleyEye client:load />
</div> </div>

View File

@ -26,6 +26,7 @@ import screenshot_geometry_nodes from "./images/screenshot-geometry-nodes.jpg"
import screenshot_houdini from "./images/screenshot-houdini.jpg" import screenshot_houdini from "./images/screenshot-houdini.jpg"
import screenshot_unreal from "./images/screenshot-unreal.jpg" import screenshot_unreal from "./images/screenshot-unreal.jpg"
import screenshot_davinci from "./images/screenshot-davinci.jpg" import screenshot_davinci from "./images/screenshot-davinci.jpg"
import Plantarium from "./images/plantarium.png"
import ImageGallery from "@components/ImageGallery.svelte" import ImageGallery from "@components/ImageGallery.svelte"
<ImageGallery client:load/> <ImageGallery client:load/>
@ -74,7 +75,7 @@ In der Beispielgrafik haben wir zwei `input-color` nodes die jeweils eine Farbe
Das coole ist das man dieses System sehr generisch gestalten kann und zum beispiel eine `generate-stem`, `generate-branches` oder eine `add-leaves` node programmieren kann. Aufgrund der Das coole ist das man dieses System sehr generisch gestalten kann und zum beispiel eine `generate-stem`, `generate-branches` oder eine `add-leaves` node programmieren kann. Aufgrund der
<img src="/projects/plantarium/screenshot-plantarium.png" alt="Plantariums uses nodes to create plants"/> <Image src={Plantarium} alt="Plantariums uses nodes to create plants"/>
<ImageSlider title="Beispiele von Node Systemen" client:load> <ImageSlider title="Beispiele von Node Systemen" client:load>
<Image src={screenshot_geometry_nodes} alt="Blenders uses nodes to create geometry"/> <Image src={screenshot_geometry_nodes} alt="Blenders uses nodes to create geometry"/>

34
src/helpers/image.ts Normal file
View File

@ -0,0 +1,34 @@
let loadingSharp = false;
import { rgbaToThumbHash } from "thumbhash";
export async function generateThumbHash(image: { width: number, height: number }) {
if (!loadingSharp) {
loadingSharp = true;
setTimeout(async () => {
// @ts-ignore
globalThis["sharp"] = (await import("sharp")).default;
}, 1000);
return;
}
// @ts-ignore
const sharp = globalThis["sharp"] as typeof import("sharp");
if (!sharp) return;
const scaleFactor = 100 / Math.max(image.width, image.height);
const smallWidth = Math.floor(image.width * scaleFactor);
const smallHeight = Math.floor(image.height * scaleFactor);
//@ts-ignore
const smallImg = await sharp(image.fsPath)
.resize(smallWidth, smallHeight)
.withMetadata()
.raw()
.ensureAlpha()
.toBuffer();
const buffer = rgbaToThumbHash(smallWidth, smallHeight, smallImg);
return Buffer.from(buffer).toString("base64");
}

View File

@ -123,7 +123,7 @@ const { title, width = "compact" } = Astro.props;
<main id="main-content" class="flex flex-col mt-4xl gap-y-2xl"> <main id="main-content" class="flex flex-col mt-4xl gap-y-2xl">
<slot /> <slot />
</main> </main>
<footer> <footer class="px-4">
<LanguagePicker /> <LanguagePicker />
</footer> </footer>
<style> <style>
@ -157,7 +157,6 @@ const { title, width = "compact" } = Astro.props;
const img = entry; const img = entry;
if (parent?.nodeName !== "PICTURE") return; if (parent?.nodeName !== "PICTURE") return;
parent.classList.add("thumb");
const hash = img.getAttribute("data-thumbhash"); const hash = img.getAttribute("data-thumbhash");
if (!hash) return; if (!hash) return;
@ -178,18 +177,31 @@ const { title, width = "compact" } = Astro.props;
parent.style.background = `url(${dataURL})`; parent.style.background = `url(${dataURL})`;
parent.style.backgroundSize = "cover"; parent.style.backgroundSize = "cover";
if (img.complete) return; if (img.complete || parent.complete) return;
let count = 0;
const interval = setInterval(() => {
count++;
if (count > 20) {
clearInterval(interval);
show(img);
}
if (img.complete || parent.complete) {
clearInterval(interval);
show(img);
}
}, 100);
img.addEventListener(
"load",
() => {
clearInterval(interval);
show(img);
},
{ once: true },
);
img.style.opacity = "0"; img.style.opacity = "0";
img.style.filter = "blur(5px)";
img.style.transition = "opacity 0.6s ease, filter 0.8s ease"; img.style.transition = "opacity 0.6s ease, filter 0.8s ease";
const sources = parent.querySelectorAll("source");
img.onload = () => show(img);
for (const source of sources) {
source.addEventListener("load", () => show(img));
}
}); });
})(); })();
</script> </script>

View File

@ -43,7 +43,7 @@ const otherPosts = posts.filter((post) => featuredPost !== post).slice(0, 3);
<span class="i-tabler-circle-arrow-right-thin"></span> <span class="i-tabler-circle-arrow-right-thin"></span>
<ArrowA /> <ArrowA />
{featuredProject && <HeroCard post={featuredProject} />} {featuredProject && <HeroCard post={featuredProject} />}
<div class="grid grid-cols-2 gap-4 mt-4"> <div class="grid xs:grid-cols-2 gap-4 mt-4">
{ {
otherProjects.length > 0 && otherProjects.length > 0 &&
otherProjects.map((project) => <SmallCard post={project} />) otherProjects.map((project) => <SmallCard post={project} />)

View File

@ -22,6 +22,14 @@ export default defineConfig({
"md": "20px", "md": "20px",
"diag-md": "0px var(--border-radius-md) 0px var(--border-radius-md)" "diag-md": "0px var(--border-radius-md) 0px var(--border-radius-md)"
}, },
screens: {
xs: "375px",
sm: "640px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1536px",
},
colors: { colors: {
neutral: { neutral: {
"000": "var(--neutral-000)", "000": "var(--neutral-000)",