261 lines
6.6 KiB
Svelte
261 lines
6.6 KiB
Svelte
<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>
|