UNPKG

hexo-theme-redefine

Version:

Redefine your writing with Hexo Theme Redefine.

1,010 lines (886 loc) 27.3 kB
const viewerState = { isBigImage: false, scale: 1, fitScale: 1, userZoomed: false, isMouseDown: false, dragged: false, currentImgIndex: 0, lastMouseX: 0, lastMouseY: 0, translateX: 0, translateY: 0, maskDom: null, targetImg: null, }; const imageSelector = ".markdown-body img, .masonry-item img, #shuoshuo-content img"; const viewerControls = { prevButton: null, nextButton: null, closeButton: null, }; const exifControls = { toggleButton: null, panel: null, cardsContainer: null, closeButton: null, cardTemplate: null, requestId: 0, }; const getI18nValue = (path) => { if (!path) { return null; } const segments = String(path).split(".").filter(Boolean); let current = window.i18n; for (const segment of segments) { if (!current || typeof current !== "object") { return null; } current = current[segment]; } return current ?? null; }; const t = (path, fallback = "") => { const value = getI18nValue(path); if (typeof value === "string") { return value; } if (value != null) { return String(value); } return fallback; }; let imageNodes = []; let didInitKeys = false; const applyTransform = () => { if (!viewerState.targetImg) { return; } viewerState.targetImg.style.transform = `translate(${viewerState.translateX}px, ${viewerState.translateY}px) scale(${viewerState.scale})`; }; const getFrameRect = () => { if (!viewerState.maskDom) { return null; } const frame = viewerState.maskDom.querySelector(".image-viewer-frame"); if (frame) { return frame.getBoundingClientRect(); } return viewerState.maskDom.getBoundingClientRect(); }; const fitToViewport = (options = {}) => { if (!viewerState.targetImg) { return; } const rect = getFrameRect(); if (!rect) { return; } const marginFactor = options.marginFactor ?? 0.98; viewerState.scale = 1; viewerState.translateX = 0; viewerState.translateY = 0; applyTransform(); const imageRect = viewerState.targetImg.getBoundingClientRect(); if (!imageRect.width || !imageRect.height) { return; } const heightLimit = window.innerHeight * marginFactor; const widthLimit = window.innerWidth; const scaleForHeight = heightLimit / imageRect.height; const scaleForWidth = widthLimit / imageRect.width; const fitScale = Math.min(1, scaleForHeight, scaleForWidth); viewerState.fitScale = fitScale; viewerState.scale = fitScale; viewerState.translateX = 0; viewerState.translateY = 0; viewerState.userZoomed = false; applyTransform(); }; const resetTransform = () => { fitToViewport(); }; const showHandle = (isShow) => { if (!viewerState.maskDom) { return; } document.body.style.overflow = isShow ? "hidden" : "auto"; viewerState.maskDom.classList.toggle("active", isShow); }; const closeViewer = () => { if (!viewerState.isBigImage) { return; } viewerState.isBigImage = false; showHandle(false); resetTransform(); viewerState.userZoomed = false; resetExifUI(); }; const updateImageNodes = () => { imageNodes = Array.from(document.querySelectorAll(imageSelector)); }; const canGoPrev = () => viewerState.currentImgIndex > 0; const canGoNext = () => viewerState.currentImgIndex < Math.max(imageNodes.length - 1, 0); const updateNavButtons = () => { if (!viewerControls.prevButton || !viewerControls.nextButton) { return; } viewerControls.prevButton.classList.toggle("is-disabled", !canGoPrev()); viewerControls.nextButton.classList.toggle("is-disabled", !canGoNext()); }; const hasExifFlag = (img) => img?.dataset?.exif === "true"; const resolveExifImageUrl = (img) => { if (!img) { return null; } const dataSrc = img.getAttribute("data-src"); const src = dataSrc || img.currentSrc || img.src; if (!src) { return null; } try { return new URL(src, window.location.href).toString(); } catch (error) { return src; } }; const formatExifValue = (tag) => { if (!tag) { return null; } if (typeof tag === "object") { if (tag.description != null) { return String(tag.description).trim(); } if (tag.value != null) { if (Array.isArray(tag.value)) { return tag.value.map((item) => String(item)).join(", ").trim(); } return String(tag.value).trim(); } } if (typeof tag === "string") { return tag.trim(); } return String(tag).trim(); }; const getExifValueByKeys = (tags, keys = []) => { if (!tags || !Array.isArray(keys)) { return null; } for (const key of keys) { const value = formatExifValue(tags[key]); if (value) { return value; } } return null; }; const parseRational = (value) => { if (typeof value === "number") { return Number.isFinite(value) ? value : null; } if (value && typeof value === "object") { const numerator = value.numerator ?? value.num; const denominator = value.denominator ?? value.den; if ( Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 ) { return numerator / denominator; } if (Number.isFinite(value.value)) { return value.value; } } if (typeof value === "string") { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : null; } return null; }; const resolveTagRawValue = (tag) => { if (!tag) { return null; } if (typeof tag === "object" && "value" in tag) { return tag.value; } return tag; }; const formatGpsCoordinate = (tags, valueKey, refKey) => { if (!tags) { return null; } const rawTag = tags[valueKey]; if (!rawTag) { return null; } const rawValue = resolveTagRawValue(rawTag); const ref = formatExifValue(tags[refKey]); let decimal = null; if (Array.isArray(rawValue)) { const parts = rawValue.map(parseRational).filter((part) => part != null); if (parts.length >= 3) { const [deg, min, sec] = parts; decimal = deg + min / 60 + sec / 3600; } } else { decimal = parseRational(rawValue); } if (decimal == null) { const fallback = formatExifValue(rawTag); if (!fallback) { return null; } if (ref && !fallback.includes(ref)) { return `${fallback} ${ref}`; } return fallback; } const normalizedRef = ref ? String(ref).trim().toUpperCase() : ""; const displayValue = Math.abs(decimal).toFixed(4); if (normalizedRef) { return `${displayValue} ${normalizedRef}`; } return decimal.toFixed(4); }; const buildExifGroups = (tags) => { if (!tags) { return []; } const groups = []; const pushGroup = (title, icon, items) => { const normalizedItems = (items || []).filter(Boolean); if (normalizedItems.length === 0) { return; } groups.push({ title, icon, items: normalizedItems }); }; const make = getExifValueByKeys(tags, ["Make"]); const model = getExifValueByKeys(tags, ["Model"]); const dateTaken = getExifValueByKeys(tags, ["DateTimeOriginal", "DateTime"]); pushGroup(t("exif.cards.camera", "Camera"), "fa-solid fa-camera", [ make && { label: t("exif.fields.make", "Brand"), value: make }, model && { label: t("exif.fields.model", "Model"), value: model }, dateTaken && { label: t("exif.fields.date_taken", "Date taken"), value: dateTaken, }, ]); const lensModel = getExifValueByKeys(tags, [ "LensModel", "Lens", "LensSpecification", ]); const focalLength = getExifValueByKeys(tags, [ "FocalLength", "FocalLengthIn35mmFilm", ]); const focusMode = getExifValueByKeys(tags, [ "FocusMode", "AFMode", "AFAreaMode", "FocusingMode", "AutoFocus", "FocusMethod", ]); pushGroup(t("exif.cards.lens", "Lens"), "fa-solid fa-eye", [ lensModel && { label: t("exif.fields.lens_model", "Lens"), value: lensModel }, focalLength && { label: t("exif.fields.focal_length", "Focal length"), value: focalLength, }, focusMode && { label: t("exif.fields.focus_mode", "Focus mode"), value: focusMode, }, ]); const shutter = getExifValueByKeys(tags, ["ExposureTime"]); const aperture = getExifValueByKeys(tags, ["FNumber"]); const iso = getExifValueByKeys(tags, ["ISO", "PhotographicSensitivity"]); const exposureProgram = getExifValueByKeys(tags, ["ExposureProgram"]); const exposureCompensation = getExifValueByKeys(tags, ["ExposureBiasValue"]); const meteringMode = getExifValueByKeys(tags, ["MeteringMode"]); pushGroup(t("exif.cards.exposure", "Exposure"), "fa-solid fa-sun", [ shutter && { label: t("exif.fields.shutter", "Shutter"), value: shutter }, aperture && { label: t("exif.fields.aperture", "Aperture"), value: aperture }, iso && { label: t("exif.fields.iso", "ISO"), value: iso }, exposureProgram && { label: t("exif.fields.exposure_program", "Exposure program"), value: exposureProgram, }, exposureCompensation && { label: t("exif.fields.exposure_compensation", "Exposure compensation"), value: exposureCompensation, }, meteringMode && { label: t("exif.fields.metering_mode", "Metering mode"), value: meteringMode, }, ]); const flash = getExifValueByKeys(tags, ["Flash"]); const whiteBalance = getExifValueByKeys(tags, ["WhiteBalance"]); const latitude = formatGpsCoordinate(tags, "GPSLatitude", "GPSLatitudeRef"); const longitude = formatGpsCoordinate(tags, "GPSLongitude", "GPSLongitudeRef"); const altitude = getExifValueByKeys(tags, ["GPSAltitude"]); pushGroup(t("exif.cards.other", "Other"), "fa-solid fa-gear", [ flash && { label: t("exif.fields.flash", "Flash"), value: flash }, whiteBalance && { label: t("exif.fields.white_balance", "White balance"), value: whiteBalance, }, latitude && { label: t("exif.fields.latitude", "Latitude"), value: latitude }, longitude && { label: t("exif.fields.longitude", "Longitude"), value: longitude, }, altitude && { label: t("exif.fields.altitude", "Altitude"), value: altitude }, ]); return groups; }; const setExifPanelOpen = (isOpen) => { if (!exifControls.panel || !exifControls.toggleButton) { return; } exifControls.panel.classList.toggle("hidden", !isOpen); exifControls.panel.setAttribute("aria-hidden", isOpen ? "false" : "true"); exifControls.toggleButton.setAttribute( "aria-expanded", isOpen ? "true" : "false", ); }; const clearExifCards = () => { if (!exifControls.cardsContainer) { return; } exifControls.cardsContainer.innerHTML = ""; }; const createExifItem = (label, value) => { const row = document.createElement("div"); row.className = "image-viewer-exif-item flex items-start justify-between gap-2"; const labelDom = document.createElement("div"); labelDom.className = "image-viewer-exif-item-label text-[0.65rem] uppercase tracking-wide shrink-0 text-third-text-color"; labelDom.textContent = label; const valueDom = document.createElement("div"); valueDom.className = "image-viewer-exif-item-value text-[0.65rem] text-first-text-color text-right"; valueDom.textContent = value; row.appendChild(labelDom); row.appendChild(valueDom); return row; }; const createExifCard = (group) => { const template = exifControls.cardTemplate; const templateCard = template?.content?.firstElementChild; let card = templateCard ? templateCard.cloneNode(true) : null; if (!card) { card = document.createElement("div"); card.className = "rounded-lg border border-border-color bg-background-color-transparent-80 px-3 py-2 shadow-redefine-flat"; const header = document.createElement("div"); header.className = "image-viewer-exif-card-header flex items-center gap-2 mb-1"; const iconDom = document.createElement("i"); iconDom.className = "image-viewer-exif-card-icon fa-solid fa-circle-info text-xs text-third-text-color"; const titleDom = document.createElement("div"); titleDom.className = "image-viewer-exif-card-title text-xs font-semibold text-first-text-color"; header.appendChild(iconDom); header.appendChild(titleDom); const itemsDom = document.createElement("div"); itemsDom.className = "image-viewer-exif-card-items flex flex-col gap-1"; card.appendChild(header); card.appendChild(itemsDom); } const titleDom = card.querySelector(".image-viewer-exif-card-title"); const iconDom = card.querySelector(".image-viewer-exif-card-icon"); let itemsDom = card.querySelector(".image-viewer-exif-card-items"); if (!itemsDom) { itemsDom = document.createElement("div"); itemsDom.className = "image-viewer-exif-card-items flex flex-col gap-1"; card.appendChild(itemsDom); } if (titleDom) { titleDom.textContent = group.title; } if (iconDom) { const iconClass = group.icon || "fa-solid fa-circle-info"; iconDom.className = `image-viewer-exif-card-icon ${iconClass} text-xs text-third-text-color`; } itemsDom.innerHTML = ""; group.items.forEach((item) => { itemsDom.appendChild(createExifItem(item.label, item.value)); }); return card; }; const renderExifGroups = (groups) => { if (!exifControls.cardsContainer) { return; } clearExifCards(); const normalized = Array.isArray(groups) ? groups .map((group) => { if (!group || typeof group !== "object") { return null; } const title = String(group.title || "").trim(); const icon = String(group.icon || "").trim(); const items = Array.isArray(group.items) ? group.items .map((item) => { if (!item || typeof item !== "object") { return null; } const label = String(item.label || "").trim(); const value = String(item.value ?? "").trim(); if (!label || !value) { return null; } return { label, value }; }) .filter(Boolean) : []; if (!title || items.length === 0) { return null; } return { title, icon, items }; }) .filter(Boolean) : []; const fallbackTitle = t("exif.title", "EXIF"); const fallbackGroups = normalized.length ? normalized : [ { title: fallbackTitle, icon: "fa-solid fa-circle-info", items: [ { label: fallbackTitle, value: t("exif.status.no_exif", "No EXIF available"), }, ], }, ]; const fragment = document.createDocumentFragment(); fallbackGroups.forEach((group) => { fragment.appendChild(createExifCard(group)); }); exifControls.cardsContainer.appendChild(fragment); }; const bumpExifRequestId = () => { exifControls.requestId = (exifControls.requestId || 0) + 1; return exifControls.requestId; }; const renderExifMessage = (message) => { const title = t("exif.title", "EXIF"); renderExifGroups([ { title, icon: "fa-solid fa-circle-info", items: [{ label: title, value: message }], }, ]); }; const loadExifForImage = async (img) => { const requestId = bumpExifRequestId(); renderExifMessage(t("exif.status.loading", "Loading...")); const ExifReader = window.ExifReader; if (!ExifReader || typeof ExifReader.load !== "function") { if (exifControls.requestId !== requestId) { return; } renderExifMessage( t("exif.status.library_missing", "EXIF library not loaded"), ); return; } const url = resolveExifImageUrl(img); if (!url) { if (exifControls.requestId !== requestId) { return; } renderExifMessage( t("exif.status.image_source_missing", "Image source unavailable"), ); return; } try { const tags = await ExifReader.load(url); if (exifControls.requestId !== requestId) { return; } const groups = buildExifGroups(tags); if (groups.length === 0) { renderExifMessage(t("exif.status.no_exif", "No EXIF available")); return; } renderExifGroups(groups); } catch (error) { if (exifControls.requestId !== requestId) { return; } renderExifMessage( t( "exif.status.unavailable", "EXIF unavailable (blocked by CORS or missing metadata)", ), ); } }; const updateExifUI = (img) => { const hasExif = hasExifFlag(img); bumpExifRequestId(); if (exifControls.toggleButton) { exifControls.toggleButton.classList.toggle("hidden", !hasExif); exifControls.toggleButton.disabled = !hasExif; } setExifPanelOpen(false); clearExifCards(); }; const resetExifUI = () => { bumpExifRequestId(); if (exifControls.toggleButton) { exifControls.toggleButton.classList.add("hidden"); exifControls.toggleButton.setAttribute("aria-expanded", "false"); } setExifPanelOpen(false); clearExifCards(); }; const updateViewerImage = (index) => { const currentImg = imageNodes[index]; if (!currentImg || !viewerState.targetImg) { return; } viewerState.currentImgIndex = index; let newSrc = currentImg.src; if (currentImg.hasAttribute("lazyload")) { newSrc = currentImg.getAttribute("data-src") || newSrc; if (newSrc) { currentImg.src = newSrc; } currentImg.removeAttribute("lazyload"); } if (newSrc) { viewerState.targetImg.src = newSrc; } viewerState.targetImg.alt = currentImg.alt || ""; const handleImageLoad = () => { viewerState.targetImg?.removeEventListener("load", handleImageLoad); fitToViewport(); }; if (viewerState.targetImg.complete) { fitToViewport(); } else { viewerState.targetImg.addEventListener("load", handleImageLoad, { once: true, }); } updateNavButtons(); updateExifUI(currentImg); }; const goPrev = () => { if (!viewerState.isBigImage || !canGoPrev()) { return; } updateViewerImage(viewerState.currentImgIndex - 1); }; const goNext = () => { if (!viewerState.isBigImage || !canGoNext()) { return; } updateViewerImage(viewerState.currentImgIndex + 1); }; const handleArrowKeys = (event) => { if (!viewerState.isBigImage) { return; } if (event.key === "Escape") { event.preventDefault(); closeViewer(); return; } if (event.key === "ArrowUp" || event.key === "ArrowLeft") { event.preventDefault(); goPrev(); return; } if (event.key === "ArrowDown" || event.key === "ArrowRight") { event.preventDefault(); goNext(); } }; const registerGlobalHandlers = (signal) => { if (didInitKeys) { return; } didInitKeys = true; if (signal) { document.addEventListener("keydown", handleArrowKeys, { signal }); } else { document.addEventListener("keydown", handleArrowKeys); } }; export default function initImageViewer({ signal, appSignal } = {}) { const maskDom = document.querySelector(".image-viewer-container"); if (!maskDom) { console.warn( "Image viewer container not found. Exiting imageViewer function.", ); return; } const targetImg = maskDom.querySelector("img"); if (!targetImg) { console.warn( "Target image not found in image viewer container. Exiting imageViewer function.", ); return; } viewerState.maskDom = maskDom; viewerState.targetImg = targetImg; viewerState.dragged = false; viewerControls.prevButton = maskDom.querySelector(".image-viewer-prev"); viewerControls.nextButton = maskDom.querySelector(".image-viewer-next"); viewerControls.closeButton = maskDom.querySelector(".image-viewer-close"); exifControls.toggleButton = maskDom.querySelector(".image-viewer-exif-toggle"); exifControls.panel = maskDom.querySelector(".image-viewer-exif-panel"); exifControls.cardsContainer = maskDom.querySelector( ".image-viewer-exif-cards", ); exifControls.closeButton = maskDom.querySelector( ".image-viewer-exif-close", ); exifControls.cardTemplate = maskDom.querySelector( ".image-viewer-exif-card-template", ); updateImageNodes(); registerGlobalHandlers(appSignal); updateNavButtons(); resetExifUI(); const zoomHandle = (event) => { if (!event.ctrlKey) { return; } event.preventDefault(); if (!viewerState.targetImg) { return; } const rect = viewerState.targetImg.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; const dx = offsetX - rect.width / 2; const dy = offsetY - rect.height / 2; const oldScale = viewerState.scale; const minScale = Math.max(0.2, viewerState.fitScale * 0.8); const maxScale = Math.min(8, viewerState.fitScale * 4); viewerState.scale += event.deltaY * -0.001; viewerState.scale = Math.min(Math.max(minScale, viewerState.scale), maxScale); viewerState.userZoomed = Math.abs(viewerState.scale - viewerState.fitScale) > 0.01; if (oldScale < viewerState.scale) { viewerState.translateX -= dx * (viewerState.scale - oldScale); viewerState.translateY -= dy * (viewerState.scale - oldScale); } else { viewerState.translateX = 0; viewerState.translateY = 0; } applyTransform(); }; const dragStartHandle = (event) => { if (viewerState.scale <= viewerState.fitScale + 0.01) { return; } event.preventDefault(); viewerState.isMouseDown = true; viewerState.lastMouseX = event.clientX; viewerState.lastMouseY = event.clientY; pendingX = event.clientX; pendingY = event.clientY; viewerState.targetImg.style.cursor = "grabbing"; viewerState.userZoomed = true; }; let rafId = null; let pendingX = null; let pendingY = null; const dragHandle = (event) => { if (!viewerState.isMouseDown) { return; } if (viewerState.scale <= viewerState.fitScale + 0.01) { return; } pendingX = event.clientX; pendingY = event.clientY; if (rafId !== null) { return; } rafId = window.requestAnimationFrame(() => { if (pendingX == null || pendingY == null) { rafId = null; return; } const deltaX = pendingX - viewerState.lastMouseX; const deltaY = pendingY - viewerState.lastMouseY; viewerState.translateX += deltaX; viewerState.translateY += deltaY; viewerState.lastMouseX = pendingX; viewerState.lastMouseY = pendingY; applyTransform(); viewerState.dragged = true; rafId = null; }); }; const dragEndHandle = (event) => { if (viewerState.isMouseDown) { event.stopPropagation(); } viewerState.isMouseDown = false; if (rafId !== null) { window.cancelAnimationFrame(rafId); rafId = null; } pendingX = null; pendingY = null; if (viewerState.dragged) { window.setTimeout(() => { viewerState.dragged = false; }, 0); } viewerState.targetImg.style.cursor = "grab"; }; const handleImageClick = (event) => { const img = event.target.closest(imageSelector); if (!img || img.closest(".image-viewer-container")) { return; } updateImageNodes(); const index = imageNodes.indexOf(img); viewerState.isBigImage = true; viewerState.dragged = false; viewerState.userZoomed = false; showHandle(true); updateViewerImage(index === -1 ? 0 : index); }; const handleMaskClick = (event) => { if (viewerState.dragged) { return; } const target = event.target; if ( target.closest( ".image-viewer-prev, .image-viewer-next, .image-viewer-close, .image-viewer-exif-panel, .image-viewer-exif-toggle", ) ) { return; } if (target.closest(".image-viewer-frame img")) { return; } const frame = maskDom.querySelector(".image-viewer-frame"); if (target === maskDom || (frame && target === frame)) { closeViewer(); } }; const handlePrevClick = (event) => { event.stopPropagation(); goPrev(); }; const handleNextClick = (event) => { event.stopPropagation(); goNext(); }; const handleCloseClick = (event) => { event.stopPropagation(); closeViewer(); }; const handleExifToggle = (event) => { event.stopPropagation(); if (!exifControls.panel) { return; } const isOpen = !exifControls.panel.classList.contains("hidden"); if (isOpen) { setExifPanelOpen(false); return; } setExifPanelOpen(true); const currentImg = imageNodes[viewerState.currentImgIndex]; if (!hasExifFlag(currentImg)) { renderExifMessage(t("exif.status.flag_unavailable", "EXIF unavailable")); return; } loadExifForImage(currentImg); }; const handleExifClose = (event) => { event.stopPropagation(); setExifPanelOpen(false); }; const handleResize = () => { if (!viewerState.isBigImage || viewerState.userZoomed) { return; } fitToViewport(); }; if (signal) { targetImg.addEventListener("wheel", zoomHandle, { passive: false, signal, }); targetImg.addEventListener("mousedown", dragStartHandle, { passive: false, signal, }); targetImg.addEventListener("mousemove", dragHandle, { passive: false, signal, }); targetImg.addEventListener("mouseup", dragEndHandle, { passive: false, signal, }); targetImg.addEventListener("mouseleave", dragEndHandle, { passive: false, signal, }); maskDom.addEventListener("click", handleMaskClick, { signal }); window.addEventListener("resize", handleResize, { signal }); document.addEventListener("click", handleImageClick, { signal }); viewerControls.prevButton?.addEventListener("click", handlePrevClick, { signal, }); viewerControls.nextButton?.addEventListener("click", handleNextClick, { signal, }); viewerControls.closeButton?.addEventListener("click", handleCloseClick, { signal, }); exifControls.toggleButton?.addEventListener("click", handleExifToggle, { signal, }); exifControls.closeButton?.addEventListener("click", handleExifClose, { signal, }); } else { targetImg.addEventListener("wheel", zoomHandle, { passive: false }); targetImg.addEventListener("mousedown", dragStartHandle, { passive: false }); targetImg.addEventListener("mousemove", dragHandle, { passive: false }); targetImg.addEventListener("mouseup", dragEndHandle, { passive: false }); targetImg.addEventListener("mouseleave", dragEndHandle, { passive: false }); maskDom.addEventListener("click", handleMaskClick); window.addEventListener("resize", handleResize); document.addEventListener("click", handleImageClick); viewerControls.prevButton?.addEventListener("click", handlePrevClick); viewerControls.nextButton?.addEventListener("click", handleNextClick); viewerControls.closeButton?.addEventListener("click", handleCloseClick); exifControls.toggleButton?.addEventListener("click", handleExifToggle); exifControls.closeButton?.addEventListener("click", handleExifClose); } }