Sleazy Fork is available in English.
Modern image gallery with highly efficient background zipping, video playback, browsing, fullscreen, and download features. Native DOM, unified pointer gestures, and zero external UI dependencies.
// ==UserScript== // @name Ultra Galleries // @namespace https://sleazyfork.org/en/users/1477603-%E3%83%A1%E3%83%AA%E3%83%BC // @version 4.3.1 // @description Modern image gallery with highly efficient background zipping, video playback, browsing, fullscreen, and download features. Native DOM, unified pointer gestures, and zero external UI dependencies. // @author ntf (original), Meri/TearTyr (maintained) // @match *://kemono.su/* // @match *://*.kemono.su/* // @match *://coomer.su/* // @match *://*.coomer.su/* // @match *://kemono.cr/* // @match *://*.kemono.cr/* // @match *://coomer.cr/* // @match *://*.coomer.cr/* // @match *://coomer.st/* // @match *://*.coomer.st/* // @match *://nekohouse.su/* // @match *://*.nekohouse.su/* // @match *://pawchive.st/* // @match *://*.pawchive.st/* // @match *://pawchive.pw/* // @match *://*.pawchive.pw/* // @icon https://pawchive.pw/static/menu/recent.svg // @connect * // @grant GM_download // @grant GM.xmlHttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_getResourceText // @resource mainCSS https://cdn.jsdelivr.net/gh/TearTyr/[email protected]/Ultra-Galleries.css // @resource jszipScript https://unpkg.com/[email protected]/dist/jszip.min.js // @noframes // ==/UserScript== (() => { 'use strict'; // ==================================================== // Core Configuration & Constants // ==================================================== const CONFIG = { MAX_CONCURRENT_FETCHES: 3, MAX_RETRIES: 5, RETRY_DELAY: 2000, MIN_SCALE: 0.05, MAX_SCALE: 5, ZOOM_STEP: 0.2, DEBOUNCE_DELAY: 250, PAN_RESISTANCE: 0.8, DOUBLE_TAP_THRESHOLD: 300, CACHE_EVICTION_COUNT: 20, PRELOAD_COUNT: 2, SCROLL_SIMULATION_BASE_TIMEOUT: 3000, SLIDESHOW_DELAY: 3000, CONTROLS_HIDE_DELAY: 3000, CONTEXT_MENU_HIDE_DELAY: 200, PRELOAD_WINDOW_BUFFER: 4, PROGRESS_NOTIFY_INTERVAL: 250 }; // ==================================================== // Pawchive Compliance Configuration & Pacer // ==================================================== const PAWCHIVE_CONFIG = { USER_AGENT: 'UltraGalleries/4.3.1 (+https://github.com/TearTyr/Ultra-Galleries; contact: Meri/TearTyr)', MIN_REQUEST_INTERVAL: 1050 // Enforces <= 1 request/second }; const isPawchiveHost = (url = '') => { const currentHost = window.location.hostname; return /pawchive\.(st|pw)/i.test(currentHost) || /pawchive\.(st|pw)/i.test(url); }; const RequestPacer = { lastRequestTime: 0, queue: Promise.resolve(), async throttle(url) { if (!isPawchiveHost(url)) return; return (this.queue = this.queue.catch(() => {}).then(async () => { const now = Date.now(); const elapsed = now - this.lastRequestTime; if (elapsed < PAWCHIVE_CONFIG.MIN_REQUEST_INTERVAL) { await Utils.delay(PAWCHIVE_CONFIG.MIN_REQUEST_INTERVAL - elapsed); } this.lastRequestTime = Date.now(); })); }, getHeaders(url) { const headers = {}; if (isPawchiveHost(url)) { headers['User-Agent'] = PAWCHIVE_CONFIG.USER_AGENT; } return headers; } }; const ActiveRequests = { bySession: new Map(), add(sessionId, handle) { if (!sessionId || !handle) return; if (!this.bySession.has(sessionId)) this.bySession.set(sessionId, new Set()); this.bySession.get(sessionId).add(handle); }, remove(sessionId, handle) { if (!sessionId) return; const set = this.bySession.get(sessionId); if (set) set.delete(handle); }, abortSession(sessionId) { if (!sessionId) return; const set = this.bySession.get(sessionId); if (!set) return; set.forEach(h => { try { h.abort(); } catch { /* ignore */ } }); this.bySession.delete(sessionId); }, abortAll() { for (const set of this.bySession.values()) { set.forEach(h => { try { h.abort(); } catch { /* ignore */ } }); } this.bySession.clear(); } }; const FALLBACK_POSTER = 'data:image/svg+xml;base64,' + btoa( '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 24 24" fill="#666"><path d="M8 5v14l11-7z"/></svg>' ); const FALLBACK_ATTACHMENT = 'data:image/svg+xml;base64,' + btoa( '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' ); const BUTTONS = { DOWNLOAD: '【DOWNLOAD】', DOWNLOAD_ALL: '【DL ALL】', DOWNLOAD_IMAGES: '【DL IMAGES】', DOWNLOAD_ATTACHMENTS: '【DL FILES】', FULL: '【FULL】', HEIGHT: '【FILL HEIGHT】', WIDTH: '【FILL WIDTH】', GALLERY: '【GALLERY】', SETTINGS: '⚙️', FULLSCREEN: '⛶', CLOSE: '✕' }; const CSS = { BTN: 'ug-button', BTN_CONTAINER: 'ug-button-container', NOTIF_AREA: 'ug-notification-area', NOTIF_CONTAINER: 'ug-notification-container', NOTIF_TEXT: 'ug-notification-text', NOTIF_CLOSE: 'ug-notification-close', NOTIF_REPORT: 'ug-notification-report', SETTINGS_BTN: 'settings-button', LONG_PRESS: 'ug-long-press', GALLERY: { OVERLAY: 'ug-gallery-overlay', CONTAINER: 'ug-gallery-container', EXPANDED_VIEW: 'ug-gallery-expanded-view', HIDE: 'ug-gallery-hide', TOOLBAR: 'ug-gallery-toolbar', ZOOM_CONTAINER: 'ug-gallery-zoom-container', MAIN_IMG_CONTAINER: 'ug-main-image-container', MAIN_IMG: 'ug-main-image', MAIN_VIDEO: 'ug-main-video', THUMBNAIL: 'ug-thumbnail', THUMBNAIL_WRAPPER: 'ug-thumbnail-container', THUMBNAIL_STRIP: 'ug-thumbnail-strip', NAV: 'ug-gallery-nav', NAV_CONTAINER: 'ug-gallery-nav-container', PREV: 'ug-gallery-prev', NEXT: 'ug-gallery-next', COUNTER: 'ug-gallery-counter', FULLSCREEN: 'ug-gallery-fullscreen', FULLSCREEN_OVERLAY: 'ug-fullscreen-overlay', STRIP_CONTAINER: 'ug-gallery-thumbnail-strip-container', TOOLBAR_BTN: 'ug-toolbar-button', CONTROLS_HIDDEN: 'ug-controls-hidden', GRABBING: 'ug-grabbing', ZOOMED: 'zoomed', IS_TRANSITIONING: 'is-transitioning', IMAGE_ERROR_MSG: 'ug-image-error-message' } }; const isNekohouse = window.location.hostname.includes('nekohouse'); const SELECTORS = { IMAGE_LINK: isNekohouse ? 'a.image-link:not(.scrape__user-profile)' : 'a.fileThumb.image-link', GENERIC_IMAGE_LINK: 'a[href*=".jpg"], a[href*=".png"], a[href*=".gif"], a[href*=".webp"], a[href*=".jpeg"]', ATTACHMENT_LINK: isNekohouse ? '.scrape__attachment-link' : '.post__attachment-link', POST_TITLE: isNekohouse ? '.scrape__title' : '.post__title', POST_USER_NAME: isNekohouse ? '.scrape__user-name' : '.post__user-name', MAIN_THUMBNAIL: isNekohouse ? '.scrape__thumbnail:not(.scrape__thumbnail--attachment)' : '.post__thumbnail:not(.post__thumbnail--attachment)', POST_ACTIONS: isNekohouse ? '.scrape__actions' : '.post__actions', FILE_DIVS: isNekohouse ? '.scrape__thumbnail' : '.post__thumbnail', VIDEO_LINK: 'a.fileThumb[href$=".mp4"], a.fileThumb[href$=".webm"], a.fileThumb[href$=".mov"], a[href$=".mp4"], a[href$=".webm"], a[href$=".mov"]', VIDEO_THUMBNAIL: isNekohouse ? '.scrape__video-thumbnail' : '.post__video-thumbnail' }; // ==================================================== // Native DOM Helpers // ==================================================== const DOM = { $(selector, parent = document) { return parent.querySelector(selector); }, $$(selector, parent = document) { return Array.from(parent.querySelectorAll(selector)); }, create(tag, attributes = {}, children = []) { const element = document.createElement(tag); for (const [key, value] of Object.entries(attributes)) { if (key === 'className') { element.className = value; } else if (key === 'html') { element.innerHTML = value; } else if (key === 'text') { element.textContent = value; } else if (key === 'style' && typeof value === 'object') { Object.assign(element.style, value); } else if (key.startsWith('on') && typeof value === 'function') { element.addEventListener(key.slice(2).toLowerCase(), value); } else if (key.startsWith('data-')) { element.setAttribute(key, value); } else { element.setAttribute(key, value); } } const childArray = Array.isArray(children) ? children : [children]; for (const child of childArray) { if (!child) continue; if (typeof child === 'string') { element.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { element.appendChild(child); } } return element; }, scrollToCenter(container, element) { if (!container || !element) return; const offset = element.offsetLeft - (container.clientWidth / 2) + (element.clientWidth / 2); container.scrollTo({ left: offset, behavior: 'smooth' }); }, saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); } }; // ==================================================== // Built-in Native Modal System // ==================================================== const UGModal = { confirm({ title, text, confirmText = 'Confirm', cancelText = 'Cancel', icon = 'question' }) { return new Promise((resolve) => { DOM.$('.ug-modal-overlay')?.remove(); const iconSvgs = { question: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>', warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>', info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12.01" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>' }; const opener = document.activeElement; const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; const focusables = []; const confirmBtn = DOM.create('button', { className: 'ug-modal-btn ug-modal-confirm', text: confirmText, onclick: () => close(true) }); focusables.push(confirmBtn); let cancelBtn = null; if (cancelText) { cancelBtn = DOM.create('button', { className: 'ug-modal-btn ug-modal-cancel', text: cancelText, onclick: () => close(false) }); focusables.push(cancelBtn); } const overlay = DOM.create('div', { className: 'ug-modal-overlay' }, [ DOM.create('div', { className: 'ug-modal-container' }, [ DOM.create('div', { className: `ug-modal-icon ${icon}`, html: iconSvgs[icon] || iconSvgs.info }), DOM.create('h3', { className: 'ug-modal-title', text: title }), DOM.create('p', { className: 'ug-modal-text', text: text }), DOM.create('div', { className: 'ug-modal-actions' }, focusables) ]) ]); document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('show')); confirmBtn.focus(); const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(false); return; } if (e.key === 'Enter') { const t = e.target; const isSafeTarget = t === confirmBtn || (cancelBtn && t === cancelBtn) || t === overlay || t === document.body; if (isSafeTarget && !(t instanceof HTMLTextAreaElement)) { e.preventDefault(); close(true); } return; } if (e.key === 'Tab') { const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); } else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); } } }; window.addEventListener('keydown', onKey); let closed = false; function close(result) { if (closed) return; closed = true; window.removeEventListener('keydown', onKey); overlay.classList.remove('show'); document.body.style.overflow = prevOverflow; setTimeout(() => { overlay.remove(); try { opener?.focus?.(); } catch { /* ignore */ } }, 200); resolve({ isConfirmed: result }); } }); }, alert(title, text, icon = 'info') { return UGModal.confirm({ title, text, confirmText: 'OK', cancelText: null, icon }); }, choose({ title, text, icon = 'question', options }) { return new Promise((resolve) => { DOM.$('.ug-modal-overlay')?.remove(); const iconSvgs = { question: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>', warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>', info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12.01" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>' }; const opener = document.activeElement; const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; const focusables = []; const buttons = options.map(opt => { const btn = DOM.create('button', { className: `ug-modal-btn ${opt.primary ? 'ug-modal-confirm' : 'ug-modal-cancel'}`, text: opt.label, onclick: () => close(opt.value) }); focusables.push(btn); return btn; }); const overlay = DOM.create('div', { className: 'ug-modal-overlay' }, [ DOM.create('div', { className: 'ug-modal-container' }, [ DOM.create('div', { className: `ug-modal-icon ${icon}`, html: iconSvgs[icon] || iconSvgs.info }), DOM.create('h3', { className: 'ug-modal-title', text: title }), DOM.create('p', { className: 'ug-modal-text', text: text }), DOM.create('div', { className: 'ug-modal-actions' }, buttons) ]) ]); document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('show')); focusables[0]?.focus(); const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(null); return; } if (e.key === 'Tab') { const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); } else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); } } }; window.addEventListener('keydown', onKey); let closed = false; function close(value) { if (closed) return; closed = true; window.removeEventListener('keydown', onKey); overlay.classList.remove('show'); document.body.style.overflow = prevOverflow; setTimeout(() => { overlay.remove(); try { opener?.focus?.(); } catch { /* ignore */ } }, 200); resolve({ value }); } }); } }; // ==================================================== // Native IndexedDB Cache Manager // ==================================================== const ImageCacheDB = { DB_NAME: 'UltraGalleriesCache', STORE_NAME: 'imageCache', dbPromise: null, init() { if (this.dbPromise) return this.dbPromise; this.dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(this.DB_NAME, 1); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(this.STORE_NAME)) { const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'url' }); store.createIndex('cachedAt', 'cachedAt', { unique: false }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); return this.dbPromise; }, async get(url) { try { const db = await this.init(); return new Promise((resolve) => { const tx = db.transaction(this.STORE_NAME, 'readwrite'); const store = tx.objectStore(this.STORE_NAME); const req = store.get(url); req.onsuccess = () => { const record = req.result; if (record && record.blob) { record.cachedAt = Date.now(); store.put(record); resolve(record.blob); } else { resolve(null); } }; req.onerror = () => resolve(null); }); } catch { return null; } }, async put(url, blob) { try { const db = await this.init(); return new Promise((resolve) => { const tx = db.transaction(this.STORE_NAME, 'readwrite'); tx.objectStore(this.STORE_NAME).put({ url, blob, cachedAt: Date.now() }); tx.oncomplete = () => resolve(true); tx.onerror = async (e) => { if (e.target.error && e.target.error.name === 'QuotaExceededError') { await this.evictOldest(CONFIG.CACHE_EVICTION_COUNT); } resolve(false); }; }); } catch { return false; } }, async evictOldest(count = 20) { try { const db = await this.init(); const tx = db.transaction(this.STORE_NAME, 'readwrite'); const store = tx.objectStore(this.STORE_NAME); const index = store.index('cachedAt'); let deleted = 0; index.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (cursor && deleted < count) { cursor.delete(); deleted++; cursor.continue(); } }; } catch { // Ignore eviction errors } }, async clear() { try { const db = await this.init(); const tx = db.transaction(this.STORE_NAME, 'readwrite'); tx.objectStore(this.STORE_NAME).clear(); state.notificationType = 'success'; state.notification = 'Persistent image cache cleared.'; } catch { state.notificationType = 'error'; state.notification = 'Error clearing cache.'; } } }; // ==================================================== // Shared Mutable References // ==================================================== let galleryOverlay = null; let loadedBlobUrls = new Map(); const elements = { galleryButton: null, settingsButton: null }; let lastFocusedElement = null; let focusTrapListener = null; let uiObserver = null; let lastProcessedUrl = null; // ==================================================== // High-Frequency View State // ==================================================== const viewState = { zoomScale: 1, imageOffset: { x: 0, y: 0 }, zoomOrigin: { x: 0, y: 0 }, initialScale: 1, pinchZoomActive: false, initialTouchDistance: 0, dragStartPosition: { x: 0, y: 0 }, dragStartOffset: { x: 0, y: 0 } }; // ==================================================== // Utilities // ==================================================== const Utils = { sanitizeFileName: name => String(name || '').replace(/[/\\:*?"<>|]/g, '-'), // Created once at script init — Intl.Collator is expensive to // instantiate, cheap to reuse. `numeric: true` gives us "file2 < file10"; // `sensitivity: 'base'` ignores case so Page_1 and page_1 group together. _naturalCollator: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }), // Natural-order string comparison. naturalCompare(a, b) { return Utils._naturalCollator.compare(String(a ?? ''), String(b ?? '')); }, // Extract the original filename from a link, in reliability order: // 1. `download` attribute (most accurate) // 2. `?f=` query param (Pawchive/Kemono) // 3. URL path tail (hash — last resort) // Safe for non-anchor elements (e.g. raw <video>) — all reads are optional. extractFileName(linkEl, fallbackUrl) { const dl = linkEl?.getAttribute?.('download'); if (dl && dl.trim()) return dl.trim(); const href = linkEl?.getAttribute?.('href') || ''; const fMatch = href.match(/[?&]f=([^&#]+)/); if (fMatch) { try { return decodeURIComponent(fMatch[1]); } catch { return fMatch[1]; } } try { return String(fallbackUrl || '').split('?')[0].split('/').pop() || ''; } catch { return ''; } }, // Sort an array of media items in place per the given mode. // Unknown modes are a silent no-op so a bad stored value can't break loading. sortItems(items, mode) { if (!mode || mode === 'dom') return items; if (mode === 'filename-asc') { items.sort((a, b) => Utils.naturalCompare(a.fileName, b.fileName)); } else if (mode === 'filename-desc') { items.sort((a, b) => Utils.naturalCompare(b.fileName, a.fileName)); } return items; }, getPostDate: (type = 'published') => { let selector; if (type === 'edited') selector = '.post__edited, .scrape__edited'; else if (type === 'added' || type === 'imported') selector = '.post__added, .scrape__added'; else selector = '.post__published, .scrape__published, time[datetime]'; const timeEl = DOM.$(selector); if (timeEl) { const dateStr = timeEl.getAttribute('datetime') || timeEl.textContent; if (dateStr) { const match = dateStr.match(/\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?|\d{4}-\d{2}/); if (match && match[0]) { return Utils.sanitizeFileName(match[0].replace(/[ T]/g, '_').replace(/:/g, '-')); } return Utils.sanitizeFileName(dateStr.replace(/.*?:\s*/, '').trim()); } } return 'UnknownDate'; }, // Returns the post timestamp as a real Date anchored to UTC so that // JSZip's DOS serializer (which reads UTC components) writes the exact // wall-clock shown on the post page. Returns null if no valid date found. getPostDateObject: (type = 'published') => { let selector; if (type === 'edited') selector = '.post__edited, .scrape__edited'; else if (type === 'added' || type === 'imported') selector = '.post__added, .scrape__added'; else selector = '.post__published, .scrape__published, time[datetime]'; const timeEl = DOM.$(selector); if (timeEl) { const raw = (timeEl.getAttribute('datetime') || timeEl.textContent || '').trim(); const match = raw.match(/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/); if (match) { const [, y, mo, d, h = '00', mi = '00', s = '00'] = match; return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)); } } return null; }, setImageStyle: (img, styles) => { if (!img) return; for (const key in styles) { const kebabKey = key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`); img.style.setProperty(kebabKey, styles[key], 'important'); } }, isPostPage: () => { const hasImages = DOM.$(SELECTORS.IMAGE_LINK) || DOM.$(SELECTORS.GENERIC_IMAGE_LINK) || DOM.$('div.post__files'); if (hasImages) return true; const path = window.location.pathname; const patterns = [ /\/user\/.*\/post\//, /\/server\/.*\/channel\//, /\/post\// ]; return patterns.some(pattern => pattern.test(path)); }, delay: ms => new Promise(resolve => setTimeout(resolve, ms)), debounce: (func, wait) => { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; }, throttle: (func, limit) => { let lastRan; let lastFunc; return function (...args) { if (!lastRan) { func.apply(this, args); lastRan = Date.now(); } else { clearTimeout(lastFunc); lastFunc = setTimeout(() => { if ((Date.now() - lastRan) >= limit) { func.apply(this, args); lastRan = Date.now(); } }, limit - (Date.now() - lastRan)); } }; }, handleMediaSrc: mediaLink => { let href = mediaLink.getAttribute('href') || mediaLink.querySelector('.fileThumb')?.getAttribute('href'); if (!href && mediaLink.href) href = mediaLink.href; if (href) { href = href.split('?')[0]; if (href.includes('/thumbnail/')) { href = href.replace(/\/thumbnail\//, '/data/'); } return href; } const directImg = mediaLink.querySelector('img'); if (directImg) { const rawSrc = directImg.getAttribute('data-src') || directImg.src; if (!rawSrc) return null; const imgSrc = rawSrc.split('?')[0]; if (imgSrc.includes('/data/')) return imgSrc; if (imgSrc.includes('/thumbnail/')) { return imgSrc.replace(/\/thumbnail\//, '/data/'); } return imgSrc; } return null; }, ensureThumbnailsExist: () => { try { const videoLinks = DOM.$$(SELECTORS.VIDEO_LINK); videoLinks.forEach(videoLink => { const videoThumb = videoLink.closest(SELECTORS.VIDEO_THUMBNAIL); if (!videoThumb) { const video = videoLink.querySelector('video'); if (video && video.hasAttribute('poster')) { const posterUrl = video.getAttribute('poster'); if (videoLink.parentNode) { const thumbnailContainer = DOM.create('div', { className: isNekohouse ? 'scrape__video-thumbnail' : 'post__video-thumbnail' }, [ DOM.create('img', { src: posterUrl, className: isNekohouse ? 'scrape__thumbnail-img' : 'post__thumbnail-img' }) ]); videoLink.parentNode.insertBefore(thumbnailContainer, videoLink); } } } }); } catch (error) { console.warn('Minor error ensuring thumbnails:', error); } } }; const notifyLoadProgress = Utils.throttle((message, type = 'info') => { state.notificationType = type; state.notification = message; }, CONFIG.PROGRESS_NOTIFY_INTERVAL); // ==================================================== // Image Sizing Module // ==================================================== const ImageSizing = { applyBestFit: (el) => { if (!el) return; Utils.setImageStyle(el, { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto', objectFit: 'contain', margin: '0' }); }, applyFillHeight: (el) => { if (!el) return; Utils.setImageStyle(el, { maxHeight: '90vh', maxWidth: '100%', width: 'auto', height: '90vh', objectFit: 'contain' }); }, applyFillWidth: (el) => { if (!el) return; Utils.setImageStyle(el, { maxHeight: 'none', maxWidth: '100vw', width: '100%', height: 'auto', objectFit: 'contain' }); }, applyFullSize: (el) => { if (!el) return; Utils.setImageStyle(el, { maxHeight: 'none', maxWidth: 'none', height: 'auto', width: 'auto', objectFit: 'none' }); } }; // ==================================================== // Zoom Boundary Helpers // ==================================================== const ZoomHelper = { calculateHardBoundaryOffsets: (offsetX, offsetY, scale, containerRect, imageDOM) => { if (!imageDOM || !containerRect) return { x: offsetX, y: offsetY }; const natW = imageDOM.naturalWidth || imageDOM.videoWidth || 0; const natH = imageDOM.naturalHeight || imageDOM.videoHeight || 0; const imgWidth = natW * scale; const imgHeight = natH * scale; let x = offsetX; let y = offsetY; if (imgWidth > containerRect.width) { const minX = containerRect.width - imgWidth; x = Math.max(minX, Math.min(x, 0)); } else { x = 0; } if (imgHeight > containerRect.height) { const minY = containerRect.height - imgHeight; y = Math.max(minY, Math.min(y, 0)); } else { y = 0; } return { x, y }; } }; // ==================================================== // Unified Pointer Events & Gesture Engine // ==================================================== const PointerEngine = { activePointers: new Map(), isDragging: false, dragStartTime: 0, lastUpdateTime: 0, velocity: { x: 0, y: 0 }, lastPosition: { x: 0, y: 0 }, animationFrame: null, inertiaAnimation: null, cachedImgEl: null, cachedZoomEl: null, lastTapTime: 0, init(containerDOM) { containerDOM.addEventListener('pointerdown', PointerEngine.onPointerDown, { passive: false }); containerDOM.addEventListener('pointermove', PointerEngine.onPointerMove, { passive: false }); containerDOM.addEventListener('pointerup', PointerEngine.onPointerUp, { passive: false }); containerDOM.addEventListener('pointercancel', PointerEngine.onPointerUp, { passive: false }); }, updateTransform() { if (!PointerEngine.cachedImgEl && galleryOverlay) { const container = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); PointerEngine.cachedImgEl = container?.querySelector('img, video'); PointerEngine.cachedZoomEl = DOM.$('#zoom-level'); } if (PointerEngine.cachedImgEl) { PointerEngine.cachedImgEl.style.transformOrigin = '0 0'; PointerEngine.cachedImgEl.style.transform = `translate(${viewState.imageOffset.x}px, ${viewState.imageOffset.y}px) scale(${viewState.zoomScale})`; } if (PointerEngine.cachedZoomEl) { PointerEngine.cachedZoomEl.textContent = `${Math.round(viewState.zoomScale * 100)}%`; } PointerEngine.animationFrame = null; }, onPointerDown(e) { if (e.pointerType === 'mouse' && e.button !== 0) return; e.preventDefault(); PointerEngine.activePointers.set(e.pointerId, e); const container = e.currentTarget; container.setPointerCapture(e.pointerId); if (PointerEngine.inertiaAnimation) { cancelAnimationFrame(PointerEngine.inertiaAnimation); PointerEngine.inertiaAnimation = null; } if (PointerEngine.activePointers.size === 1) { const now = Date.now(); if (e.pointerType === 'touch' && now - PointerEngine.lastTapTime < CONFIG.DOUBLE_TAP_THRESHOLD) { PointerEngine.handleDoubleTap(e); PointerEngine.lastTapTime = 0; return; } PointerEngine.lastTapTime = now; PointerEngine.isDragging = true; PointerEngine.dragStartTime = performance.now(); PointerEngine.lastUpdateTime = PointerEngine.dragStartTime; viewState.dragStartPosition = { x: e.clientX, y: e.clientY }; viewState.dragStartOffset = { x: viewState.imageOffset.x, y: viewState.imageOffset.y }; PointerEngine.lastPosition = { x: e.clientX, y: e.clientY }; PointerEngine.velocity = { x: 0, y: 0 }; container.classList.add(CSS.GALLERY.GRABBING); container.style.willChange = 'transform'; } else if (PointerEngine.activePointers.size === 2) { PointerEngine.isDragging = false; viewState.pinchZoomActive = true; const [p1, p2] = Array.from(PointerEngine.activePointers.values()); viewState.initialTouchDistance = Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY); viewState.initialScale = viewState.zoomScale; const rect = container.getBoundingClientRect(); viewState.zoomOrigin = { x: ((p1.clientX + p2.clientX) / 2) - rect.left, y: ((p1.clientY + p2.clientY) / 2) - rect.top }; } }, onPointerMove(e) { if (!PointerEngine.activePointers.has(e.pointerId)) return; e.preventDefault(); PointerEngine.activePointers.set(e.pointerId, e); if (PointerEngine.isDragging && PointerEngine.activePointers.size === 1) { const currentTime = performance.now(); const deltaTime = currentTime - PointerEngine.lastUpdateTime; if (deltaTime > 0) { PointerEngine.velocity.x = (e.clientX - PointerEngine.lastPosition.x) / deltaTime * 16; PointerEngine.velocity.y = (e.clientY - PointerEngine.lastPosition.y) / deltaTime * 16; } PointerEngine.lastPosition = { x: e.clientX, y: e.clientY }; PointerEngine.lastUpdateTime = currentTime; const deltaX = e.clientX - viewState.dragStartPosition.x; const deltaY = e.clientY - viewState.dragStartPosition.y; viewState.imageOffset.x = viewState.dragStartOffset.x + deltaX; viewState.imageOffset.y = viewState.dragStartOffset.y + deltaY; if (!PointerEngine.animationFrame) { PointerEngine.animationFrame = requestAnimationFrame(PointerEngine.updateTransform); } } else if (viewState.pinchZoomActive && PointerEngine.activePointers.size === 2) { const [p1, p2] = Array.from(PointerEngine.activePointers.values()); const currentDistance = Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY); if (viewState.initialTouchDistance === 0) return; const scaleFactor = currentDistance / viewState.initialTouchDistance; const newScale = Math.max(CONFIG.MIN_SCALE, Math.min(viewState.initialScale * scaleFactor, state.maxZoomScale)); const imageX = (viewState.zoomOrigin.x - viewState.imageOffset.x) / viewState.zoomScale; const imageY = (viewState.zoomOrigin.y - viewState.imageOffset.y) / viewState.zoomScale; viewState.imageOffset.x = viewState.zoomOrigin.x - (imageX * newScale); viewState.imageOffset.y = viewState.zoomOrigin.y - (imageY * newScale); viewState.zoomScale = newScale; PointerEngine.updateTransform(); } }, onPointerUp(e) { if (PointerEngine.activePointers.has(e.pointerId)) { try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { // Ignore capture errors } PointerEngine.activePointers.delete(e.pointerId); } if (PointerEngine.activePointers.size === 0) { if (PointerEngine.isDragging) { PointerEngine.isDragging = false; const container = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); if (container) { container.classList.remove(CSS.GALLERY.GRABBING); setTimeout(() => container.style.willChange = '', 1000); } if (state.inertiaEnabled && (Math.abs(PointerEngine.velocity.x) > 0.5 || Math.abs(PointerEngine.velocity.y) > 0.5)) { PointerEngine.applyInertia(); } else { PointerEngine.enforceBoundaries(); } } viewState.pinchZoomActive = false; } else if (PointerEngine.activePointers.size === 1) { const remaining = PointerEngine.activePointers.values().next().value; viewState.dragStartPosition = { x: remaining.clientX, y: remaining.clientY }; viewState.dragStartOffset = { ...viewState.imageOffset }; PointerEngine.lastPosition = { x: remaining.clientX, y: remaining.clientY }; PointerEngine.isDragging = true; } }, applyInertia() { const friction = 0.95; const minVelocity = 0.5; const animate = () => { PointerEngine.velocity.x *= friction; PointerEngine.velocity.y *= friction; viewState.imageOffset.x += PointerEngine.velocity.x; viewState.imageOffset.y += PointerEngine.velocity.y; if (Math.abs(PointerEngine.velocity.x) < minVelocity && Math.abs(PointerEngine.velocity.y) < minVelocity) { PointerEngine.inertiaAnimation = null; PointerEngine.enforceBoundaries(); return; } PointerEngine.updateTransform(); PointerEngine.inertiaAnimation = requestAnimationFrame(animate); }; PointerEngine.inertiaAnimation = requestAnimationFrame(animate); }, enforceBoundaries() { if (!galleryOverlay) return; const containerDOM = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); const imageDOM = DOM.$(`.${CSS.GALLERY.MAIN_IMG}, .${CSS.GALLERY.MAIN_VIDEO}`, containerDOM); if (!containerDOM || !imageDOM) return; const containerRect = containerDOM.getBoundingClientRect(); const boundedOffset = ZoomHelper.calculateHardBoundaryOffsets( viewState.imageOffset.x, viewState.imageOffset.y, viewState.zoomScale, containerRect, imageDOM ); if (boundedOffset.x !== viewState.imageOffset.x || boundedOffset.y !== viewState.imageOffset.y) { const duration = 300; const startX = viewState.imageOffset.x; const startY = viewState.imageOffset.y; const deltaX = boundedOffset.x - startX; const deltaY = boundedOffset.y - startY; const startTime = performance.now(); const animateToBoundary = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easeProgress = 1 - Math.pow(1 - progress, 3); viewState.imageOffset.x = startX + deltaX * easeProgress; viewState.imageOffset.y = startY + deltaY * easeProgress; PointerEngine.updateTransform(); if (progress < 1) { requestAnimationFrame(animateToBoundary); } }; requestAnimationFrame(animateToBoundary); } }, handleDoubleTap(e) { e.preventDefault(); if (!galleryOverlay) return; const containerDOM = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); if (!containerDOM) return; const rect = containerDOM.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; if (viewState.zoomScale > 1) { Zoom.resetZoom(); } else { const newScale = 2.5; const imageX = (clickX - viewState.imageOffset.x) / viewState.zoomScale; const imageY = (clickY - viewState.imageOffset.y) / viewState.zoomScale; const newOffsetX = clickX - (imageX * newScale); const newOffsetY = clickY - (imageY * newScale); Zoom._applyTransition(containerDOM, () => { viewState.imageOffset.x = newOffsetX; viewState.imageOffset.y = newOffsetY; viewState.zoomScale = newScale; PointerEngine.updateTransform(); }); } } }; // ==================================================== // Slideshow Module // ==================================================== const Slideshow = { interval: null, isActive: false, delay: CONFIG.SLIDESHOW_DELAY, pauseOnHover: true, init() { Slideshow.delay = state.slideshowDelay || CONFIG.SLIDESHOW_DELAY; Slideshow.pauseOnHover = state.slideshowPauseOnHover !== false; }, start() { if (Slideshow.isActive) return; Slideshow.isActive = true; state.isSlideshowActive = true; Slideshow.interval = setInterval(() => Gallery.nextImage(), Slideshow.delay); Slideshow.showIndicator(); if (Slideshow.pauseOnHover && galleryOverlay) { galleryOverlay.addEventListener('mouseenter', Slideshow.pause); galleryOverlay.addEventListener('mouseleave', Slideshow.resume); } Slideshow.syncControls(); Accessibility.announce('Slideshow started'); state.notificationType = 'info'; state.notification = 'Slideshow started'; }, stop() { if (!Slideshow.isActive) return; Slideshow.isActive = false; state.isSlideshowActive = false; if (Slideshow.interval) { clearInterval(Slideshow.interval); Slideshow.interval = null; } if (galleryOverlay) { Slideshow.hideIndicator(); galleryOverlay.removeEventListener('mouseenter', Slideshow.pause); galleryOverlay.removeEventListener('mouseleave', Slideshow.resume); } Slideshow.syncControls(); Accessibility.announce('Slideshow stopped'); state.notificationType = 'info'; state.notification = 'Slideshow stopped'; }, pause() { if (Slideshow.interval && Slideshow.isActive) { clearInterval(Slideshow.interval); Slideshow.interval = null; Slideshow.updateIndicator(true); Slideshow.syncControls(); } }, resume() { if (!Slideshow.interval && Slideshow.isActive) { Slideshow.interval = setInterval(() => Gallery.nextImage(), Slideshow.delay); Slideshow.updateIndicator(false); Slideshow.syncControls(); } }, toggle() { Slideshow.isActive ? Slideshow.stop() : Slideshow.start(); }, handleButton() { if (!Slideshow.isActive) Slideshow.start(); else if (Slideshow.interval) Slideshow.pause(); else Slideshow.resume(); }, syncControls() { if (!galleryOverlay) return; const btn = DOM.$('#slideshow-btn', galleryOverlay); if (!btn) return; if (!Slideshow.isActive) { btn.innerHTML = '▶'; btn.setAttribute('title', 'Start Slideshow (Space)'); btn.classList.remove('slideshow-running', 'slideshow-paused'); } else if (Slideshow.interval) { btn.innerHTML = '❚❚'; btn.setAttribute('title', 'Pause Slideshow'); btn.classList.add('slideshow-running'); btn.classList.remove('slideshow-paused'); } else { btn.innerHTML = '▶'; btn.setAttribute('title', 'Resume Slideshow'); btn.classList.add('slideshow-paused'); btn.classList.remove('slideshow-running'); } }, showIndicator() { if (!galleryOverlay || DOM.$('.ug-slideshow-indicator', galleryOverlay)) return; const toolbar = DOM.$(`.${CSS.GALLERY.TOOLBAR}`, galleryOverlay); if (!toolbar) return; const indicator = DOM.create('div', { className: 'ug-slideshow-indicator' }, [ DOM.create('span', { className: 'ug-slideshow-icon', text: '▶' }), DOM.create('span', { className: 'ug-slideshow-text', text: 'Slideshow' }), DOM.create('button', { className: 'ug-slideshow-stop', title: 'Stop slideshow', text: '✕', onclick: (e) => { e.stopPropagation(); Slideshow.stop(); } }) ]); toolbar.appendChild(indicator); }, hideIndicator() { DOM.$('.ug-slideshow-indicator', galleryOverlay)?.remove(); }, updateIndicator(isPaused) { const indicator = DOM.$('.ug-slideshow-indicator', galleryOverlay); if (!indicator) return; const icon = DOM.$('.ug-slideshow-icon', indicator); if (isPaused) { if (icon) icon.textContent = '❚❚'; indicator.classList.add('paused'); } else { if (icon) icon.textContent = '▶'; indicator.classList.remove('paused'); } }, setDelay(delay) { Slideshow.delay = delay; SettingsManager.saveSetting('slideshowDelay', delay); if (Slideshow.isActive) { Slideshow.stop(); Slideshow.start(); } } }; // ==================================================== // Error Handler Module // ==================================================== const ErrorHandler = { retryAttempts: new Map(), async handleImageError(error, url, element = null, context = {}) { const retryCount = ErrorHandler.retryAttempts.get(url) || 0; console.error(`Image load error (${retryCount + 1}/${CONFIG.MAX_RETRIES}):`, error, url); if (retryCount < CONFIG.MAX_RETRIES) { ErrorHandler.retryAttempts.set(url, retryCount + 1); const delay = Math.pow(2, retryCount) * 1000; if (retryCount === 0) { state.notificationType = 'warning'; state.notification = `Retrying failed image... (${retryCount + 1}/${CONFIG.MAX_RETRIES})`; } setTimeout(async () => { try { if (element) element.classList.add('retrying'); const blob = await ImageLoader.fetchWithRetry(url, state.currentLoadSessionId); if (blob && element) { const blobUrl = BlobManager.createUrl(blob); element.src = blobUrl; element.classList.remove('error', 'retrying'); ErrorHandler.retryAttempts.delete(url); state.notificationType = 'success'; state.notification = 'Image loaded successfully'; } } catch (retryError) { ErrorHandler.handleImageError(retryError, url, element, context); } }, delay); } else { ErrorHandler.showErrorPlaceholder(element, url, context); ErrorHandler.retryAttempts.delete(url); state.notificationType = 'error'; state.notification = `Failed to load image after ${CONFIG.MAX_RETRIES} attempts`; } }, showErrorPlaceholder(element, url, context) { if (!element) return; const errorContainer = DOM.create('div', { className: 'ug-error-container' }, [ DOM.create('div', { className: 'ug-error-icon', html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>' }), DOM.create('div', { className: 'ug-error-message', text: 'Failed to load image' }), DOM.create('button', { className: 'ug-error-retry', text: 'Retry', title: 'Retry loading', onclick: () => { if (errorContainer.parentNode) { errorContainer.parentNode.replaceChild(element, errorContainer); } element.classList.add('loading'); ErrorHandler.retryAttempts.delete(url); ImageLoader.loadImageAndApplyToPage( context.linkElement, context.galleryIndex, context.posterHref, context.isUniqueForGallery, state.currentLoadSessionId, context.itemData ); } }) ]); if (element.parentNode) { element.parentNode.replaceChild(errorContainer, element); } }, clearRetries() { ErrorHandler.retryAttempts.clear(); } }; // ==================================================== // Settings Manager // ==================================================== const SettingsManager = { defaultSettings: { galleryKey: 'g', prevImageKey: 'k', nextImageKey: 'l', zoomEnabled: true, animationsEnabled: true, notificationsEnabled: true, notificationPosition: 'bottom', bottomStripeVisible: true, hideNavArrows: false, // Post-action bar (global) visibility hideGlobalGalleryButton: false, hideGlobalHeightButton: false, hideGlobalWidthButton: false, hideGlobalFullButton: false, hideGlobalDownloadAllButton: false, hideGlobalDownloadImagesButton: false, hideGlobalDownloadAttachmentsButton: false, // Per-thumbnail button visibility hideThumbnailHeightButton: false, hideThumbnailWidthButton: false, hideThumbnailFullButton: false, hideThumbnailDownloadButton: false, enablePersistentCaching: true, preserveFileDates: true, slideshowDelay: CONFIG.SLIDESHOW_DELAY, slideshowPauseOnHover: true, inertiaEnabled: true, maxZoomScale: 5, zipFileNameFormat: '{date_published}-{title}-{artistName}.zip', imageFileNameFormat: '{date_published}-{title}-{artistName}-{fileName}-{index}', autoLoadOriginals: true, downloadBtnText: '【DOWNLOAD】', downloadAllBtnText: '【DL ALL】', downloadImagesBtnText: '【DL IMAGES】', downloadAttachmentsBtnText: '【DL FILES】', fullBtnText: '【FULL】', heightBtnText: '【FILL HEIGHT】', widthBtnText: '【FILL WIDTH】', galleryBtnText: '【GALLERY】', sortMode: 'dom', currentResizeMode: 'height', fullscreenMode: 'native' }, saveSetting: (key, value) => { try { GM_setValue(key, JSON.stringify(value)); return true; } catch (error) { console.error('Failed to save setting:', key, error); return false; } }, loadSetting: (key, defaultValue = null) => { try { const value = GM_getValue(key); if (value === undefined) return defaultValue; try { return JSON.parse(value); } catch { return value; } } catch (error) { console.error('Failed to load setting:', key, error); return defaultValue; } }, loadAllSettings: () => { const settings = {}; Object.keys(SettingsManager.defaultSettings).forEach(key => { settings[key] = SettingsManager.loadSetting(key, SettingsManager.defaultSettings[key]); }); return settings; }, saveAllSettings: (settings) => { let success = true; Object.keys(settings).forEach(key => { if (!SettingsManager.saveSetting(key, settings[key])) success = false; }); return success; }, resetToDefaults: () => SettingsManager.saveAllSettings(SettingsManager.defaultSettings), exportSettings: () => JSON.stringify(SettingsManager.loadAllSettings(), null, 2), sanitizeSetting: (key, value) => { const def = SettingsManager.defaultSettings[key]; switch (key) { case 'slideshowDelay': { const n = parseInt(value, 10); return (Number.isFinite(n) && n >= 500) ? n : CONFIG.SLIDESHOW_DELAY; } case 'maxZoomScale': { const n = parseFloat(value); return (Number.isFinite(n) && n >= 1) ? n : 5; } case 'galleryKey': case 'prevImageKey': case 'nextImageKey': return (typeof value === 'string' && value.length === 1) ? value : def; case 'notificationPosition': return ['top', 'bottom'].includes(value) ? value : 'bottom'; case 'fullscreenMode': return ['native', 'css', 'ask'].includes(value) ? value : 'native'; case 'sortMode': return ['dom', 'filename-asc', 'filename-desc'].includes(value) ? value : 'dom'; case 'zipFileNameFormat': case 'imageFileNameFormat': return (typeof value === 'string' && value.trim()) ? value : def; default: if (typeof def === 'boolean') return Boolean(value); if (typeof def === 'string') return (typeof value === 'string') ? value : def; return value; } }, importSettings: (settingsJson) => { try { const settings = JSON.parse(settingsJson); const validatedSettings = {}; Object.keys(SettingsManager.defaultSettings).forEach(key => { const incoming = Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : SettingsManager.defaultSettings[key]; validatedSettings[key] = SettingsManager.sanitizeSetting(key, incoming); }); if (SettingsManager.saveAllSettings(validatedSettings)) { Object.assign(state, validatedSettings); state.notificationType = 'success'; state.notification = 'Settings imported successfully'; return true; } } catch (error) { console.error('Failed to import settings:', error); state.notificationType = 'error'; state.notification = 'Failed to import settings: Invalid format'; } return false; } }; // ==================================================== // Accessibility Module // ==================================================== const Accessibility = { init() { if (galleryOverlay) { galleryOverlay.setAttribute('role', 'dialog'); galleryOverlay.setAttribute('aria-modal', 'true'); galleryOverlay.setAttribute('aria-label', 'Image Gallery'); } if (!DOM.$('.ug-sr-only')) { const liveRegion = DOM.create('div', { className: 'ug-sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' }); document.body.appendChild(liveRegion); } }, announce(message) { const sr = DOM.$('.ug-sr-only'); if (!sr) return; sr.textContent = ''; requestAnimationFrame(() => { sr.textContent = message; }); } }; // ==================================================== // Blob Manager // ==================================================== const BlobManager = { blobUrls: new Set(), createUrl(blob) { if (!blob) return ''; const url = URL.createObjectURL(blob); BlobManager.blobUrls.add(url); return url; }, revokeUrl(url) { if (typeof url === 'string' && url.startsWith('blob:')) { try { URL.revokeObjectURL(url); BlobManager.blobUrls.delete(url); } catch { // Ignore revoke errors } } }, revokeAll() { BlobManager.blobUrls.forEach(url => { try { URL.revokeObjectURL(url); } catch { // Ignore revoke errors } }); BlobManager.blobUrls.clear(); } }; // ==================================================== // State Manager & Reactive State // ==================================================== const StateManager = { generateSessionId: () => crypto.randomUUID?.() || Date.now().toString(36) + Math.random().toString(36).slice(2), withSessionCheck(callback) { return (value, oldValue) => { if (state.currentLoadSessionId === null) return; callback(value, oldValue); }; }, createReactiveState(initialState, updateCallbacks = {}) { return new Proxy(initialState, { set(target, key, value) { const oldValue = target[key]; target[key] = value; if (updateCallbacks[key]) { try { updateCallbacks[key](value, oldValue); } catch (e) { console.error(`Error in state callback for "${key}":`, e); } } return true; } }); } }; const state = StateManager.createReactiveState({ zipFileNameFormat: SettingsManager.loadSetting('zipFileNameFormat', '{date_published}-{title}-{artistName}.zip'), imageFileNameFormat: SettingsManager.loadSetting('imageFileNameFormat', '{date_published}-{title}-{artistName}-{fileName}-{index}'), galleryKey: SettingsManager.loadSetting('galleryKey', 'g'), galleryReady: false, currentGalleryIndex: 0, currentResizeMode: SettingsManager.loadSetting('currentResizeMode', 'height'), isFullscreen: false, currentPostUrl: null, maxZoomScale: SettingsManager.loadSetting('maxZoomScale', 5), originalImageSrcs: [], fullSizeImageSrcs: [], totalImages: 0, loadedImages: 0, isLoading: false, isGalleryMode: false, isDownloading: false, errorCount: 0, currentLoadSessionId: null, notificationsEnabled: SettingsManager.loadSetting('notificationsEnabled', true), notificationPosition: SettingsManager.loadSetting('notificationPosition', 'bottom'), animationsEnabled: SettingsManager.loadSetting('animationsEnabled', true), enablePersistentCaching: SettingsManager.loadSetting('enablePersistentCaching', true), preserveFileDates: SettingsManager.loadSetting('preserveFileDates', true), notification: null, notificationType: 'info', hideNavArrows: SettingsManager.loadSetting('hideNavArrows', false), // Global post-action button visibility hideGlobalGalleryButton: SettingsManager.loadSetting('hideGlobalGalleryButton', false), hideGlobalHeightButton: SettingsManager.loadSetting('hideGlobalHeightButton', false), hideGlobalWidthButton: SettingsManager.loadSetting('hideGlobalWidthButton', false), hideGlobalFullButton: SettingsManager.loadSetting('hideGlobalFullButton', false), hideGlobalDownloadAllButton: SettingsManager.loadSetting('hideGlobalDownloadAllButton', false), hideGlobalDownloadImagesButton: SettingsManager.loadSetting('hideGlobalDownloadImagesButton', false), hideGlobalDownloadAttachmentsButton: SettingsManager.loadSetting('hideGlobalDownloadAttachmentsButton', false), // Thumbnail button visibility hideThumbnailHeightButton: SettingsManager.loadSetting('hideThumbnailHeightButton', false), hideThumbnailWidthButton: SettingsManager.loadSetting('hideThumbnailWidthButton', false), hideThumbnailFullButton: SettingsManager.loadSetting('hideThumbnailFullButton', false), hideThumbnailDownloadButton: SettingsManager.loadSetting('hideThumbnailDownloadButton', false), settingsOpen: false, prevImageKey: SettingsManager.loadSetting('prevImageKey', 'k'), nextImageKey: SettingsManager.loadSetting('nextImageKey', 'l'), bottomStripeVisible: SettingsManager.loadSetting('bottomStripeVisible', true), zoomEnabled: SettingsManager.loadSetting('zoomEnabled', true), controlsVisible: true, isDragging: false, lastTapTime: 0, zoomIndicatorVisible: true, inertiaEnabled: SettingsManager.loadSetting('inertiaEnabled', true), isSlideshowActive: false, autoLoadOriginals: SettingsManager.loadSetting('autoLoadOriginals', true), downloadBtnText: SettingsManager.loadSetting('downloadBtnText', '【DOWNLOAD】'), downloadAllBtnText: SettingsManager.loadSetting('downloadAllBtnText', '【DL ALL】'), downloadImagesBtnText: SettingsManager.loadSetting('downloadImagesBtnText', '【DL IMAGES】'), downloadAttachmentsBtnText: SettingsManager.loadSetting('downloadAttachmentsBtnText', '【DL FILES】'), fullBtnText: SettingsManager.loadSetting('fullBtnText', '【FULL】'), heightBtnText: SettingsManager.loadSetting('heightBtnText', '【FILL HEIGHT】'), widthBtnText: SettingsManager.loadSetting('widthBtnText', '【FILL WIDTH】'), galleryBtnText: SettingsManager.loadSetting('galleryBtnText', '【GALLERY】'), slideshowDelay: SettingsManager.loadSetting('slideshowDelay', CONFIG.SLIDESHOW_DELAY), slideshowPauseOnHover: SettingsManager.loadSetting('slideshowPauseOnHover', true), fullscreenMode: SettingsManager.loadSetting('fullscreenMode', 'native'), sortMode: SettingsManager.loadSetting('sortMode', 'dom') }, { controlsVisible(value) { if (galleryOverlay) { const toolbar = DOM.$(`.${CSS.GALLERY.TOOLBAR}`, galleryOverlay); const expandedView = DOM.$(`.${CSS.GALLERY.EXPANDED_VIEW}`, galleryOverlay); toolbar?.classList.toggle(CSS.GALLERY.CONTROLS_HIDDEN, !value); expandedView?.classList.toggle(CSS.GALLERY.CONTROLS_HIDDEN, !value); } }, galleryReady(value) { updateGalleryButton(value); }, loadedImages: StateManager.withSessionCheck((value) => { if (value === state.totalImages && state.totalImages > 0) { state.notificationType = 'success'; state.notification = `Media Done Loading! Total: ${state.totalImages}`; } else if (state.totalImages > 0) { notifyLoadProgress(`Loading media (${value}/${state.totalImages})...`, 'info'); } }), totalImages: StateManager.withSessionCheck((value) => { if (value > 0) { notifyLoadProgress(`Loading media (${state.loadedImages}/${value})...`, 'info'); } }), notification(value) { if (value) UI.showNotification(value, state.notificationType); else UI.hideNotification(); }, notificationType(value) { void value; }, settingsOpen(value) { value ? UI.showSettings() : UI.closeSettings(); }, isFullscreen(value) { if (value) { document.body.classList.add('ug-fullscreen'); galleryOverlay?.classList.add(CSS.GALLERY.FULLSCREEN_OVERLAY); } else { document.body.classList.remove('ug-fullscreen'); galleryOverlay?.classList.remove(CSS.GALLERY.FULLSCREEN_OVERLAY); } if (galleryOverlay) { const btn = DOM.$(`.${CSS.GALLERY.FULLSCREEN}`, galleryOverlay); if (btn) btn.classList.toggle('active', Boolean(value)); } }, zoomEnabled(value) { SettingsManager.saveSetting('zoomEnabled', value); }, animationsEnabled(value) { SettingsManager.saveSetting('animationsEnabled', value); document.body.classList.toggle('ug-animations-disabled', !value); }, bottomStripeVisible(value) { SettingsManager.saveSetting('bottomStripeVisible', value); if (galleryOverlay) { const stripContainer = DOM.$(`.${CSS.GALLERY.STRIP_CONTAINER}`, galleryOverlay); if (stripContainer) stripContainer.style.display = value ? 'flex' : 'none'; } }, isDragging(value) { if (galleryOverlay) { const container = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); container?.classList.toggle(CSS.GALLERY.GRABBING, value); } }, notificationPosition(value) { SettingsManager.saveSetting('notificationPosition', value); const notifArea = document.getElementById(CSS.NOTIF_AREA); if (notifArea) { notifArea.style.top = value === 'top' ? '10px' : 'auto'; notifArea.style.bottom = value === 'bottom' ? '10px' : 'auto'; } }, enablePersistentCaching(value) { SettingsManager.saveSetting('enablePersistentCaching', value); if (value) ImageCacheDB.init(); }, currentResizeMode(value) { SettingsManager.saveSetting('currentResizeMode', value); } }); function updateButtonLabels() { BUTTONS.DOWNLOAD = state.downloadBtnText || '【DOWNLOAD】'; BUTTONS.DOWNLOAD_ALL = state.downloadAllBtnText || '【DL ALL】'; BUTTONS.DOWNLOAD_IMAGES = state.downloadImagesBtnText || '【DL IMAGES】'; BUTTONS.DOWNLOAD_ATTACHMENTS = state.downloadAttachmentsBtnText || '【DL FILES】'; BUTTONS.FULL = state.fullBtnText || '【FULL】'; BUTTONS.HEIGHT = state.heightBtnText || '【FILL HEIGHT】'; BUTTONS.WIDTH = state.widthBtnText || '【FILL WIDTH】'; BUTTONS.GALLERY = state.galleryBtnText || '【GALLERY】'; const labels = { DOWNLOAD: BUTTONS.DOWNLOAD, DOWNLOAD_ALL: BUTTONS.DOWNLOAD_ALL, DOWNLOAD_IMAGES: BUTTONS.DOWNLOAD_IMAGES, DOWNLOAD_ATTACHMENTS: BUTTONS.DOWNLOAD_ATTACHMENTS, FULL: BUTTONS.FULL, HEIGHT: BUTTONS.HEIGHT, WIDTH: BUTTONS.WIDTH, GALLERY: BUTTONS.GALLERY }; DOM.$$(`.${CSS.BTN}[data-action]`).forEach(btn => { const label = labels[btn.dataset.action]; if (label) btn.textContent = label; }); updateGalleryButton(state.galleryReady); PostActions.updateButtonVisibilityLight(); } // ==================================================== // Zoom Module // ==================================================== const Zoom = { _applyTransition(element, action) { element.classList.add(CSS.GALLERY.IS_TRANSITIONING); action(); let cleared = false; const clear = () => { if (!cleared) { cleared = true; element.classList.remove(CSS.GALLERY.IS_TRANSITIONING); } }; element.addEventListener('transitionend', clear, { once: true }); setTimeout(clear, 600); }, applyZoom() { if (!galleryOverlay) return; const container = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); if (!container) return; PointerEngine.updateTransform(); const zoomDisplay = DOM.$('#zoom-level', galleryOverlay); if (zoomDisplay) { zoomDisplay.textContent = `${Math.round(viewState.zoomScale * 100)}%`; } container.classList.toggle(CSS.GALLERY.ZOOMED, viewState.zoomScale !== 1); }, handleWheelZoom(event) { if (!state.zoomEnabled || !galleryOverlay) return; event.preventDefault(); event.stopPropagation(); const container = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); if (!container) return; const rect = container.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; const delta = event.deltaY; const zoomFactor = delta > 0 ? (1 - CONFIG.ZOOM_STEP) : (1 + CONFIG.ZOOM_STEP); const newScale = Math.max(CONFIG.MIN_SCALE, Math.min(viewState.zoomScale * zoomFactor, state.maxZoomScale)); if (newScale === viewState.zoomScale) return; const imageXUnderPointer = (mouseX - viewState.imageOffset.x) / viewState.zoomScale; const imageYUnderPointer = (mouseY - viewState.imageOffset.y) / viewState.zoomScale; viewState.imageOffset.x = mouseX - (imageXUnderPointer * newScale); viewState.imageOffset.y = mouseY - (imageYUnderPointer * newScale); viewState.zoomScale = newScale; Zoom.applyZoom(); }, resetZoom() { if (!galleryOverlay) return; const container = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); if (container) { Zoom._applyTransition(container, () => { viewState.zoomScale = 1; viewState.imageOffset = { x: 0, y: 0 }; Zoom.applyZoom(); }); } }, zoom(step) { if (!galleryOverlay) return; const container = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); if (!container) return; const rect = container.getBoundingClientRect(); const centerX = rect.width / 2; const centerY = rect.height / 2; const newScale = Math.max(CONFIG.MIN_SCALE, Math.min(viewState.zoomScale + step, state.maxZoomScale)); if (viewState.zoomScale !== newScale) { const imageX = (centerX - viewState.imageOffset.x) / viewState.zoomScale; const imageY = (centerY - viewState.imageOffset.y) / viewState.zoomScale; const newOffsetX = centerX - (imageX * newScale); const newOffsetY = centerY - (imageY * newScale); Zoom._applyTransition(container, () => { viewState.imageOffset.x = newOffsetX; viewState.imageOffset.y = newOffsetY; viewState.zoomScale = newScale; Zoom.applyZoom(); }); } } }; // ==================================================== // Thumbnail Strip Module // ==================================================== const ThumbnailStrip = { _contextMenuTimeout: null, _abortController: null, init() { if (!galleryOverlay) return; const strip = DOM.$('.ug-thumbnail-strip', galleryOverlay); if (!strip) return; ThumbnailStrip._abortController?.abort(); ThumbnailStrip._abortController = new AbortController(); const signal = ThumbnailStrip._abortController.signal; ThumbnailStrip.updateScrollIndicators(); ThumbnailStrip.setupKeyboardNavigation(); ThumbnailStrip.setupDragNavigation(signal); ThumbnailStrip.setupHoverPreview(signal); ThumbnailStrip.setupContextMenu(signal); strip.addEventListener('scroll', Utils.throttle(ThumbnailStrip.updateScrollIndicators, 100), { signal, passive: true }); }, cleanup() { ThumbnailStrip._abortController?.abort(); ThumbnailStrip._abortController = null; ThumbnailStrip.hideContextMenu(); DOM.$('.ug-thumbnail-zoom-preview')?.remove(); DOM.$('.ug-slideshow-indicator', galleryOverlay)?.remove(); }, updateScrollIndicators() { if (!galleryOverlay) return; const strip = DOM.$('.ug-thumbnail-strip', galleryOverlay); if (!strip) return; const hasScroll = strip.scrollWidth > strip.clientWidth; strip.classList.toggle('no-scroll', !hasScroll); }, setupKeyboardNavigation() { const strip = DOM.$('.ug-thumbnail-strip', galleryOverlay); if (!strip) return; strip.onkeydown = (e) => { const focused = e.target.closest(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}`); if (!focused) return; switch (e.key) { case 'ArrowLeft': e.preventDefault(); ThumbnailStrip.navigateThumbnails('prev'); break; case 'ArrowRight': e.preventDefault(); ThumbnailStrip.navigateThumbnails('next'); break; case 'Enter': case ' ': e.preventDefault(); Gallery.showExpandedView(parseInt(focused.dataset.index, 10)); break; } }; }, navigateThumbnails(direction) { const strip = DOM.$('.ug-thumbnail-strip', galleryOverlay); const current = DOM.$(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}.selected`, strip); const currentIndex = current ? parseInt(current.dataset.index, 10) : state.currentGalleryIndex; const total = state.fullSizeImageSrcs.length; if (!total) return; const target = Math.max(0, Math.min(total - 1, currentIndex + (direction === 'next' ? 1 : -1))); const targetEl = DOM.$(`[data-index="${target}"]`, strip); if (targetEl) { targetEl.focus(); DOM.scrollToCenter(strip, targetEl); } else { Gallery.showExpandedView(target); } }, setupDragNavigation(signal) { const strip = DOM.$('.ug-thumbnail-strip', galleryOverlay); if (!strip) return; let isDragging = false; let startX = 0; let scrollLeft = 0; strip.addEventListener('mousedown', (e) => { if (e.button !== 0 || e.target.closest(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}`)) return; isDragging = true; startX = e.pageX - strip.offsetLeft; scrollLeft = strip.scrollLeft; strip.style.cursor = 'grabbing'; strip.classList.add('ug-dragging'); }, { signal }); window.addEventListener('mousemove', (e) => { if (!isDragging || !strip.isConnected) return; e.preventDefault(); const x = e.pageX - strip.offsetLeft; strip.scrollLeft = scrollLeft - (x - startX) * 2; }, { signal }); window.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; strip.style.cursor = ''; strip.classList.remove('ug-dragging'); } }, { signal }); }, setupHoverPreview(signal) { const strip = DOM.$('.ug-thumbnail-strip', galleryOverlay); if (!strip) return; let previewTimeout; strip.addEventListener('mouseover', (e) => { const thumb = e.target.closest(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}`); if (!thumb) return; clearTimeout(previewTimeout); previewTimeout = setTimeout(() => { const index = parseInt(thumb.dataset.index, 10); ThumbnailStrip.showZoomPreview(thumb, index); }, 500); }, { signal }); strip.addEventListener('mouseout', (e) => { if (e.target.closest(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}`)) { clearTimeout(previewTimeout); ThumbnailStrip.hideZoomPreview(); } }, { signal }); }, showZoomPreview(thumb, index) { const mediaItem = state.fullSizeImageSrcs[index]; if (!mediaItem) return; DOM.$('.ug-thumbnail-zoom-preview')?.remove(); if (mediaItem.type === 'attachment') return; const thumbImg = thumb.querySelector('img'); const src = mediaItem.type === 'video' ? (mediaItem.poster || thumbImg?.src) : (thumbImg?.src || mediaItem.src); if (!src) return; const preview = DOM.create('div', { className: 'ug-thumbnail-zoom-preview' }, [ DOM.create('img', { src }) ]); document.body.appendChild(preview); const rect = thumb.getBoundingClientRect(); const previewWidth = 220; const left = Math.min( Math.max(rect.left + rect.width / 2, previewWidth / 2 + 8), window.innerWidth - previewWidth / 2 - 8 ); let top; let transform; if (rect.top > 240) { top = rect.top - 10; transform = 'translate(-50%, -100%)'; } else { top = rect.bottom + 10; transform = 'translate(-50%, 0)'; } Object.assign(preview.style, { position: 'fixed', left: `${left}px`, top: `${top}px`, bottom: 'auto', transform, zIndex: '10000' }); setTimeout(() => preview.classList.add('show'), 10); }, hideZoomPreview() { const preview = DOM.$('.ug-thumbnail-zoom-preview'); if (!preview) return; preview.classList.remove('show'); setTimeout(() => preview.remove(), 300); }, setupContextMenu(signal) { const strip = DOM.$('.ug-thumbnail-strip', galleryOverlay); if (!strip) return; strip.addEventListener('contextmenu', (e) => { const thumb = e.target.closest(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}`); if (!thumb) return; e.preventDefault(); ThumbnailStrip.showContextMenu(thumb, parseInt(thumb.dataset.index, 10), e.pageX, e.pageY); }, { signal }); window.addEventListener('click', ThumbnailStrip.hideContextMenu, { signal }); }, showContextMenu(thumb, index, x, y) { if (ThumbnailStrip._contextMenuTimeout) { clearTimeout(ThumbnailStrip._contextMenuTimeout); ThumbnailStrip._contextMenuTimeout = null; } DOM.$('.ug-thumbnail-context-menu')?.remove(); const menuItems = [ { text: 'Open Image', action: () => Gallery.showExpandedView(index) }, { text: 'Download File', action: () => DownloadManager.downloadImageByIndex(index) }, { text: 'Copy URL', action: () => ThumbnailStrip.copyImageUrl(index) }, { text: 'Remove from Gallery', action: () => ThumbnailStrip.removeFromGallery(index), danger: true } ]; const menu = DOM.create('div', { className: 'ug-thumbnail-context-menu' }, menuItems.map(item => DOM.create('button', { className: `ug-thumbnail-context-menu-item${item.danger ? ' danger' : ''}`, text: item.text, onclick: (e) => { e.stopPropagation(); item.action(); ThumbnailStrip.hideContextMenu(); } })) ); menu.style.left = `${Math.min(x, window.innerWidth - 170)}px`; menu.style.top = `${Math.min(y - 10, window.innerHeight - 200)}px`; document.body.appendChild(menu); setTimeout(() => menu.classList.add('show'), 10); }, hideContextMenu() { const menu = DOM.$('.ug-thumbnail-context-menu'); if (!menu) return; menu.classList.remove('show'); if (ThumbnailStrip._contextMenuTimeout) clearTimeout(ThumbnailStrip._contextMenuTimeout); ThumbnailStrip._contextMenuTimeout = setTimeout(() => menu.remove(), CONFIG.CONTEXT_MENU_HIDE_DELAY); }, copyImageUrl(index) { const mediaItem = state.fullSizeImageSrcs[index]; if (!mediaItem) return; navigator.clipboard.writeText(mediaItem.src).then(() => { state.notificationType = 'success'; state.notification = 'URL copied to clipboard'; }).catch(err => { console.error('Failed to copy URL:', err); state.notificationType = 'error'; state.notification = 'Failed to copy URL'; }); }, async removeFromGallery(index) { const doRemove = () => { state.fullSizeImageSrcs.splice(index, 1); state.originalImageSrcs.splice(index, 1); Gallery._clearPreloadCache(); if (state.fullSizeImageSrcs.length === 0) { Gallery.closeGallery(); return; } const strip = DOM.$(`.${CSS.GALLERY.THUMBNAIL_STRIP}`, galleryOverlay); const thumbToRemove = strip?.querySelector(`[data-index="${index}"]`); thumbToRemove?.remove(); if (strip) { DOM.$$(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}`, strip).forEach(w => { const i = parseInt(w.dataset.index, 10); if (i > index) w.dataset.index = String(i - 1); }); } if (index < state.currentGalleryIndex) state.currentGalleryIndex--; if (state.currentGalleryIndex >= state.fullSizeImageSrcs.length) { state.currentGalleryIndex = state.fullSizeImageSrcs.length - 1; } if (state.currentGalleryIndex < 0) state.currentGalleryIndex = 0; Gallery.showExpandedView(state.currentGalleryIndex); ThumbnailStrip.updateThumbnailNumbers(); ThumbnailStrip.updateScrollIndicators(); state.notificationType = 'info'; state.notification = 'Item removed from gallery'; }; const result = await UGModal.confirm({ title: 'Remove from gallery?', text: 'This only affects the current gallery view.', icon: 'question', confirmText: 'Remove' }); if (result.isConfirmed) doRemove(); }, updateThumbnailNumbers() { if (!galleryOverlay) return; DOM.$$(`.${CSS.GALLERY.THUMBNAIL_WRAPPER}`, galleryOverlay).forEach(wrapper => { const index = parseInt(wrapper.dataset.index, 10); let numberEl = DOM.$('.ug-thumbnail-number', wrapper); if (!numberEl) { wrapper.appendChild(DOM.create('span', { className: 'ug-thumbnail-number', text: index + 1 })); } else { numberEl.textContent = index + 1; } }); } }; // ==================================================== // UI Builder & Notifications // ==================================================== const UI = { _notificationTimeoutId: null, _notificationHideTimeoutId: null, createToggleButton(name, action, disabled = false, actionName = null, scope = 'global') { const btn = DOM.create('button', { type: 'button', text: name, className: CSS.BTN, style: { cursor: 'pointer' }, onclick: (e) => { if (btn.classList.contains('disabled')) { e.preventDefault(); return; } action(e); } }); if (actionName) btn.dataset.action = actionName; if (scope) btn.dataset.scope = scope; if (disabled) btn.classList.add('disabled'); return btn; }, createButtonGroup(buttonsConfig) { const div = DOM.create('div', { className: CSS.BTN_CONTAINER }); buttonsConfig.forEach(config => { let createThisButton = true; switch (config.name) { case 'FULL': if (state.hideThumbnailFullButton) createThisButton = false; break; case 'DOWNLOAD': if (state.hideThumbnailDownloadButton) createThisButton = false; break; case 'HEIGHT': if (state.hideThumbnailHeightButton) createThisButton = false; break; case 'WIDTH': if (state.hideThumbnailWidthButton) createThisButton = false; break; } if (!createThisButton) return; div.appendChild(UI.createToggleButton(config.text, config.action, false, config.name, 'thumbnail')); }); return div; }, createNavigationButton(direction) { return DOM.create('button', { text: direction === 'prev' ? '←' : '→', className: `${CSS.GALLERY.NAV} ${direction === 'prev' ? CSS.GALLERY.PREV : CSS.GALLERY.NEXT}`, 'aria-label': direction === 'prev' ? 'Previous Image' : 'Next Image', onclick: direction === 'prev' ? Gallery.prevImage : Gallery.nextImage }); }, createNotificationArea() { const area = DOM.create('div', { id: CSS.NOTIF_AREA, className: CSS.NOTIF_AREA, style: { top: state.notificationPosition === 'top' ? '10px' : 'auto', bottom: state.notificationPosition === 'bottom' ? '10px' : 'auto' } }); document.body.appendChild(area); return area; }, createNotification() { let area = document.getElementById(CSS.NOTIF_AREA) || UI.createNotificationArea(); const container = DOM.create('div', { id: CSS.NOTIF_CONTAINER, className: CSS.NOTIF_CONTAINER }, [ DOM.create('div', { id: CSS.NOTIF_TEXT, className: 'ug-notification-text' }), DOM.create('button', { id: CSS.NOTIF_CLOSE, className: 'ug-notification-close', text: '×', onclick: () => state.notification = null }), DOM.create('a', { id: CSS.NOTIF_REPORT, className: 'ug-notification-report', text: 'Report Issue', href: 'https://github.com/TearTyr/Ultra-Galleries/issues', target: '_blank' }) ]); area.appendChild(container); return container; }, showNotification(message, type = 'info') { if (!state.notificationsEnabled && !['error', 'warning'].includes(type)) return; let area = document.getElementById(CSS.NOTIF_AREA) || UI.createNotificationArea(); let container = DOM.$(`.${CSS.NOTIF_CONTAINER}`, area) || UI.createNotification(); const isAlreadyVisible = container.style.display === 'flex' && !container.classList.contains('ug-slide-out'); if (UI._notificationTimeoutId) { clearTimeout(UI._notificationTimeoutId); UI._notificationTimeoutId = null; } if (UI._notificationHideTimeoutId) { clearTimeout(UI._notificationHideTimeoutId); UI._notificationHideTimeoutId = null; } container.classList.remove('ug-update', 'ug-slide-in', 'ug-slide-out'); const textNode = DOM.$(`#${CSS.NOTIF_TEXT}`, container); if (textNode) textNode.textContent = message; container.className = `${CSS.NOTIF_CONTAINER} ${type}`; if (state.animationsEnabled) { if (isAlreadyVisible) { container.classList.add('ug-update'); container.addEventListener('animationend', () => container.classList.remove('ug-update'), { once: true }); } else { container.classList.add('ug-slide-in'); } } container.style.display = 'flex'; if (['info', 'success'].includes(type)) { UI._notificationTimeoutId = setTimeout(() => { state.notification = null; }, 5000); } }, hideNotification() { const container = document.getElementById(CSS.NOTIF_CONTAINER); if (!container) return; if (UI._notificationTimeoutId) { clearTimeout(UI._notificationTimeoutId); UI._notificationTimeoutId = null; } if (UI._notificationHideTimeoutId) { clearTimeout(UI._notificationHideTimeoutId); UI._notificationHideTimeoutId = null; } if (state.animationsEnabled) { container.classList.add('ug-slide-out'); container.classList.remove('ug-slide-in'); UI._notificationHideTimeoutId = setTimeout(() => { container.style.display = 'none'; container.classList.remove('ug-slide-out'); UI._notificationHideTimeoutId = null; }, 500); } else { container.classList.remove('ug-slide-in', 'ug-slide-out'); container.style.display = 'none'; } }, forceHideNotification() { if (UI._notificationTimeoutId) clearTimeout(UI._notificationTimeoutId); if (UI._notificationHideTimeoutId) clearTimeout(UI._notificationHideTimeoutId); document.getElementById(CSS.NOTIF_CONTAINER)?.remove(); }, _createSettingElement(setting) { if (setting.type === 'header') { return DOM.create('h3', { className: 'ug-settings-subheader', text: setting.label }); } if (setting.type === 'divider') { return DOM.create('hr', { className: 'ug-settings-divider' }); } if (setting.type === 'description') { return DOM.create('p', { className: 'ug-settings-description', text: setting.label }); } const div = DOM.create('div', { className: 'ug-setting-item' }); const label = DOM.create('label', { for: setting.id, text: setting.label }); const handleChange = (value) => { if (setting.stateKey) state[setting.stateKey] = value; if (setting.gmKey) SettingsManager.saveSetting(setting.gmKey, value); if (setting.onChange) setting.onChange(value); }; switch (setting.type) { case 'checkbox': { div.className = 'ug-setting-item ug-settings-checkbox-label'; const checkbox = DOM.create('input', { type: 'checkbox', id: setting.id, onchange: (e) => handleChange(e.target.checked) }); checkbox.checked = state[setting.stateKey]; div.append(checkbox, label); break; } case 'text': { div.appendChild(label); const input = DOM.create('input', { type: 'text', id: setting.id, value: state[setting.stateKey] || '', maxlength: setting.maxLength || 50, className: 'ug-settings-input', onchange: (e) => handleChange(e.target.value) }); div.appendChild(input); break; } case 'select': { div.appendChild(label); const select = DOM.create('select', { id: setting.id, className: 'ug-settings-input', onchange: (e) => handleChange(e.target.value) }); setting.options.forEach(opt => { const optEl = DOM.create('option', { value: opt.value, text: opt.text }); if (opt.value === state[setting.stateKey]) optEl.selected = true; select.appendChild(optEl); }); div.appendChild(select); break; } case 'button': return DOM.create('button', { className: 'ug-button ug-settings-input', text: setting.label, onclick: setting.action }); } return div; }, createSettingsUI() { const settingsConfig = [ { title: 'General', key: 'general', settings: [ { type: 'header', label: 'Appearance' }, { id: 'animationsToggle', label: 'Enable Animations', type: 'checkbox', stateKey: 'animationsEnabled', gmKey: 'animationsEnabled' }, { id: 'bottomStripeToggle', label: 'Show Thumbnail Strip in Gallery', type: 'checkbox', stateKey: 'bottomStripeVisible', gmKey: 'bottomStripeVisible' }, { type: 'divider' }, { type: 'header', label: 'Notifications' }, { id: 'notificationsEnabledToggle', label: 'Enable Notifications', type: 'checkbox', stateKey: 'notificationsEnabled', gmKey: 'notificationsEnabled' }, { id: 'notificationPosition', label: 'Notification Position', type: 'select', stateKey: 'notificationPosition', gmKey: 'notificationPosition', options: [{ value: 'top', text: 'Top' }, { value: 'bottom', text: 'Bottom' }] }, { type: 'divider' }, { type: 'header', label: 'Fullscreen' }, { id: 'fullscreenMode', label: 'Fullscreen Mode', type: 'select', stateKey: 'fullscreenMode', gmKey: 'fullscreenMode', options: [ { value: 'native', text: 'Native (browser fullscreen)' }, { value: 'css', text: 'CSS (styled overlay)' }, { value: 'ask', text: 'Ask each time' } ] }, { type: 'divider' }, { type: 'header', label: 'Image Loading' }, { id: 'autoLoadOriginalsToggle', label: 'Auto-load Original Images', type: 'checkbox', stateKey: 'autoLoadOriginals', gmKey: 'autoLoadOriginals' } ] }, { title: 'Gallery Viewer', key: 'galleryViewer', settings: [ { type: 'header', label: 'Navigation' }, { id: 'hideNavArrows', label: 'Hide Navigation Arrows in Gallery', type: 'checkbox', stateKey: 'hideNavArrows', gmKey: 'hideNavArrows', onChange: () => PostActions.updateButtonVisibilityLight() }, { type: 'divider' }, { type: 'header', label: 'Zoom & Pan' }, { id: 'zoomEnabledToggle', label: 'Enable Zoom & Pan', type: 'checkbox', stateKey: 'zoomEnabled', gmKey: 'zoomEnabled' }, { id: 'inertiaEnabledToggle', label: 'Enable Smooth Pan Inertia', type: 'checkbox', stateKey: 'inertiaEnabled', gmKey: 'inertiaEnabled' }, { type: 'divider' }, { type: 'header', label: 'Slideshow' }, { id: 'slideshowDelay', label: 'Slideshow Delay (ms)', type: 'text', stateKey: 'slideshowDelay', gmKey: 'slideshowDelay', maxLength: 5, onChange: (value) => { const validDelay = parseInt(value, 10) || CONFIG.SLIDESHOW_DELAY; state.slideshowDelay = validDelay; Slideshow.setDelay(validDelay); } }, { id: 'slideshowPauseOnHover', label: 'Pause Slideshow on Hover', type: 'checkbox', stateKey: 'slideshowPauseOnHover', gmKey: 'slideshowPauseOnHover' } ] }, { title: 'Downloads', key: 'downloads', settings: [ { type: 'header', label: 'Behavior' }, { id: 'preserveFileDatesToggle', label: 'Preserve Post Date on ZIP Files', type: 'checkbox', stateKey: 'preserveFileDates', gmKey: 'preserveFileDates' }, { type: 'description', label: 'ZIP entries will be stamped with the post\'s published date. Requires an archive tool that restores timestamps (7-Zip, WinRAR, Explorer).' }, { type: 'divider' }, { type: 'header', label: 'Caching' }, { id: 'persistentCachingToggle', label: 'Enable Persistent Image Caching', type: 'checkbox', stateKey: 'enablePersistentCaching', gmKey: 'enablePersistentCaching' }, { id: 'clearCacheButton', label: 'Clear Persistent Cache', type: 'button', action: () => ImageCacheDB.clear() }, { type: 'divider' }, { type: 'header', label: 'File Naming' }, { id: 'zipFileNameFormatInput', label: 'ZIP Filename Format', type: 'text', stateKey: 'zipFileNameFormat', gmKey: 'zipFileNameFormat', maxLength: 200 }, { type: 'description', label: 'Tokens: {date_published}, {date_edited}, {date_imported}, {date}, {title}, {artistName}' }, { id: 'imageFileNameFormatInput', label: 'Image Filename Format', type: 'text', stateKey: 'imageFileNameFormat', gmKey: 'imageFileNameFormat', maxLength: 200 }, { type: 'description', label: 'Tokens: same as above plus {fileName} and {index}' } ] }, { title: 'Sorting', key: 'sorting', settings: [ { type: 'header', label: 'Media Order' }, { type: 'description', label: 'Fixes scrambled order — especially useful for comic posts where pages are numbered ("page_01.jpg", "page_02.jpg", …). Natural sort treats numbers as numbers, so "page_10" comes after "page_9" instead of before it.' }, { id: 'sortModeSelect', label: 'Sort Order', type: 'select', stateKey: 'sortMode', gmKey: 'sortMode', options: [ { value: 'dom', text: 'Default (site order)' }, { value: 'filename-asc', text: 'Filename: A → Z (natural)' }, { value: 'filename-desc', text: 'Filename: Z → A (natural)' } ], onChange: () => { if (state.isGalleryMode) Gallery.closeGallery(); if (Utils.isPostPage() && !state.isLoading) { state.notificationType = 'info'; state.notification = 'Applying sort order...'; ImageLoader.loadImages(); } } }, { type: 'divider' }, { type: 'header', label: 'How it works' }, { type: 'description', label: 'Filenames are read from the post\'s download attribute, the file URL\'s ?f= parameter, or the URL itself — in that order. If none contain a usable name, items fall back to the site\'s DOM order.' } ] }, { title: 'Post Actions', key: 'postActions', settings: [ { type: 'header', label: 'Button Visibility' }, { type: 'description', label: 'The bar that appears at the top of a post.' }, { id: 'hideGlobalGalleryButton', label: 'Hide GALLERY Button', type: 'checkbox', stateKey: 'hideGlobalGalleryButton', gmKey: 'hideGlobalGalleryButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideGlobalHeightButton', label: 'Hide FILL HEIGHT Button', type: 'checkbox', stateKey: 'hideGlobalHeightButton', gmKey: 'hideGlobalHeightButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideGlobalWidthButton', label: 'Hide FILL WIDTH Button', type: 'checkbox', stateKey: 'hideGlobalWidthButton', gmKey: 'hideGlobalWidthButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideGlobalFullButton', label: 'Hide FULL Button', type: 'checkbox', stateKey: 'hideGlobalFullButton', gmKey: 'hideGlobalFullButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideGlobalDownloadAllButton', label: 'Hide DL ALL Button', type: 'checkbox', stateKey: 'hideGlobalDownloadAllButton', gmKey: 'hideGlobalDownloadAllButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideGlobalDownloadImagesButton', label: 'Hide DL IMAGES Button', type: 'checkbox', stateKey: 'hideGlobalDownloadImagesButton', gmKey: 'hideGlobalDownloadImagesButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideGlobalDownloadAttachmentsButton', label: 'Hide DL FILES Button', type: 'checkbox', stateKey: 'hideGlobalDownloadAttachmentsButton', gmKey: 'hideGlobalDownloadAttachmentsButton', onChange: () => PostActions.updateButtonVisibilityLight() } ] }, { title: 'Thumbnails', key: 'thumbnails', settings: [ { type: 'header', label: 'Per-Image Button Visibility' }, { type: 'description', label: 'The small button row that appears above each thumbnail image.' }, { id: 'hideThumbnailHeightButton', label: 'Hide FILL HEIGHT Button', type: 'checkbox', stateKey: 'hideThumbnailHeightButton', gmKey: 'hideThumbnailHeightButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideThumbnailWidthButton', label: 'Hide FILL WIDTH Button', type: 'checkbox', stateKey: 'hideThumbnailWidthButton', gmKey: 'hideThumbnailWidthButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideThumbnailFullButton', label: 'Hide FULL Button', type: 'checkbox', stateKey: 'hideThumbnailFullButton', gmKey: 'hideThumbnailFullButton', onChange: () => PostActions.updateButtonVisibilityLight() }, { id: 'hideThumbnailDownloadButton', label: 'Hide DOWNLOAD Button', type: 'checkbox', stateKey: 'hideThumbnailDownloadButton', gmKey: 'hideThumbnailDownloadButton', onChange: () => PostActions.updateButtonVisibilityLight() } ] }, { title: 'Button Labels', key: 'buttonLabels', settings: [ { type: 'header', label: 'Download Buttons' }, { id: 'downloadBtnTextInput', label: 'DOWNLOAD', type: 'text', stateKey: 'downloadBtnText', gmKey: 'downloadBtnText', maxLength: 40, onChange: updateButtonLabels }, { id: 'downloadAllBtnTextInput', label: 'DL ALL', type: 'text', stateKey: 'downloadAllBtnText', gmKey: 'downloadAllBtnText', maxLength: 40, onChange: updateButtonLabels }, { id: 'downloadImagesBtnTextInput', label: 'DL IMAGES', type: 'text', stateKey: 'downloadImagesBtnText', gmKey: 'downloadImagesBtnText', maxLength: 40, onChange: updateButtonLabels }, { id: 'downloadAttachmentsBtnTextInput', label: 'DL FILES', type: 'text', stateKey: 'downloadAttachmentsBtnText', gmKey: 'downloadAttachmentsBtnText', maxLength: 40, onChange: updateButtonLabels }, { type: 'divider' }, { type: 'header', label: 'Sizing & View Buttons' }, { id: 'fullBtnTextInput', label: 'FULL', type: 'text', stateKey: 'fullBtnText', gmKey: 'fullBtnText', maxLength: 40, onChange: updateButtonLabels }, { id: 'heightBtnTextInput', label: 'FILL HEIGHT', type: 'text', stateKey: 'heightBtnText', gmKey: 'heightBtnText', maxLength: 40, onChange: updateButtonLabels }, { id: 'widthBtnTextInput', label: 'FILL WIDTH', type: 'text', stateKey: 'widthBtnText', gmKey: 'widthBtnText', maxLength: 40, onChange: updateButtonLabels }, { id: 'galleryBtnTextInput', label: 'GALLERY', type: 'text', stateKey: 'galleryBtnText', gmKey: 'galleryBtnText', maxLength: 40, onChange: updateButtonLabels } ] }, { title: 'Keyboard', key: 'keyboard', settings: [ { type: 'header', label: 'Shortcuts' }, { type: 'description', label: 'Single letters work best. These shortcuts are disabled while typing in any input field.' }, { id: 'galleryKeyInput', label: 'Open / Close Gallery', type: 'text', stateKey: 'galleryKey', gmKey: 'galleryKey', maxLength: 1 }, { id: 'prevImageKeyInput', label: 'Previous Image', type: 'text', stateKey: 'prevImageKey', gmKey: 'prevImageKey', maxLength: 1 }, { id: 'nextImageKeyInput', label: 'Next Image', type: 'text', stateKey: 'nextImageKey', gmKey: 'nextImageKey', maxLength: 1 }, { type: 'divider' }, { type: 'header', label: 'Fixed Shortcuts (in Gallery)' }, { type: 'description', label: '← / → Navigate · + / − Zoom · 0 Reset Zoom · Space Slideshow · F Fullscreen · Esc Close' } ] }, { title: 'Advanced', key: 'advanced', settings: [ { type: 'header', label: 'Backup & Restore' }, { id: 'exportSettingsButton', label: 'Export Settings to File', type: 'button', action: () => { const blob = new Blob([SettingsManager.exportSettings()], { type: 'application/json' }); DOM.saveBlob(blob, 'ultra-galleries-settings.json'); state.notificationType = 'success'; state.notification = 'Settings exported'; } }, { id: 'importSettingsButton', label: 'Import Settings from File', type: 'button', action: () => { const input = DOM.create('input', { type: 'file', accept: '.json' }); input.onchange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (ev) => SettingsManager.importSettings(ev.target.result); reader.readAsText(file); } }; input.click(); } }, { type: 'divider' }, { type: 'header', label: 'Danger Zone' }, { type: 'description', label: 'This will erase all your customizations and reload the page.' }, { id: 'resetSettingsButton', label: 'Reset All Settings to Defaults', type: 'button', action: async () => { const result = await UGModal.confirm({ title: 'Reset settings?', text: 'All settings will return to defaults.', icon: 'warning', confirmText: 'Reset' }); if (result.isConfirmed) { SettingsManager.resetToDefaults(); location.reload(); } } } ] } ]; const headerText = DOM.create('h2', { id: 'ug-settings-main-header' }); const body = DOM.create('div', { className: 'ug-settings-body' }); const sidebar = DOM.create('div', { className: 'ug-sidebar-header', text: 'Settings' }); const sidebarContainer = DOM.create('div', { className: 'ug-settings-sidebar' }, [sidebar]); const overlay = DOM.create('div', { id: 'ug-settings-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'ug-settings-main-header', className: 'ug-settings-overlay' }, [ DOM.create('div', { className: 'ug-settings-container' }, [ sidebarContainer, DOM.create('div', { className: 'ug-settings-content' }, [ DOM.create('div', { className: 'ug-settings-header' }, [ headerText, DOM.create('button', { className: 'ug-settings-close-btn', text: BUTTONS.CLOSE, onclick: () => state.settingsOpen = false }) ]), body ]) ]) ]); settingsConfig.forEach(section => { const sectionEl = DOM.create('div', { className: 'ug-settings-section', 'data-section-key': section.key, style: { display: 'none' } }); section.settings.forEach(setting => { sectionEl.appendChild(UI._createSettingElement(setting)); }); body.appendChild(sectionEl); const sideBtn = DOM.create('button', { className: 'ug-sidebar-button', text: section.title, onclick: function () { DOM.$$('.ug-sidebar-button', sidebarContainer).forEach(b => b.classList.remove('active')); this.classList.add('active'); DOM.$$('.ug-settings-section', body).forEach(s => s.style.display = 'none'); sectionEl.style.display = 'block'; headerText.textContent = section.title; body.scrollTop = 0; } }); sidebarContainer.appendChild(sideBtn); }); DOM.$('.ug-sidebar-button', sidebarContainer)?.click(); document.body.appendChild(overlay); }, showSettings() { lastFocusedElement = document.activeElement; document.getElementById('ug-settings-overlay')?.remove(); UI.createSettingsUI(); const overlay = document.getElementById('ug-settings-overlay'); if (!overlay) return; overlay.classList.add('opening'); const focusable = DOM.$$('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', overlay); const firstFocusable = focusable[0]; const lastFocusable = focusable[focusable.length - 1]; firstFocusable?.focus(); focusTrapListener = (e) => { if (e.key !== 'Tab') return; if (e.shiftKey) { if (document.activeElement === firstFocusable) { lastFocusable.focus(); e.preventDefault(); } } else { if (document.activeElement === lastFocusable) { firstFocusable.focus(); e.preventDefault(); } } }; document.addEventListener('keydown', focusTrapListener); }, closeSettings() { if (focusTrapListener) { document.removeEventListener('keydown', focusTrapListener); focusTrapListener = null; } const overlay = document.getElementById('ug-settings-overlay'); if (overlay) { overlay.classList.remove('opening'); setTimeout(() => { overlay.remove(); lastFocusedElement?.focus(); }, 300); } } }; // ==================================================== // Gallery Core Module // ==================================================== const Gallery = { _preloadedImageCache: {}, _preloadingInProgress: {}, _controlsHideTimer: null, _clearPreloadCache() { for (const index in Gallery._preloadedImageCache) { const cachedItem = Gallery._preloadedImageCache[index]; if (typeof cachedItem === 'string' && cachedItem.startsWith('blob:')) { BlobManager.revokeUrl(cachedItem); } } Gallery._preloadedImageCache = {}; Gallery._preloadingInProgress = {}; }, async _fetchAndCacheImage(indexToPreload, sessionId = null) { if (indexToPreload < 0 || indexToPreload >= state.originalImageSrcs.length) return; if (Gallery._preloadedImageCache[indexToPreload] || Gallery._preloadingInProgress[indexToPreload]) return; const mediaItem = state.originalImageSrcs[indexToPreload]; if (!mediaItem || mediaItem.type !== 'image') return; if (sessionId !== null && state.currentLoadSessionId !== sessionId) return; const originalImageUrl = mediaItem.src; if (!originalImageUrl) return; Gallery._preloadingInProgress[indexToPreload] = true; try { const blob = await ImageLoader.fetchWithRetry(originalImageUrl, sessionId); if (blob) { Gallery._preloadedImageCache[indexToPreload] = BlobManager.createUrl(blob); } } catch (error) { console.error(`Preload failed for ${indexToPreload}`, error); } finally { delete Gallery._preloadingInProgress[indexToPreload]; } }, _preloadAdjacentImages(currentIndex) { const sessionId = state.currentLoadSessionId; const maxKeep = CONFIG.PRELOAD_COUNT + CONFIG.PRELOAD_WINDOW_BUFFER; for (const indexStr in Gallery._preloadedImageCache) { const index = parseInt(indexStr, 10); if (Math.abs(index - currentIndex) > maxKeep) { if (typeof Gallery._preloadedImageCache[indexStr] === 'string') { BlobManager.revokeUrl(Gallery._preloadedImageCache[indexStr]); } delete Gallery._preloadedImageCache[indexStr]; } } for (let i = 1; i <= CONFIG.PRELOAD_COUNT; i++) { Gallery._fetchAndCacheImage(currentIndex + i, sessionId); } Gallery._fetchAndCacheImage(currentIndex - 1, sessionId); }, async _fetchVideoForElement(videoEl, url, sessionId, token) { try { const blob = await ImageLoader.fetchBlob(url, sessionId); if (!videoEl.isConnected || videoEl.dataset.ugFetchToken !== token) return; videoEl.src = blob ? BlobManager.createUrl(blob) : url; } catch (err) { if (!videoEl.isConnected || videoEl.dataset.ugFetchToken !== token) return; console.warn('Ultra Galleries: video blob fetch failed, falling back:', err); videoEl.src = url; } }, _releaseVideo(video) { if (!video || video.tagName !== 'VIDEO') return; try { video.pause(); video.removeAttribute('src'); video.load(); } catch { // Ignore release errors } }, _releaseMediaElements(container) { if (!container) return; DOM.$$('video', container).forEach(v => Gallery._releaseVideo(v)); }, safePlay(video) { if (!video) return; try { const p = video.play(); if (p && typeof p.catch === 'function') { p.catch(err => console.warn('Ultra Galleries: playback blocked:', err)); } } catch (e) { console.warn('Ultra Galleries: playback error:', e); } }, _attachVideoPlayOverlay(video, container) { const overlay = DOM.create('button', { className: 'ug-video-play-overlay', 'aria-label': 'Play video', title: 'Play video', html: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>', onclick: (e) => { e.stopPropagation(); Gallery.safePlay(video); overlay.classList.add('ug-hidden'); } }); video.addEventListener('play', () => overlay.classList.add('ug-hidden'), { once: true }); container.appendChild(overlay); }, _createExpandedViewToolbar(expandedView) { const toolbar = DOM.create('div', { className: CSS.GALLERY.TOOLBAR, onmousedown: e => e.stopPropagation() }); const resetBtn = DOM.create('button', { id: 'reset-btn', title: 'Reset Zoom & Position', className: CSS.GALLERY.TOOLBAR_BTN, text: 'Reset', onclick: Zoom.resetZoom }); const zoomControls = DOM.create('div', { className: 'zoom-controls' }, [ DOM.create('button', { id: 'zoom-out-btn', title: 'Zoom Out', className: CSS.GALLERY.TOOLBAR_BTN, html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="8" y1="11" x2="14" y2="11"></line></svg>', onclick: () => Zoom.zoom(-CONFIG.ZOOM_STEP) }), DOM.create('span', { id: 'zoom-level', className: 'zoom-level', text: '100%' }), DOM.create('button', { id: 'zoom-in-btn', title: 'Zoom In', className: CSS.GALLERY.TOOLBAR_BTN, html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="11" y1="8" x2="11" y2="14"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>', onclick: () => Zoom.zoom(CONFIG.ZOOM_STEP) }) ]); const slideshowBtn = DOM.create('button', { id: 'slideshow-btn', title: 'Start Slideshow (Space)', className: CSS.GALLERY.TOOLBAR_BTN, text: '▶', onclick: Slideshow.handleButton }); const fillHeightBtn = DOM.create('button', { id: 'ug-fill-height-btn', 'aria-label': 'Fill Height', className: CSS.GALLERY.TOOLBAR_BTN, text: BUTTONS.HEIGHT, onclick: () => { const media = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay)?.querySelector('img, video'); if (media) ImageSizing.applyFillHeight(media); } }); const fullscreenBtn = DOM.create('button', { className: `${CSS.GALLERY.FULLSCREEN} ${CSS.GALLERY.TOOLBAR_BTN}`, 'aria-label': 'Toggle Fullscreen', text: BUTTONS.FULLSCREEN, onclick: Gallery.toggleFullscreen }); toolbar.append(resetBtn, zoomControls, slideshowBtn, fillHeightBtn, fullscreenBtn); expandedView.append( toolbar, DOM.create('button', { className: 'ug-gallery-close-button', 'aria-label': 'Close Gallery', text: BUTTONS.CLOSE, onclick: Gallery.closeGallery }) ); }, _createExpandedViewMainImageArea(expandedView) { const zoomContainer = DOM.create('div', { className: CSS.GALLERY.ZOOM_CONTAINER }); const ambientBg = DOM.create('div', { className: 'ug-ambient-background' }); const mainImageContainer = DOM.create('div', { className: `${CSS.GALLERY.MAIN_IMG_CONTAINER} image-container` }); const panIndicator = DOM.create('div', { className: 'pan-indicator', html: '<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="white" opacity="0.7"><path d="M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H7l5 5 5-5h-3v-3z"/></svg>' }); mainImageContainer.appendChild(panIndicator); zoomContainer.append(ambientBg, mainImageContainer); expandedView.appendChild(zoomContainer); return { mainImageContainer }; }, _createExpandedViewNavigationAndCounter(expandedView) { const navContainer = DOM.create('div', { className: CSS.GALLERY.NAV_CONTAINER, onmousedown: e => e.stopPropagation() }); if (!state.hideNavArrows) { navContainer.append( UI.createNavigationButton('prev'), UI.createNavigationButton('next') ); } expandedView.append( navContainer, DOM.create('div', { className: `${CSS.GALLERY.COUNTER} ${CSS.GALLERY.HIDE}` }) ); }, _createExpandedViewThumbnailStrip(expandedView) { const thumbnailStripContainer = DOM.create('div', { className: CSS.GALLERY.STRIP_CONTAINER, style: { display: state.bottomStripeVisible ? 'flex' : 'none' }, onmousedown: e => e.stopPropagation() }); const strip = DOM.create('div', { className: CSS.GALLERY.THUMBNAIL_STRIP }); thumbnailStripContainer.appendChild(strip); expandedView.appendChild(thumbnailStripContainer); return strip; }, _populateAllThumbnails(stripThumbnailsContainer) { stripThumbnailsContainer.replaceChildren(); const fragment = document.createDocumentFragment(); const fileIconSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'; state.fullSizeImageSrcs.forEach((mediaItem, index) => { if (!mediaItem) return; const wrapper = DOM.create('div', { className: CSS.GALLERY.THUMBNAIL_WRAPPER, 'data-index': index, 'aria-label': `Thumbnail ${index + 1}`, tabindex: '0', role: 'button', onclick: () => Gallery.showExpandedView(index) }); if (mediaItem.type === 'video') { wrapper.appendChild(DOM.create('div', { className: 'ug-play-icon', html: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' })); wrapper.appendChild(DOM.create('img', { src: mediaItem.poster || FALLBACK_POSTER, loading: 'lazy', decoding: 'async', className: CSS.GALLERY.THUMBNAIL })); } else if (mediaItem.type === 'attachment') { wrapper.appendChild(DOM.create('div', { className: 'ug-thumbnail-file-icon', html: fileIconSvg })); } else { wrapper.appendChild(DOM.create('img', { src: mediaItem.src, loading: 'lazy', decoding: 'async', className: CSS.GALLERY.THUMBNAIL })); } fragment.appendChild(wrapper); }); stripThumbnailsContainer.appendChild(fragment); }, _setupGalleryInteractions(expandedView, mainImageContainer) { mainImageContainer.addEventListener('wheel', (e) => { const currentItem = state.fullSizeImageSrcs[state.currentGalleryIndex]; if (currentItem && currentItem.type === 'image') Zoom.handleWheelZoom(e); }, { passive: false }); PointerEngine.init(mainImageContainer); const resetControlsTimer = () => { state.controlsVisible = true; clearTimeout(Gallery._controlsHideTimer); Gallery._controlsHideTimer = setTimeout(() => { if (!PointerEngine.isDragging && !viewState.pinchZoomActive) { state.controlsVisible = false; } }, CONFIG.CONTROLS_HIDE_DELAY); }; expandedView.addEventListener('pointermove', resetControlsTimer); expandedView.addEventListener('pointerdown', resetControlsTimer); expandedView.addEventListener('mousemove', resetControlsTimer); resetControlsTimer(); }, createGallery() { if (galleryOverlay) { Gallery.showExpandedView(0); state.isGalleryMode = true; return; } galleryOverlay = DOM.create('div', { id: 'gallery-overlay', className: CSS.GALLERY.OVERLAY }); const galleryContainer = DOM.create('div', { className: CSS.GALLERY.CONTAINER }); galleryOverlay.appendChild(galleryContainer); const expandedView = DOM.create('div', { className: `${CSS.GALLERY.EXPANDED_VIEW} ${CSS.GALLERY.HIDE}` }); galleryContainer.appendChild(expandedView); Gallery._createExpandedViewToolbar(expandedView); const { mainImageContainer } = Gallery._createExpandedViewMainImageArea(expandedView); Gallery._createExpandedViewNavigationAndCounter(expandedView); const stripThumbnailsContainer = Gallery._createExpandedViewThumbnailStrip(expandedView); document.body.appendChild(galleryOverlay); if (state.isFullscreen) { document.body.classList.add('ug-fullscreen'); galleryOverlay.classList.add(CSS.GALLERY.FULLSCREEN_OVERLAY); } Gallery._populateAllThumbnails(stripThumbnailsContainer); Gallery._setupGalleryInteractions(expandedView, mainImageContainer); Gallery.showExpandedView(0); state.isGalleryMode = true; Accessibility.init(); }, showExpandedView(index) { if (!galleryOverlay || index < 0) return; let mediaItem = state.fullSizeImageSrcs[index] || state.originalImageSrcs[index]; const mainMediaContainer = DOM.$(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`, galleryOverlay); const ambientBackground = DOM.$('.ug-ambient-background', galleryOverlay); const counter = DOM.$(`.${CSS.GALLERY.COUNTER}`, galleryOverlay); const zoomControls = DOM.$('.zoom-controls', galleryOverlay); const resetBtn = DOM.$('#reset-btn', galleryOverlay); const fillHeightBtn = DOM.$('#ug-fill-height-btn', galleryOverlay); const expandedView = DOM.$(`.${CSS.GALLERY.EXPANDED_VIEW}`, galleryOverlay); if (!mainMediaContainer) return; state.currentGalleryIndex = index; if (!mediaItem || !mediaItem.src) { Gallery._releaseMediaElements(mainMediaContainer); mainMediaContainer.replaceChildren( DOM.create('div', { className: CSS.GALLERY.IMAGE_ERROR_MSG, text: 'Loading media data...' }) ); expandedView?.classList.remove(CSS.GALLERY.HIDE); return; } Gallery._releaseMediaElements(mainMediaContainer); if (PointerEngine.inertiaAnimation) { cancelAnimationFrame(PointerEngine.inertiaAnimation); PointerEngine.inertiaAnimation = null; } if (PointerEngine.animationFrame) { cancelAnimationFrame(PointerEngine.animationFrame); PointerEngine.animationFrame = null; } mainMediaContainer.replaceChildren(); mainMediaContainer.classList.remove(CSS.GALLERY.ZOOMED); viewState.zoomScale = 1; viewState.imageOffset = { x: 0, y: 0 }; PointerEngine.cachedImgEl = null; PointerEngine.cachedZoomEl = null; if (mediaItem.type === 'image' || !mediaItem.type) { if (zoomControls) zoomControls.style.display = 'flex'; if (resetBtn) resetBtn.style.display = 'block'; if (fillHeightBtn) fillHeightBtn.style.display = 'none'; const imageUrlToLoad = Gallery._preloadedImageCache[index] || mediaItem.src; if (ambientBackground) { ambientBackground.style.backgroundImage = `url("${imageUrlToLoad}")`; } const mainImage = DOM.create('img', { className: CSS.GALLERY.MAIN_IMG, decoding: 'async', ondragstart: e => e.preventDefault(), onload() { this.style.opacity = '1'; this.style.transformOrigin = '0 0'; ImageSizing.applyBestFit(this); PointerEngine.cachedImgEl = this; PointerEngine.cachedZoomEl = DOM.$('#zoom-level'); viewState.zoomScale = 1; viewState.imageOffset = { x: 0, y: 0 }; Zoom.applyZoom(); Gallery._preloadAdjacentImages(index); }, onerror() { if (imageUrlToLoad !== mediaItem.src && !this.dataset.ugRetried) { this.dataset.ugRetried = 'true'; this.src = mediaItem.src; return; } mainMediaContainer.appendChild( DOM.create('div', { className: CSS.GALLERY.IMAGE_ERROR_MSG, text: 'Failed to load image' }) ); } }); mainImage.src = imageUrlToLoad; mainMediaContainer.appendChild(mainImage); } else if (mediaItem.type === 'video') { if (zoomControls) zoomControls.style.display = 'none'; if (resetBtn) resetBtn.style.display = 'none'; if (fillHeightBtn) fillHeightBtn.style.display = 'block'; if (ambientBackground) ambientBackground.style.backgroundImage = ''; const mainVideo = DOM.create('video', { className: CSS.GALLERY.MAIN_VIDEO, poster: mediaItem.poster, controls: 'true', loop: 'true', preload: 'metadata', playsinline: 'true', ondragstart: e => e.preventDefault(), onloadedmetadata() { this.style.transformOrigin = '0 0'; Utils.setImageStyle(this, { width: '100%', height: '100%', maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }); PointerEngine.cachedImgEl = this; PointerEngine.cachedZoomEl = DOM.$('#zoom-level'); } }); mainMediaContainer.appendChild(mainVideo); if (isPawchiveHost(mediaItem.src)) { const token = (crypto.randomUUID?.() || Math.random().toString(36)); mainVideo.dataset.ugFetchToken = token; const sessionAtLoad = state.currentLoadSessionId; Gallery._fetchVideoForElement(mainVideo, mediaItem.src, sessionAtLoad, token); } else { mainVideo.src = mediaItem.src; } Gallery._attachVideoPlayOverlay(mainVideo, mainMediaContainer); } else if (mediaItem.type === 'attachment') { if (zoomControls) zoomControls.style.display = 'none'; if (resetBtn) resetBtn.style.display = 'none'; if (fillHeightBtn) fillHeightBtn.style.display = 'none'; if (ambientBackground) ambientBackground.style.backgroundImage = ''; const fileName = mediaItem.fileName || mediaItem.src.split('/').pop() || 'attachment'; const fileExt = (fileName.match(/\.([a-z0-9]+)$/i) || [, 'file'])[1].toUpperCase(); const preview = DOM.create('div', { className: 'ug-attachment-preview' }, [ DOM.create('div', { html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' }), DOM.create('div', { className: 'ug-attachment-type', text: fileExt }), DOM.create('div', { className: 'ug-attachment-info', text: fileName }), DOM.create('button', { className: 'ug-attachment-download-btn', text: 'Download File', onclick: (e) => { e.stopPropagation(); DownloadManager.downloadImageByIndex(index); } }) ]); mainMediaContainer.appendChild(preview); } if (counter) { counter.textContent = `${index + 1} / ${state.fullSizeImageSrcs.length}`; counter.classList.remove(CSS.GALLERY.HIDE); } expandedView?.classList.remove(CSS.GALLERY.HIDE); const strip = DOM.$(`.${CSS.GALLERY.THUMBNAIL_STRIP}`, galleryOverlay); if (strip) { DOM.$$('.selected', strip).forEach(s => s.classList.remove('selected')); const activeThumb = DOM.$(`[data-index="${index}"]`, strip); if (activeThumb) { activeThumb.classList.add('selected'); DOM.scrollToCenter(strip, activeThumb); } } setTimeout(() => { ThumbnailStrip.init(); ThumbnailStrip.updateThumbnailNumbers(); ThumbnailStrip.updateScrollIndicators(); }, 100); }, closeGallery() { if (!galleryOverlay) { state.isGalleryMode = false; state.isFullscreen = false; Slideshow.stop(); ThumbnailStrip.cleanup(); return; } if (document.fullscreenElement === galleryOverlay) { const exit = document.exitFullscreen(); if (exit && typeof exit.catch === 'function') exit.catch(() => {}); } state.isGalleryMode = false; state.isFullscreen = false; clearTimeout(Gallery._controlsHideTimer); Gallery._controlsHideTimer = null; Slideshow.stop(); Gallery._clearPreloadCache(); ThumbnailStrip.cleanup(); if (PointerEngine.inertiaAnimation) cancelAnimationFrame(PointerEngine.inertiaAnimation); if (PointerEngine.animationFrame) cancelAnimationFrame(PointerEngine.animationFrame); Gallery._releaseMediaElements(galleryOverlay); galleryOverlay.remove(); galleryOverlay = null; PointerEngine.cachedImgEl = null; PointerEngine.cachedZoomEl = null; }, toggleGallery() { if (state.isGalleryMode) { Gallery.closeGallery(); } else { if (state.galleryReady && state.fullSizeImageSrcs.length > 0) { Gallery.createGallery(); } else if (Utils.isPostPage()) { ImageLoader.loadImages(); state.notificationType = 'info'; state.notification = 'Refreshing gallery list...'; } else { state.notificationType = 'warning'; state.notification = 'No post page detected.'; } } }, async toggleFullscreen() { let mode = state.fullscreenMode || 'native'; if (mode === 'ask') { const result = await UGModal.choose({ title: 'Fullscreen mode', text: 'How should the gallery enter fullscreen?', icon: 'question', options: [ { label: 'Native', value: 'native', primary: true }, { label: 'CSS', value: 'css' } ] }); if (!result.value) return; mode = result.value; } state.isFullscreen = !state.isFullscreen; if (mode !== 'native') return; if (!galleryOverlay || typeof galleryOverlay.requestFullscreen !== 'function') return; if (state.isFullscreen) { if (!document.fullscreenElement) { const req = galleryOverlay.requestFullscreen(); if (req && typeof req.catch === 'function') { req.catch(err => { console.warn('Ultra Galleries: native fullscreen failed, staying in CSS mode:', err); }); } } } else { if (document.fullscreenElement === galleryOverlay) { const exit = document.exitFullscreen(); if (exit && typeof exit.catch === 'function') exit.catch(() => {}); } } }, nextImage() { if (state.fullSizeImageSrcs.length === 0) return; Gallery.showExpandedView((state.currentGalleryIndex + 1) % state.fullSizeImageSrcs.length); }, prevImage() { if (state.fullSizeImageSrcs.length === 0) return; Gallery.showExpandedView( (state.currentGalleryIndex - 1 + state.fullSizeImageSrcs.length) % state.fullSizeImageSrcs.length ); } }; // ==================================================== // Image Loader Module // ==================================================== const ImageLoader = { _inflight: new Map(), imageActions: { height: ImageSizing.applyFillHeight, width: ImageSizing.applyFillWidth, full: ImageSizing.applyFullSize }, async simulateScrollDown(sessionId = null) { return new Promise(resolve => { const selectors = [ SELECTORS.IMAGE_LINK + ' img', SELECTORS.MAIN_THUMBNAIL + ' img', '.post__content img', '.post__body img' ]; const images = DOM.$$(selectors.join(', ')); if (images.length === 0) { resolve(); return; } let loadedCount = 0; const checkAllLoaded = () => { loadedCount++; if (loadedCount >= images.length) { clearTimeout(timeout); observer.disconnect(); resolve(); } }; const observer = new IntersectionObserver(entries => { if (sessionId !== null && state.currentLoadSessionId !== sessionId) { observer.disconnect(); clearTimeout(timeout); resolve(); return; } entries.forEach(entry => { if (entry.isIntersecting) { observer.unobserve(entry.target); checkAllLoaded(); } }); }); images.forEach(img => observer.observe(img)); const dynamicTimeout = Math.max(CONFIG.SCROLL_SIMULATION_BASE_TIMEOUT, images.length * 200); const timeout = setTimeout(() => { observer.disconnect(); resolve(); }, dynamicTimeout); }); }, async _fetchBlobInternal(url, sessionId, retries, delay) { try { await RequestPacer.throttle(url); return await new Promise((resolve, reject) => { if (sessionId !== null && state.currentLoadSessionId !== sessionId) { reject(new Error('Stale session')); return; } let handle = null; const cleanup = () => { ActiveRequests.remove(sessionId, handle); }; handle = GM.xmlHttpRequest({ method: 'GET', url, headers: RequestPacer.getHeaders(url), responseType: 'blob', timeout: 120000, onload: async (response) => { cleanup(); if (response.status === 200 || response.status === 206) { const blob = response.response; if (state.enablePersistentCaching) { await ImageCacheDB.put(url, blob); } resolve(blob); } else { const err = new Error(`HTTP ${response.status}`); err.status = response.status; reject(err); } }, onerror: (error) => { cleanup(); reject(error); }, ontimeout: () => { cleanup(); reject(new Error('Request timeout')); }, onabort: () => { cleanup(); reject(new Error('Request aborted')); } }); ActiveRequests.add(sessionId, handle); }); } catch (err) { if (err.message === 'Stale session' || err.message === 'Request aborted') throw err; if (err.status && err.status >= 400 && err.status < 500 && err.status !== 429) throw err; if (retries <= 0) throw err; await Utils.delay(delay); return ImageLoader._fetchBlobInternal(url, sessionId, retries - 1, delay * 1.5); } }, fetchBlob(url, sessionId = null, retries = CONFIG.MAX_RETRIES, delay = CONFIG.RETRY_DELAY) { if (sessionId !== null && state.currentLoadSessionId !== sessionId) { return Promise.reject(new Error('Stale session')); } const key = `${sessionId ?? 'direct'}:${url}`; const existing = ImageLoader._inflight.get(key); if (existing) return existing; const promise = (async () => { if (state.enablePersistentCaching) { const cachedBlob = await ImageCacheDB.get(url); if (cachedBlob) return cachedBlob; } if (sessionId !== null && state.currentLoadSessionId !== sessionId) { throw new Error('Stale session'); } return ImageLoader._fetchBlobInternal(url, sessionId, retries, delay); })(); ImageLoader._inflight.set(key, promise); const cleanup = () => { if (ImageLoader._inflight.get(key) === promise) { ImageLoader._inflight.delete(key); } }; promise.then(cleanup, cleanup); return promise; }, fetchWithRetry(url, sessionId) { return ImageLoader.fetchBlob(url, sessionId); }, fetchBlobDirect(url) { return ImageLoader.fetchBlob(url, null); }, async loadImageAndApplyToPage(linkElement, galleryIndex, posterHref, isUniqueForGallery, sessionId, itemData) { const imgElement = linkElement.querySelector('img') || linkElement; if (!imgElement) { if (state.currentLoadSessionId === sessionId) state.loadedImages++; return; } if (imgElement.tagName === 'IMG' && !imgElement.classList.contains('post__image')) { imgElement.classList.add('post__image'); } const cacheKey = itemData.originalUrl; let blobUrlToUse = loadedBlobUrls.get(posterHref); try { if (itemData.type !== 'attachment') { if (!blobUrlToUse) { if (itemData.type === 'video') { const posterBlob = await ImageLoader.fetchWithRetry(posterHref, sessionId); if (state.currentLoadSessionId !== sessionId) return; if (!posterBlob) throw new Error('Failed to fetch poster blob'); blobUrlToUse = BlobManager.createUrl(posterBlob); } else { const blob = await ImageLoader.fetchWithRetry(cacheKey, sessionId); if (state.currentLoadSessionId !== sessionId) return; if (!blob) throw new Error('Failed to fetch blob'); blobUrlToUse = BlobManager.createUrl(blob); } loadedBlobUrls.set(posterHref, blobUrlToUse); } if (state.currentLoadSessionId !== sessionId) return; if (imgElement.tagName === 'IMG') { imgElement.src = blobUrlToUse; imgElement.dataset.originalSrc = cacheKey; imgElement.classList.add('ug-image-loaded'); imgElement.style.cursor = 'default'; const parentLink = imgElement.closest('a'); if (parentLink) parentLink.style.cursor = 'default'; ImageLoader.imageActions[state.currentResizeMode](imgElement); } } if (isUniqueForGallery) { if (itemData.type === 'video') { state.fullSizeImageSrcs[galleryIndex] = { type: 'video', src: cacheKey, poster: blobUrlToUse }; } else if (itemData.type === 'attachment') { state.fullSizeImageSrcs[galleryIndex] = { type: 'attachment', src: cacheKey, poster: FALLBACK_ATTACHMENT, fileName: itemData.fileName }; } else { state.fullSizeImageSrcs[galleryIndex] = { type: 'image', src: cacheKey, originalSrc: cacheKey }; } state.originalImageSrcs[galleryIndex] = { src: cacheKey, type: itemData.type, fileName: itemData.fileName || linkElement.getAttribute('download') || cacheKey.split('/').pop() }; } state.loadedImages++; } catch (error) { ErrorHandler.handleImageError( error, cacheKey, imgElement.tagName === 'IMG' ? imgElement : null, { linkElement, galleryIndex, posterHref, isUniqueForGallery, itemData } ); if (state.currentLoadSessionId === sessionId) { state.loadedImages++; state.errorCount++; } } }, collectUniqueMediaItems(postContainer) { const uniqueGalleryItems = new Map(); const targets = DOM.$$(`${SELECTORS.IMAGE_LINK}, ${SELECTORS.ATTACHMENT_LINK}, ${SELECTORS.VIDEO_LINK}, ${SELECTORS.GENERIC_IMAGE_LINK}`, postContainer); targets.forEach(linkElement => { if (linkElement.closest('.post__user-profile') || linkElement.closest('.scrape__user-profile')) return; if (linkElement.classList.contains('user-header__avatar')) return; const isVideo = linkElement.matches(SELECTORS.VIDEO_LINK) || linkElement.href?.match(/\.(mp4|webm|mov)$/i); let url; let poster; if (isVideo) { url = linkElement.getAttribute('href')?.split('?')[0]; poster = linkElement.querySelector('img, video')?.getAttribute('poster') || (linkElement.querySelector('img')?.getAttribute('data-src') || linkElement.querySelector('img')?.src); if (!url) return; if (!poster) poster = FALLBACK_POSTER; if (!uniqueGalleryItems.has(url)) { uniqueGalleryItems.set(url, { linkElement, originalUrl: url, posterUrl: poster, type: 'video', fileName: Utils.extractFileName(linkElement, url) }); } } else { url = Utils.handleMediaSrc(linkElement); if (!url && linkElement.href) url = linkElement.href.split('?')[0]; if (!url) return; const isImage = /\.(jpe?g|png|gif|webp|bmp)$/i.test(url); const isAttachmentLink = linkElement.matches(SELECTORS.ATTACHMENT_LINK); if (isImage) { if (!uniqueGalleryItems.has(url)) { uniqueGalleryItems.set(url, { linkElement, originalUrl: url, posterUrl: url, type: 'image', fileName: Utils.extractFileName(linkElement, url) }); } } else if (isAttachmentLink) { const downloadName = Utils.extractFileName(linkElement, url); if (!uniqueGalleryItems.has(url)) { uniqueGalleryItems.set(url, { linkElement, originalUrl: url, posterUrl: FALLBACK_ATTACHMENT, type: 'attachment', fileName: downloadName }); } } } }); DOM.$$('video', postContainer).forEach(videoEl => { let url = videoEl.getAttribute('src') || videoEl.querySelector('source')?.getAttribute('src'); if (url) { url = url.split('?')[0]; if (!uniqueGalleryItems.has(url)) { const poster = videoEl.getAttribute('poster') || FALLBACK_POSTER; uniqueGalleryItems.set(url, { linkElement: videoEl, originalUrl: url, posterUrl: poster, type: 'video', fileName: Utils.extractFileName(videoEl, url) }); } } }); return uniqueGalleryItems; }, _concurrentRunner(items, sessionId) { const concurrencyLimit = CONFIG.MAX_CONCURRENT_FETCHES; const tasks = items.map((item, index) => () => ImageLoader.loadImageAndApplyToPage( item.linkElement, index, item.posterUrl, true, sessionId, item )); return new Promise((resolve) => { let running = 0; let index = 0; const total = tasks.length; if (total === 0) { resolve(); return; } const next = () => { if (state.currentLoadSessionId !== sessionId) { resolve(); return; } if (index >= total) { if (running === 0) resolve(); return; } const task = tasks[index++]; running++; task().then(() => { running--; next(); }).catch(() => { running--; next(); }); }; for (let i = 0; i < concurrencyLimit && i < total; i++) { next(); } }); }, async loadImages() { const postContainer = DOM.$('section.site-section--post') || DOM.$('section.site-section--scrape') || DOM.$('.post__body') || DOM.$('.post__content') || document; if (!Utils.isPostPage() || state.isLoading) return; if (state.currentLoadSessionId) ActiveRequests.abortSession(state.currentLoadSessionId); const sessionId = StateManager.generateSessionId(); state.currentLoadSessionId = sessionId; try { state.isLoading = true; await Utils.delay(16); if (state.currentLoadSessionId !== sessionId) return; const previousBlobUrls = new Map(loadedBlobUrls); loadedBlobUrls.clear(); Object.assign(state, { fullSizeImageSrcs: [], originalImageSrcs: [], loadedImages: 0, errorCount: 0 }); const uniqueGalleryItems = ImageLoader.collectUniqueMediaItems(postContainer); if (state.currentLoadSessionId !== sessionId) return; const uniqueItems = Array.from(uniqueGalleryItems.values()); Utils.sortItems(uniqueItems, state.sortMode); state.totalImages = uniqueItems.length; state.fullSizeImageSrcs = Array(uniqueItems.length).fill(null); state.originalImageSrcs = Array(uniqueItems.length).fill(null); uniqueItems.forEach((item, index) => { if (item.type === 'video') { state.fullSizeImageSrcs[index] = { type: 'video', src: item.originalUrl, poster: item.posterUrl }; } else if (item.type === 'attachment') { state.fullSizeImageSrcs[index] = { type: 'attachment', src: item.originalUrl, poster: FALLBACK_ATTACHMENT, fileName: item.fileName }; } else { state.fullSizeImageSrcs[index] = { type: 'image', src: item.originalUrl, originalSrc: item.originalUrl }; } state.originalImageSrcs[index] = { src: item.originalUrl, type: item.type, fileName: item.fileName }; }); state.galleryReady = true; updateGalleryButton(true); if (state.autoLoadOriginals) { await ImageLoader.simulateScrollDown(sessionId); Utils.ensureThumbnailsExist(); await ImageLoader._concurrentRunner(uniqueItems, sessionId); if (state.currentLoadSessionId !== sessionId) return; previousBlobUrls.forEach((url, key) => { if (loadedBlobUrls.has(key)) BlobManager.revokeUrl(url); }); ImageLoader.updateFinalStatus(); DOM.$$('img.post__image.ug-image-loaded').forEach(img => { ImageLoader.imageActions[state.currentResizeMode](img); }); } else { state.notificationType = 'success'; state.notification = `Gallery Ready (${state.totalImages} items).`; } state.isLoading = false; } catch (error) { console.error('Critical Error in ImageLoader.loadImages:', error); state.isLoading = false; state.galleryReady = true; updateGalleryButton(true); } }, updateFinalStatus() { if (state.loadedImages >= state.totalImages && state.totalImages > 0) { if (state.errorCount === 0) { state.notificationType = 'success'; state.notification = `Media Done Loading! Total: ${state.totalImages}`; } else { state.notificationType = 'warning'; state.notification = `Gallery: Loaded with ${state.errorCount} error(s).`; } } else if (state.totalImages === 0) { state.notificationType = 'info'; state.notification = 'No gallery images found.'; } } }; // ==================================================== // CSP-Safe Background Download Manager // ==================================================== const DownloadManager = { _worker: null, _workerUrl: null, _getPostMeta: () => ({ title: DOM.$(SELECTORS.POST_TITLE)?.textContent?.trim() || 'Untitled', artistName: DOM.$(SELECTORS.POST_USER_NAME)?.textContent?.trim() || 'Unknown Artist', datePublished: Utils.getPostDate('published'), dateEdited: Utils.getPostDate('edited'), dateImported: Utils.getPostDate('imported'), publishedDate: Utils.getPostDateObject('published') }), _buildFileName: (item, index) => { const meta = DownloadManager._getPostMeta(); const extMatch = item.fileName.match(/\.([a-z0-9]+)$/i); const correctExt = extMatch ? extMatch[1].toLowerCase() : (item.type === 'video' ? 'mp4' : item.type === 'attachment' ? 'bin' : 'jpg'); const fileNameWithoutExt = item.fileName.replace(/\.[^/.]+$/, ''); let formattedName = state.imageFileNameFormat .replace(/{date_published}/gi, meta.datePublished) .replace(/{date_edited}/gi, meta.dateEdited) .replace(/{date_imported}/gi, meta.dateImported) .replace(/{date}/gi, meta.datePublished) .replace('{title}', Utils.sanitizeFileName(meta.title)) .replace('{artistName}', Utils.sanitizeFileName(meta.artistName)) .replace('{fileName}', Utils.sanitizeFileName(fileNameWithoutExt)) .replace('{index}', index + 1); if (!formattedName.toLowerCase().endsWith(`.${correctExt}`)) { formattedName += `.${correctExt}`; } return Utils.sanitizeFileName(formattedName); }, downloadFile: async (url, name) => { await RequestPacer.throttle(url); return new Promise(resolve => { if (typeof GM_download !== 'function') { resolve(false); return; } try { GM_download({ url, name, headers: RequestPacer.getHeaders(url), onload: () => resolve(true), onerror: () => resolve(false), ontimeout: () => resolve(false) }); } catch (e) { console.error('GM_download failed:', e); resolve(false); } }); }, createZipWorker() { const jszipSource = GM_getResourceText('jszipScript') || ''; const workerHandlerCode = ` self.usedNames = new Set(); self.onmessage = async (e) => { const { type, data } = e.data; if (type === 'init') { self.zip = new self.JSZip(); self.totalFiles = data.totalFiles; self.filesAdded = 0; self.usedNames = new Set(); } else if (type === 'addFile') { let { blob, name, date } = data; let finalName = name; let counter = 1; const dotIndex = name.lastIndexOf('.'); const baseName = dotIndex > -1 ? name.substring(0, dotIndex) : name; const ext = dotIndex > -1 ? name.substring(dotIndex) : ''; while (self.usedNames.has(finalName)) { finalName = \`\${baseName}_\${counter}\${ext}\`; counter++; } self.usedNames.add(finalName); let fileOptions; if (date !== null && date !== undefined) { const parsedDate = (typeof date === 'number') ? new Date(date) : new Date(String(date)); if (!Number.isNaN(parsedDate.getTime())) { fileOptions = { date: parsedDate }; } } self.zip.file(finalName, blob, fileOptions); self.filesAdded++; self.postMessage({ type: 'progress', message: \`Added \${self.filesAdded}/\${self.totalFiles}\` }); } else if (type === 'generate') { self.postMessage({ type: 'progress', message: 'Bundling files... this may take a moment.' }); try { const zipBlob = await self.zip.generateAsync({ type: 'blob', compression: 'STORE' }, (meta) => { self.postMessage({ type: 'progress', message: \`Bundling... \${Math.round(meta.percent)}%\` }); }); self.postMessage({ type: 'complete', zipBlob }); } catch (err) { self.postMessage({ type: 'error', message: err.message }); } } }; `; const blob = new Blob([jszipSource, '\n', workerHandlerCode], { type: 'application/javascript' }); DownloadManager._workerUrl = URL.createObjectURL(blob); DownloadManager._worker = new Worker(DownloadManager._workerUrl); return DownloadManager._worker; }, downloadAllImages: async (filter = 'all') => { if (state.isDownloading) { UGModal.alert('Download in Progress', 'A download is already running.', 'info'); return; } const meta = DownloadManager._getPostMeta(); let indexedItems = state.originalImageSrcs .map((item, index) => ({ item, index })) .filter(x => x.item && x.item.src); if (filter === 'images') { indexedItems = indexedItems.filter(x => x.item.type === 'image'); } else if (filter === 'attachments') { indexedItems = indexedItems.filter(x => x.item.type !== 'image'); } if (indexedItems.length === 0) { state.notificationType = 'warning'; state.notification = filter === 'images' ? 'No images found to download.' : filter === 'attachments' ? 'No attachments found to download.' : 'No media found to download.'; return; } let title, text, confirmText, zipSuffix, kindWord; if (filter === 'images') { title = 'Download Images?'; text = `Create ZIP from ${indexedItems.length} image(s)?`; confirmText = 'Create ZIP'; zipSuffix = 'images'; kindWord = 'image(s)'; } else if (filter === 'attachments') { title = 'Download Files?'; text = `Create ZIP from ${indexedItems.length} attachment(s)? (videos + other files)`; confirmText = 'Create ZIP'; zipSuffix = 'files'; kindWord = 'attachment(s)'; } else { title = 'Download All?'; text = `Create ZIP from all ${indexedItems.length} item(s)? (images + videos + attachments)`; confirmText = 'Create ZIP'; zipSuffix = 'all'; kindWord = 'item(s)'; } const result = await UGModal.confirm({ title, text, icon: 'question', confirmText, cancelText: 'Cancel' }); if (!result.isConfirmed) return; state.isDownloading = true; state.notificationType = 'info'; state.notification = `Preparing ${kindWord}...`; const notifyProgress = Utils.throttle((message) => { state.notificationType = 'info'; state.notification = message; }, CONFIG.PROGRESS_NOTIFY_INTERVAL); const worker = DownloadManager.createZipWorker(); worker.onmessage = (e) => { const { type, message, zipBlob } = e.data; if (type === 'progress') { notifyProgress(message); } else if (type === 'complete') { const sanitizedTitle = Utils.sanitizeFileName(meta.title); const sanitizedArtistName = Utils.sanitizeFileName(meta.artistName); let zipFileName = state.zipFileNameFormat .replace(/{date_published}/gi, meta.datePublished) .replace(/{date_edited}/gi, meta.dateEdited) .replace(/{date_imported}/gi, meta.dateImported) .replace(/{date}/gi, meta.datePublished) .replace('{artistName}', sanitizedArtistName) .replace('{title}', sanitizedTitle); if (filter !== 'all') { if (zipFileName.toLowerCase().endsWith('.zip')) { zipFileName = zipFileName.slice(0, -4) + `-${zipSuffix}.zip`; } else { zipFileName += `-${zipSuffix}`; } } if (!zipFileName.toLowerCase().endsWith('.zip')) { zipFileName += '.zip'; } DOM.saveBlob(zipBlob, zipFileName); state.notification = 'Download complete!'; state.notificationType = 'success'; DownloadManager.cleanupWorker(); } else if (type === 'error') { state.notificationType = 'error'; state.notification = `Download failed: ${message}`; DownloadManager.cleanupWorker(); } }; worker.postMessage({ type: 'init', data: { totalFiles: indexedItems.length } }); const streamFiles = async () => { let index = 0; let running = 0; const concurrencyLimit = CONFIG.MAX_CONCURRENT_FETCHES; const postDateMs = ( state.preserveFileDates && meta.publishedDate instanceof Date && !Number.isNaN(meta.publishedDate.getTime()) ) ? meta.publishedDate.getTime() : null; return new Promise((resolve) => { const next = () => { if (!state.isDownloading || index >= indexedItems.length) { if (running === 0) resolve(); return; } while (running < concurrencyLimit && index < indexedItems.length) { const entry = indexedItems[index++]; running++; (async () => { try { if (!state.isDownloading) return; const pathInZip = DownloadManager._buildFileName(entry.item, entry.index); let blob = await ImageCacheDB.get(entry.item.src); if (!blob) blob = await ImageLoader.fetchBlobDirect(entry.item.src); if (!state.isDownloading) return; if (blob && state.isDownloading && DownloadManager._worker) { DownloadManager._worker.postMessage({ type: 'addFile', data: { blob, name: pathInZip, date: postDateMs } }); } } catch (e) { console.warn(`Skipping ${entry.item.src}`, e); } finally { running--; next(); } })(); } }; next(); }).then(() => { if (state.isDownloading && DownloadManager._worker) { DownloadManager._worker.postMessage({ type: 'generate' }); } }); }; streamFiles(); }, downloadImageByIndex: async (index) => { const item = state.originalImageSrcs[index]; if (!item || !item.src) { state.notificationType = 'warning'; state.notification = 'No media found at this index.'; return; } const formattedName = DownloadManager._buildFileName(item, index); if (item.type === 'video' || item.type === 'attachment') { state.notificationType = 'info'; state.notification = item.type === 'video' ? 'Starting video download...' : 'Starting file download...'; const ok = await DownloadManager.downloadFile(item.src, formattedName); state.notification = ok ? (item.type === 'video' ? 'Video download complete' : 'File download complete') : (item.type === 'video' ? 'Video download failed' : 'File download failed'); state.notificationType = ok ? 'success' : 'error'; } else { try { let blob = await ImageCacheDB.get(item.src); if (!blob) blob = await ImageLoader.fetchBlobDirect(item.src); if (blob) { DOM.saveBlob(blob, formattedName); state.notificationType = 'success'; state.notification = 'Download started'; } else { state.notificationType = 'error'; state.notification = 'Download failed: Blob is null'; } } catch (e) { state.notificationType = 'error'; state.notification = 'Download failed'; console.error(e); } } }, cleanupWorker() { if (DownloadManager._worker) { DownloadManager._worker.terminate(); DownloadManager._worker = null; } if (DownloadManager._workerUrl) { URL.revokeObjectURL(DownloadManager._workerUrl); DownloadManager._workerUrl = null; } state.isDownloading = false; } }; // ==================================================== // Post Actions & UI Injection // ==================================================== const PostActions = { imageLinkClickHandler: (event) => { if (event.button !== 0) return; const clickedImageLink = event.target.closest(SELECTORS.IMAGE_LINK) || event.target.closest(SELECTORS.VIDEO_LINK); if (clickedImageLink) { event.preventDefault(); event.stopPropagation(); } }, initPostActions() { try { let postActionsContainer = DOM.$(SELECTORS.POST_ACTIONS); if (!postActionsContainer) { const fallbackContainer = DOM.$('.post__body') || DOM.$('.post__files'); if (fallbackContainer) { postActionsContainer = DOM.create('div', { className: 'post__actions ug-injected-ui' }); fallbackContainer.prepend(postActionsContainer); } } if (postActionsContainer && !DOM.$('.ug-global-actions', postActionsContainer)) { const globalButtons = DOM.create('div', { className: 'ug-injected-ui ug-global-actions' }); elements.galleryButton = UI.createToggleButton('Loading Gallery...', Gallery.toggleGallery, true, 'GALLERY', 'global'); globalButtons.append( UI.createToggleButton(BUTTONS.HEIGHT, () => PostActions.resizeAllImages('height'), false, 'HEIGHT', 'global'), UI.createToggleButton(BUTTONS.WIDTH, () => PostActions.resizeAllImages('width'), false, 'WIDTH', 'global'), UI.createToggleButton(BUTTONS.FULL, () => PostActions.resizeAllImages('full'), false, 'FULL', 'global'), UI.createToggleButton(BUTTONS.DOWNLOAD_ALL, () => DownloadManager.downloadAllImages('all'), false, 'DOWNLOAD_ALL', 'global'), UI.createToggleButton(BUTTONS.DOWNLOAD_IMAGES, () => DownloadManager.downloadAllImages('images'), false, 'DOWNLOAD_IMAGES', 'global'), UI.createToggleButton(BUTTONS.DOWNLOAD_ATTACHMENTS, () => DownloadManager.downloadAllImages('attachments'), false, 'DOWNLOAD_ATTACHMENTS', 'global'), elements.galleryButton ); postActionsContainer.appendChild(globalButtons); // Apply any previously-saved hide settings to the freshly-created buttons. PostActions.updateButtonVisibilityLight(); } if (!DOM.$('.settings-button-wrapper')) { const settingsButton = DOM.create('button', { type: 'button', text: BUTTONS.SETTINGS, className: 'settings-button', onclick: () => state.settingsOpen = !state.settingsOpen }); const wrapper = DOM.create('div', { className: 'settings-button-wrapper ug-injected-ui' }, [settingsButton]); document.body.appendChild(wrapper); elements.settingsButton = settingsButton; } const filesArea = DOM.$('div.post__files') || DOM.$('section.site-section--post') || DOM.$('.post__body'); if (filesArea) { DOM.$$(SELECTORS.FILE_DIVS, filesArea).forEach(thumbnailDiv => { const imgElement = thumbnailDiv.querySelector('img'); if (!imgElement) return; if (thumbnailDiv.querySelector(`.${CSS.BTN_CONTAINER}`)) return; imgElement.classList.add('post__image'); const buttonGroupConfig = [ { text: BUTTONS.HEIGHT, action: (evt) => PostActions.resizeImage('height', evt), name: 'HEIGHT' }, { text: BUTTONS.WIDTH, action: (evt) => PostActions.resizeImage('width', evt), name: 'WIDTH' }, { text: BUTTONS.FULL, action: () => ImageLoader.imageActions.full(imgElement), name: 'FULL' }, { text: BUTTONS.DOWNLOAD, action: () => { const link = imgElement.closest('a'); const originalSrc = link ? link.href.split('?')[0] : imgElement.dataset.originalSrc; const downloadIndex = state.originalImageSrcs.findIndex(item => item && item.src === originalSrc); if (downloadIndex > -1) { DownloadManager.downloadImageByIndex(downloadIndex); } }, name: 'DOWNLOAD' } ]; const buttonGroupElement = UI.createButtonGroup(buttonGroupConfig); if (buttonGroupElement.childElementCount > 0) { buttonGroupElement.classList.add('ug-injected-ui'); thumbnailDiv.insertBefore(buttonGroupElement, thumbnailDiv.firstChild); } }); if (!filesArea.dataset.ugLeftClickHandlerAttached) { filesArea.addEventListener('click', PostActions.imageLinkClickHandler); filesArea.dataset.ugLeftClickHandlerAttached = 'true'; } } ImageLoader.loadImages(); state.currentPostUrl = window.location.href; } catch (error) { console.error('Error initializing post actions:', error); } }, cleanupPostActions() { ActiveRequests.abortAll(); state.currentLoadSessionId = null; ErrorHandler.clearRetries(); UI.forceHideNotification(); DOM.$$('img.post__image.ug-image-loaded').forEach(img => img.classList.remove('ug-image-loaded')); DOM.$$('.ug-injected-ui').forEach(el => el.remove()); document.getElementById(CSS.NOTIF_AREA)?.remove(); const filesArea = DOM.$('div.post__files'); if (filesArea) { filesArea.removeEventListener('click', PostActions.imageLinkClickHandler); filesArea.removeAttribute('data-ug-left-click-handler-attached'); } if (state.isGalleryMode) { Gallery.closeGallery(); } else if (galleryOverlay) { galleryOverlay.remove(); galleryOverlay = null; } if (state.settingsOpen) { state.settingsOpen = false; } else { document.getElementById('ug-settings-overlay')?.remove(); } BlobManager.revokeAll(); loadedBlobUrls.clear(); state.notification = null; Object.assign(state, { fullSizeImageSrcs: [], originalImageSrcs: [], galleryReady: false, loadedImages: 0, totalImages: 0, errorCount: 0, isLoading: false, currentPostUrl: null }); elements.galleryButton = null; elements.settingsButton = null; }, updateButtonVisibilityLight() { const globalHideMap = { GALLERY: state.hideGlobalGalleryButton, HEIGHT: state.hideGlobalHeightButton, WIDTH: state.hideGlobalWidthButton, FULL: state.hideGlobalFullButton, DOWNLOAD_ALL: state.hideGlobalDownloadAllButton, DOWNLOAD_IMAGES: state.hideGlobalDownloadImagesButton, DOWNLOAD_ATTACHMENTS: state.hideGlobalDownloadAttachmentsButton }; const thumbnailHideMap = { HEIGHT: state.hideThumbnailHeightButton, WIDTH: state.hideThumbnailWidthButton, FULL: state.hideThumbnailFullButton, DOWNLOAD: state.hideThumbnailDownloadButton }; DOM.$$(`.${CSS.BTN}[data-action]`).forEach(btn => { const action = btn.dataset.action; const scope = btn.dataset.scope || 'global'; const map = scope === 'thumbnail' ? thumbnailHideMap : globalHideMap; if (Object.prototype.hasOwnProperty.call(map, action)) { btn.classList.toggle('ug-hidden', !!map[action]); } }); if (galleryOverlay) { DOM.$$(`.${CSS.GALLERY.NAV}`, galleryOverlay).forEach(n => { n.classList.toggle('ug-hidden', state.hideNavArrows); }); } }, resizeAllImages(action) { if (!ImageLoader.imageActions[action]) return; state.currentResizeMode = action; DOM.$$('img.post__image').forEach(img => ImageLoader.imageActions[action](img)); }, resizeImage(action, evt) { if (!ImageLoader.imageActions[action]) return; state.currentResizeMode = action; const button = evt.currentTarget; let thumbContainer = button.closest('.post__thumbnail, .scrape__thumbnail'); if (!thumbContainer) { const buttonContainer = button.closest(`.${CSS.BTN_CONTAINER}`); thumbContainer = buttonContainer?.nextElementSibling; } const displayedImage = thumbContainer?.querySelector('img.post__image'); if (displayedImage) ImageLoader.imageActions[action](displayedImage); } }; // ==================================================== // Keyboard & Event Handlers // ==================================================== const EventHandlers = { keyMatchesSetting: (eventKey, settingValue) => { return Boolean(settingValue) && String(eventKey).toLowerCase() === String(settingValue).toLowerCase(); }, handleGlobalKeyDown(event) { const activeEl = document.activeElement; if (activeEl && (activeEl.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(activeEl.tagName))) { return; } const keyLower = event.key.toLowerCase(); if (Utils.isPostPage() && EventHandlers.keyMatchesSetting(event.key, state.galleryKey)) { if (!event.altKey && !event.ctrlKey && !event.metaKey) { event.preventDefault(); if (state.galleryReady) { Gallery.toggleGallery(); } else { state.notificationType = 'info'; state.notification = 'Gallery content is still loading.'; } } return; } if (state.settingsOpen && event.key === 'Escape') { event.preventDefault(); state.settingsOpen = false; return; } if (state.isGalleryMode && galleryOverlay) { const expandedView = DOM.$(`.${CSS.GALLERY.EXPANDED_VIEW}`, galleryOverlay); if (event.key === 'Escape') { event.preventDefault(); Gallery.closeGallery(); return; } if (expandedView && !expandedView.classList.contains(CSS.GALLERY.HIDE)) { if (EventHandlers.keyMatchesSetting(event.key, state.nextImageKey) || keyLower === 'arrowright') { event.preventDefault(); Gallery.nextImage(); } else if (EventHandlers.keyMatchesSetting(event.key, state.prevImageKey) || keyLower === 'arrowleft') { event.preventDefault(); Gallery.prevImage(); } else if (event.key === 'Home') { event.preventDefault(); Gallery.showExpandedView(0); } else if (event.key === 'End') { event.preventDefault(); Gallery.showExpandedView(state.fullSizeImageSrcs.length - 1); } if (keyLower === '+' || keyLower === '=') { event.preventDefault(); Zoom.zoom(CONFIG.ZOOM_STEP); } else if (keyLower === '-') { event.preventDefault(); Zoom.zoom(-CONFIG.ZOOM_STEP); } else if (keyLower === '0') { event.preventDefault(); Zoom.resetZoom(); } else if (keyLower === ' ') { event.preventDefault(); Slideshow.toggle(); } else if (keyLower === 'f') { event.preventDefault(); Gallery.toggleFullscreen(); } } } } }; const updateGalleryButton = enabled => { if (elements.galleryButton) { elements.galleryButton.textContent = enabled ? BUTTONS.GALLERY : 'Loading Gallery...'; elements.galleryButton.disabled = !enabled; elements.galleryButton.classList.toggle('disabled', !enabled); } }; // ==================================================== // SPA-Safe Injection & Initialization // ==================================================== const injectUI = () => { try { const onPostPage = Utils.isPostPage(); const postContainer = DOM.$('section.site-section--post') || DOM.$('section.site-section--scrape') || DOM.$('.post__body') || DOM.$('.post__content'); const currentUrl = window.location.href; if (onPostPage && postContainer) { if (currentUrl !== lastProcessedUrl) { if ( DOM.$(SELECTORS.POST_ACTIONS) || DOM.$('.post__files') || DOM.$('.post__body') ) { PostActions.cleanupPostActions(); PostActions.initPostActions(); lastProcessedUrl = currentUrl; } } } else { if (lastProcessedUrl !== null) { PostActions.cleanupPostActions(); lastProcessedUrl = null; } } } catch (error) { console.error('Error in injectUI:', error); } }; const fullCleanup = () => { if (uiObserver) { uiObserver.disconnect(); uiObserver = null; } if (galleryOverlay && document.fullscreenElement === galleryOverlay) { const exit = document.exitFullscreen(); if (exit && typeof exit.catch === 'function') exit.catch(() => {}); } ActiveRequests.abortAll(); ImageLoader._inflight.clear(); PostActions.cleanupPostActions(); Gallery._clearPreloadCache(); DownloadManager.cleanupWorker(); UI.forceHideNotification(); ErrorHandler.clearRetries(); ThumbnailStrip.cleanup(); document.removeEventListener('keydown', EventHandlers.handleGlobalKeyDown); }; const init = async () => { try { const cssText = GM_getResourceText('mainCSS'); if (cssText) { const styleNode = GM_addStyle(cssText); if (styleNode && styleNode.setAttribute) { styleNode.setAttribute('data-keep', ''); } else { const lastStyle = document.head.querySelector('style:last-of-type'); if (lastStyle) lastStyle.setAttribute('data-keep', ''); } } else { console.warn('Ultra Galleries: Failed to load main CSS resource.'); } // One-time migration from 4.2.x single-scope button-visibility keys. if (GM_getValue('hideFullButton') !== undefined && GM_getValue('hideThumbnailFullButton') === undefined) { SettingsManager.saveSetting('hideThumbnailFullButton', SettingsManager.loadSetting('hideFullButton', false)); SettingsManager.saveSetting('hideThumbnailWidthButton', SettingsManager.loadSetting('hideWidthButton', false)); SettingsManager.saveSetting('hideThumbnailHeightButton', SettingsManager.loadSetting('hideHeightButton', false)); SettingsManager.saveSetting('hideThumbnailDownloadButton', SettingsManager.loadSetting('hideDownloadButton', false)); } const allSettings = SettingsManager.loadAllSettings(); Object.assign(state, allSettings); Slideshow.init(); document.body.classList.toggle('ug-animations-disabled', !state.animationsEnabled); updateButtonLabels(); state.notification = null; if (state.enablePersistentCaching) { ImageCacheDB.init(); } document.addEventListener('keydown', EventHandlers.handleGlobalKeyDown); window.addEventListener('beforeunload', fullCleanup); let galleryWasNativeFullscreen = false; document.addEventListener('fullscreenchange', () => { const isGalleryNative = Boolean(galleryOverlay) && document.fullscreenElement === galleryOverlay; if (galleryWasNativeFullscreen && !isGalleryNative && state.isFullscreen) { state.isFullscreen = false; } galleryWasNativeFullscreen = isGalleryNative; }); const debouncedInject = Utils.debounce(injectUI, 150); if ('navigation' in window) { window.navigation.addEventListener('navigate', (e) => { if (!e.canIntercept || e.hashChange) return; e.intercept({ async handler() { await Utils.delay(100); injectUI(); } }); }); } else { const originalPushState = history.pushState; history.pushState = function (...args) { originalPushState.apply(this, args); debouncedInject(); }; const originalReplaceState = history.replaceState; history.replaceState = function (...args) { originalReplaceState.apply(this, args); debouncedInject(); }; window.addEventListener('popstate', debouncedInject); } const UG_ROOT_SELECTOR = '.ug-gallery-overlay, #ug-settings-overlay, .ug-notification-area, .ug-modal-overlay, .ug-injected-ui, .ug-thumbnail-context-menu, .ug-thumbnail-zoom-preview, .settings-button-wrapper'; uiObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.(UG_ROOT_SELECTOR)) continue; for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; if (node.matches?.(UG_ROOT_SELECTOR)) continue; if (node.matches?.('.post__files, .scrape__files, article, .post__body') || node.querySelector?.('a.fileThumb, .post__thumbnail')) { debouncedInject(); return; } } } }); uiObserver.observe(document.body, { childList: true, subtree: true }); injectUI(); setTimeout(() => ThumbnailStrip.updateThumbnailNumbers(), 50); } catch (error) { console.error('Error in init:', error); } }; init(); })();