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 loaderMsg = overlay.querySelector("[data-imgv-loader-msg]"); 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]"); const btnPrev = overlay.querySelector("[data-imgv-prev]"); const btnNext = overlay.querySelector("[data-imgv-next]"); const dotsWrap = overlay.querySelector("[data-imgv-dots]"); 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 panDirX = 0; let closeTimer = 0; let openRaf = 0; const viewerAnimMs = 210; let centeredAnchor = null; // Состояние листалки: группа картинок (например, одна галерея) + индекс в ней. let groupImgs = []; let groupIndex = -1; function resolveGroup(img) { // Поднимаемся до ближайшего контейнера группы, пропуская отдельные ячейки/слайды. const container = img.closest( ".case-media-gallery, .case-media-slider, .case-media, figure", ); const scope = container || img.parentElement || document; const list = Array.from( scope.querySelectorAll("img[data-image-viewer]"), ).filter((el) => { if (el.hasAttribute("data-src")) return false; const s = el.getAttribute("src") || el.currentSrc || ""; if (isViewerThumbnailPlaceholder(s)) return false; return true; }); return list; } 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 showAt(group, index) { if (index < 0 || index >= group.length) return; groupIndex = index; const img = group[groupIndex]; const src = img.getAttribute("src") || img.currentSrc || ""; const alt = img.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"); if (loading) loading.classList.remove("is-error"); if (loaderMsg) loaderMsg.textContent = ""; centeredAnchor = null; viewImg.onload = null; viewImg.onerror = null; viewImg.src = src; viewImg.alt = alt; // Открываем просмотрщик сразу (поверх показывается спиннер), не дожидаясь загрузки. openRaf = window.requestAnimationFrame(() => { overlay.classList.add("is-open"); openRaf = 0; }); // Навигация внутри группы (галерея / слайдер). Скрываем, если картинка одна. const multi = group.length > 1; btnPrev.hidden = !multi; btnNext.hidden = !multi; if (multi) { // Точки-пагинация в тулбаре. if (dotsWrap.childElementCount !== group.length) { dotsWrap.innerHTML = group .map( (_, i) => ``, ) .join(""); dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot) => { dot.addEventListener("click", (e) => { e.stopPropagation(); const i = Number(dot.getAttribute("data-imgv-dot")); showAt(group, i); }); }); } dotsWrap.hidden = false; dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot, i) => { const active = i === groupIndex; dot.classList.toggle("is-active", active); dot.setAttribute("aria-selected", active ? "true" : "false"); dot.tabIndex = active ? 0 : -1; }); btnPrev.disabled = groupIndex === 0; btnNext.disabled = groupIndex === group.length - 1; } else { dotsWrap.hidden = 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.classList.add("is-error"); if (loaderMsg) loaderMsg.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.classList.remove("is-error"); if (loaderMsg) loaderMsg.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(); const group = resolveGroup(img); const index = group.indexOf(img); groupImgs = group; groupIndex = index >= 0 ? index : 0; lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; overlay.hidden = false; document.documentElement.classList.add("imgv-lock"); active = true; showAt(groupImgs, groupIndex >= 0 ? groupIndex : 0); }, 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); function navigateGroup(dir) { if (!groupImgs.length) return; const next = Math.min(Math.max(groupIndex + dir, 0), groupImgs.length - 1); if (next === groupIndex) return; showAt(groupImgs, next); } btnPrev.addEventListener("click", (e) => { e.stopPropagation(); navigateGroup(-1); }); btnNext.addEventListener("click", (e) => { e.stopPropagation(); navigateGroup(1); }); overlay.addEventListener("click", (e) => { if (e.target === overlay && !overlay.classList.contains("is-loading")) 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; panDirX = dx; } 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 horizSwipe = panMoved && Math.abs(panDirX) > 40 && groupImgs.length > 1; const shouldClose = !panMoved; stopPan(e.pointerId); if (horizSwipe) { e.preventDefault(); navigateGroup(panDirX < 0 ? 1 : -1); return; } // Пока грузится изображение — не закрываем по тапу/клику. if (shouldClose && active && !overlay.classList.contains("is-loading")) { 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()); return; } if (e.key === "ArrowLeft") { e.preventDefault(); navigateGroup(-1); return; } if (e.key === "ArrowRight") { e.preventDefault(); navigateGroup(1); return; } }); }