Files
ds-galo/frontend/src/lib/components/Viewer.svelte
T
iu 2668d87347 fix(web): искажение цветов в предпросмотре правок
Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который
линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для
чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным
данным (отсюда «каша» вместо плавного градиента).

Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только
применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной
JS-реализацией на маленьком образце и, если они расходятся, доверяет JS.

Service worker больше не кэширует .wasm: старый модуль в кэше ломал
предпросмотр даже после обновления сборки.
2026-10-21 01:19:45 +03:00

800 lines
25 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 { 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>