В режиме просмотра фотографии (обычный и полноэкранный) клавиши + и - меняют масштаб изображения на шаг, сохраняя точку в центре просмотра. Пока открыт просмотр, эти клавиши больше не меняют размер миниатюр в сетке.
578 lines
17 KiB
Svelte
578 lines
17 KiB
Svelte
<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>
|