Files
ds-galo/frontend/src/lib/components/Viewer.svelte
T
iu 35f84399bc feat(web): зум клавишами + и - в просмотре фото
В режиме просмотра фотографии (обычный и полноэкранный) клавиши + и - меняют
масштаб изображения на шаг, сохраняя точку в центре просмотра. Пока открыт
просмотр, эти клавиши больше не меняют размер миниатюр в сетке.
2026-10-20 22:01:41 +03:00

578 lines
17 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 { tick } from 'svelte';
import { api } from '$lib/api';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import PanoViewer from './PanoViewer.svelte';
import { isPanoTag } from '$lib/pano';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte';
import type { Photo } from '$lib/types';
interface Props {
photos: Photo[];
index: number;
onClose: () => void;
onIndexChange: (index: number) => void;
}
let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
const current = $derived(photos[index]);
const MAX_ZOOM = 2;
// Шаг зума клавишами `+`/`-`.
const ZOOM_STEP = 1.25;
let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state();
let imgEl: HTMLImageElement | undefined = $state();
let topEl: HTMLDivElement | undefined = $state();
let bottomEl: HTMLDivElement | undefined = $state();
let isFullscreen = $state(false);
let zoomed = $state(false);
let scale = $state(1);
let hiResLoaded = $state(false);
let panoFailed = $state(false);
let panoRef: { reset: () => void } | undefined = $state();
let showTop = $state(false);
let showBottom = $state(false);
let showLeft = $state(false);
let showRight = $state(false);
const EDGE = 8;
let dragging = false;
let startX = 0;
let startY = 0;
let startL = 0;
let startT = 0;
const previewSrc = $derived(
fresh?.preview_url ?? fresh?.original_url ?? current?.preview_url ?? current?.original_url ?? ''
);
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
const panoSrc = $derived(fullSrc || previewSrc);
const isVideo = $derived((current?.kind ?? fresh?.kind) === 'video');
const playbackSrc = $derived(fresh?.playback_url ?? current?.playback_url ?? '');
const videoPoster = $derived(
fresh?.preview_url ?? fresh?.thumb_url ?? current?.preview_url ?? current?.thumb_url ?? ''
);
$effect(() => {
// Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото.
viewer.open = true;
return () => {
viewer.open = false;
};
});
$effect(() => {
const id = current?.id;
fresh = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
panoFailed = false;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.catch(() => {});
});
// In zoom mode, preload the full-resolution original and swap it in once loaded.
$effect(() => {
if (!zoomed || hiResLoaded) return;
const url = fullSrc;
if (!url) return;
const im = new Image();
let alive = true;
im.onload = () => {
if (alive) hiResLoaded = true;
};
im.src = url;
return () => {
alive = false;
im.onload = null;
};
});
$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;
resetBars();
};
document.addEventListener('fullscreenchange', onFs);
return () => document.removeEventListener('fullscreenchange', onFs);
});
const topClass = $derived(
`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 bg-transparent px-2 py-0.5 transition-transform duration-200 ${isFullscreen && !showTop ? '-translate-y-full' : 'translate-y-0'}`
);
const bottomClass = $derived(
isFullscreen
? `border-border bg-chrome absolute inset-x-0 bottom-0 z-30 flex gap-2 overflow-x-auto border-t p-2 transition-transform duration-200 ${showBottom ? 'translate-y-0' : 'translate-y-full'}`
: 'border-border bg-chrome flex shrink-0 gap-2 overflow-x-auto border-t p-2'
);
const arrowPos =
'absolute top-1/2 z-20 inline-flex -translate-y-1/2 items-center justify-center rounded-full bg-black/40 p-1.5 text-text backdrop-blur transition-opacity duration-200 hover:text-white';
const arrowVisibility = (shown: boolean, disabled: boolean, isZoomed: boolean) =>
isZoomed || !shown ? 'pointer-events-none opacity-0' : disabled ? 'opacity-40' : 'opacity-100';
const leftDisabled = $derived(index === 0 || zoomed);
const rightDisabled = $derived(index === photos.length - 1 || zoomed);
const leftBtnClass = $derived(
`${arrowPos} left-2 ${arrowVisibility(showLeft, leftDisabled, zoomed)}`
);
const rightBtnClass = $derived(
`${arrowPos} right-2 ${arrowVisibility(showRight, rightDisabled, zoomed)}`
);
function resetBars() {
showTop = false;
showBottom = false;
showLeft = false;
showRight = false;
}
function inRect(el: HTMLElement | undefined, x: number, y: number): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
function onWinMouseMove(e: MouseEvent) {
const r = viewerEl?.getBoundingClientRect();
if (r) {
const zoneX = r.width / 6;
const insideY = e.clientY >= r.top && e.clientY <= r.bottom;
showLeft = !zoomed && insideY && e.clientX >= r.left && e.clientX <= r.left + zoneX;
showRight = !zoomed && insideY && e.clientX <= r.right && e.clientX >= r.right - zoneX;
}
if (!isFullscreen) return;
const nearTop = e.clientY <= EDGE;
const nearBottom = e.clientY >= window.innerHeight - EDGE;
showTop = nearTop || inRect(topEl, e.clientX, e.clientY);
showBottom = nearBottom || inRect(bottomEl, e.clientX, e.clientY);
}
function baseWidth(): number {
return fresh?.width || current?.width || imgEl?.naturalWidth || 0;
}
function baseHeight(): number {
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
}
function prev() {
if (index > 0) onIndexChange(index - 1);
}
/** Масштаб, при котором фото целиком помещается в просмотр. */
function fitScale(): number {
if (!scroller) return 1;
const natW = baseWidth();
const natH = baseHeight();
if (!natW || !natH) return 1;
return Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 1);
}
/**
* Зум клавишами: `+`/`-`. Точка в центре просмотра остаётся на месте,
* как при зуме колесом мыши.
*/
function zoomBy(factor: number) {
const el = scroller;
if (!el || !imgEl) return;
const natW = baseWidth();
const natH = baseHeight();
if (!natW || !natH) return;
const fit = fitScale();
const cur = zoomed ? scale : fit;
const next = Math.min(MAX_ZOOM, Math.max(fit, cur * factor));
if (Math.abs(next - cur) < 1e-4) return;
const relX = imgEl.offsetWidth
? (el.scrollLeft + el.clientWidth / 2 - imgEl.offsetLeft) / imgEl.offsetWidth
: 0.5;
const relY = imgEl.offsetHeight
? (el.scrollTop + el.clientHeight / 2 - imgEl.offsetTop) / imgEl.offsetHeight
: 0.5;
zoomed = next > fit * 1.001;
scale = next;
tick().then(() => {
const iw = imgEl?.offsetWidth ?? 0;
const ih = imgEl?.offsetHeight ?? 0;
if (!iw || !ih) return;
const offX = Math.max(0, (el.clientWidth - iw) / 2);
const offY = Math.max(0, (el.clientHeight - ih) / 2);
el.scrollLeft = offX + relX * iw - el.clientWidth / 2;
el.scrollTop = offY + relY * ih - el.clientHeight / 2;
});
}
function next() {
if (index < photos.length - 1) onIndexChange(index + 1);
}
function toggleActual() {
if (zoomed) {
zoomed = false;
scale = 1;
if (scroller) {
scroller.scrollLeft = 0;
scroller.scrollTop = 0;
}
} else {
zoomed = true;
scale = 1;
tick().then(() => {
if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
});
}
}
function onWheel(ev: WheelEvent) {
if (!current || !scroller || !imgEl) return;
ev.preventDefault();
ev.stopPropagation();
const natW = baseWidth();
const natH = baseHeight();
if (!natW || !natH) return;
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
const fit = fitScale();
const curScale = zoomed ? scale : fit;
const newScale = Math.min(MAX_ZOOM, Math.max(fit, curScale * factor));
if (Math.abs(newScale - curScale) < 1e-4) return;
const r = imgEl.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width;
const fy = (ev.clientY - r.top) / r.height;
zoomed = true;
scale = newScale;
tick().then(() => {
if (!scroller) return;
const sr = scroller.getBoundingClientRect();
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
});
}
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 === viewerEl) 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 (isTyping()) return;
// Let the native video player own its playback keys.
if (isVideo && (ev.code === 'Space' || ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
return;
}
if (ev.key === 'Escape') {
if (!document.fullscreenElement) onClose();
return;
}
if (ev.code === 'Space') {
ev.preventDefault();
if (pano) panoRef?.reset();
else toggleActual();
return;
}
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
ev.preventDefault();
toggleViewerFullscreen();
return;
}
if (ev.key === '+' || ev.key === '=') {
ev.preventDefault();
zoomBy(ZOOM_STEP);
return;
}
if (ev.key === '-' || ev.key === '_') {
ev.preventDefault();
zoomBy(1 / ZOOM_STEP);
return;
}
if (zoomed) return;
if (ev.key === 'ArrowLeft') prev();
else if (ev.key === 'ArrowRight') next();
}
function onTouchStart(ev: TouchEvent) {
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();
}
function onContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const p = current;
if (!p) return;
const items: ContextMenuItem[] = [
...(p.download_url
? [
{
label: 'Скачать',
icon: 'download' as const,
run: () => window.open(p.download_url, '_blank')
}
]
: []),
{
label: 'Переместить',
icon: 'arrow_back',
run: () => transfer.start([p.id], null, 'move')
},
{
label: 'Копировать',
icon: 'content_copy',
run: () => transfer.start([p.id], null, 'copy')
},
{
label: 'Удалить',
icon: 'delete',
danger: true,
run: () => viewerActions.requestDelete(p.id)
}
];
contextMenu.show(e.clientX, e.clientY, items);
}
const btn =
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
</script>
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
<div
bind:this={viewerEl}
class="bg-canvas relative flex h-full flex-col overflow-hidden"
data-pano={panoTag ? '1' : '0'}
>
<div bind:this={topEl} class={topClass}>
<div class="pointer-events-auto flex items-center gap-1">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" class="size-4" />
</button>
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
{#if pano}
<span class="text-primary text-xs font-semibold" title="360-панорама">360</span>
{/if}
</div>
<div class="pointer-events-auto flex items-center gap-1">
<button
type="button"
class={btn}
aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'}
title={isFullscreen
? 'Выйти из полноэкранного просмотра (f)'
: 'Полноэкранный просмотр (f)'}
aria-pressed={isFullscreen}
onclick={toggleViewerFullscreen}
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
</button>
</div>
</div>
<div class="relative flex min-h-0 flex-1 items-stretch">
<button
type="button"
class={leftBtnClass}
aria-label="Предыдущее фото"
title="Предыдущее фото"
onclick={prev}
disabled={leftDisabled}
>
<Icon name="chevron_left" class="size-8" />
</button>
{#if pano}
<div
class="bg-canvas flex min-h-0 flex-1 overflow-hidden"
role="group"
aria-label="360-панорама"
oncontextmenu={onContextMenu}
>
<PanoViewer
bind:this={panoRef}
src={panoSrc}
fallbackSrc={previewSrc}
onFallback={() => (panoFailed = true)}
/>
</div>
{:else if isVideo}
<div
class="bg-canvas flex min-h-0 flex-1 items-center justify-center overflow-hidden"
role="group"
aria-label="Просмотр видео"
oncontextmenu={onContextMenu}
>
{#if playbackSrc}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="m-auto block max-h-full max-w-full"
src={playbackSrc}
poster={videoPoster}
controls
playsinline
preload="metadata"
></video>
{:else}
<div class="relative flex items-center justify-center">
{#if videoPoster}
<img
src={videoPoster}
alt={current?.original_filename ?? ''}
class="max-h-full max-w-full object-contain"
/>
{/if}
<span
class="absolute rounded bg-black/60 px-2 py-1 text-xs text-white"
role="status"
>
Обработка видео…
</span>
</div>
{/if}
</div>
{:else}
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
{#if current}
<img
bind:this={imgEl}
src={displaySrc}
alt={current.original_filename}
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}"
/>
{/if}
</div>
{/if}
<button
type="button"
class={rightBtnClass}
aria-label="Следующее фото"
title="Следующее фото"
onclick={next}
disabled={rightDisabled}
>
<Icon name="chevron_right" class="size-8" />
</button>
</div>
<div bind:this={bottomEl} class={bottomClass} aria-label="Лента миниатюр">
{#each photos as photo, i (photo.id)}
<button
type="button"
class="group h-16 w-16 shrink-0 overflow-hidden rounded border {i === index
? 'border-primary'
: 'border-border'}"
onclick={() => onIndexChange(i)}
aria-label="Фото {i + 1}"
aria-current={i === index ? 'true' : undefined}
>
{#if photo.thumb_url}
<img
src={photo.thumb_url}
alt=""
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105 group-focus-visible:scale-105"
loading="lazy"
/>
{:else}
<span class="bg-bg-muted block h-full w-full"></span>
{/if}
</button>
{/each}
</div>
</div>