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:
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
@@ -30,15 +31,52 @@
|
|||||||
|
|
||||||
const current = $derived(photos[index]);
|
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(() => {
|
$effect(() => {
|
||||||
const id = current?.id;
|
const id = current?.id;
|
||||||
fresh = null;
|
fresh = null;
|
||||||
|
zoomed = false;
|
||||||
|
scale = 1;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
api<{ photo: Photo }>(`/photos/${id}`)
|
api<{ photo: Photo }>(`/photos/${id}`)
|
||||||
.then((data) => (fresh = data.photo))
|
.then((data) => (fresh = data.photo))
|
||||||
.catch(() => {});
|
.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() {
|
function prev() {
|
||||||
if (index > 0) onIndexChange(index - 1);
|
if (index > 0) onIndexChange(index - 1);
|
||||||
}
|
}
|
||||||
@@ -46,9 +84,110 @@
|
|||||||
if (index < photos.length - 1) onIndexChange(index + 1);
|
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) {
|
function onKey(ev: KeyboardEvent) {
|
||||||
if (ev.key === 'Escape') onClose();
|
if (isTyping()) return;
|
||||||
else if (ev.key === 'ArrowLeft') prev();
|
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();
|
else if (ev.key === 'ArrowRight') next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +195,7 @@
|
|||||||
touchX = ev.changedTouches[0].clientX;
|
touchX = ev.changedTouches[0].clientX;
|
||||||
}
|
}
|
||||||
function onTouchEnd(ev: TouchEvent) {
|
function onTouchEnd(ev: TouchEvent) {
|
||||||
|
if (zoomed) return;
|
||||||
const dx = ev.changedTouches[0].clientX - touchX;
|
const dx = ev.changedTouches[0].clientX - touchX;
|
||||||
if (dx > 50) prev();
|
if (dx > 50) prev();
|
||||||
else if (dx < -50) next();
|
else if (dx < -50) next();
|
||||||
@@ -69,8 +209,8 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div bind:this={viewerEl} class="bg-canvas 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 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">
|
<div class="flex items-center gap-2">
|
||||||
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||||
<Icon name="arrow_back" />
|
<Icon name="arrow_back" />
|
||||||
@@ -78,6 +218,16 @@
|
|||||||
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
|
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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}
|
{#if canEdit}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -109,46 +259,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="flex min-h-0 flex-1 items-stretch">
|
||||||
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}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="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="Предыдущее фото"
|
aria-label="Предыдущее фото"
|
||||||
title="Предыдущее фото"
|
title="Предыдущее фото"
|
||||||
onclick={prev}
|
onclick={prev}
|
||||||
disabled={index === 0}
|
disabled={index === 0 || zoomed}
|
||||||
>
|
>
|
||||||
<Icon name="chevron_left" class="size-8" />
|
<Icon name="chevron_left" class="size-8" />
|
||||||
</button>
|
</button>
|
||||||
{#if current}
|
|
||||||
<img
|
<div
|
||||||
src={fresh?.preview_url ??
|
bind:this={scroller}
|
||||||
fresh?.original_url ??
|
class="bg-canvas flex min-h-0 flex-1 {zoomed ? 'cursor-grab overflow-auto' : 'overflow-hidden'}"
|
||||||
current.preview_url ??
|
role="group"
|
||||||
current.original_url}
|
aria-label="Просмотр фото"
|
||||||
alt={current.original_filename}
|
onpointerdown={onPointerDown}
|
||||||
class="max-h-full max-w-full rounded-lg object-contain"
|
onpointermove={onPointerMove}
|
||||||
/>
|
onpointerup={onPointerUp}
|
||||||
{/if}
|
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}
|
||||||
|
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
|
<button
|
||||||
type="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="Следующее фото"
|
aria-label="Следующее фото"
|
||||||
title="Следующее фото"
|
title="Следующее фото"
|
||||||
onclick={next}
|
onclick={next}
|
||||||
disabled={index === photos.length - 1}
|
disabled={index === photos.length - 1 || zoomed}
|
||||||
>
|
>
|
||||||
<Icon name="chevron_right" class="size-8" />
|
<Icon name="chevron_right" class="size-8" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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)}
|
{#each photos as photo, i (photo.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user