Files
website/src/lib/components/viewer/ImageViewer.svelte
T
2026-11-01 18:42:28 -05:00

261 lines
6.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import type { ImageViewerWindow } from '#lib/desktop/windows.svelte';
import { imageFileName } from '#lib/desktop/imageViewer';
import ViewerToolbar from './ViewerToolbar.svelte';
import ViewerThumbnails from './ViewerThumbnails.svelte';
let { viewer, focused, onclose }: { viewer: ImageViewerWindow; focused: boolean; onclose: () => void } = $props();
const minZoom = 0.25;
const maxZoom = 8;
const zoomStep = 1.25;
const swipeDistance = 60;
let zoom = $state(1);
let rotation = $state(0);
let offsetX = $state(0);
let offsetY = $state(0);
let dragStart = $state<{ pointerX: number; pointerY: number; offsetX: number; offsetY: number } | null>(null);
let pinchStart: { distance: number; zoom: number } | null = null;
const activePointers = new Map<number, { x: number; y: number }>();
const image = $derived(viewer.images[viewer.currentIndex]);
const hasMultiple = $derived(viewer.images.length > 1);
const transform = $derived(`translate(${offsetX}px, ${offsetY}px) scale(${zoom}) rotate(${rotation}deg)`);
function resetView() {
zoom = 1;
rotation = 0;
offsetX = 0;
offsetY = 0;
}
function showImage(index: number) {
const count = viewer.images.length;
viewer.currentIndex = (index + count) % count;
resetView();
}
function setZoom(nextZoom: number) {
zoom = Math.min(maxZoom, Math.max(minZoom, nextZoom));
if (zoom <= 1) {
offsetX = 0;
offsetY = 0;
}
}
const controls = {
previous: () => showImage(viewer.currentIndex - 1),
next: () => showImage(viewer.currentIndex + 1),
zoomIn: () => setZoom(zoom * zoomStep),
zoomOut: () => setZoom(zoom / zoomStep),
fit: resetView,
rotateLeft: () => (rotation -= 90),
rotateRight: () => (rotation += 90)
};
function handleWheel(event: WheelEvent) {
event.preventDefault();
setZoom(event.deltaY < 0 ? zoom * zoomStep : zoom / zoomStep);
}
function distanceBetweenPointers(): number {
const [first, second] = [...activePointers.values()];
return Math.hypot(first.x - second.x, first.y - second.y);
}
function handlePointerDown(event: PointerEvent) {
if (event.button !== 0) return;
if (event.target instanceof Element && event.target.closest('button')) return;
if (event.currentTarget instanceof HTMLElement) event.currentTarget.setPointerCapture(event.pointerId);
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (activePointers.size === 2) {
pinchStart = { distance: distanceBetweenPointers(), zoom };
dragStart = null;
return;
}
dragStart = { pointerX: event.clientX, pointerY: event.clientY, offsetX, offsetY };
}
function handlePointerMove(event: PointerEvent) {
if (!activePointers.has(event.pointerId)) return;
activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pinchStart && activePointers.size === 2) {
setZoom(pinchStart.zoom * (distanceBetweenPointers() / pinchStart.distance));
return;
}
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.pointerX;
offsetY = dragStart.offsetY + event.clientY - dragStart.pointerY;
}
function handlePointerEnd(event: PointerEvent) {
activePointers.delete(event.pointerId);
if (activePointers.size < 2) pinchStart = null;
if (dragStart && zoom === 1) {
const movedX = event.clientX - dragStart.pointerX;
const movedY = event.clientY - dragStart.pointerY;
const isSwipe = hasMultiple && Math.abs(movedX) > swipeDistance && Math.abs(movedX) > Math.abs(movedY);
offsetX = 0;
offsetY = 0;
if (isSwipe) showImage(viewer.currentIndex + (movedX < 0 ? 1 : -1));
}
dragStart = null;
}
function handleDoubleClick() {
if (zoom === 1) setZoom(2);
else resetView();
}
const keyActions: Record<string, () => void> = {
ArrowLeft: controls.previous,
ArrowRight: controls.next,
'+': controls.zoomIn,
'=': controls.zoomIn,
'-': controls.zoomOut,
'0': controls.fit,
r: controls.rotateRight,
R: controls.rotateLeft,
Escape: () => onclose()
};
function handleKeydown(event: KeyboardEvent) {
if (!focused || event.altKey || event.ctrlKey || event.metaKey) return;
const action = keyActions[event.key];
if (!action) return;
event.preventDefault();
action();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="image-viewer">
<ViewerToolbar
{controls}
zoomPercent={Math.round(zoom * 100)}
position={viewer.currentIndex + 1}
count={viewer.images.length}
fileName={imageFileName(image.src)}
/>
<div
class="stage"
class:dragging={!!dragStart}
role="presentation"
onwheel={handleWheel}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerEnd}
onpointercancel={handlePointerEnd}
ondblclick={handleDoubleClick}
>
{#key image.src}
<img src={image.src} alt={image.alt} style:transform draggable="false" />
{/key}
{#if hasMultiple}
<button class="nav-arrow previous" title="Previous (Left arrow)" onclick={controls.previous}>‹</button>
<button class="nav-arrow next" title="Next (Right arrow)" onclick={controls.next}>›</button>
{/if}
</div>
{#if image.alt}
<div class="caption">{image.alt}</div>
{/if}
{#if hasMultiple}
<ViewerThumbnails images={viewer.images} currentIndex={viewer.currentIndex} onselect={showImage} />
{/if}
</div>
<style>
.image-viewer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--dark-background);
}
.stage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
cursor: grab;
touch-action: none;
}
.stage.dragging {
cursor: grabbing;
}
.stage img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
transition: transform 0.15s ease;
animation: image-fade-in 0.2s ease-out;
}
.stage.dragging img {
transition: none;
}
.nav-arrow {
position: absolute;
top: 50%;
display: grid;
place-items: center;
width: 36px;
height: 56px;
background: color-mix(in srgb, var(--background) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
color: var(--foreground);
font-size: 1.6rem;
transform: translateY(-50%);
opacity: 0.6;
transition: opacity 0.15s ease;
}
.nav-arrow:hover {
border-color: var(--accent);
color: var(--accent);
opacity: 1;
}
.nav-arrow.previous {
left: 8px;
}
.nav-arrow.next {
right: 8px;
}
.caption {
padding: 0.35rem 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
@keyframes image-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>