196 lines
6.5 KiB
Svelte
196 lines
6.5 KiB
Svelte
<script lang="ts">
|
|
import { marked } from "marked";
|
|
import { fade } from "svelte/transition";
|
|
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
|
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
|
|
import "../../lib/iconify-offline";
|
|
import Icon from "@iconify/svelte";
|
|
import { useTranslate } from "../../lib/translations";
|
|
import { T } from "../../lib/translations";
|
|
|
|
const t = useTranslate();
|
|
let { block }: { block: ImageGalleryBlockData } = $props();
|
|
|
|
marked.setOptions({ gfm: true });
|
|
|
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
|
const descriptionHtml = $derived(
|
|
block.description && typeof block.description === "string"
|
|
? (marked.parse(block.description) as string)
|
|
: "",
|
|
);
|
|
const images = $derived(
|
|
(block.images ?? []).filter((i): i is ImageGalleryImage => i != null && typeof i === "object"),
|
|
);
|
|
const withUrl = $derived(
|
|
images
|
|
.map((img) => ({ img, url: img.resolvedSrc ?? imageUrl(img) }))
|
|
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
|
|
);
|
|
|
|
let currentIndex = $state(0);
|
|
|
|
function imageUrl(img: ImageGalleryImage): string | null {
|
|
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
|
|
if (img.file?.url) return img.file.url.startsWith("//") ? `https:${img.file.url}` : img.file.url;
|
|
return null;
|
|
}
|
|
|
|
function prev() {
|
|
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
|
}
|
|
|
|
function next() {
|
|
currentIndex = (currentIndex + 1) % withUrl.length;
|
|
}
|
|
|
|
const SWIPE_THRESHOLD = 50;
|
|
let startX = $state(0);
|
|
let isDragging = $state(false);
|
|
let dragDelta = $state(0);
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
if (withUrl.length <= 1) return;
|
|
startX = e.clientX;
|
|
isDragging = true;
|
|
dragDelta = 0;
|
|
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (!isDragging) return;
|
|
dragDelta = e.clientX - startX;
|
|
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
|
|
}
|
|
|
|
function onPointerUp() {
|
|
if (!isDragging) return;
|
|
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
|
if (dragDelta < 0) next();
|
|
else prev();
|
|
}
|
|
isDragging = false;
|
|
dragDelta = 0;
|
|
startX = 0;
|
|
}
|
|
|
|
function onTouchStart(e: TouchEvent) {
|
|
if (withUrl.length <= 1) return;
|
|
startX = e.touches[0].clientX;
|
|
isDragging = true;
|
|
dragDelta = 0;
|
|
}
|
|
|
|
function onTouchMove(e: TouchEvent) {
|
|
if (!isDragging || !e.touches[0]) return;
|
|
const delta = e.touches[0].clientX - startX;
|
|
dragDelta = delta;
|
|
if (Math.abs(delta) > 10) e.preventDefault();
|
|
}
|
|
|
|
function onTouchEnd() {
|
|
if (!isDragging) return;
|
|
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
|
if (dragDelta < 0) next();
|
|
else prev();
|
|
}
|
|
isDragging = false;
|
|
dragDelta = 0;
|
|
startX = 0;
|
|
}
|
|
</script>
|
|
|
|
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
|
|
{#if descriptionHtml}
|
|
<div class="markdown max-w-none prose prose-zinc mb-4">
|
|
{@html descriptionHtml}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if withUrl.length === 0}
|
|
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
|
{:else if withUrl.length === 1}
|
|
<figure class="m-0">
|
|
<img
|
|
src={withUrl[0].url}
|
|
alt={withUrl[0].img.description ?? ""}
|
|
class="w-full rounded-sm object-cover aspect-video"
|
|
loading="eager"
|
|
/>
|
|
{#if withUrl[0].img.description}
|
|
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
|
|
{/if}
|
|
</figure>
|
|
{:else}
|
|
<div class="group relative">
|
|
<div
|
|
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
|
|
role="region"
|
|
aria-label={t(T.image_gallery_swipe_aria)}
|
|
onpointerdown={onPointerDown}
|
|
onpointermove={onPointerMove}
|
|
onpointerup={onPointerUp}
|
|
onpointercancel={onPointerUp}
|
|
ontouchstart={onTouchStart}
|
|
ontouchmove={onTouchMove}
|
|
ontouchend={onTouchEnd}
|
|
ontouchcancel={onTouchEnd}
|
|
>
|
|
{#key currentIndex}
|
|
{@const current = withUrl[currentIndex]}
|
|
<figure
|
|
class="m-0 absolute inset-0"
|
|
transition:fade={{ duration: 200 }}
|
|
>
|
|
<img
|
|
src={current.url}
|
|
alt={current.img.description ?? ""}
|
|
class="w-full h-full object-cover"
|
|
loading={currentIndex === 0 ? "eager" : "lazy"}
|
|
/>
|
|
{#if current.img.description}
|
|
<figcaption
|
|
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-xs text-white bg-black/50 rounded-b-sm transition-opacity lg:opacity-0 lg:group-hover:opacity-100"
|
|
>
|
|
{current.img.description}
|
|
</figcaption>
|
|
{/if}
|
|
</figure>
|
|
{/key}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
|
aria-label={t(T.image_gallery_prev_aria)}
|
|
onclick={prev}
|
|
>
|
|
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
|
aria-label={t(T.image_gallery_next_aria)}
|
|
onclick={next}
|
|
>
|
|
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
|
|
</button>
|
|
|
|
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
|
|
{#each withUrl as _, i}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
|
|
aria-selected={i === currentIndex}
|
|
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
|
|
? "bg-wald-600"
|
|
: "bg-stein-300 hover:bg-stein-400"}"
|
|
onclick={() => (currentIndex = i)}
|
|
></button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|