Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным данным (отсюда «каша» вместо плавного градиента). Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной JS-реализацией на маленьком образце и, если они расходятся, доверяет JS. Service worker больше не кэширует .wasm: старый модуль в кэше ломал предпросмотр даже после обновления сборки.
800 lines
25 KiB
Svelte
800 lines
25 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 { edit } from '$lib/stores/edit.svelte';
|
||
import { applyLight, initLightWasm } from '$lib/light-wasm';
|
||
import {
|
||
isEmptyEditParams,
|
||
photoFullUrl,
|
||
photoPreviewUrl,
|
||
readEditParams
|
||
} from '$lib/edit';
|
||
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 ? photoPreviewUrl(fresh) : undefined) ??
|
||
(current ? photoPreviewUrl(current) : undefined) ??
|
||
fresh?.original_url ??
|
||
current?.original_url ??
|
||
''
|
||
);
|
||
const fullSrc = $derived(
|
||
(fresh ? photoFullUrl(fresh) : undefined) ??
|
||
(current ? photoFullUrl(current) : undefined) ??
|
||
''
|
||
);
|
||
const originalSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
|
||
// Базовые (неотредактированные) версии: на них браузер рисует предпросмотр.
|
||
const basePreviewSrc = $derived(fresh?.preview_url ?? current?.preview_url ?? '');
|
||
|
||
// Пока сервер не отрендерил текущие правки, видимую область фото рисует
|
||
// сам браузер: правки видны сразу, без ожидания рендера.
|
||
const liveParams = $derived(
|
||
edit.photoId === current?.id ? edit.params : readEditParams(current)
|
||
);
|
||
const hasEdits = $derived(!isEmptyEditParams(liveParams));
|
||
const editUrl = $derived(fresh?.edit_url ?? current?.edit_url ?? '');
|
||
const savedParams = $derived(readEditParams(current));
|
||
const pending = $derived(
|
||
edit.photoId === current?.id &&
|
||
JSON.stringify(edit.params) !== JSON.stringify(savedParams)
|
||
);
|
||
const needClientRender = $derived(hasEdits && (pending || !editUrl));
|
||
const clientPreview = $derived(needClientRender);
|
||
|
||
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
|
||
const clientSource = $derived(
|
||
zoomed ? originalSrc || basePreviewSrc : basePreviewSrc
|
||
);
|
||
const displaySrc = $derived(
|
||
clientPreview
|
||
? clientSource || previewSrc
|
||
: 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;
|
||
// Заранее поднимаем WASM-модуль фильтров: предпросмотр правок должен
|
||
// быть готов сразу.
|
||
void initLightWasm();
|
||
return () => {
|
||
viewer.open = false;
|
||
};
|
||
});
|
||
|
||
$effect(() => {
|
||
const id = current?.id;
|
||
// Версия фото входит в зависимость: после рендера правок превью
|
||
// меняется, и данные нужно перечитать.
|
||
current?.edit_preview_url ?? current?.preview_url ?? '';
|
||
if (!id) return;
|
||
api<{ photo: Photo }>(`/photos/${id}`)
|
||
.then((data) => (fresh = data.photo))
|
||
.catch(() => {});
|
||
});
|
||
|
||
// Состояние просмотра сбрасывается только при смене фото: зум не должен
|
||
// сбрасываться, когда приходит отрендеренная версия правок.
|
||
let lastPhotoId = '';
|
||
$effect(() => {
|
||
const id = current?.id;
|
||
if (id === lastPhotoId) return;
|
||
lastPhotoId = id;
|
||
fresh = null;
|
||
zoomed = false;
|
||
scale = 1;
|
||
hiResLoaded = false;
|
||
panoFailed = false;
|
||
});
|
||
|
||
// 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);
|
||
});
|
||
|
||
// Пока сервер не отрендерил отредактированную версию, видимую область
|
||
// фото рисует сам браузер: правки видны сразу, без ожидания рендера.
|
||
let canvas: HTMLCanvasElement | undefined = $state();
|
||
let viewportFrame: number | null = null;
|
||
|
||
/**
|
||
* Рисует видимую область фото с применёнными правками. Работает и в зуме,
|
||
* и в обычном виде: берём ту часть картинки, что попала во вьюпорт.
|
||
*/
|
||
function renderViewport() {
|
||
const c = canvas;
|
||
const img = imgEl;
|
||
const el = scroller;
|
||
if (!c || !img || !el) return;
|
||
const natural = img.naturalWidth;
|
||
if (!natural) return;
|
||
const imgRect = img.getBoundingClientRect();
|
||
const viewRect = el.getBoundingClientRect();
|
||
if (!imgRect.width || !viewRect.width) return;
|
||
const ratio = imgRect.width / natural;
|
||
|
||
// Видимая часть картинки.
|
||
const left = Math.max(imgRect.left, viewRect.left);
|
||
const top = Math.max(imgRect.top, viewRect.top);
|
||
const right = Math.min(imgRect.right, viewRect.right);
|
||
const bottom = Math.min(imgRect.bottom, viewRect.bottom);
|
||
const visibleW = right - left;
|
||
const visibleH = bottom - top;
|
||
if (visibleW <= 0 || visibleH <= 0) return;
|
||
|
||
const width = el.clientWidth;
|
||
const height = el.clientHeight;
|
||
if (!width || !height) return;
|
||
if (c.width !== width || c.height !== height) {
|
||
c.width = width;
|
||
c.height = height;
|
||
}
|
||
c.style.width = `${width}px`;
|
||
c.style.height = `${height}px`;
|
||
|
||
const ctx = c.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.clearRect(0, 0, width, height);
|
||
|
||
// Исходный кусок в пикселях картинки и его место на холсте.
|
||
const sx = (left - imgRect.left) / ratio;
|
||
const sy = (top - imgRect.top) / ratio;
|
||
const sw = visibleW / ratio;
|
||
const sh = visibleH / ratio;
|
||
const dx = Math.round(left - viewRect.left);
|
||
const dy = Math.round(top - viewRect.top);
|
||
const dw = Math.round(visibleW);
|
||
const dh = Math.round(visibleH);
|
||
|
||
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
|
||
|
||
// Фильтруем весь холст: так прошлый кадр не смешивается с новым.
|
||
const frame = ctx.getImageData(0, 0, width, height);
|
||
applyLight(frame.data, liveParams.light);
|
||
ctx.putImageData(frame, 0, 0);
|
||
}
|
||
|
||
function scheduleViewport() {
|
||
if (!clientPreview) return;
|
||
if (viewportFrame !== null) return;
|
||
viewportFrame = requestAnimationFrame(() => {
|
||
viewportFrame = null;
|
||
renderViewport();
|
||
});
|
||
}
|
||
|
||
$effect(() => {
|
||
// Перерисовываем вьюпорт при смене зума, правок или источника фото.
|
||
zoomed;
|
||
scale;
|
||
liveParams.light;
|
||
displaySrc;
|
||
scheduleViewport();
|
||
});
|
||
|
||
$effect(() => {
|
||
const onFs = () => {
|
||
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
||
resetBars();
|
||
};
|
||
document.addEventListener('fullscreenchange', onFs);
|
||
return () => document.removeEventListener('fullscreenchange', onFs);
|
||
});
|
||
|
||
$effect(() => {
|
||
// Лента миниатюр следует за текущим фото: активная плитка всегда в поле зрения.
|
||
const i = index;
|
||
const count = photos.length;
|
||
const strip = bottomEl;
|
||
if (!strip || !count) return;
|
||
const cell = strip.querySelectorAll<HTMLElement>('[data-strip-cell]')[i];
|
||
if (!cell) return;
|
||
const pad = 8;
|
||
const cellRect = cell.getBoundingClientRect();
|
||
const stripRect = strip.getBoundingClientRect();
|
||
if (cellRect.left < stripRect.left + pad) {
|
||
strip.scrollLeft -= stripRect.left + pad - cellRect.left;
|
||
} else if (cellRect.right > stripRect.right - pad) {
|
||
strip.scrollLeft += cellRect.right - (stripRect.right - pad);
|
||
}
|
||
});
|
||
|
||
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 currentScale(): number {
|
||
return zoomed ? scale : fitScale();
|
||
}
|
||
|
||
/** Смещение картинки в содержимом: картинка центрируется, если влезает. */
|
||
function imageOffset(width: number, height: number): { x: number; y: number } {
|
||
if (!scroller) return { x: 0, y: 0 };
|
||
return {
|
||
x: Math.max(0, (scroller.clientWidth - width) / 2),
|
||
y: Math.max(0, (scroller.clientHeight - height) / 2)
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Масштабирует фото, удерживая точку под курсором (или в центре вьюпорта)
|
||
* на месте. cursorX/cursorY — координаты точки в области просмотра.
|
||
*/
|
||
function zoomTo(next: number, cursorX: number, cursorY: number) {
|
||
const el = scroller;
|
||
if (!el) return;
|
||
const natW = baseWidth();
|
||
const natH = baseHeight();
|
||
if (!natW || !natH) return;
|
||
|
||
const cur = currentScale();
|
||
if (Math.abs(next - cur) < 1e-4) return;
|
||
|
||
// Положение точки на картинке до изменения масштаба.
|
||
const oldWidth = natW * cur;
|
||
const oldHeight = natH * cur;
|
||
const oldOffset = imageOffset(oldWidth, oldHeight);
|
||
const relX = oldWidth > 0 ? (el.scrollLeft + cursorX - oldOffset.x) / oldWidth : 0.5;
|
||
const relY = oldHeight > 0 ? (el.scrollTop + cursorY - oldOffset.y) / oldHeight : 0.5;
|
||
|
||
const fit = fitScale();
|
||
zoomed = next > fit * 1.001;
|
||
scale = next;
|
||
|
||
tick().then(() => {
|
||
const width = natW * next;
|
||
const height = natH * next;
|
||
const offset = imageOffset(width, height);
|
||
el.scrollLeft = offset.x + relX * width - cursorX;
|
||
el.scrollTop = offset.y + relY * height - cursorY;
|
||
scheduleViewport();
|
||
});
|
||
}
|
||
|
||
/** Зум клавишами `+`/`-`: точка в центре просмотра остаётся на месте. */
|
||
function zoomBy(factor: number) {
|
||
const el = scroller;
|
||
if (!el) return;
|
||
const fit = fitScale();
|
||
const next = Math.min(MAX_ZOOM, Math.max(fit, currentScale() * factor));
|
||
zoomTo(next, el.clientWidth / 2, el.clientHeight / 2);
|
||
}
|
||
|
||
function next() {
|
||
if (index < photos.length - 1) onIndexChange(index + 1);
|
||
}
|
||
|
||
/** Пробел: 100% от оригинала и обратно к вписанному виду. */
|
||
function toggleActual() {
|
||
const el = scroller;
|
||
if (!el) return;
|
||
if (zoomed) {
|
||
zoomed = false;
|
||
scale = 1;
|
||
el.scrollLeft = 0;
|
||
el.scrollTop = 0;
|
||
scheduleViewport();
|
||
return;
|
||
}
|
||
// 100% от оригинала, по центру фото.
|
||
zoomed = true;
|
||
scale = 1;
|
||
tick().then(() => {
|
||
const natW = baseWidth();
|
||
const natH = baseHeight();
|
||
el.scrollLeft = Math.max(0, (natW - el.clientWidth) / 2);
|
||
el.scrollTop = Math.max(0, (natH - el.clientHeight) / 2);
|
||
scheduleViewport();
|
||
});
|
||
}
|
||
|
||
function onWheel(ev: WheelEvent) {
|
||
if (!current || !scroller) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
|
||
const natW = baseWidth();
|
||
const natH = baseHeight();
|
||
if (!natW || !natH) return;
|
||
|
||
const rect = scroller.getBoundingClientRect();
|
||
const cursorX = ev.clientX - rect.left;
|
||
const cursorY = ev.clientY - rect.top;
|
||
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||
const fit = fitScale();
|
||
const next = Math.min(MAX_ZOOM, Math.max(fit, currentScale() * factor));
|
||
zoomTo(next, cursorX, cursorY);
|
||
}
|
||
|
||
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 */
|
||
}
|
||
scheduleViewport();
|
||
}
|
||
|
||
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;
|
||
}
|
||
// `f` — полноэкранный просмотр; `Ctrl+F` отдан кнопке «На весь экран» каркаса.
|
||
if (
|
||
!ev.ctrlKey &&
|
||
!ev.metaKey &&
|
||
!ev.altKey &&
|
||
(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) {
|
||
// В зуме стрелки панорамируют фото, а не переключают его.
|
||
panByKey(ev);
|
||
return;
|
||
}
|
||
if (ev.key === 'ArrowLeft') prev();
|
||
else if (ev.key === 'ArrowRight') next();
|
||
}
|
||
|
||
/** Панорамирование зума стрелками. */
|
||
function panByKey(ev: KeyboardEvent) {
|
||
const el = scroller;
|
||
if (!el) return;
|
||
const step = Math.max(60, Math.round(el.clientWidth / 8));
|
||
switch (ev.key) {
|
||
case 'ArrowLeft':
|
||
el.scrollLeft -= step;
|
||
break;
|
||
case 'ArrowRight':
|
||
el.scrollLeft += step;
|
||
break;
|
||
case 'ArrowUp':
|
||
el.scrollTop -= step;
|
||
break;
|
||
case 'ArrowDown':
|
||
el.scrollTop += step;
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
ev.preventDefault();
|
||
scheduleViewport();
|
||
}
|
||
|
||
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 min-w-0 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 min-w-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 class="relative flex min-h-0 min-w-0 flex-1">
|
||
<div
|
||
bind:this={scroller}
|
||
class="bg-canvas flex min-h-0 min-w-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}
|
||
onscroll={scheduleViewport}
|
||
>
|
||
{#if current}
|
||
<img
|
||
bind:this={imgEl}
|
||
src={displaySrc}
|
||
alt={current.original_filename}
|
||
draggable="false"
|
||
onload={scheduleViewport}
|
||
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 clientPreview}
|
||
<!-- Правки видны сразу: браузер рисует видимую область, пока
|
||
сервер готовит отредактированную версию фото. -->
|
||
<canvas
|
||
bind:this={canvas}
|
||
class="pointer-events-none absolute inset-0 z-10 select-none"
|
||
></canvas>
|
||
{/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"
|
||
data-strip-cell
|
||
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>
|