From 35f84399bcc77a36c996ab174efbda7b8d3e3c1f Mon Sep 17 00:00:00 2001 From: Iu Date: Tue, 20 Oct 2026 22:01:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B7=D1=83=D0=BC=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=88=D0=B0=D0=BC=D0=B8=20+=20=D0=B8=20-=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5?= =?UTF-8?q?=20=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В режиме просмотра фотографии (обычный и полноэкранный) клавиши + и - меняют масштаб изображения на шаг, сохраняя точку в центре просмотра. Пока открыт просмотр, эти клавиши больше не меняют размер миниатюр в сетке. --- frontend/src/lib/components/AppShell.svelte | 4 +- frontend/src/lib/components/Viewer.svelte | 73 ++++++++++++++++++++- frontend/src/lib/stores/viewer.svelte.ts | 6 ++ openspec/specs/ui-workspace/spec.md | 11 +++- 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/stores/viewer.svelte.ts diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 402bf97..a994d37 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -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; diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index 1824b93..163f025 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -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(); diff --git a/frontend/src/lib/stores/viewer.svelte.ts b/frontend/src/lib/stores/viewer.svelte.ts new file mode 100644 index 0000000..2fa5c34 --- /dev/null +++ b/frontend/src/lib/stores/viewer.svelte.ts @@ -0,0 +1,6 @@ +class ViewerStore { + /** Открыт просмотр фото: горячие клавиши отданы зуму, а не сетке. */ + open = $state(false); +} + +export const viewer = new ViewerStore(); diff --git a/openspec/specs/ui-workspace/spec.md b/openspec/specs/ui-workspace/spec.md index 7900bc5..b778962 100644 --- a/openspec/specs/ui-workspace/spec.md +++ b/openspec/specs/ui-workspace/spec.md @@ -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%, а из любого зума возвращается во вписанный