371 lines
13 KiB
JavaScript
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());
|
|
}
|
|
});
|
|
}
|