feat(frontend): photo viewer fullscreen and zoom

- fullscreen toggle button + "f" hotkey (both layouts)
- wheel zoom anchored to cursor; Space toggles 100% (1:1)
- pan when zoomed via drag/scrollbars; reset zoom on photo change
- hotkeys ignored while typing in inputs
This commit is contained in:
iu
2026-10-10 19:15:43 +03:00
parent b901544dc5
commit 3886cc6511
+184 -17
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import { api } from '$lib/api';
import Icon from './Icon.svelte';
import type { Photo } from '$lib/types';
@@ -30,15 +31,52 @@
const current = $derived(photos[index]);
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state();
let imgEl: HTMLImageElement | undefined = $state();
let isFullscreen = $state(false);
let zoomed = $state(false);
let scale = $state(1);
let dragging = false;
let startX = 0;
let startY = 0;
let startL = 0;
let startT = 0;
$effect(() => {
const id = current?.id;
fresh = null;
zoomed = false;
scale = 1;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.catch(() => {});
});
$effect(() => {
const el = scroller;
if (!el) return;
const handler = (e: WheelEvent) => onWheel(e);
el.addEventListener('wheel', handler, { passive: false });
return () => el.removeEventListener('wheel', handler);
});
$effect(() => {
const onFs = () => (isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl);
document.addEventListener('fullscreenchange', onFs);
return () => document.removeEventListener('fullscreenchange', onFs);
});
function baseWidth(): number {
return fresh?.width || current?.width || imgEl?.naturalWidth || 0;
}
function prev() {
if (index > 0) onIndexChange(index - 1);
}
@@ -46,9 +84,110 @@
if (index < photos.length - 1) onIndexChange(index + 1);
}
function toggleActual() {
if (zoomed && scale === 1) {
zoomed = false;
scale = 1;
} else {
zoomed = true;
scale = 1;
}
}
function onWheel(ev: WheelEvent) {
if (!current || !scroller) return;
ev.preventDefault();
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale * factor));
if (newScale === scale) return;
const rect = scroller.getBoundingClientRect();
const ox = ev.clientX - rect.left;
const oy = ev.clientY - rect.top;
const wasFit = !zoomed;
const oldScale = scale;
zoomed = true;
scale = newScale;
tick().then(() => {
if (!scroller) return;
if (wasFit) {
scroller.scrollLeft = scroller.scrollWidth / 2 - ox;
scroller.scrollTop = scroller.scrollHeight / 2 - oy;
} else {
const nx = (scroller.scrollLeft + ox) / oldScale;
const ny = (scroller.scrollTop + oy) / oldScale;
scroller.scrollLeft = nx * newScale - ox;
scroller.scrollTop = ny * newScale - oy;
}
});
}
function onPointerDown(ev: PointerEvent) {
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
dragging = true;
startX = ev.clientX;
startY = ev.clientY;
startL = scroller.scrollLeft;
startT = scroller.scrollTop;
try {
scroller.setPointerCapture(ev.pointerId);
} catch {
/* ignore */
}
}
function onPointerMove(ev: PointerEvent) {
if (!dragging || !scroller) return;
ev.preventDefault();
scroller.scrollLeft = startL - (ev.clientX - startX);
scroller.scrollTop = startT - (ev.clientY - startY);
}
function onPointerUp(ev: PointerEvent) {
if (!dragging) return;
dragging = false;
try {
scroller?.releasePointerCapture(ev.pointerId);
} catch {
/* ignore */
}
}
async function toggleViewerFullscreen() {
if (!viewerEl) return;
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await viewerEl.requestFullscreen();
} catch {
/* недоступно */
}
}
function isTyping(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') onClose();
else if (ev.key === 'ArrowLeft') prev();
if (isTyping()) return;
if (ev.key === 'Escape') {
if (!document.fullscreenElement) onClose();
return;
}
if (ev.code === 'Space') {
ev.preventDefault();
toggleActual();
return;
}
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
ev.preventDefault();
toggleViewerFullscreen();
return;
}
if (zoomed) return;
if (ev.key === 'ArrowLeft') prev();
else if (ev.key === 'ArrowRight') next();
}
@@ -56,6 +195,7 @@
touchX = ev.changedTouches[0].clientX;
}
function onTouchEnd(ev: TouchEvent) {
if (zoomed) return;
const dx = ev.changedTouches[0].clientX - touchX;
if (dx > 50) prev();
else if (dx < -50) next();
@@ -69,8 +209,8 @@
<svelte:window onkeydown={onKey} />
<div class="flex h-full flex-col">
<div class="border-border flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2">
<div bind:this={viewerEl} class="bg-canvas flex h-full flex-col">
<div class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2">
<div class="flex items-center gap-2">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" />
@@ -78,6 +218,16 @@
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class={btn}
aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'}
title={isFullscreen ? 'Выйти из полноэкранного просмотра (f)' : 'Полноэкранный просмотр (f)'}
aria-pressed={isFullscreen}
onclick={toggleViewerFullscreen}
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} />
</button>
{#if canEdit}
<button
type="button"
@@ -109,46 +259,63 @@
</div>
</div>
<div
class="bg-canvas flex min-h-0 flex-1 items-center justify-center overflow-hidden px-2 py-2"
role="group"
aria-label="Просмотр фото"
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
<div class="flex min-h-0 flex-1 items-stretch">
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center px-1 disabled:opacity-30"
class="text-text-muted hover:text-text inline-flex shrink-0 items-center self-center px-1 disabled:opacity-30"
aria-label="Предыдущее фото"
title="Предыдущее фото"
onclick={prev}
disabled={index === 0}
disabled={index === 0 || zoomed}
>
<Icon name="chevron_left" class="size-8" />
</button>
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 {zoomed ? 'cursor-grab overflow-auto' : 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
{#if current}
<img
bind:this={imgEl}
src={fresh?.preview_url ??
fresh?.original_url ??
current.preview_url ??
current.original_url}
alt={current.original_filename}
class="max-h-full max-w-full rounded-lg object-contain"
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none select-none'
: 'max-h-full max-w-full object-contain'}"
/>
{/if}
</div>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center px-1 disabled:opacity-30"
class="text-text-muted hover:text-text inline-flex shrink-0 items-center self-center px-1 disabled:opacity-30"
aria-label="Следующее фото"
title="Следующее фото"
onclick={next}
disabled={index === photos.length - 1}
disabled={index === photos.length - 1 || zoomed}
>
<Icon name="chevron_right" class="size-8" />
</button>
</div>
<div class="border-border flex shrink-0 gap-2 overflow-x-auto border-t p-2" aria-label="Лента миниатюр">
<div
class="border-border flex shrink-0 gap-2 overflow-x-auto border-t p-2"
aria-label="Лента миниатюр"
>
{#each photos as photo, i (photo.id)}
<button
type="button"