Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 231 additions & 15 deletions XMOJ.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -5612,7 +5612,8 @@ int main()
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
overflow: hidden;
position: relative;
}

.xmoj-image-modal-image {
Expand Down Expand Up @@ -5662,11 +5663,46 @@ int main()
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);

Expand All @@ -5685,6 +5721,21 @@ int main()

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);
Expand Down Expand Up @@ -5716,23 +5767,71 @@ int main()
ImageModal.appendChild(Toolbar);
document.body.appendChild(ImageModal);

// Zoom level state
// 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 size
// Function to update image transform (zoom + pan)
let UpdateImageSize = () => {
ModalImage.style.transform = `scale(${CurrentZoom})`;
ModalImage.style.transition = "transform 0.2s ease";
ModalImage.style.transform = `translate(${PanX}px, ${PanY}px) scale(${CurrentZoom})`;
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transform = translate(...) scale(...) scales the translation as zoom changes, so panning speed becomes multiplied by CurrentZoom (e.g., a 10px drag moves ~20px at 2x). To keep pan in screen pixels, apply transforms in the opposite order (scale then translate) or divide the translation by the zoom factor.

Suggested change
ModalImage.style.transform = `translate(${PanX}px, ${PanY}px) scale(${CurrentZoom})`;
ModalImage.style.transform = `scale(${CurrentZoom}) translate(${PanX}px, ${PanY}px)`;

Copilot uses AI. Check for mistakes.
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 = (imgSrc) => {
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;
ModalImage.src = imgSrc;
PanX = 0;
PanY = 0;
ModalImage.src = ImageList[CurrentImageIndex];
ImageModal.classList.add("show");
UpdateNavButtons();
UpdateImageSize();
};

Expand Down Expand Up @@ -5760,10 +5859,104 @@ int main()
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;
Comment on lines +5912 to +5917
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The drag handler starts panning on any mouse button; right/middle click can unintentionally set IsDragging = true and suppress expected behavior. Gate drag start to the primary button only (e.g., e.button === 0).

Copilot uses AI. Check for mistakes.
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);
Expand All @@ -5777,17 +5970,40 @@ int main()

ResetZoomBtn.addEventListener("click", () => {
CurrentZoom = 1;
PanX = 0;
PanY = 0;
UpdateImageSize();
});

// Save/Download image
// Save/Download image: fetch via GM_xmlhttpRequest to bypass CORS, then use blob URL for reliable download
SaveBtn.addEventListener("click", () => {
let Link = document.createElement("a");
Link.href = ModalImage.src;
Link.download = ModalImage.src.split("/").pop() || "image.png";
document.body.appendChild(Link);
Link.click();
document.body.removeChild(Link);
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);
Comment on lines +5988 to +5995
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GM_xmlhttpRequest calls onload even for non-2xx HTTP responses. The current implementation will still create a blob and prompt a download for 404/500 HTML error pages. Please check resp.status (and that resp.response is present) before downloading, and fall back to the onerror/new-tab behavior when the request fails.

Suggested change
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);
if (resp && resp.status >= 200 && resp.status < 300 && resp.response) {
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);
} else {
let Link = document.createElement("a");
Link.href = src;
Link.download = filename;
Link.target = "_blank";
document.body.appendChild(Link);
Link.click();
document.body.removeChild(Link);
}

Copilot uses AI. Check for mistakes.
},
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
Expand All @@ -5805,7 +6021,7 @@ int main()
}
img.addEventListener("click", (e) => {
e.stopPropagation();
OpenImageModal(effectiveSrc);
OpenImageModal(img);
});
}
};
Expand Down