fix(web): починить зум в просмотре

Механика зума переписана:

- Пробел зумит фото до 100% от оригинала (и обратно к вписанному виду).
- Колесо мыши зумит к курсору: точка под указателем остаётся на месте при
  любом изменении масштаба.
- Стрелки влево/вправо переключают фото и в зуме — панорамирование осталось
  за мышью.
- Масштаб и позиция считаются от размеров оригинала, а не от текущей рамки,
  поэтому зум не «уезжает» и картинка корректно центрируется.
This commit is contained in:
iu
2026-10-21 00:17:15 +03:00
parent 2adcb1374b
commit 500ec10d7f
+71 -62
View File
@@ -337,15 +337,9 @@
return fresh?.height || current?.height || imgEl?.naturalHeight || 0; return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
} }
/** Элемент, занимающий весь кадр фото. */
function frameElement(): HTMLElement | undefined {
return imgEl ?? undefined;
}
function prev() { function prev() {
if (index > 0) onIndexChange(index - 1); if (index > 0) onIndexChange(index - 1);
} }
/** Масштаб, при котором фото целиком помещается в просмотр. */ /** Масштаб, при котором фото целиком помещается в просмотр. */
function fitScale(): number { function fitScale(): number {
if (!scroller) return 1; if (!scroller) return 1;
@@ -355,72 +349,95 @@
return Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 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 zoomBy(factor: number) { function zoomTo(next: number, cursorX: number, cursorY: number) {
const el = scroller; const el = scroller;
const frame = frameElement(); if (!el) return;
if (!el || !frame) return;
const natW = baseWidth(); const natW = baseWidth();
const natH = baseHeight(); const natH = baseHeight();
if (!natW || !natH) return; if (!natW || !natH) return;
const fit = fitScale(); const cur = currentScale();
const cur = zoomed ? scale : fit;
const next = Math.min(MAX_ZOOM, Math.max(fit, cur * factor));
if (Math.abs(next - cur) < 1e-4) return; if (Math.abs(next - cur) < 1e-4) return;
const relX = frame.offsetWidth // Положение точки на картинке до изменения масштаба.
? (el.scrollLeft + el.clientWidth / 2 - frame.offsetLeft) / frame.offsetWidth const oldWidth = natW * cur;
: 0.5; const oldHeight = natH * cur;
const relY = frame.offsetHeight const oldOffset = imageOffset(oldWidth, oldHeight);
? (el.scrollTop + el.clientHeight / 2 - frame.offsetTop) / frame.offsetHeight const relX = oldWidth > 0 ? (el.scrollLeft + cursorX - oldOffset.x) / oldWidth : 0.5;
: 0.5; const relY = oldHeight > 0 ? (el.scrollTop + cursorY - oldOffset.y) / oldHeight : 0.5;
const fit = fitScale();
zoomed = next > fit * 1.001; zoomed = next > fit * 1.001;
scale = next; scale = next;
tick().then(() => { tick().then(() => {
const frame = frameElement(); const width = natW * next;
const iw = frame?.offsetWidth ?? 0; const height = natH * next;
const ih = frame?.offsetHeight ?? 0; const offset = imageOffset(width, height);
if (!iw || !ih) return; el.scrollLeft = offset.x + relX * width - cursorX;
const offX = Math.max(0, (el.clientWidth - iw) / 2); el.scrollTop = offset.y + relY * height - cursorY;
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;
scheduleViewport(); 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() { function next() {
if (index < photos.length - 1) onIndexChange(index + 1); if (index < photos.length - 1) onIndexChange(index + 1);
} }
/** Пробел: 100% от оригинала и обратно к вписанному виду. */
function toggleActual() { function toggleActual() {
const el = scroller;
if (!el) return;
if (zoomed) { if (zoomed) {
zoomed = false; zoomed = false;
scale = 1; scale = 1;
if (scroller) { el.scrollLeft = 0;
scroller.scrollLeft = 0; el.scrollTop = 0;
scroller.scrollTop = 0; scheduleViewport();
} return;
} 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();
});
} }
// 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) { function onWheel(ev: WheelEvent) {
const frame = frameElement(); if (!current || !scroller) return;
if (!current || !scroller || !frame) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -428,26 +445,13 @@
const natH = baseHeight(); const natH = baseHeight();
if (!natW || !natH) return; 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 factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
const fit = fitScale(); const fit = fitScale();
const curScale = zoomed ? scale : fit; const next = Math.min(MAX_ZOOM, Math.max(fit, currentScale() * factor));
const newScale = Math.min(MAX_ZOOM, Math.max(fit, curScale * factor)); zoomTo(next, cursorX, cursorY);
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();
});
} }
function onPointerDown(ev: PointerEvent) { function onPointerDown(ev: PointerEvent) {
@@ -534,7 +538,12 @@
zoomBy(1 / ZOOM_STEP); zoomBy(1 / ZOOM_STEP);
return; return;
} }
if (zoomed) return; if (zoomed) {
// В зуме стрелки переключают фото, а панорамирование — мышью.
if (ev.key === 'ArrowLeft') prev();
else if (ev.key === 'ArrowRight') next();
return;
}
if (ev.key === 'ArrowLeft') prev(); if (ev.key === 'ArrowLeft') prev();
else if (ev.key === 'ArrowRight') next(); else if (ev.key === 'ArrowRight') next();
} }