392 lines
8.9 KiB
Svelte
392 lines
8.9 KiB
Svelte
|
<script lang="ts">
|
||
|
import { onMount } from "svelte";
|
||
|
import { fade } from "svelte/transition";
|
||
|
import normalizeWheel from "@helpers/normalizeWheel";
|
||
|
let images = [];
|
||
|
let progress = [];
|
||
|
let currentIndex = -1;
|
||
|
const maxZoom = 5;
|
||
|
import { swipe } from "svelte-gestures";
|
||
|
|
||
|
const mod = (a: number, b: number) => ((a % b) + b) % b;
|
||
|
|
||
|
const lerp = (a: number, b: number, t: number) => a * t + b * (1 - t);
|
||
|
|
||
|
const addIndex = (offset: number) => {
|
||
|
return setIndex(mod(currentIndex + offset, images.length));
|
||
|
};
|
||
|
|
||
|
let mx = 0,
|
||
|
my = 0,
|
||
|
_mx = 0,
|
||
|
_my = 0,
|
||
|
scale = 1;
|
||
|
|
||
|
const setIndex = (index: number) => {
|
||
|
mx = window.innerWidth / 2;
|
||
|
my = window.innerHeight / 2;
|
||
|
scale = 1;
|
||
|
if (index < 0) {
|
||
|
document.body.style.overflowY = "auto";
|
||
|
} else {
|
||
|
document.body.style.overflowY = "hidden";
|
||
|
}
|
||
|
currentIndex = index;
|
||
|
};
|
||
|
|
||
|
const handleKeyDown = ({ key }) => {
|
||
|
if (currentIndex < 0) return;
|
||
|
if (key === "Escape" && currentIndex > -1) setIndex(-1);
|
||
|
if (key === "ArrowLeft") addIndex(-1);
|
||
|
if (key === "ArrowRight") addIndex(+1);
|
||
|
};
|
||
|
|
||
|
const handleOriginalLoading = async (image) => {
|
||
|
if (!image.startedLoading) {
|
||
|
image.startedLoading = true;
|
||
|
let cIndex = currentIndex;
|
||
|
if (image.original) {
|
||
|
const response = await fetch(image.original, {
|
||
|
headers: {
|
||
|
"Accept-Encoding": "gzip, deflate, br",
|
||
|
"Content-Type":
|
||
|
"image/avif,image/webp,image/apng,image/svg+xml,image/jpeg,image/png,image/*,*/*;q=0.8",
|
||
|
},
|
||
|
});
|
||
|
const total = Number(response.headers.get("content-length"));
|
||
|
const reader = response.body.getReader();
|
||
|
let bytesReceived = 0;
|
||
|
let chunks = [];
|
||
|
console.log("[SLIDER] started loading " + image.original);
|
||
|
while (true) {
|
||
|
const { done, value } = await reader.read();
|
||
|
if (done) {
|
||
|
console.log("[SLIDER] Image complete");
|
||
|
break;
|
||
|
}
|
||
|
chunks.push(value);
|
||
|
if (total) {
|
||
|
bytesReceived += value.length;
|
||
|
progress[cIndex] = bytesReceived / total;
|
||
|
console.log(
|
||
|
"[SLIDER] " + Math.floor(progress[cIndex] * 1000) / 10 + "%",
|
||
|
);
|
||
|
progress = progress;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const b = new Blob(chunks);
|
||
|
images[cIndex].loaded = URL.createObjectURL(b);
|
||
|
images = images;
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
function handleSwipe(ev: any) {
|
||
|
if (currentIndex === -1) return;
|
||
|
if (ev.detail.direction === "right") addIndex(-1);
|
||
|
if (ev.detail.direction === "left") addIndex(+1);
|
||
|
}
|
||
|
|
||
|
const handleScroll = (ev: WheelEvent) => {
|
||
|
const { pixelY } = normalizeWheel(ev);
|
||
|
scale = Math.min(Math.max(scale - pixelY / 100, 1), maxZoom);
|
||
|
if (scale > 3) {
|
||
|
handleOriginalLoading(images[currentIndex]);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
let isUpdating = false;
|
||
|
const update = () => {
|
||
|
isUpdating && requestAnimationFrame(update);
|
||
|
|
||
|
let dist = Math.abs(mx - _mx) + Math.abs(my - _my);
|
||
|
|
||
|
if (dist < 0.1 || scale == 1) {
|
||
|
mx = _mx;
|
||
|
my = _my;
|
||
|
isUpdating = false;
|
||
|
} else {
|
||
|
let s = (0.2 + (1 - scale / maxZoom) * 0.8) * 0.3;
|
||
|
mx = lerp(mx, _mx, 1 - s);
|
||
|
my = lerp(my, _my, 1 - s);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const startUpdate = () => {
|
||
|
if (!isUpdating && scale !== 1) {
|
||
|
isUpdating = true;
|
||
|
update();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const handleMouseMove = (ev: MouseEvent) => {
|
||
|
_mx = ev.clientX;
|
||
|
_my = ev.clientY;
|
||
|
startUpdate();
|
||
|
};
|
||
|
|
||
|
const handlePointerMove = () => {
|
||
|
// console.log(ev);
|
||
|
};
|
||
|
|
||
|
onMount(() => {
|
||
|
const wrappers = Array.prototype.slice.call(
|
||
|
document.querySelectorAll("[data-image-component]"),
|
||
|
);
|
||
|
console.log({ wrappers });
|
||
|
|
||
|
images = wrappers.map((image, i: number) => {
|
||
|
console.log({ image });
|
||
|
image.classList.add("image-active");
|
||
|
image.addEventListener("click", () => setIndex(i));
|
||
|
image.addEventListener("touch", () => setIndex(i));
|
||
|
image.style.cursor = "pointer";
|
||
|
|
||
|
image.addEventListener("error", () => {
|
||
|
console.log("Error loading", image);
|
||
|
});
|
||
|
|
||
|
let exif = null;
|
||
|
|
||
|
try {
|
||
|
let rawExif = image.getAttribute("data-exif");
|
||
|
exif = JSON.parse(rawExif);
|
||
|
} catch (error) {
|
||
|
// No biggie
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
exif,
|
||
|
// preview: preview.getAttribute("src"),
|
||
|
src: image.getAttribute("srcset"),
|
||
|
alt: image.getAttribute("alt"),
|
||
|
sizes: image.getAttribute("sizes"),
|
||
|
original: image.getAttribute("data-original"),
|
||
|
originalLoaded: false,
|
||
|
};
|
||
|
});
|
||
|
|
||
|
return () => {
|
||
|
wrappers.forEach(({ children: [_, image] }, i: number) => {
|
||
|
image.removeEventListener("click", () => setIndex(i));
|
||
|
});
|
||
|
};
|
||
|
});
|
||
|
</script>
|
||
|
|
||
|
<svelte:window on:keydown={handleKeyDown} />
|
||
|
|
||
|
<div class="image-wrapper">
|
||
|
<slot />
|
||
|
</div>
|
||
|
|
||
|
<div class="gallery-wrapper" class:visible={currentIndex > -1}>
|
||
|
{#if images.length > 1}
|
||
|
<div class="controls">
|
||
|
{#each images as _, i}
|
||
|
<button
|
||
|
class:active={currentIndex === i}
|
||
|
on:click={() => {
|
||
|
currentIndex = i;
|
||
|
}}
|
||
|
/>
|
||
|
{/each}
|
||
|
</div>
|
||
|
{/if}
|
||
|
|
||
|
<button class="left" on:click={() => addIndex(-1)}><</button>
|
||
|
<button class="right" on:click={() => addIndex(+1)}>></button>
|
||
|
<button class="close" on:click={() => setIndex(-1)}>X</button>
|
||
|
|
||
|
{#if currentIndex > -1}
|
||
|
<div
|
||
|
class="image"
|
||
|
use:swipe
|
||
|
on:swipe={handleSwipe}
|
||
|
on:wheel|passive={handleScroll}
|
||
|
on:mousemove={handleMouseMove}
|
||
|
on:pointermove={handlePointerMove}
|
||
|
>
|
||
|
{#if progress[currentIndex] && progress[currentIndex] < 0.99}
|
||
|
<div
|
||
|
transition:fade
|
||
|
id="progress"
|
||
|
style={`transform: scaleX(${progress[currentIndex]});`}
|
||
|
/>
|
||
|
{/if}
|
||
|
|
||
|
<img
|
||
|
class="background"
|
||
|
src={images[currentIndex].preview}
|
||
|
alt="background blur"
|
||
|
/>
|
||
|
|
||
|
<span>
|
||
|
<img
|
||
|
style={`transform: scale(${scale}); transform-origin: ${
|
||
|
window.innerWidth - mx
|
||
|
}px ${window.innerHeight - my}px`}
|
||
|
srcset={images[currentIndex].loaded ? "" : images[currentIndex].src}
|
||
|
src={images[currentIndex].loaded}
|
||
|
alt={images[currentIndex].alt}
|
||
|
/>
|
||
|
</span>
|
||
|
</div>
|
||
|
{#if images[currentIndex].exif}
|
||
|
{@const exif = images[currentIndex].exif}
|
||
|
<div class="exif" on:click={() => console.log(exif)}>
|
||
|
{#if "FocalLength" in exif}
|
||
|
{exif.FocalLength}mm |
|
||
|
{/if}
|
||
|
|
||
|
{#if "FNumber" in exif}
|
||
|
<i>f</i>{exif.FNumber} |
|
||
|
{/if}
|
||
|
|
||
|
{#if "ExposureTime" in exif}
|
||
|
{exif.ExposureTime.replace(" s", "s")} |
|
||
|
{/if}
|
||
|
|
||
|
{#if "Date" in exif}
|
||
|
{exif.Date}
|
||
|
{/if}
|
||
|
</div>
|
||
|
{/if}
|
||
|
{/if}
|
||
|
</div>
|
||
|
|
||
|
<style>
|
||
|
#progress {
|
||
|
position: absolute;
|
||
|
bottom: 0px;
|
||
|
height: 10px;
|
||
|
z-index: 99;
|
||
|
left: 47px;
|
||
|
background-color: black;
|
||
|
width: calc(100% - 94px);
|
||
|
transform-origin: left;
|
||
|
transition: transform 0.3s ease;
|
||
|
}
|
||
|
.gallery-wrapper {
|
||
|
position: fixed;
|
||
|
z-index: 199;
|
||
|
top: 0;
|
||
|
left: 0;
|
||
|
height: 100vh;
|
||
|
width: 100vw;
|
||
|
background-color: rgba(24, 24, 24, 0.99);
|
||
|
|
||
|
opacity: 0;
|
||
|
transition: opacity 0.1s ease;
|
||
|
pointer-events: none;
|
||
|
backdrop-filter: blur(20px);
|
||
|
}
|
||
|
|
||
|
.gallery-wrapper.visible {
|
||
|
pointer-events: all;
|
||
|
opacity: 1;
|
||
|
}
|
||
|
|
||
|
.image {
|
||
|
position: relative;
|
||
|
display: grid;
|
||
|
place-items: center;
|
||
|
width: 100%;
|
||
|
height: 100%;
|
||
|
}
|
||
|
|
||
|
.image > span {
|
||
|
height: calc(100vh - 70px);
|
||
|
width: 95%;
|
||
|
}
|
||
|
|
||
|
.image img {
|
||
|
position: relative;
|
||
|
max-width: 100vw;
|
||
|
max-height: 100vh;
|
||
|
width: 100%;
|
||
|
height: 100%;
|
||
|
z-index: 98;
|
||
|
object-fit: contain;
|
||
|
image-rendering: pixelated;
|
||
|
transition:
|
||
|
transform 0.2s ease,
|
||
|
transform-origin 0.1s linear;
|
||
|
}
|
||
|
|
||
|
.image > .background {
|
||
|
filter: brightness(0.2);
|
||
|
position: absolute;
|
||
|
opacity: 0.3;
|
||
|
width: 100%;
|
||
|
z-index: 98;
|
||
|
height: 100%;
|
||
|
object-fit: fill;
|
||
|
}
|
||
|
|
||
|
.controls {
|
||
|
width: fit-content;
|
||
|
left: 50vw;
|
||
|
transform: translateX(-50%);
|
||
|
background: black;
|
||
|
padding: 5px;
|
||
|
position: absolute;
|
||
|
z-index: 99;
|
||
|
display: flex;
|
||
|
}
|
||
|
|
||
|
.controls > button {
|
||
|
width: 15px;
|
||
|
height: 15px;
|
||
|
border-style: none;
|
||
|
background: black;
|
||
|
border: solid 1px white;
|
||
|
cursor: pointer;
|
||
|
margin: 2px;
|
||
|
padding: 0px;
|
||
|
}
|
||
|
|
||
|
.controls > button.active {
|
||
|
background: white;
|
||
|
}
|
||
|
|
||
|
.exif {
|
||
|
position: absolute;
|
||
|
bottom: 0px;
|
||
|
left: 50vw;
|
||
|
transform: translateX(-50%);
|
||
|
z-index: 99;
|
||
|
background-color: black;
|
||
|
color: white;
|
||
|
padding: 5px;
|
||
|
font-size: 0.8em;
|
||
|
white-space: pre;
|
||
|
}
|
||
|
|
||
|
.gallery-wrapper > button {
|
||
|
background: black;
|
||
|
color: white;
|
||
|
border-radius: 0px;
|
||
|
border: none;
|
||
|
padding: 10px 20px;
|
||
|
position: fixed;
|
||
|
z-index: 200;
|
||
|
}
|
||
|
|
||
|
.close {
|
||
|
top: 0;
|
||
|
right: 0;
|
||
|
}
|
||
|
|
||
|
.left {
|
||
|
bottom: 0;
|
||
|
left: 0;
|
||
|
}
|
||
|
|
||
|
.right {
|
||
|
bottom: 0;
|
||
|
right: 0;
|
||
|
}
|
||
|
</style>
|