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": {