Files
site-portfolio/public/js/modules/cases/image-viewer.js
T

371 lines
13 KiB
JavaScript

import { isTinyPlaceholderSrc } from "../shared/image-src.js";
function isViewerThumbnailPlaceholder(src) {
return isTinyPlaceholderSrc(src);
}
export function initCaseImageViewer() {
const targets = Array.from(document.querySelectorAll("img[data-image-viewer]")).filter(
(img) => !img.closest(".case-media--natural"),
);
if (!targets.length) return;
const overlay = document.createElement("div");
overlay.className = "case-image-viewer";
overlay.hidden = true;
overlay.innerHTML = `
<div class="case-image-viewer__toolbar glass-neutral" role="toolbar" aria-label="Просмотр изображения">
<span class="case-image-viewer__zoom" data-imgv-zoom-label>100%</span>
<input class="case-image-viewer__slider" type="range" min="1" max="150" step="1" value="100" data-imgv-slider aria-label="Масштаб изображения" />
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn" data-imgv-fit aria-label="Уместить на экран">
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/zoom_to_fit.svg')" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn" data-imgv-original aria-label="Оригинальный размер">
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/original_size.svg')" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn" data-imgv-close aria-label="Закрыть просмотр">
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/close.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="case-image-viewer__stage" data-imgv-stage tabindex="0">
<div class="case-image-viewer__loading" data-imgv-loading aria-live="polite">Загрузка...</div>
<img class="case-image-viewer__img" data-imgv-img alt="" />
</div>
`;
document.body.appendChild(overlay);
const stage = overlay.querySelector("[data-imgv-stage]");
const viewImg = overlay.querySelector("[data-imgv-img]");
const loading = overlay.querySelector("[data-imgv-loading]");
const zoomLabel = overlay.querySelector("[data-imgv-zoom-label]");
const zoomSlider = overlay.querySelector("[data-imgv-slider]");
const btnFit = overlay.querySelector("[data-imgv-fit]");
const btnOriginal = overlay.querySelector("[data-imgv-original]");
const btnClose = overlay.querySelector("[data-imgv-close]");
let scale = 1;
let naturalW = 0;
let naturalH = 0;
let active = false;
let lastFocused = null;
const minScale = 0.01;
const maxScale = 1.5;
let isPanning = false;
let panStartX = 0;
let panStartY = 0;
let panScrollLeft = 0;
let panScrollTop = 0;
let panMoved = false;
let closeTimer = 0;
let openRaf = 0;
const viewerAnimMs = 210;
let centeredAnchor = null;
function measureToolbarSpace() {
const toolbarRect = overlay.querySelector(".case-image-viewer__toolbar")?.getBoundingClientRect();
const next = toolbarRect ? Math.ceil(toolbarRect.height + 22) : 0;
overlay.style.setProperty("--imgv-toolbar-space", `${next}px`);
}
function stageMetrics() {
const s = window.getComputedStyle(stage);
const padLeft = Number.parseFloat(s.paddingLeft) || 0;
const padRight = Number.parseFloat(s.paddingRight) || 0;
const padTop = Number.parseFloat(s.paddingTop) || 0;
const padBottom = Number.parseFloat(s.paddingBottom) || 0;
const innerWidth = Math.max(1, stage.clientWidth - padLeft - padRight);
const innerHeight = Math.max(1, stage.clientHeight - padTop - padBottom);
return { padLeft, padRight, padTop, padBottom, innerWidth, innerHeight };
}
function syncCenteredMode() {
const m = stageMetrics();
const scaledW = naturalW * scale;
const scaledH = naturalH * scale;
const fitX = scaledW <= m.innerWidth;
const fitY = scaledH <= m.innerHeight;
const centered = fitX && fitY;
stage.classList.toggle("is-center-y", fitY);
stage.classList.toggle("is-centered", centered);
if (centered) {
stage.scrollLeft = 0;
stage.scrollTop = 0;
}
return centered;
}
function renderScale(nextScale) {
const bounded = Math.min(maxScale, Math.max(minScale, nextScale));
scale = bounded;
viewImg.style.width = `${Math.max(1, Math.round(naturalW * scale))}px`;
const p = `${Math.round(scale * 100)}%`;
zoomLabel.textContent = p;
if (zoomSlider) zoomSlider.value = String(Math.round(scale * 100));
}
function fitsAtNaturalSize() {
if (!naturalW || !naturalH) return true;
const m = stageMetrics();
return naturalW <= m.innerWidth && naturalH <= m.innerHeight;
}
/** Уместить на экран: только уменьшение, без увеличения мелких картинок. */
function fitScale() {
if (!naturalW || !naturalH) return 1;
const m = stageMetrics();
const fitW = m.innerWidth / naturalW;
const fitH = m.innerHeight / naturalH;
const fit = Math.min(fitW, fitH, 1);
return Math.min(maxScale, Math.max(minScale, fit));
}
function initialOpenScale() {
if (fitsAtNaturalSize()) return 1;
return fitScale();
}
function zoomTo(nextScale, clientX = null, clientY = null) {
const wasCentered = stage.classList.contains("is-centered");
const oldScale = scale || 1;
const stageRect = stage.getBoundingClientRect();
const imgRectBefore = viewImg.getBoundingClientRect();
const mBefore = stageMetrics();
const stageCenterX = stageRect.left + mBefore.padLeft + mBefore.innerWidth / 2;
const stageCenterY = stageRect.top + mBefore.padTop + mBefore.innerHeight / 2;
const pointClientX = clientX === null ? stageCenterX : clientX;
const pointClientY = clientY === null ? stageCenterY : clientY;
const pointInsideImageBefore =
pointClientX >= imgRectBefore.left &&
pointClientX <= imgRectBefore.right &&
pointClientY >= imgRectBefore.top &&
pointClientY <= imgRectBefore.bottom;
const refClientX = pointInsideImageBefore ? pointClientX : stageCenterX;
const refClientY = pointInsideImageBefore ? pointClientY : stageCenterY;
const viewX = refClientX - stageRect.left;
const viewY = refClientY - stageRect.top;
const imgLocalX = (refClientX - imgRectBefore.left) / oldScale;
const imgLocalY = (refClientY - imgRectBefore.top) / oldScale;
renderScale(nextScale);
const nowCentered = syncCenteredMode();
if (nowCentered) {
centeredAnchor = { x: imgLocalX, y: imgLocalY, viewX, viewY };
return;
}
const anchor = wasCentered && centeredAnchor ? centeredAnchor : { x: imgLocalX, y: imgLocalY, viewX, viewY };
const mAfter = stageMetrics();
stage.scrollLeft = anchor.x * scale - (anchor.viewX - mAfter.padLeft);
stage.scrollTop = anchor.y * scale - (anchor.viewY - mAfter.padTop);
centeredAnchor = null;
}
function openViewer(src, alt) {
if (closeTimer) {
window.clearTimeout(closeTimer);
closeTimer = 0;
}
if (openRaf) {
window.cancelAnimationFrame(openRaf);
openRaf = 0;
}
overlay.classList.remove("is-open");
overlay.classList.add("is-loading");
centeredAnchor = null;
viewImg.onload = null;
viewImg.onerror = null;
viewImg.src = src;
viewImg.alt = alt || "";
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
overlay.hidden = false;
document.documentElement.classList.add("imgv-lock");
active = true;
const applyAfterLoad = () => {
overlay.classList.remove("is-loading");
measureToolbarSpace();
naturalW = Math.max(1, viewImg.naturalWidth || viewImg.width || 1);
naturalH = Math.max(1, viewImg.naturalHeight || viewImg.height || 1);
renderScale(initialOpenScale());
openRaf = window.requestAnimationFrame(() => {
overlay.classList.add("is-open");
openRaf = 0;
});
if (syncCenteredMode()) {
stage.focus();
return;
}
const m = stageMetrics();
stage.scrollTop = Math.max(0, Math.floor((naturalH * scale - m.innerHeight) / 2));
stage.scrollLeft = Math.max(0, Math.floor((naturalW * scale - m.innerWidth) / 2));
stage.focus();
};
const applyLoadError = () => {
overlay.classList.remove("is-loading");
if (loading) loading.textContent = "Не удалось загрузить";
};
if (loading) loading.textContent = "Загрузка...";
if (viewImg.complete && viewImg.naturalWidth > 0) applyAfterLoad();
else {
viewImg.onload = applyAfterLoad;
viewImg.onerror = applyLoadError;
}
}
function closeViewer() {
if (!active) return;
isPanning = false;
stage.classList.remove("is-panning");
active = false;
centeredAnchor = null;
overlay.classList.remove("is-open");
overlay.classList.remove("is-loading");
document.documentElement.classList.remove("imgv-lock");
if (closeTimer) window.clearTimeout(closeTimer);
closeTimer = window.setTimeout(() => {
overlay.hidden = true;
stage.classList.remove("is-center-y");
stage.classList.remove("is-centered");
if (loading) loading.textContent = "Загрузка...";
viewImg.onload = null;
viewImg.onerror = null;
viewImg.removeAttribute("src");
if (lastFocused) lastFocused.focus();
closeTimer = 0;
}, viewerAnimMs);
}
document.addEventListener(
"click",
(e) => {
if (document.body.classList.contains("content-edit-mode-active")) return;
const t = e.target;
if (!(t instanceof Element)) return;
const img = t.closest("img[data-image-viewer]");
if (!img || img.closest(".case-media--natural")) return;
if (img.hasAttribute("data-src")) return;
const src = img.currentSrc || img.getAttribute("src") || "";
if (!src || isViewerThumbnailPlaceholder(src)) return;
e.preventDefault();
openViewer(src, img.alt || "");
},
true,
);
if (zoomSlider) {
zoomSlider.addEventListener("input", () => {
const next = Number(zoomSlider.value) / 100;
if (!Number.isFinite(next)) return;
zoomTo(next);
});
}
btnFit.addEventListener("click", () => zoomTo(fitScale()));
btnOriginal.addEventListener("click", () => zoomTo(1));
btnClose.addEventListener("click", closeViewer);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeViewer();
});
stage.addEventListener(
"wheel",
(e) => {
if (!active) return;
if (!e.ctrlKey) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
zoomTo(scale * factor, e.clientX, e.clientY);
},
{ passive: false },
);
stage.addEventListener("pointerdown", (e) => {
if (!active || e.button !== 0) return;
const target = e.target instanceof Element ? e.target : null;
if (target && target.closest(".case-image-viewer__toolbar")) return;
isPanning = true;
panStartX = e.clientX;
panStartY = e.clientY;
panScrollLeft = stage.scrollLeft;
panScrollTop = stage.scrollTop;
panMoved = false;
stage.classList.add("is-panning");
stage.setPointerCapture(e.pointerId);
e.preventDefault();
});
stage.addEventListener("pointermove", (e) => {
if (!active || !isPanning) return;
const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) panMoved = true;
stage.scrollLeft = panScrollLeft - dx;
stage.scrollTop = panScrollTop - dy;
e.preventDefault();
});
function stopPan(pointerId = null) {
if (!isPanning) return;
isPanning = false;
stage.classList.remove("is-panning");
if (pointerId !== null && stage.hasPointerCapture(pointerId)) stage.releasePointerCapture(pointerId);
}
stage.addEventListener("pointerup", (e) => {
const shouldClose = !panMoved;
stopPan(e.pointerId);
if (shouldClose && active) {
e.preventDefault();
closeViewer();
}
});
stage.addEventListener("pointercancel", (e) => stopPan(e.pointerId));
window.addEventListener(
"resize",
() => {
if (!active) return;
measureToolbarSpace();
zoomTo(scale);
},
{ passive: true },
);
if (typeof ResizeObserver === "function") {
const toolbar = overlay.querySelector(".case-image-viewer__toolbar");
if (toolbar) {
const ro = new ResizeObserver(() => {
if (!active) return;
measureToolbarSpace();
zoomTo(scale);
});
ro.observe(toolbar);
}
}
document.addEventListener("keydown", (e) => {
if (!active) return;
if (e.key === "Escape") {
e.preventDefault();
closeViewer();
return;
}
if (e.key === "+" || e.key === "=") {
e.preventDefault();
zoomTo(scale * 1.2);
return;
}
if (e.key === "-") {
e.preventDefault();
zoomTo(scale / 1.2);
return;
}
if (e.key === "0") {
e.preventDefault();
zoomTo(fitScale());
}
});
}