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 = `
Загрузка...
`; 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()); } }); }