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

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

- Пробел зумит фото до 100% от оригинала (и обратно к вписанному виду).
- Колесо мыши зумит к курсору: точка под указателем остаётся на месте при
  любом изменении масштаба.
- Стрелки влево/вправо переключают фото и в зуме — панорамирование осталось
  за мышью.
- Масштаб и позиция считаются от размеров оригинала, а не от текущей рамки,
  поэтому зум не «уезжает» и картинка корректно центрируется.
This commit is contained in:
iu
2026-10-21 00:17:15 +03:00
parent 2adcb1374b
commit 500ec10d7f
+66 -57
View File
@@ -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;
el.scrollLeft = 0;
el.scrollTop = 0;
scheduleViewport();
return;
}
} else {
// 100% от оригинала.
zoomed = true;
scale = 1;
tick().then(() => {
if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
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();
}