NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Yet Another E-hentai Viewer // @namespace Violentmonkey Scripts // @match https://exhentai.org/g/* // @match https://e-hentai.org/g/* // @icon https://e-hentai.org/favicon.ico // @version 1.1.0 // @author shlsdv // @license MIT // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @description Fullscreen Image Viewer for E-hentai/Exhentai with a sidebar, preloading, dual page mode, and other stuff. // @downloadURL https://update.greasyfork.org/scripts/531375/Yet%20Another%20E-hentai%20Viewer.user.js // @updateURL https://update.greasyfork.org/scripts/531375/Yet%20Another%20E-hentai%20Viewer.meta.js // @homepageURL https://greasyfork.org/en/scripts/531375-yet-another-e-hentai-viewer // ==/UserScript== (function () { "use strict"; // ---------------------------------------------------------------------------------------------- // CONFIGURATION // ---------------------------------------------------------------------------------------------- class Config { constructor(title, configDefinitions = {}) { this.title = title; this.configDefinitions = { ...configDefinitions }; this.data = {}; Object.keys(this.configDefinitions).forEach((key) => { this.data[key] = this.configDefinitions[key].default; }); this.load(); this.registerMenuCommand(); this.overlay = null; this.boundEscapeHandler = this.handleEscape.bind(this); } load() { for (const key in this.configDefinitions) { if (Object.prototype.hasOwnProperty.call(this.configDefinitions, key)) { const storedValue = GM_getValue(key); if (typeof storedValue !== 'undefined') { this.data[key] = storedValue; } } } Object.keys(this.configDefinitions).forEach((key) => { Object.defineProperty(this, key, { get: () => this.data[key], set: (newValue) => { this.data[key] = newValue; this.save(); }, configurable: true, enumerable: true }); }); } save() { for (const key in this.data) { if (!Object.prototype.hasOwnProperty.call(this.data, key)) continue; if (this.data[key] !== this.configDefinitions[key].default) { GM_setValue(key, this.data[key]); } else { GM_deleteValue(key); } } } handleEscape(e) { if (e.key === "Escape" && this.overlay) { this.closeUI(); } } closeUI() { if (this.overlay && this.overlay.parentNode) { this.overlay.parentNode.removeChild(this.overlay); this.overlay = null; document.removeEventListener("keydown", this.boundEscapeHandler); } } showingUI() { return !!this.overlay; } showUI(parent) { if (this.showingUI()) return; if (!parent) parent = document.body; const overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.top = 0; overlay.style.left = 0; overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; overlay.style.zIndex = 1000000; overlay.style.display = "flex"; overlay.style.alignItems = "center"; overlay.style.justifyContent = "center"; this.overlay = overlay; const configContainer = document.createElement("div"); configContainer.style.padding = "1em"; configContainer.style.backgroundColor = "#333"; configContainer.style.color = "#fff"; configContainer.style.border = "1px solid #555"; configContainer.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.3)"; configContainer.style.width = "450px"; configContainer.style.borderRadius = "4px"; configContainer.style.fontFamily = "Arial, sans-serif"; if (this.title) { const titleElement = document.createElement("h2"); titleElement.textContent = this.title; titleElement.style.marginTop = 0; titleElement.style.marginBottom = "1em"; titleElement.style.textAlign = "center"; configContainer.appendChild(titleElement); } const form = document.createElement("form"); Object.keys(this.configDefinitions).forEach((key) => { if (this.configDefinitions[key].hidden === true) return; const value = this.data[key]; const { label, choices } = this.configDefinitions[key]; const fieldWrapper = document.createElement("div"); fieldWrapper.style.marginBottom = "0.75em"; fieldWrapper.style.display = "flex"; fieldWrapper.style.alignItems = "center"; const labelElement = document.createElement("label"); labelElement.textContent = label || key; labelElement.htmlFor = `config-${key}`; labelElement.style.width = "200px"; labelElement.style.marginRight = "0.5em"; labelElement.style.fontWeight = "bold"; let input; if (choices && Array.isArray(choices)) { input = document.createElement("select"); input.id = `config-${key}`; input.style.flexGrow = "1"; input.style.padding = "0.3em"; input.style.border = "1px solid #777"; input.style.borderRadius = "2px"; input.style.backgroundColor = "#555"; input.style.color = "#fff"; choices.forEach((choice) => { const option = document.createElement("option"); option.value = choice; option.textContent = choice; if (choice === value) { option.selected = true; } input.appendChild(option); }); } else if (typeof value === "boolean") { input = document.createElement("input"); input.id = `config-${key}`; input.type = "checkbox"; input.checked = value; input.style.transform = "scale(1.2)"; } else if (typeof value === "number") { input = document.createElement("input"); input.id = `config-${key}`; input.type = "number"; input.value = value; input.style.flexGrow = "1"; input.style.padding = "0.3em"; input.style.border = "1px solid #777"; input.style.borderRadius = "2px"; input.style.backgroundColor = "#555"; input.style.color = "#fff"; } else { input = document.createElement("input"); input.id = `config-${key}`; input.type = "text"; input.value = value; input.style.flexGrow = "1"; input.style.padding = "0.3em"; input.style.border = "1px solid #777"; input.style.borderRadius = "2px"; input.style.backgroundColor = "#555"; input.style.color = "#fff"; } fieldWrapper.appendChild(labelElement); fieldWrapper.appendChild(input); form.appendChild(fieldWrapper); }); const buttonsWrapper = document.createElement("div"); buttonsWrapper.style.textAlign = "right"; const saveButton = document.createElement("button"); saveButton.textContent = "Save Config"; saveButton.type = "submit"; saveButton.style.marginRight = "0.5em"; saveButton.style.padding = "0.5em 1em"; saveButton.style.backgroundColor = "#4CAF50"; saveButton.style.border = "none"; saveButton.style.borderRadius = "3px"; saveButton.style.color = "#fff"; saveButton.style.cursor = "pointer"; const cancelButton = document.createElement("button"); cancelButton.textContent = "Cancel"; cancelButton.type = "button"; cancelButton.style.padding = "0.5em 1em"; cancelButton.style.backgroundColor = "#f44336"; cancelButton.style.border = "none"; cancelButton.style.borderRadius = "3px"; cancelButton.style.color = "#fff"; cancelButton.style.cursor = "pointer"; cancelButton.addEventListener("click", () => { this.closeUI(); }); buttonsWrapper.appendChild(saveButton); buttonsWrapper.appendChild(cancelButton); form.appendChild(buttonsWrapper); form.addEventListener("submit", (e) => { e.preventDefault(); Object.keys(this.configDefinitions).forEach((key) => { if (this.configDefinitions[key].hidden === true) return; const input = form.querySelector(`#config-${key}`); if (!input) return; let newValue; if (input.tagName.toLowerCase() === "select") { newValue = input.value; } else if (input.type === "checkbox") { newValue = input.checked; } else if (input.type === "number") { newValue = input.valueAsNumber; if (isNaN(newValue)) { newValue = this.data[key]; } } else { newValue = input.value; } this.data[key] = newValue; }); this.save(); this.closeUI(); }); configContainer.appendChild(form); overlay.appendChild(configContainer); parent.appendChild(overlay); document.addEventListener("keydown", this.boundEscapeHandler); } registerMenuCommand(name = "Configuration") { GM_registerMenuCommand(name, () => this.showUI()); } } const config = new Config("Viewer Settings", { // Behavior settings useFullscreen: { default: true, label: "Open Viewer in Fullscreen" }, fitMode: { default: "fit-window", label: "Fit Mode", choices: ["fit-window", "one-to-one"] }, preloadCount: { default: 3, label: "Number of Images to Preload" }, zoomAnimation: { default: true, label: "Enable Zoom Animation" }, exitToViewerPage: { default: false, label: "Exit Viewer to Current Page" }, // Sidebar configuration sidebarPosition: { default: "left", label: "Sidebar Position", choices: ["left", "right", "bottom", "top"] }, sidebarWidth: { default: 400, label: "Sidebar Width" }, imageMargin: { default: 10, label: "Image Margin" }, useFullImagesWhenAvailable: { default: true, label: "Use Full Images When Available" }, pinSidebar: { default: false, label: "Pin Sidebar" }, // Image hover settings. enableImageHover: { default: false, label: "Enable Hover To Show Large Image", hidden: true }, imageHoverRequireShift: { default: false, label: "Require Shift for Hover Effect", hidden: true }, hoverOffsetHorizontal: { default: 50, label: "Horizontal Hover Offset", hidden: true }, hoverOffsetBottom: { default: 25, label: "Bottom Hover Offset", hidden: true }, }); // ---------------------------------------------------------------------------------------------- // CONSTANTS & STATE // ---------------------------------------------------------------------------------------------- const PAGINATION = 20; const THUMB_WIDTH = 200; const THUMB_HEIGHT = 284; const imageCache = {}; let thumbs = []; let currentIndex = 0; let isNavigating = false; let currentRotation = 0; let displayedRotation = 0; let backwardNavigationCount = 0; let dualPageMode = false; let sidebarVisible = false; let hasScrolledToCurrent = false; let isLoading = false; let hoverImage = null; // Determine the current page index from URL parameter. function getPageIndexFromUrl(url) { const params = new URL(url).searchParams; const p = params.get("p"); return p ? parseInt(p, 10) : 0; } const initialPage = getPageIndexFromUrl(window.location.href); // Determine the total number of pages function getTotalPages() { const pageLinks = Array.from(document.querySelectorAll('table.ptt a')) .filter(a => /^\d+$/.test(a.textContent)); if (pageLinks.length === 0) return initialPage + 1; const lastPageLink = pageLinks[pageLinks.length - 1]; const pParam = getPageIndexFromUrl(lastPageLink.href); return pParam ? pParam + 1 : parseInt(lastPageLink.textContent, 10); } const totalPages = getTotalPages(); // ---------------------------------------------------------------------------------------------- // UTILITY FUNCTIONS // ---------------------------------------------------------------------------------------------- async function fetchDocument(url) { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const htmlText = await response.text(); const parser = new DOMParser(); return parser.parseFromString(htmlText, "text/html"); } catch (error) { console.error("Error fetching document:", error); return null; } } async function extractImageUrl(url) { const doc = await fetchDocument(url); return doc?.getElementById("img")?.src || null; } // Return objects that include the link element and the page index. function extractThumbnailLinks(doc, pageIndex) { const gdtDiv = doc.getElementById("gdt"); if (!gdtDiv) return []; return Array.from(gdtDiv.querySelectorAll('a[href^="https://exhentai.org/s/"]')).map(a => { const href = a.href; const divElement = a.querySelector('div'); let thumbUrl = null; let background = null; let width = null; let height = null; if (divElement) { background = divElement.style.background; thumbUrl = background.match(/url\(["']?(.*?)["']?\)/)?.[1] || null; width = divElement.offsetWidth; height = divElement.offsetHeight; } return { link: a, page: pageIndex, href, thumbUrl, background, width, height }; }); } function createNotification(text, duration = 3) { const existingNotification = document.getElementById('yaevNotification'); if (existingNotification) { existingNotification.remove(); } const notificationContainer = document.createElement('div'); notificationContainer.zIndex = 99999; notificationContainer.id = 'yaevNotification'; notificationContainer.style.position = 'fixed'; notificationContainer.style.bottom = '30px'; notificationContainer.style.left = '50%'; notificationContainer.style.transform = 'translateX(-50%)'; const notificationButton = document.createElement('input'); notificationButton.type = 'button'; notificationButton.value = text; notificationButton.style.padding = '10px'; notificationButton.style.fontSize = '16px'; notificationContainer.appendChild(notificationButton); overlay.appendChild(notificationContainer); setTimeout(() => { if (notificationContainer.parentElement) { notificationContainer.parentElement.removeChild(notificationContainer); } }, duration * 1000); } function handleCopyEvent(e) { // Only proceed if the overlay is open and an image is displayed if (overlay.style.display !== "flex" || !imgDisplay.src) return; // Prevent the default copy behavior e.preventDefault(); // Check if the Clipboard API is supported if (!navigator.clipboard || !navigator.clipboard.write) { console.error("Clipboard API not supported"); return; } // Create a canvas element to copy the image data const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); // Set canvas dimensions to match the image canvas.width = imgDisplay.naturalWidth; canvas.height = imgDisplay.naturalHeight; // Draw the image onto the canvas ctx.drawImage(imgDisplay, 0, 0); // Convert the canvas content to a Blob canvas.toBlob(async (blob) => { try { // Copy the Blob to the clipboard const item = new ClipboardItem({ "image/png": blob }); await navigator.clipboard.write([item]); console.log("Image copied to clipboard"); } catch (err) { console.error("Failed to copy image:", err); } }, "image/png"); createNotification("Copied image") } function handlePasteEvent(e) { // Only proceed if the overlay is open if (overlay.style.display !== "flex") return; // Prevent the default paste behavior e.preventDefault(); // Get the pasted items from the clipboard const items = (e.clipboardData || window.clipboardData).items; // Look for an image in the pasted items for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf("image") !== -1) { const blob = items[i].getAsFile(); const reader = new FileReader(); // Read the pasted image as a data URL reader.onload = (event) => { const pastedImageUrl = event.target.result; // Replace the current image with the pasted image imgDisplay.src = pastedImageUrl; // Update the image cache and sidebar const currentThumb = thumbs[currentIndex]; if (currentThumb) { imageCache[currentThumb.href] = pastedImageUrl; sidebarUpdateFullImages(); } }; reader.readAsDataURL(blob); break; } } } function exitHandler(e) { if (!document.fullscreenElement) { console.log(e.shiftKey); closeOverlay(config.exitToViewerPage ^ e.shiftKey); } } // not working properly function saveImage(url) { const downloadUrl = `${url}?force-download=true`; const filename = url.split('/').pop(); GM_download({ url: downloadUrl, name: filename, saveAs: false }); } // ---------------------------------------------------------------------------------------------- // SIDEBAR // ---------------------------------------------------------------------------------------------- function sidebarAppendImages(images, prepend = false) { const isHorizontal = config.sidebarPosition === 'top' || config.sidebarPosition === 'bottom'; const scale = config.sidebarWidth / THUMB_WIDTH; const scaledWidth = isHorizontal ? THUMB_WIDTH * scale : null; const scaledHeight = THUMB_HEIGHT * scale; images.forEach(({ href, background, width, height }, index) => { if (images[index] == 'deleted') { return; } const link = document.createElement('a'); link.style.cssText = isHorizontal ? 'display: block; flex: 0 0 auto;' : 'display: block;'; link.href = href; link.style.display = 'block'; const div = document.createElement('div'); // Get the original background position offset const originalX = getThumbOffset(background); const scaledX = originalX * scale; const sizeOffset = 16 * scale; // needed for some reason // Check if the full-size image is available in the cache const fullImageUrl = imageCache[href]; const widthCss = isHorizontal ? `${scaledWidth}px` : '100%'; const heightCss = isHorizontal ? `100%` : `${scaledHeight}px`; if (fullImageUrl && config.useFullImagesWhenAvailable) { // Use the full-size image div.style.cssText = ` width: ${widthCss}; height: ${heightCss}; background: url(${fullImageUrl}); background-size: cover; background-position: center; margin-bottom: ${config.imageMargin}px; display: block; `; } else { // Fall back to the thumbnail div.style.cssText = ` width: ${widthCss}; height: ${heightCss}; background: ${background}; margin-bottom: ${config.imageMargin}px; display: block; background-position: ${scaledX}px 0px; background-size: auto calc(100% + ${sizeOffset}px); `; } link.appendChild(div); // Add click event listener to load the image in the viewer link.addEventListener('click', (e) => { e.preventDefault(); const thumbIndex = thumbs.findIndex(thumb => thumb.href === href); if (thumbIndex !== -1) { backwardNavigationCount = 0; loadImageAtIndex(thumbIndex); } }); if (prepend) { sidebar.insertBefore(link, sidebar.firstChild); } else { sidebar.appendChild(link); } }); } function sidebarUpdateFullImages() { if (!sidebarVisible || !config.useFullImagesWhenAvailable) { return; } const scale = config.sidebarWidth / THUMB_WIDTH; const scaledHeight = THUMB_HEIGHT * scale; Array.from(sidebar.children).forEach(link => { const href = link.getAttribute('href'); const fullImageUrl = imageCache[href]; if (fullImageUrl) { const div = link.firstElementChild; if (div) { div.style.cssText = ` width: 100%; height: ${scaledHeight}px; background: url(${fullImageUrl}); background-size: cover; background-position: center; margin-bottom: ${config.imageMargin}px; display: block; `; } } }); } async function sidebarFetchPrevOrNextPage() { if (isLoading) return; const lastThumb = thumbs[thumbs.length - 1]; const firstThumb = thumbs[0]; if (sidebar.scrollTop + sidebar.clientHeight >= sidebar.scrollHeight) { // Fetch next page of thumbs isLoading = true; const nextPage = lastThumb ? lastThumb.page + 1 : initialPage + 1; const newThumbs = await fetchGalleryPage(nextPage); if (newThumbs?.length) { thumbs = thumbs.concat(newThumbs); sidebarAppendImages(newThumbs); } isLoading = false; } else if (sidebar.scrollTop === 0 && firstThumb?.page > 0) { // Fetch previous page of thumbs isLoading = true; const prevPage = firstThumb.page - 1; const newThumbs = await fetchGalleryPage(prevPage); if (newThumbs?.length) { thumbs = newThumbs.concat(thumbs); sidebarAppendImages(newThumbs, true); sidebar.scrollTop = newThumbs.length * (THUMB_HEIGHT + config.imageMargin); } isLoading = false; } } function getThumbOffset(backgroundString) { const sanitizedBackground = backgroundString.replace(/url\([^)]+\)/, ''); const tempDiv = document.createElement('div'); tempDiv.style.cssText = ` position: absolute; visibility: hidden; width: 0; height: 0; background: ${sanitizedBackground}; `; document.body.appendChild(tempDiv); const backgroundPositionX = window.getComputedStyle(tempDiv).backgroundPositionX; document.body.removeChild(tempDiv); return parseFloat(backgroundPositionX); } function sidebarClearImages() { while (sidebar.firstChild) { sidebar.removeChild(sidebar.firstChild); } } function sidebarScrollToCurrent() { if (!sidebarVisible) return; let currentLink = sidebar.querySelector(`a[href="${thumbs[currentIndex].href}"]`); if (currentLink) { const offset = sidebar.clientHeight / 2 - 200; sidebar.scrollTop = currentLink.offsetTop - offset; hasScrolledToCurrent = true; } } function sidebarShowOrHide(e = null) { // Only expand sidebar if the viewer is active if (overlay.style.display !== 'flex') return; // Determine if the sidebar was hidden based on its transform value. const sidebarWasHidden = sidebar.style.transform === sidebarHidden; sidebarVisible = false; // Determine the "visible" transform based on config.sidebarPosition. let visibleTransform; if (config.sidebarPosition === "top" || config.sidebarPosition === "bottom") { visibleTransform = "translateY(0)"; } else { // left or right visibleTransform = "translateX(0)"; } if (e) { let nearEdge = false; let overSidebar = false; if (config.sidebarPosition === "left") { overSidebar = e.clientX <= config.sidebarWidth && !sidebarWasHidden; nearEdge = e.clientX < 50; } else if (config.sidebarPosition === "right") { overSidebar = e.clientX >= (window.innerWidth - config.sidebarWidth) && !sidebarWasHidden; nearEdge = e.clientX > (window.innerWidth - 50); } else if (config.sidebarPosition === "top") { overSidebar = e.clientY <= config.sidebarWidth && !sidebarWasHidden; nearEdge = e.clientY < 50; } else if (config.sidebarPosition === "bottom") { overSidebar = e.clientY >= (window.innerHeight - config.sidebarWidth) && !sidebarWasHidden; nearEdge = e.clientY > (window.innerHeight - 50); } sidebarVisible = nearEdge || overSidebar; } // config.pinSidebar can force the sidebar visible. sidebarVisible |= config.pinSidebar; // Update the sidebar's transform property. sidebar.style.transform = sidebarVisible ? visibleTransform : sidebarHidden; // Load images and scroll if the sidebar was just revealed. if (sidebarVisible && sidebarWasHidden && !hasScrolledToCurrent) { sidebarClearImages(); sidebarAppendImages(thumbs); console.log(sidebarVisible); sidebarScrollToCurrent(); } // Reset scroll flag when hiding if (!sidebarVisible) { hasScrolledToCurrent = false; } } // ---------------------------------------------------------------------------------------------- // IMAGE HOVER // ---------------------------------------------------------------------------------------------- function hoverImageCreate(href) { let img = document.createElement('img'); img.src = href; img.style.position = 'fixed'; img.style.maxHeight = (window.innerHeight - config.hoverOffsetBottom) + 'px'; img.style.zIndex = 1000; return img; } function hoverImagePosition(img, e) { if (!img) return; const computedWidth = img.offsetWidth; const computedHeight = img.offsetHeight; let isCloseToRightEdge = e.clientX > window.innerWidth / 2; img.style.top = Math.max(0, Math.min(e.clientY - computedHeight / 2, window.innerHeight - computedHeight - config.hoverOffsetBottom)) + 'px'; img.style.left = (isCloseToRightEdge ? e.clientX - computedWidth - config.hoverOffsetHorizontal : e.clientX + config.hoverOffsetHorizontal) + 'px'; } async function hoverImageMouseEnter(link, e) { if (config.imageHoverRequireShift && !e.shiftKey) return; let href = imageCache[link]; if (!href) { href = await extractImageUrl(link); imageCache[link] = href; } if (hoverImage) { hoverImage.remove(); hoverImage = null; } hoverImage = hoverImageCreate(href); document.body.appendChild(hoverImage); hoverImage.onload = function() { hoverImagePosition(hoverImage, e); } } // ---------------------------------------------------------------------------------------------- // GALLERY PAGE FETCHING & NAVIGATION // ---------------------------------------------------------------------------------------------- const galleryPages = {}; galleryPages[initialPage] = document; async function fetchGalleryPage(pageIndex) { if (pageIndex < 0 || pageIndex >= totalPages) return null; const url = new URL(window.location.href); url.searchParams.set("p", pageIndex); console.log(`Fetching page: ${url.href}`); const doc = await fetchDocument(url.href); if (doc) { galleryPages[pageIndex] = doc; } return doc ? extractThumbnailLinks(doc, pageIndex) : []; } async function preloadNextImages() { let loaded = 0; let index = currentIndex + 1; while (loaded < config.preloadCount) { if (index >= thumbs.length) { const lastThumb = thumbs[thumbs.length - 1]; const nextPage = lastThumb ? lastThumb.page + 1 : initialPage + 1; const newThumbs = await fetchGalleryPage(nextPage); if (newThumbs?.length) { thumbs = thumbs.concat(newThumbs); console.log("Appended next page thumbs", nextPage); } else { break; } } if (index < thumbs.length) { const thumbHref = thumbs[index].link.href; if (!imageCache[thumbHref]) { const fullUrl = await extractImageUrl(thumbHref); if (fullUrl) { imageCache[thumbHref] = fullUrl; new Image().src = fullUrl; } } loaded++; index++; } else { break; } } } async function preloadPrevImages() { let loaded = 0; let index = currentIndex - 1; while (loaded < config.preloadCount) { if (index < 0) { const firstThumb = thumbs[0]; const prevPage = firstThumb ? firstThumb.page - 1 : initialPage - 1; if (prevPage < 0) break; const newThumbs = await fetchGalleryPage(prevPage); if (newThumbs?.length) { thumbs = newThumbs.concat(thumbs); currentIndex += newThumbs.length; console.log("Prepended previous page thumbs", prevPage); index = newThumbs.length + index; } else { break; } } if (index >= 0 && index < thumbs.length) { const thumbHref = thumbs[index].link.href; if (!imageCache[thumbHref]) { const fullUrl = await extractImageUrl(thumbHref); if (fullUrl) { imageCache[thumbHref] = fullUrl; new Image().src = fullUrl; } } loaded++; index--; } else { break; } } } async function loadImageAtIndex(index) { console.log(`Loading image at index ${index}`); if (index < 0) { const firstThumb = thumbs[0]; const prevPage = firstThumb ? firstThumb.page - 1 : initialPage - 1; if (prevPage >= 0) { const newThumbs = await fetchGalleryPage(prevPage); if (newThumbs.length) { thumbs = newThumbs.concat(thumbs); index = newThumbs.length + index; return loadImageAtIndex(index); } else { return; } } else { return; } } if (index >= thumbs.length) { const lastThumb = thumbs[thumbs.length - 1]; const nextPage = lastThumb ? lastThumb.page + 1 : initialPage + 1; const newThumbs = await fetchGalleryPage(nextPage); if (newThumbs?.length) { thumbs = thumbs.concat(newThumbs); } else { return; } } if (index < 0 || index >= thumbs.length) return; currentIndex = index; if (thumbs[currentIndex] == 'deleted') { return; } const thumbHref = thumbs[currentIndex].link.href; if (imageCache[thumbHref]) { // Depending on dual mode, decide which view to show. if (dualPageMode) { showDualPage(); } else { showSinglePage(); } if (backwardNavigationCount >= 1) { preloadPrevImages(); } else { preloadNextImages(); } return; } overlay.style.cursor = "progress"; const fullUrl = await extractImageUrl(thumbHref); overlay.style.cursor = "default"; if (fullUrl) { imageCache[thumbHref] = fullUrl; if (dualPageMode) { showDualPage(); } else { showSinglePage(); } if (backwardNavigationCount >= 1) { preloadPrevImages(); } else { preloadNextImages(); } } else { alert("Could not load image."); } } // ---------------------------------------------------------------------------------------------- // VIEWER UI & FULLSCREEN HANDLING // ---------------------------------------------------------------------------------------------- const overlay = document.createElement("div"); overlay.id = "vim-overlay"; Object.assign(overlay.style, { position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", backgroundColor: "rgba(0, 0, 0, 1)", display: "none", justifyContent: "center", alignItems: "center", zIndex: "9999", cursor: "default", overflow: "auto", }); // Create a container for images. const imgContainer = document.createElement("div"); imgContainer.style.transition = config.zoomAnimation ? "transform 0.3s" : "none"; // We will use flex display for dual-page mode, and center things in single-page mode. imgContainer.style.display = "flex"; imgContainer.style.alignItems = "center"; imgContainer.style.justifyContent = "center"; // No gap between images imgContainer.style.gap = "0"; // Primary image element. const imgDisplay = document.createElement("img"); imgDisplay.style.transition = "transform 0.3s"; imgDisplay.style.transformOrigin = "center"; imgDisplay.style.margin = "0"; imgDisplay.style.padding = "0"; // Secondary image element, used only in dual mode. const imgDisplay2 = document.createElement("img"); imgDisplay2.style.transition = "transform 0.3s"; imgDisplay2.style.transformOrigin = "center"; imgDisplay2.style.margin = "0"; imgDisplay2.style.padding = "0"; // Hide this by default. imgDisplay2.style.display = "none"; imgContainer.appendChild(imgDisplay); imgContainer.appendChild(imgDisplay2); overlay.appendChild(imgContainer); document.body.appendChild(overlay); // Create the sidebar element const sidebar = document.createElement('div'); let sidebarHidden = ""; let posCss = ""; if (config.sidebarPosition === "top") { sidebarHidden = "translateY(-100%)"; posCss = ` top: 0; left: 0; width: 100%; height: ${config.sidebarWidth}px; overflow-x: auto; display: flex; flex-direction: row; box-shadow: 0 2px 5px rgba(0,0,0,0.2); `; } else if (config.sidebarPosition === "right") { sidebarHidden = "translateX(100%)"; posCss = ` top: 0; right: 0; width: ${config.sidebarWidth}px; height: 100%; overflow-y: auto; box-shadow: -2px 0 5px rgba(0,0,0,0.2); `; } else if (config.sidebarPosition === "bottom") { sidebarHidden = "translateY(100%)"; posCss = ` bottom: 0; left: 0; width: 100%; height: ${config.sidebarWidth}px; overflow-x: auto; display: flex; flex-direction: row; box-shadow: 0 -2px 5px rgba(0,0,0,0.2); `; } else if (config.sidebarPosition === "left") { sidebarHidden = "translateX(-100%)"; posCss = ` top: 0; left: 0; width: ${config.sidebarWidth}px; height: 100%; overflow-y: auto; box-shadow: 2px 0 5px rgba(0,0,0,0.2); `; } const baseCss = ` position: fixed; background: rgb(24,24,24); transform: ${sidebarHidden}; transition: transform 0.3s; z-index: 999999; `; // Apply the combined CSS sidebar.style.cssText = baseCss + posCss; overlay.appendChild(sidebar); // updateViewerTransforms updates the transform / layout for both single and dual modes. function updateViewerTransforms() { // this is slightly broken for dual page mode if (!imgDisplay.naturalWidth || !imgDisplay.naturalHeight) return; // Clear any max properties on images. imgDisplay.style.maxWidth = ""; imgDisplay.style.maxHeight = ""; imgDisplay2.style.maxWidth = ""; imgDisplay2.style.maxHeight = ""; let scale = 1; // For fit-window mode, determine the scale so the image(s) fully fit; for dual view we split the screen. if (config.fitMode === "fit-window") { overlay.style.overflow = "hidden"; // Determine effective width/height for the current image. // When in dual mode and not rotated (or rotated by multiple of 180) the images are side by side, // so each gets roughly half the available width (and full height). const rotIs90 = displayedRotation % 180 !== 0; let effectiveWidth = rotIs90 ? imgDisplay.naturalHeight : imgDisplay.naturalWidth; let effectiveHeight = rotIs90 ? imgDisplay.naturalWidth : imgDisplay.naturalHeight; if (dualPageMode) { // In horizontal layout, available width is halved. if (!rotIs90) { scale = Math.min((window.innerWidth / 2) / effectiveWidth, window.innerHeight / effectiveHeight); } else { // if rotated, we stack vertically so available height is halved. scale = Math.min(window.innerWidth / effectiveWidth, (window.innerHeight / 2) / effectiveHeight); } } else { scale = Math.min(window.innerWidth / effectiveWidth, window.innerHeight / effectiveHeight); } } // Apply container scale imgContainer.style.transform = `scale(${scale})`; // Apply fitMode = one-to-one adjustments: if (config.fitMode === "one-to-one") { overlay.style.overflow = "auto"; imgDisplay.style.maxWidth = "100%"; imgDisplay.style.maxHeight = "100%"; if (dualPageMode) { imgDisplay2.style.maxWidth = "100vw"; imgDisplay2.style.maxHeight = "100vh"; } } // Set the rotation transform on each image. imgDisplay.style.transform = `rotate(${displayedRotation}deg)`; imgDisplay2.style.transform = `rotate(${displayedRotation}deg)`; // In dual page mode change container flex direction. if (dualPageMode) { const rotIs90 = displayedRotation % 180 !== 0; // if not rotated (0 or 180), use row (side-by-side) without gap; otherwise stack vertically. imgContainer.style.flexDirection = rotIs90 ? "column" : "row"; } } // When an image finishes loading, update its transforms. imgDisplay.addEventListener("load", updateViewerTransforms); imgDisplay2.addEventListener("load", updateViewerTransforms); window.addEventListener("resize", () => { if (overlay.style.display === "flex") updateViewerTransforms(); }); function isFullscreen() { return ( document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement ); } function requestFullscreen(element) { if (!isFullscreen() && config.useFullscreen) { element.requestFullscreen?.() || element.webkitRequestFullscreen?.() || element.msRequestFullscreen?.(); } } function exitFullscreen() { if (isFullscreen()) { document.exitFullscreen?.() || document.webkitExitFullscreen?.() || document.msExitFullscreen?.(); } } // showViewer accepts an object with one (or two if dual mode) image URLs. function showViewer(urlObj) { // urlObj has properties: primary and (optional) secondary. imgDisplay.src = urlObj.primary; if (dualPageMode) { // Show the second image element if (urlObj.secondary) { imgDisplay2.style.display = ""; imgDisplay2.src = urlObj.secondary; } else { // If second image missing, hide it. imgDisplay2.style.display = "none"; } } else { imgDisplay2.style.display = "none"; } overlay.style.display = "flex"; requestFullscreen(overlay); sidebarScrollToCurrent(); } function closeOverlay(exitToPage) { overlay.style.display = "none"; imgDisplay.src = ""; imgDisplay2.src = ""; exitFullscreen(); if (exitToPage) { const page = thumbs[currentIndex].page; const url = new URL(window.location.href); url.searchParams.set("p", page); window.location.href = url.href; } } document.addEventListener("fullscreenchange", exitHandler); function rotateImage(delta) { currentRotation = (currentRotation + delta) % 360; if (currentRotation < 0) currentRotation += 360; let diff = currentRotation - displayedRotation; if (diff > 180) { diff -= 360; } else if (diff < -180) { diff += 360; } displayedRotation += diff; updateViewerTransforms(); } // When toggling dual-page mode, if enabling, the current image remains on the left and we additionally load next image. async function toggleDualPageMode() { dualPageMode = !dualPageMode; // If enabling dual mode, try to load the secondary image (currentIndex+1). if (dualPageMode) { const nextIndex = currentIndex + 1; if (nextIndex < thumbs.length) { const thumbHref = thumbs[nextIndex].link.href; if (imageCache[thumbHref]) { // already cached showDualPage(); } else { overlay.style.cursor = "progress"; const fullUrl = await extractImageUrl(thumbHref); overlay.style.cursor = "default"; if (fullUrl) { imageCache[thumbHref] = fullUrl; } showDualPage(); } } else { // No next image available. In dual mode, we simply hide the second image. showDualPage(); } } else { // switching off dual mode; simply show the primary image. showSinglePage(); } } function showDualPage() { // Prepare URL object with primary image. const thumbHref = thumbs[currentIndex].link.href; const primaryUrl = imageCache[thumbHref]; let secondaryUrl = null; const nextIndex = currentIndex + 1; if (nextIndex < thumbs.length) { const thumbHref2 = thumbs[nextIndex].link.href; secondaryUrl = imageCache[thumbHref2] || null; } showViewer({ primary: primaryUrl, secondary: secondaryUrl }); // Make sure the second image element is visible. imgDisplay2.style.display = ""; updateViewerTransforms(); } function showSinglePage() { // Show only primary image. const thumbHref = thumbs[currentIndex].link.href; const primaryUrl = imageCache[thumbHref]; showViewer({ primary: primaryUrl }); updateViewerTransforms(); } // ---------------------------------------------------------------------------------------------- // EVENT HANDLERS & INIT // ---------------------------------------------------------------------------------------------- function initializeThumbnailListeners() { thumbs.forEach((thumbObj, index) => { thumbObj.link.addEventListener("click", (e) => { e.preventDefault(); // Reset state. currentRotation = 0; displayedRotation = 0; backwardNavigationCount = 0; // Ensure dual mode is off when clicking a thumbnail. if (dualPageMode) { dualPageMode = false; imgDisplay2.style.display = "none"; } loadImageAtIndex(index); }); if (config.enableImageHover) { thumbObj.link.addEventListener('mouseenter', async (e) => { hoverImageMouseEnter(thumbObj.link.getAttribute('href'), e); }); thumbObj.link.addEventListener('mousemove', (e) => { if (hoverImage) { hoverImagePosition(hoverImage, e); } }); thumbObj.link.addEventListener('mouseleave', (e) => { if (hoverImage) { hoverImage.remove(); hoverImage = null; } }); } }); } function initializeSidebar() { document.addEventListener('mousemove', async (e) => { sidebarShowOrHide(e); }); // attempt to implement smooth horizontal scroll if (config.sidebarPosition === "top" || config.sidebarPosition === "bottom") { let targetScrollLeft = sidebar.scrollLeft; let isAnimating = false; let animationFrameId = null; // Get proper line height from CSS const getLineHeight = () => { const computedStyle = getComputedStyle(sidebar); const lineHeight = parseFloat(computedStyle.lineHeight); return isNaN(lineHeight) ? 40 : lineHeight; // Fallback to 40px if undefined }; // Smoothing function with deceleration const smoothScroll = () => { const current = sidebar.scrollLeft; const diff = targetScrollLeft - current; if (Math.abs(diff) < 0.5) { // Threshold to stop animation sidebar.scrollLeft = targetScrollLeft; isAnimating = false; return; } // Apply smooth movement with easing sidebar.scrollLeft += diff * 0.5; // Adjust this value for speed (0.1-0.5) animationFrameId = requestAnimationFrame(smoothScroll); }; sidebar.addEventListener('wheel', (e) => { e.preventDefault(); // Calculate delta based on scroll mode let delta = e.deltaY; switch(e.deltaMode) { case 1: // LINE_MODE (convert lines to pixels) delta *= getLineHeight(); break; case 2: // PAGE_MODE (use container height) delta *= sidebar.clientHeight; break; } // Apply system-appropriate scroll speed targetScrollLeft += delta * 1.2; // Adjust multiplier for different devices targetScrollLeft = Math.max(0, Math.min(targetScrollLeft, sidebar.scrollWidth - sidebar.clientWidth)); // Start animation if not already running if (!isAnimating) { isAnimating = true; animationFrameId = requestAnimationFrame(smoothScroll); } }); } sidebar.addEventListener('scroll', async () => { sidebarFetchPrevOrNextPage(); }); } function initializeHelpOverlay() { // Create overlay element with styles const helpOverlay = document.createElement("div"); helpOverlay.id = 'yaevHelpOverlay'; Object.assign(helpOverlay.style, { position: "fixed", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(0, 0, 0, 0.8)", display: "none", alignItems: "center", justifyContent: "center", zIndex: "9999999999", padding: "20px", boxSizing: "border-box" }); // Create help box element with styles const helpBox = document.createElement("div"); Object.assign(helpBox.style, { backgroundColor: "#222", color: "#f1f1f1", borderRadius: "12px", padding: "30px", maxWidth: "700px", width: "100%", boxShadow: "0 6px 12px rgba(0, 0, 0, 0.5)", fontFamily: "Arial, sans-serif", lineHeight: "1.6", fontSize: "16px" }); // Define shortcuts array: key(s) in left column, action in right column. const shortcuts = [ { key: "Esc/q", action: "Exit viewer" }, { key: "Shift+q", action: "Exit to current page" }, { key: "1", action: "One-to-one Fit Mode" }, { key: "2", action: "Fit-window Mode" }, { key: "Arrow Right/d", action: "Next Image" }, { key: "Arrow Left/a", action: "Previous Image" }, { key: "r", action: "Rotate 90° (clockwise)" }, { key: "l", action: "Rotate -90° (counterclockwise)" }, { key: "s", action: "Toggle sidebar pin" }, { key: "Shift+d", action: "Toggle dual page mode" }, { key: "f", action: "Toggle fullscreen" }, { key: "Delete", action: "Remove image" }, { key: "Ctrl+v", action: "Replace image with clipboard image" }, { key: "p", action: "Open settings" }, { key: "h or ?", action: "Show this help overlay" }, ]; // Build table inner HTML using a loop let rows = ""; shortcuts.forEach(item => { rows += `<tr style="border-bottom: 1px solid #333;"> <td style="padding: 8px; text-align: right; width: 30%;"><code>${item.key}</code></td> <td style="padding: 8px; text-align: left;">${item.action}</td> </tr>`; }); helpBox.innerHTML = ` <h2 style="margin-top: 0; font-size: 26px; text-align: center;">Keyboard Shortcuts</h2> <hr style="border: none; height: 1px; background: #444; margin-bottom: 20px;"> <table style="width: 100%; border-collapse: collapse;"> <tbody>${rows}</tbody> </table> `; helpOverlay.appendChild(helpBox); overlay.appendChild(helpOverlay); // Helper functions to show/hide overlay const showHelp = () => helpOverlay.style.display = "flex"; const hideHelp = () => helpOverlay.style.display = "none"; // Keyboard event handling document.addEventListener("keydown", e => { if (e.key === "h" || e.key === "?") { if (helpOverlay.style.display !== "flex") showHelp(); else hideHelp(); return; } // if (helpOverlay.style.display === "flex" && e.key === "Escape") hideHelp(); }); // Hide overlay on click outside the help box helpOverlay.addEventListener("click", e => { if (e.target === helpOverlay) hideHelp(); }); } function initializeKeyboardNavigation() { const navigateForward = () => { backwardNavigationCount = 0; isNavigating = true; // When in dual mode, move two images forward. let offset = dualPageMode ? 2 : 1; while (thumbs[currentIndex + offset] == 'deleted') { offset++; } loadImageAtIndex(currentIndex + offset) .finally(() => { sidebarClearImages(); sidebarAppendImages(thumbs); sidebarScrollToCurrent(); // sidebarUpdateFullImages(); isNavigating = false; }); }; const navigateBack = () => { backwardNavigationCount++; isNavigating = true; let offset = dualPageMode ? -2 : -1; while (thumbs[currentIndex + offset] == 'deleted') { offset--; } loadImageAtIndex(currentIndex + offset) .finally(() => { sidebarClearImages(); sidebarAppendImages(thumbs); sidebarScrollToCurrent(); // sidebarUpdateFullImages(); isNavigating = false; }); }; document.addEventListener("paste", handlePasteEvent); document.addEventListener("copy", handleCopyEvent); document.addEventListener("keydown", (e) => { // Only proceed if the overlay is open if (overlay.style.display !== "flex") return; if (e.ctrlKey || e.altKey || e.metaKey) return; if (e.key === "1") { config.fitMode = "one-to-one"; updateViewerTransforms(); } else if (e.key === "2") { config.fitMode = "fit-window"; updateViewerTransforms(); } else if (!isNavigating) { switch (e.key) { case "Escape": let helpOverlay = document.getElementById('yaevHelpOverlay'); console.log(helpOverlay) if (helpOverlay.style.display != 'none') { helpOverlay.style.display = 'none'; } else if (config.showingUI()) { config.closeUI(); } else { closeOverlay(config.exitToViewerPage ^ e.shiftKey); } break; case "q": case "Q": closeOverlay(config.exitToViewerPage ^ e.shiftKey); break; case "d": case "ArrowRight": navigateForward(); break; case "a": case "ArrowLeft": navigateBack(); break; case "r": rotateImage(90); break; case "l": rotateImage(-90); break; case "s": config.pinSidebar = !config.pinSidebar; sidebarShowOrHide(); break; case "S": const thumbHref = thumbs[currentIndex].link.href; const primaryUrl = imageCache[thumbHref]; saveImage(primaryUrl); break; case "D": toggleDualPageMode(); break; case "p": if (config.showingUI()) { config.closeUI(); } else { config.showUI(overlay); } break; case "f": config.useFullscreen = !config.useFullscreen; if (!config.useFullscreen && isFullscreen()) { const onFullscreenChange = () => { if (!document.fullscreenElement) { document.removeEventListener("fullscreenchange", onFullscreenChange); loadImageAtIndex(currentIndex); } }; document.addEventListener("fullscreenchange", onFullscreenChange); exitFullscreen(); } else if (config.useFullscreen && !isFullscreen()) { loadImageAtIndex(currentIndex); } break; case "Delete": var current = thumbs[currentIndex].href thumbs[currentIndex] = 'deleted'; imageCache[current] = 'deleted'; navigateForward(); break; } } }); } async function init() { thumbs = extractThumbnailLinks(document, initialPage); initializeThumbnailListeners(); initializeKeyboardNavigation(); initializeSidebar(); initializeHelpOverlay(); } init(); })();