From 500ec10d7f62bddcee4bd9d63fcf3ca9adbb666b Mon Sep 17 00:00:00 2001 From: Iu Date: Wed, 21 Oct 2026 00:17:15 +0300 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B7=D1=83=D0=BC=20=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Механика зума переписана: - Пробел зумит фото до 100% от оригинала (и обратно к вписанному виду). - Колесо мыши зумит к курсору: точка под указателем остаётся на месте при любом изменении масштаба. - Стрелки влево/вправо переключают фото и в зуме — панорамирование осталось за мышью. - Масштаб и позиция считаются от размеров оригинала, а не от текущей рамки, поэтому зум не «уезжает» и картинка корректно центрируется. --- frontend/src/lib/components/Viewer.svelte | 133 ++++++++++++---------- 1 file changed, 71 insertions(+), 62 deletions(-) diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index 977b1d2..a6b2706 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -337,15 +337,9 @@ return fresh?.height || current?.height || imgEl?.naturalHeight || 0; } - /** Элемент, занимающий весь кадр фото. */ - function frameElement(): HTMLElement | undefined { - return imgEl ?? undefined; - } - function prev() { if (index > 0) onIndexChange(index - 1); } - /** Масштаб, при котором фото целиком помещается в просмотр. */ function fitScale(): number { if (!scroller) return 1; @@ -355,72 +349,95 @@ 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 zoomBy(factor: number) { + function zoomTo(next: number, cursorX: number, cursorY: number) { const el = scroller; - const frame = frameElement(); - if (!el || !frame) return; + if (!el) 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)); + const cur = currentScale(); if (Math.abs(next - cur) < 1e-4) return; - const relX = frame.offsetWidth - ? (el.scrollLeft + el.clientWidth / 2 - frame.offsetLeft) / frame.offsetWidth - : 0.5; - const relY = frame.offsetHeight - ? (el.scrollTop + el.clientHeight / 2 - frame.offsetTop) / frame.offsetHeight - : 0.5; + // Положение точки на картинке до изменения масштаба. + 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 frame = frameElement(); - const iw = frame?.offsetWidth ?? 0; - const ih = frame?.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; + 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; - 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; - scheduleViewport(); - }); + el.scrollLeft = 0; + el.scrollTop = 0; + scheduleViewport(); + return; } + // 100% от оригинала. + zoomed = true; + scale = 1; + tick().then(() => { + const natW = baseWidth(); + const natH = baseHeight(); + const offset = imageOffset(natW, natH); + el.scrollLeft = offset.x; + el.scrollTop = offset.y; + scheduleViewport(); + }); } function onWheel(ev: WheelEvent) { - const frame = frameElement(); - if (!current || !scroller || !frame) return; + if (!current || !scroller) return; ev.preventDefault(); ev.stopPropagation(); @@ -428,26 +445,13 @@ 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 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 = frame.getBoundingClientRect(); - const fx = r.width ? (ev.clientX - r.left) / r.width : 0.5; - const fy = r.height ? (ev.clientY - r.top) / r.height : 0.5; - - 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); - scheduleViewport(); - }); + const next = Math.min(MAX_ZOOM, Math.max(fit, currentScale() * factor)); + zoomTo(next, cursorX, cursorY); } function onPointerDown(ev: PointerEvent) { @@ -534,7 +538,12 @@ zoomBy(1 / ZOOM_STEP); return; } - if (zoomed) return; + if (zoomed) { + // В зуме стрелки переключают фото, а панорамирование — мышью. + if (ev.key === 'ArrowLeft') prev(); + else if (ev.key === 'ArrowRight') next(); + return; + } if (ev.key === 'ArrowLeft') prev(); else if (ev.key === 'ArrowRight') next(); }