feat(web): зум клавишами + и - в просмотре фото

В режиме просмотра фотографии (обычный и полноэкранный) клавиши + и - меняют
масштаб изображения на шаг, сохраняя точку в центре просмотра. Пока открыт
просмотр, эти клавиши больше не меняют размер миниатюр в сетке.
This commit is contained in:
iu
2026-10-20 22:01:41 +03:00
parent 50b9983be4
commit 35f84399bc
4 changed files with 88 additions and 6 deletions
+3 -1
View File
@@ -9,6 +9,7 @@
import { auth } from '$lib/stores/auth.svelte';
import { density } from '$lib/stores/density.svelte';
import { panels } from '$lib/stores/panels.svelte';
import { viewer } from '$lib/stores/viewer.svelte';
import type { Snippet } from 'svelte';
interface Props {
@@ -178,8 +179,9 @@
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.repeat) return;
// `+`/`-` меняют размер миниатюр — как кнопки под регулятором.
// В просмотре фото эти клавиши отданы зуму.
if (ev.key === '+' || ev.key === '=' || ev.key === '-' || ev.key === '_') {
if (isTextEntry() || hasOverlay()) return;
if (viewer.open || isTextEntry() || hasOverlay()) return;
ev.preventDefault();
density.stepSize(ev.key === '+' || ev.key === '=' ? 1 : -1);
return;
+70 -3
View File
@@ -8,6 +8,7 @@
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 {
@@ -25,6 +26,8 @@
const current = $derived(photos[index]);
const MAX_ZOOM = 2;
// Шаг зума клавишами `+`/`-`.
const ZOOM_STEP = 1.25;
let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state();
@@ -66,6 +69,14 @@
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;
@@ -172,6 +183,52 @@
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);
}
@@ -205,9 +262,9 @@
if (!natW || !natH) return;
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
const fitScale = Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 1);
const curScale = zoomed ? scale : fitScale;
const newScale = Math.min(MAX_ZOOM, Math.max(fitScale, curScale * factor));
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();
@@ -292,6 +349,16 @@
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();
+6
View File
@@ -0,0 +1,6 @@
class ViewerStore {
/** Открыт просмотр фото: горячие клавиши отданы зуму, а не сетке. */
open = $state(false);
}
export const viewer = new ViewerStore();
+9 -2
View File
@@ -83,7 +83,8 @@ SHALL отображаться вне каркаса, но в общей тем
сторону уменьшения и увеличения. Выбранный размер SHALL сохраняться между сессиями и SHALL
быть общим для обзора альбомов и сетки фото альбома. Клавиши `+` и `-` SHALL менять размер
миниатюр на шаг так же, как кнопки регулятора; при вводе текста и в открытых диалогах либо
меню эти клавиши SHALL сохранять обычное действие.
меню эти клавиши SHALL сохранять обычное действие. В режиме просмотра фото те же клавиши
SHALL изменять зум изображения, а не размер миниатюр.
#### Scenario: Изменение размера
@@ -254,7 +255,8 @@ SHALL выдвигать правую панель поверх содержим
### Requirement: Зум в просмотре
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
верхним пределом 200% размеров фото. Клавиши `+` и `-` SHALL менять масштаб на шаг, сохраняя
точку в центре просмотра. `Space` SHALL возвращать изображение из любого зума во
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
работать и в полноэкранном режиме.
@@ -263,6 +265,11 @@ SHALL выдвигать правую панель поверх содержим
- **WHEN** пользователь вращает колесо мыши над фото
- **THEN** масштаб меняется относительно курсора, страница не прокручивается, предел — 200%
#### Scenario: Зум клавишами
- **WHEN** пользователь нажимает `+` или `-` в режиме просмотра фото
- **THEN** масштаб меняется на шаг, точка в центре просмотра остаётся на месте, а в полноэкранном
режиме зум работает так же
#### Scenario: Пробел
- **WHEN** пользователь нажимает `Space`
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный