Files
site-portfolio/public/js/modules/cases/image-viewer.js
T
root 95b4eaca93 feat: галерея-слайдер, листалка в просмотрщике и TUI braille-спиннер
- case-media-slider: переиспользуемый слайдер (prev/next + точки, swipe, клавиатура, lazy)
- image-viewer: листалка prev/next в тулбаре как пилюли, пагинация-точки в тулбаре слева,
  группа масштаба (slider + текст) с gap 0.25rem, текст масштаба --text-muted/400
- tui-spinner: переиспользуемый braille-спиннер (чистый CSS), лоадер просмотрщика
- design-system.html: живой пример TUI braille-спиннера
- fix: удалён мёртвый textContent='Загрузка...', убивавший .tui-spinner при повторном открытии
2026-09-09 17:49:56 +02:00

496 lines
19 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="Просмотр изображения">
<div class="case-image-viewer__nav-group" role="group" aria-label="Навигация по изображениям">
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn case-image-viewer__btn--nav" data-imgv-prev aria-label="Предыдущее изображение" hidden>
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/shevron-left.svg')" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn case-image-viewer__btn--nav" data-imgv-next aria-label="Следующее изображение" hidden>
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/shevron-right.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="case-image-viewer__dots" data-imgv-dots role="tablist" aria-label="Выбор изображения" hidden></div>
<div class="case-image-viewer__scale-group" role="group" aria-label="Масштаб">
<input class="case-image-viewer__slider" type="range" min="1" max="150" step="1" value="100" data-imgv-slider aria-label="Масштаб изображения" />
<span class="case-image-viewer__zoom" data-imgv-zoom-label>100%</span>
</div>
<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__loader" data-imgv-loading aria-label="Загрузка изображения" role="status"><span class="tui-spinner" aria-hidden="true"><span>⠋</span><span>⠙</span><span>⠹</span><span>⠸</span><span>⠼</span><span>⠴</span><span>⠦</span><span>⠧</span><span>⠇</span><span>⠏</span></span><span class="case-image-viewer__loader-msg" data-imgv-loader-msg></span></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 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) =>
`<button type="button" class="case-image-viewer__dot" data-imgv-dot="${i}" role="tab" aria-label="Перейти к изображению ${i + 1}"></button>`,
)
.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;
}
});
}