diff --git a/Update.json b/Update.json index 38e5a669..e12d6229 100644 --- a/Update.json +++ b/Update.json @@ -3422,6 +3422,17 @@ } ], "Notes": "Bug 修复
\n- 修复了暗色模式下比赛排名表(contestrank-oi.php 和 contestrank-correct.php)颜色显示异常的问题(#916)
\n- 修复了 WebSocket 弹窗通知未遵循各功能独立弹窗开关(BBSPopup/MessagePopup)的问题(#919)" + }, + "3.3.1": { + "UpdateDate": 1772981411290, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 924, + "Description": "Add ImageEnlarger feature with modal viewer" + } + ], + "Notes": "No release notes were provided for this release." } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index 4c9d072c..49538eed 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.3.0 +// @version 3.3.1 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen @@ -2121,6 +2121,8 @@ async function main() { }, {"ID": "CompareSource", "Type": "A", "Name": "比较代码"}, { "ID": "BBSPopup", "Type": "A", "Name": "讨论提醒" }, {"ID": "MessagePopup", "Type": "A", "Name": "短消息提醒"}, { + "ID": "ImageEnlarger", "Type": "A", "Name": "图片放大功能" + }, { "ID": "DebugMode", "Type": "A", "Name": "调试模式(仅供开发者使用)" }, { "ID": "SuperDebug", "Type": "A", "Name": "本地调试模式(仅供开发者使用) (未经授权的擅自开启将导致大部分功能不可用!)" @@ -5572,6 +5574,491 @@ int main() } } } + + // Image Enlargement Feature + if (UtilityEnabled("ImageEnlarger")) { + try { + // Add CSS styles for the enlarger + let EnlargerStyle = document.createElement("style"); + EnlargerStyle.textContent = ` + .xmoj-image-preview { + cursor: pointer; + } + + .xmoj-image-preview:hover { + opacity: 0.8; + transition: opacity 0.2s ease; + } + + + .xmoj-image-modal { + display: none; + position: fixed; + z-index: 2000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + } + + .xmoj-image-modal.show { + display: flex; + flex-direction: column; + } + + .xmoj-image-modal-content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + } + + .xmoj-image-modal-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .xmoj-image-modal-toolbar { + display: flex; + justify-content: center; + gap: 10px; + padding: 15px; + background-color: rgba(0, 0, 0, 0.5); + flex-wrap: wrap; + } + + .xmoj-image-modal-toolbar button { + padding: 8px 16px; + background-color: #0d6efd; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s ease; + } + + .xmoj-image-modal-toolbar button:hover { + background-color: #0b5ed7; + } + + .xmoj-image-modal-toolbar button:active { + background-color: #0a58ca; + } + + .xmoj-image-modal-close { + position: absolute; + top: 20px; + right: 30px; + color: white; + background: none; + border: none; + padding: 0; + line-height: 1; + font-size: 40px; + font-weight: bold; + cursor: pointer; + transition: color 0.2s ease; + z-index: 1; + } + + .xmoj-image-modal-close:hover { + color: #ccc; + } + + .xmoj-image-modal-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.5); + color: white; + border: none; + padding: 20px 12px; + cursor: pointer; + font-size: 28px; + transition: background-color 0.2s ease; + user-select: none; + -webkit-user-select: none; + } + + .xmoj-image-modal-nav:hover { + background: rgba(0, 0, 0, 0.8); + } + + .xmoj-image-modal-nav:disabled { + opacity: 0.3; + cursor: default; + } + + .xmoj-image-modal-nav-prev { + left: 0; + border-radius: 0 4px 4px 0; + } + + .xmoj-image-modal-nav-next { + right: 0; + border-radius: 4px 0 0 4px; + } + `; + document.head.appendChild(EnlargerStyle); + + // Create modal element + let ImageModal = document.createElement("div"); + ImageModal.className = "xmoj-image-modal"; + ImageModal.id = "xmoj-image-modal"; + + let CloseButton = document.createElement("button"); + CloseButton.className = "xmoj-image-modal-close"; + CloseButton.type = "button"; + CloseButton.setAttribute("aria-label", "关闭图片"); + CloseButton.title = "关闭图片"; + CloseButton.innerHTML = "×"; + ImageModal.appendChild(CloseButton); + + let ModalContent = document.createElement("div"); + ModalContent.className = "xmoj-image-modal-content"; + + let PrevBtn = document.createElement("button"); + PrevBtn.className = "xmoj-image-modal-nav xmoj-image-modal-nav-prev"; + PrevBtn.type = "button"; + PrevBtn.setAttribute("aria-label", "上一张"); + PrevBtn.innerHTML = "❮"; + ModalContent.appendChild(PrevBtn); + + let NextBtn = document.createElement("button"); + NextBtn.className = "xmoj-image-modal-nav xmoj-image-modal-nav-next"; + NextBtn.type = "button"; + NextBtn.setAttribute("aria-label", "下一张"); + NextBtn.innerHTML = "❯"; + ModalContent.appendChild(NextBtn); + + let ModalImage = document.createElement("img"); + ModalImage.className = "xmoj-image-modal-image"; + ModalContent.appendChild(ModalImage); + ImageModal.appendChild(ModalContent); + + let Toolbar = document.createElement("div"); + Toolbar.className = "xmoj-image-modal-toolbar"; + + let ZoomInBtn = document.createElement("button"); + ZoomInBtn.innerHTML = "放大 (+)"; + ZoomInBtn.type = "button"; + Toolbar.appendChild(ZoomInBtn); + + let ZoomOutBtn = document.createElement("button"); + ZoomOutBtn.innerHTML = "缩小 (-)"; + ZoomOutBtn.type = "button"; + Toolbar.appendChild(ZoomOutBtn); + + let ResetZoomBtn = document.createElement("button"); + ResetZoomBtn.innerHTML = "重置大小"; + ResetZoomBtn.type = "button"; + Toolbar.appendChild(ResetZoomBtn); + + let SaveBtn = document.createElement("button"); + SaveBtn.innerHTML = "保存图片"; + SaveBtn.type = "button"; + Toolbar.appendChild(SaveBtn); + + ImageModal.appendChild(Toolbar); + document.body.appendChild(ImageModal); + + // Zoom level and navigation state + let CurrentZoom = 1; + const ZoomStep = 0.1; + const MinZoom = 0.1; + const MaxZoom = 5; + let ImageList = []; + let CurrentImageIndex = -1; + let PanX = 0; + let PanY = 0; + let IsDragging = false; + let DragStartX = 0; + let DragStartY = 0; + let DragStartPanX = 0; + let DragStartPanY = 0; + let IsTouchPanning = false; + let TouchStartX = 0; + let TouchStartY = 0; + let TouchPanStartPanX = 0; + let TouchPanStartPanY = 0; + + // Function to update image transform (zoom + pan) + let UpdateImageSize = () => { + ModalImage.style.transform = `translate(${PanX}px, ${PanY}px) scale(${CurrentZoom})`; + ModalImage.style.transition = IsDragging ? "none" : "transform 0.2s ease"; + let CursorStyle = CurrentZoom > 1 ? "grab" : ""; + ModalImage.style.cursor = CursorStyle; + ModalContent.style.cursor = CursorStyle; + }; + + // Function to update prev/next button state + let UpdateNavButtons = () => { + let HasMultiple = ImageList.length > 1; + PrevBtn.style.display = HasMultiple ? "" : "none"; + NextBtn.style.display = HasMultiple ? "" : "none"; + PrevBtn.disabled = CurrentImageIndex <= 0; + NextBtn.disabled = CurrentImageIndex >= ImageList.length - 1; + }; + + // Function to navigate to a specific image by index + let NavigateTo = (index) => { + if (index < 0 || index >= ImageList.length) return; + CurrentImageIndex = index; + CurrentZoom = 1; + PanX = 0; + PanY = 0; + ModalImage.src = ImageList[CurrentImageIndex]; + UpdateNavButtons(); + UpdateImageSize(); + }; + + // Function to open modal + let OpenImageModal = (imgElement) => { + let PreviewImages = [...document.querySelectorAll("img.xmoj-image-preview")]; + ImageList = PreviewImages.map(img => img.currentSrc || img.src).filter(src => src); + CurrentImageIndex = PreviewImages.indexOf(imgElement); + if (CurrentImageIndex === -1) { + ImageList = [(imgElement.currentSrc || imgElement.src)]; + CurrentImageIndex = 0; + } + CurrentZoom = 1; + PanX = 0; + PanY = 0; + ModalImage.src = ImageList[CurrentImageIndex]; + ImageModal.classList.add("show"); + UpdateNavButtons(); + UpdateImageSize(); + }; + + // Function to close modal + let CloseImageModal = () => { + ImageModal.classList.remove("show"); + }; + + // Close button click + CloseButton.addEventListener("click", CloseImageModal); + + // Close when clicking outside the image + ImageModal.addEventListener("click", (e) => { + if (e.target === ImageModal || e.target === ModalContent) { + CloseImageModal(); + } + }); + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + if (ImageModal.classList.contains("show")) { + if (e.key === "Escape") { + CloseImageModal(); + } else if (e.key === "+") { + ZoomInBtn.click(); + } else if (e.key === "-") { + ZoomOutBtn.click(); + } else if (e.key === "ArrowLeft") { + NavigateTo(CurrentImageIndex - 1); + } else if (e.key === "ArrowRight") { + NavigateTo(CurrentImageIndex + 1); + } + } + }); + + // Touch events: pan when zoomed, swipe to navigate when at zoom level 1 + ModalContent.addEventListener("touchstart", (e) => { + if (e.touches.length !== 1) return; + TouchStartX = e.touches[0].clientX; + TouchStartY = e.touches[0].clientY; + if (CurrentZoom > 1) { + IsTouchPanning = true; + TouchPanStartPanX = PanX; + TouchPanStartPanY = PanY; + } else { + IsTouchPanning = false; + } + }, { passive: true }); + + ModalContent.addEventListener("touchmove", (e) => { + if (!IsTouchPanning || e.touches.length !== 1) return; + PanX = TouchPanStartPanX + (e.touches[0].clientX - TouchStartX); + PanY = TouchPanStartPanY + (e.touches[0].clientY - TouchStartY); + UpdateImageSize(); + e.preventDefault(); + }, { passive: false }); + + ModalContent.addEventListener("touchend", (e) => { + if (IsTouchPanning) { + IsTouchPanning = false; + return; + } + let TouchEndX = e.changedTouches[0].clientX; + let TouchEndY = e.changedTouches[0].clientY; + let DeltaX = TouchEndX - TouchStartX; + let DeltaY = TouchEndY - TouchStartY; + const SwipeThreshold = 50; + if (Math.abs(DeltaX) > SwipeThreshold && Math.abs(DeltaX) > Math.abs(DeltaY)) { + if (DeltaX < 0) { + NavigateTo(CurrentImageIndex + 1); + } else { + NavigateTo(CurrentImageIndex - 1); + } + } + }, { passive: true }); + + // Mouse drag to pan when zoomed + ModalContent.addEventListener("mousedown", (e) => { + if (CurrentZoom <= 1) return; + if (e.target.tagName.toUpperCase() === "BUTTON") return; + IsDragging = true; + DragStartX = e.clientX; + DragStartY = e.clientY; + DragStartPanX = PanX; + DragStartPanY = PanY; + ModalImage.style.cursor = "grabbing"; + ModalContent.style.cursor = "grabbing"; + e.preventDefault(); + }); + + document.addEventListener("mousemove", (e) => { + if (!IsDragging) return; + PanX = DragStartPanX + (e.clientX - DragStartX); + PanY = DragStartPanY + (e.clientY - DragStartY); + UpdateImageSize(); + }); + + document.addEventListener("mouseup", () => { + if (IsDragging) { + IsDragging = false; + let CursorStyle = CurrentZoom > 1 ? "grab" : ""; + ModalImage.style.cursor = CursorStyle; + ModalContent.style.cursor = CursorStyle; + } + }); + + // Mouse wheel to zoom in/out + ModalContent.addEventListener("wheel", (e) => { + e.preventDefault(); + let ZoomDelta = e.deltaY > 0 ? -ZoomStep : ZoomStep; + CurrentZoom = Math.max(MinZoom, Math.min(MaxZoom, CurrentZoom + ZoomDelta)); + UpdateImageSize(); + }, { passive: false }); + + // Navigation button clicks + PrevBtn.addEventListener("click", (e) => { + e.stopPropagation(); + NavigateTo(CurrentImageIndex - 1); + }); + + NextBtn.addEventListener("click", (e) => { + e.stopPropagation(); + NavigateTo(CurrentImageIndex + 1); + }); + + // Zoom controls + ZoomInBtn.addEventListener("click", () => { + CurrentZoom = Math.min(CurrentZoom + ZoomStep, MaxZoom); + UpdateImageSize(); + }); + + ZoomOutBtn.addEventListener("click", () => { + CurrentZoom = Math.max(CurrentZoom - ZoomStep, MinZoom); + UpdateImageSize(); + }); + + ResetZoomBtn.addEventListener("click", () => { + CurrentZoom = 1; + PanX = 0; + PanY = 0; + UpdateImageSize(); + }); + + // Save/Download image: fetch via GM_xmlhttpRequest to bypass CORS, then use blob URL for reliable download + SaveBtn.addEventListener("click", () => { + let src = ModalImage.src; + let urlPath = src.split("?")[0]; + let filename = urlPath.split("/").pop() || "image.png"; + GM_xmlhttpRequest({ + method: "GET", + url: src, + responseType: "blob", + onload: (resp) => { + let BlobUrl = URL.createObjectURL(resp.response); + let Link = document.createElement("a"); + Link.href = BlobUrl; + Link.download = filename; + document.body.appendChild(Link); + Link.click(); + document.body.removeChild(Link); + setTimeout(() => URL.revokeObjectURL(BlobUrl), 100); + }, + onerror: () => { + let Link = document.createElement("a"); + Link.href = src; + Link.download = filename; + Link.target = "_blank"; + document.body.appendChild(Link); + Link.click(); + document.body.removeChild(Link); + } + }); + }); + + // Apply to all images on the page + let ApplyEnlargerToImage = (img) => { + const effectiveSrc = img.currentSrc || img.src; + if (!img.classList.contains("xmoj-image-preview") && + !img.closest(".xmoj-image-modal") && + effectiveSrc && + !effectiveSrc.includes("gravatar") && + !effectiveSrc.includes("cravatar")) { + + img.classList.add("xmoj-image-preview"); + if (!img.title) { + img.title = "点击放大"; + } + img.addEventListener("click", (e) => { + e.stopPropagation(); + OpenImageModal(img); + }); + } + }; + + let ApplyEnlargerToImages = () => { + document.querySelectorAll("img").forEach(ApplyEnlargerToImage); + }; + + // Apply to existing images + ApplyEnlargerToImages(); + + // Apply to dynamically added images + let Observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + if (node.tagName === "IMG") { + ApplyEnlargerToImage(node); + } else { + node.querySelectorAll("img").forEach(ApplyEnlargerToImage); + } + }); + }); + }); + + Observer.observe(document.body, { + childList: true, + subtree: true + }); + + } catch (e) { + console.error(e); + if (UtilityEnabled("DebugMode")) { + SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode."); + } + } + } } catch (e) { console.error(e); if (UtilityEnabled("DebugMode")) { diff --git a/package.json b/package.json index 33385212..9ffb1aa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "3.3.0", + "version": "3.3.1", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": {