// ==UserScript==
// @name Ultra Galleries
// @namespace https://sleazyfork.org/en/users/1477603-%E3%83%A1%E3%83%AA%E3%83%BC
// @version 3.1.3
// @description Modern image gallery with enhanced browsing, fullscreen, and download features
// @author ntf (original), Meri/TearTyr (maintained and improved)
// @match *://kemono.su/*
// @match *://coomer.su/*
// @match *://nekohouse.su/*
// @icon https://kemono.party/static/menu/recent.svg
// @grant GM_download
// @grant GM.download
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_getResourceText
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js
// @require https://unpkg.com/[email protected]/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/FileSaver.min.js
// @require https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require https://unpkg.com/[email protected]/dist/dexie.min.js
// @resource upngJsRaw https://unpkg.com/[email protected]/UPNG.js
// @resource pakoJsRaw https://unpkg.com/[email protected]/dist/pako.min.js
// ==/UserScript==
(function() {
'use strict';
// ====================================================
// Core Configuration
// ====================================================
const CONFIG = {
BATCH_SIZE: 5,
MAX_RETRIES: 3,
RETRY_DELAY: 1500,
MIN_SCALE: 0.05,
MAX_SCALE: 5,
ZOOM_STEP: 0.2,
DEBOUNCE_DELAY: 250,
PAN_RESISTANCE: 0.8,
DOUBLE_TAP_THRESHOLD: 300,
};
const BUTTONS = {
DOWNLOAD: '【DOWNLOAD】',
DOWNLOAD_ALL: '【DL ALL】',
FULL: '【FULL】',
HEIGHT: '【FILL HEIGHT】',
REMOVE: '【REMOVE】',
WIDTH: '【FILL WIDTH】',
GALLERY: '【GALLERY】',
SETTINGS: '⚙️',
FULLSCREEN: '⛶',
CLOSE: '✕'
};
// CSS class names
const CSS = {
BTN: 'ug-button',
BTN_CONTAINER: 'ug-button-container',
LOADING: 'loading-overlay',
NO_CLICK: 'ug-no-click',
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',
VIRTUAL_IMAGE: 'virtual-image',
LONG_PRESS: 'long-press',
// Gallery classes
GALLERY: {
OVERLAY: 'ug-gallery-overlay',
CONTAINER: 'ug-gallery-container',
GRID_VIEW: 'ug-gallery-grid-view',
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',
THUMBNAIL: 'ug-gallery-thumbnail',
THUMBNAIL_GRID: 'ug-gallery-thumbnail-grid',
THUMBNAIL_CONTAINER: 'ug-gallery-thumbnail-grid-container',
THUMBNAIL_STRIP: 'ug-thumbnail-strip',
THUMBNAIL_ITEM: 'ug-thumbnail',
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',
GRID_CLOSE: 'ug-gallery-grid-close',
STRIP_CONTAINER: 'ug-gallery-thumbnail-strip-container',
TOOLBAR_BTN: 'ug-toolbar-button',
CONTROLS_HIDDEN: 'ug-controls-hidden',
GRABBING: 'ug-grabbing',
ZOOMED: 'zoomed',
},
// Settings classes
SETTINGS: {
OVERLAY: 'ug-settings-overlay',
CONTAINER: 'ug-settings-container',
HEADER: 'ug-settings-header',
BODY: 'ug-settings-body',
CLOSE_BTN: 'ug-settings-close-btn',
SECTION: 'ug-settings-section',
SECTION_HEADER: 'ug-settings-section-header',
LABEL: 'ug-settings-label',
INPUT: 'ug-settings-input',
CHECKBOX_LABEL: 'ug-settings-checkbox-label',
}
};
// Website-specific selectors
const website = window.location.hostname.split('.')[0];
const SELECTORS = {
IMAGE_LINK: website === 'nekohouse' ? 'a.image-link:not(.scrape__user-profile)' : 'a.fileThumb.image-link',
ATTACHMENT_LINK: website === 'nekohouse' ? '.scrape__attachment-link' : '.post__attachment-link',
POST_TITLE: website === 'nekohouse' ? '.scrape__title' : '.post__title',
POST_USER_NAME: website === 'nekohouse' ? '.scrape__user-name' : '.post__user-name',
POST_IMAGE: 'img.post__image',
THUMBNAIL: website === 'nekohouse' ? '.scrape__thumbnail' : '.post__thumbnail',
MAIN_THUMBNAIL: website === 'nekohouse' ? '.scrape__thumbnail:not(.scrape__thumbnail--attachment)' : '.post__thumbnail:not(.post__thumbnail--attachment)',
POST_ACTIONS: website === 'nekohouse' ? '.scrape__actions' : '.post__actions',
FAVORITE_BUTTON: website === 'nekohouse' ? '.scrape__actions a.favorite-button' : '.post__actions a.favorite-button',
FILE_DIVS: website === 'nekohouse' ? '.scrape__thumbnail' : '.post__thumbnail',
FILES_IMG: website === 'nekohouse' ? '.scrape__files img' : 'img.post__image',
VIDEO_LINK: website === 'nekohouse' ? 'a.video-link' : 'a.fileThumb.video-link',
VIDEO_THUMBNAIL: website === 'nekohouse' ? '.scrape__video-thumbnail' : '.post__video-thumbnail',
};
// ====================================================
// Utility Functions
// ====================================================
const Utils = {
getExtension: filename => filename.split('.').pop().toLowerCase() || 'jpg',
sanitizeFileName: name => name.replace(/[/\\:*?"<>|]/g, '-'),
setImageStyle: (img, styles) => img && Object.assign(img.style, styles),
isPostPage: () => {
const url = window.location.href;
const patterns = [
/https:\/\/(kemono\.su|coomer\.su|nekohouse\.su)\/.*\/post\//,
/https:\/\/(kemono\.su|coomer\.su|nekohouse\.su)\/.*\/user\/.*\/post\//,
];
return patterns.some(pattern => pattern.test(url));
},
delay: ms => new Promise(resolve => setTimeout(resolve, ms)),
debounce: (func, wait) => {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
},
throttle: (func, limit) => {
let lastRan, lastFunc;
return function(...args) {
if (!lastRan) {
func(...args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(() => {
if ((Date.now() - lastRan) >= limit) {
func(...args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
},
handleMediaSrc: mediaLink => {
const fileThumbDiv = mediaLink.querySelector('.fileThumb');
return fileThumbDiv?.getAttribute('href')?.split('?')[0] ||
mediaLink.getAttribute('href')?.split('?')[0] || null;
},
supportsPassiveEvents: () => {
let supportsPassive = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get: function() {
supportsPassive = true;
return true;
}
});
window.addEventListener('testPassive', null, opts);
window.removeEventListener('testPassive', null, opts);
} catch (e) {}
return supportsPassive;
},
createTooltip: (text, duration = 3000) => {
const tooltip = document.createElement('div');
tooltip.className = 'zoom-tooltip';
tooltip.textContent = text;
Object.assign(tooltip.style, {
position: 'absolute',
bottom: '120px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0,0,0,0.7)',
color: 'white',
padding: '10px 15px',
borderRadius: '5px',
zIndex: '100',
pointerEvents: 'none'
});
setTimeout(() => {
tooltip.style.opacity = '0';
tooltip.style.transition = 'opacity 0.5s ease';
setTimeout(() => tooltip.remove(), 500);
}, duration);
return tooltip;
},
getDistance: (touch1, touch2) => {
return Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
},
getMidpoint: (touch1, touch2) => ({
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
}),
ensureThumbnailsExist: () => {
try {
// Look for posts with images but no thumbnails
const posts = document.querySelectorAll('.post');
posts.forEach(post => {
const hasImages = post.querySelector(SELECTORS.IMAGE_LINK) !== null;
const hasThumbnail = post.querySelector(SELECTORS.THUMBNAIL) !== null;
if (hasImages && !hasThumbnail) {
const firstImage = post.querySelector(SELECTORS.IMAGE_LINK + ' img');
if (firstImage) {
const thumbnailContainer = document.createElement('div');
thumbnailContainer.className = website === 'nekohouse' ? 'scrape__thumbnail' : 'post__thumbnail';
const thumbnailImg = document.createElement('img');
thumbnailImg.src = firstImage.src;
thumbnailImg.className = website === 'nekohouse' ? 'scrape__thumbnail-img' : 'post__thumbnail-img';
thumbnailContainer.appendChild(thumbnailImg);
const insertPoint = post.querySelector('.post__header') || post.firstChild;
if (insertPoint) {
post.insertBefore(thumbnailContainer, insertPoint.nextSibling);
} else {
post.appendChild(thumbnailContainer);
}
}
}
});
// Generate video thumbnails
const videoLinks = document.querySelectorAll(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');
const thumbnailContainer = document.createElement('div');
thumbnailContainer.className = website === 'nekohouse' ? 'scrape__video-thumbnail' : 'post__video-thumbnail';
const thumbnailImg = document.createElement('img');
thumbnailImg.src = posterUrl;
thumbnailImg.className = website === 'nekohouse' ? 'scrape__thumbnail-img' : 'post__thumbnail-img';
thumbnailContainer.appendChild(thumbnailImg);
videoLink.parentNode?.insertBefore(thumbnailContainer, videoLink);
}
}
});
} catch (error) {
console.error('Error ensuring thumbnails exist:', error);
}
}
};
// ====================================================
// State Management
// ====================================================
// Create reactive state with update callbacks
const createReactiveState = (initialState, updateCallbacks = {}) => {
return new Proxy(initialState, {
set(target, key, value) {
const oldValue = target[key];
target[key] = value;
if (updateCallbacks[key]) {
updateCallbacks[key](value, oldValue);
}
return true;
},
});
};
// Core state with reactive updates
const state = createReactiveState({
// File naming settings
zipFileNameFormat: GM_getValue('zipFileNameFormat', '{title}-{artistName}.zip'),
imageFileNameFormat: GM_getValue('imageFileNameFormat', '{title}-{artistName}-{fileName}-{index}'),
// Gallery state
galleryKey: GM_getValue('galleryKey', 'g'),
galleryReady: false,
galleryActive: false,
currentGalleryIndex: 0,
isFullscreen: GM_getValue('isFullscreen', false),
virtualGallery: [],
originalImageSrcs: [],
fullSizeImageSrcs: [],
// Post tracking
currentPostUrl: null,
displayedImages: [],
totalImages: 0,
loadedImages: 0,
// Status tracking
downloadedCount: 0,
isLoading: false,
loadingMessage: null,
hasImages: false,
postActionsInitialized: false,
mediaLoaded: {},
isGalleryMode: false,
isDownloading: false,
errorCount: 0,
// UI preferences
notificationsEnabled: GM_getValue('notificationsEnabled', true),
notificationAreaVisible: GM_getValue('notificationAreaVisible', true),
notificationPosition: GM_getValue('notificationPosition', 'bottom'),
animationsEnabled: GM_getValue('animationsEnabled', true),
// Download settings
optimizePngInZip: GM_getValue('optimizePngInZip', false),
enablePersistentCaching: GM_getValue('enablePersistentCaching', true),
// Notification state
notification: null,
notificationType: 'info',
// Button visibility settings
hideNavArrows: GM_getValue('hideNavArrows', false),
hideRemoveButton: GM_getValue('hideRemoveButton', false),
hideFullButton: GM_getValue('hideFullButton', false),
hideDownloadButton: GM_getValue('hideDownloadButton', false),
hideHeightButton: GM_getValue('hideHeightButton', false),
hideWidthButton: GM_getValue('hideWidthButton', false),
// Settings state
settingsOpen: false,
// Keyboard shortcuts
prevImageKey: GM_getValue('prevImageKey', 'k'),
nextImageKey: GM_getValue('nextImageKey', 'l'),
// Gallery display options
bottomStripeVisible: GM_getValue('bottomStripeVisible', true),
dynamicResizing: GM_getValue('dynamicResizing', true),
// Zoom state
zoomEnabled: GM_getValue('zoomEnabled', true),
isZoomed: false,
zoomScale: 1,
controlsVisible: true,
isDragging: false,
dragStartPosition: { x: 0, y: 0 },
lastMousePosition: { x: 0, y: 0 },
imageOffset: { x: 0, y: 0 },
lastWidth: 0,
lastHeight: 0,
zoomOrigin: { x: 0, y: 0 },
dragStartOffset: { x: 0, y: 0 },
// Touch interaction
pendingRetries: {},
lastTapTime: 0,
pinchZoomActive: false,
initialTouchDistance: 0,
initialScale: 1,
zoomIndicatorVisible: true,
inertiaEnabled: GM_getValue('inertiaEnabled', true),
velocity: { x: 0, y: 0 },
inertiaActive: false,
}, {
// State update callbacks
controlsVisible: (value) => {
if (galleryOverlay && galleryOverlay.length) {
const $toolbar = galleryOverlay.find(`.${CSS.GALLERY.TOOLBAR}`);
if ($toolbar.length) { // Check if toolbar was found
$toolbar.toggleClass(CSS.GALLERY.CONTROLS_HIDDEN, !value);
}
}
},
galleryReady: (value) => {
updateGalleryButton(value);
},
loadedImages: (value) => {
if (value === state.totalImages && state.totalImages > 0) {
state.notification = `Images Done Loading! Total: ${state.totalImages}`;
state.notificationType = 'success';
} else if (state.totalImages > 0) {
state.notification = `Loading media (${value}/${state.totalImages})...`;
}
},
downloadedCount: (value) => {
state.notification = `Downloading... (${value}/${state.totalImages})`;
if (value === state.totalImages) {
state.notification = `Done Downloading! Total: ${state.totalImages}`;
state.notificationType = 'success';
}
},
totalImages: (value, oldValue) => {
if (value > 0) {
state.notification = `Loading media (${state.loadedImages}/${value})...`;
}
state.hasImages = value > 0;
},
isLoading: (value, oldValue) => {
if (value && !oldValue) {
if ((state.galleryActive || state.isDownloading) && state.loadedImages === 0) {
UI.showLoadingOverlay(state.loadingMessage);
}
} else if (!value && oldValue) {
UI.hideLoadingOverlay();
}
},
loadingMessage: (value) => {
if (state.isLoading && (state.galleryActive || state.isDownloading)) {
UI.updateLoadingOverlayText(value);
}
},
notification: (value) => {
if (value) {
UI.showNotification(value, state.notificationType);
} else {
UI.hideNotification();
}
},
settingsOpen: (value) => {
if (value) {
UI.showSettings();
} else {
UI.closeSettings();
}
},
isFullscreen: (value) => {
GM_setValue('isFullscreen', value);
if (galleryOverlay) {
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);
}
}
},
zoomEnabled: (value) => {
GM_setValue('zoomEnabled', value);
},
bottomStripeVisible: (value) => {
GM_setValue('bottomStripeVisible', value);
if (galleryOverlay) {
const stripContainer = galleryOverlay.querySelector(`.${CSS.GALLERY.STRIP_CONTAINER}`);
if (stripContainer) {
stripContainer.style.display = value ? 'flex' : 'none';
}
}
},
zoomScale: (value, oldValue) => {
Zoom.applyZoom();
if (galleryOverlay && galleryOverlay.length) {
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if ($container.length) {
$container.toggleClass(CSS.GALLERY.ZOOMED, value > 1);
$container.css('cursor', value > 1 ? 'grab' : 'default');
}
// Show instructions tooltip first time
if (value > 1 && oldValue === 1 && state.zoomIndicatorVisible) {
const tooltip = Utils.createTooltip('Click and drag to pan image');
galleryOverlay.append(tooltip);
state.zoomIndicatorVisible = false;
}
}
},
imageOffset: () => Zoom.applyZoom(),
isDragging: (value) => {
if (galleryOverlay && galleryOverlay.length) {
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if ($container.length) {
$container.toggleClass(CSS.GALLERY.GRABBING, value);
if (value && state.inertiaActive) {
state.inertiaActive = false;
state.velocity = { x: 0, y: 0 };
if (state.inertiaAnimFrame) {
cancelAnimationFrame(state.inertiaAnimFrame);
state.inertiaAnimFrame = null;
}
}
}
}
},
notificationPosition: (value) => {
GM_setValue('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) => {
GM_setValue('enablePersistentCaching', value);
if (value && !db) { // Initialize Dexie if enabled and not already done
initDexie();
} else if (!value && db) {
// Optionally, you might want to clear the cache when disabling, or just leave it.
// For now, we just stop using it.
console.log("Ultra Galleries: Persistent caching disabled. Existing cache remains but won't be used.");
}
},
optimizePngInZip: (value) => {
GM_setValue('optimizePngInZip', value);
},
});
// ====================================================
// Resource Loading
// ====================================================
let loadedUPNG = null;
let loadedPako = null;
async function loadResourceScript(resourceName, globalVarName, onWindow = true) {
try {
const scriptText = GM_getResourceText(resourceName);
if (!scriptText) {
console.error(`Ultra Galleries: Resource ${resourceName} not found or empty.`);
return null;
}
if (onWindow && typeof window[globalVarName] !== 'undefined') {
console.log(`Ultra Galleries: ${globalVarName} already on window.`);
return window[globalVarName];
}
// check if a common global for the library exists after eval
if (resourceName === 'upngJsRaw' && typeof UPNG !== 'undefined') return UPNG;
if (resourceName === 'pakoJsRaw' && typeof pako !== 'undefined') return pako;
console.log(`Ultra Galleries: Loading resource ${resourceName} into global scope...`);
// Indirect eval to run in global scope
(0, eval)(scriptText);
if (onWindow && typeof window[globalVarName] !== 'undefined') {
console.log(`Ultra Galleries: ${globalVarName} loaded from resource ${resourceName}.`);
return window[globalVarName];
} else if (resourceName === 'upngJsRaw' && typeof UPNG !== 'undefined') {
console.log(`Ultra Galleries: UPNG loaded from resource ${resourceName}.`);
window.UPNG = UPNG; // Ensure it's explicitly on window if needed elsewhere by this name
return UPNG;
} else if (resourceName === 'pakoJsRaw' && typeof pako !== 'undefined') {
console.log(`Ultra Galleries: pako loaded from resource ${resourceName}.`);
window.pako = pako; // Ensure it's explicitly on window
return pako;
} else {
console.warn(`Ultra Galleries: Resource ${resourceName} evaluated, but expected global '${globalVarName}' not found. The library might use a different name or be an ESM module not directly exposing a global via eval.`);
return null;
}
} catch (e) {
console.error(`Ultra Galleries: Error loading resource ${resourceName}:`, e);
return null;
}
}
// ====================================================
// Dexie Database Initialization
// ====================================================
let db = null;
function initDexie() {
if (typeof Dexie === 'undefined') {
console.error("Ultra Galleries: Dexie.js is not loaded. Persistent caching will be unavailable.");
return false;
}
db = new Dexie('UltraGalleriesCache');
db.version(1).stores({
// Store original URL as key, and the image blob, plus when it was cached
imageCache: 'url, cachedAt, blob'
});
console.log("Ultra Galleries: Dexie database initialized.");
return true;
}
async function storeImageInDexie(url, blob) {
if (!db || !state.enablePersistentCaching) return;
try {
await db.imageCache.put({ url: url, blob: blob, cachedAt: Date.now() });
// console.log(`Ultra Galleries: Cached image in Dexie: ${url}`);
} catch (e) {
console.error(`Ultra Galleries: Error caching image ${url} in Dexie:`, e);
// Handle QuotaExceededError or other errors if necessary
if (e.name === 'QuotaExceededError') {
console.warn("Ultra Galleries: Dexie cache quota exceeded. Consider clearing cache or increasing quota.");
// Potentially implement a cache cleanup strategy here (e.g., remove oldest items)
}
}
}
async function getImageFromDexie(url) {
if (!db || !state.enablePersistentCaching) return null;
try {
const record = await db.imageCache.get(url);
if (record && record.blob) {
// console.log(`Ultra Galleries: Retrieved image from Dexie: ${url}`);
return record.blob;
}
return null;
} catch (e) {
console.error(`Ultra Galleries: Error retrieving image ${url} from Dexie:`, e);
return null;
}
}
async function clearDexieCache() {
if (!db) return;
try {
await db.imageCache.clear();
state.notification = "Persistent image cache cleared.";
state.notificationType = "success";
console.log("Ultra Galleries: Dexie imageCache cleared.");
} catch (e) {
console.error("Ultra Galleries: Error clearing Dexie cache:", e);
state.notification = "Error clearing cache. See console.";
state.notificationType = "error";
}
}
// ====================================================
// Zoom & Pan Module
// ====================================================
const Zoom = {
applyZoom: () => {
if (!galleryOverlay || !galleryOverlay.length) return;
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if (!$container.length) return;
$container.css('transform', `translate(${state.imageOffset.x}px, ${state.imageOffset.y}px) scale(${state.zoomScale})`);
const $zoomDisplay = galleryOverlay.find('#zoom-level');
if ($zoomDisplay.length) {
$zoomDisplay.text(`${Math.round(state.zoomScale * 100)}%`);
}
$container.toggleClass(CSS.GALLERY.ZOOMED, state.zoomScale !== 1);
},
handleWheelZoom: (event) => {
if (!state.zoomEnabled || !galleryOverlay || !galleryOverlay.length) return;
event.preventDefault();
event.stopPropagation();
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
const $image = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG}`);
if (!$image.length || !$container.length) return;
const containerDOM = $container[0];
// const imageDOM = $image[0];
const rect = containerDOM.getBoundingClientRect();
const originalEvent = event.originalEvent || event; // Get original DOM event for deltaY
const mouseX = originalEvent.clientX - rect.left;
const mouseY = originalEvent.clientY - rect.top;
const delta = Math.sign(originalEvent.deltaY) * -0.1; // Use originalEvent.deltaY for scroll direction
const newScale = Math.max(CONFIG.MIN_SCALE, Math.min(state.zoomScale + delta, CONFIG.MAX_SCALE));
// Calculate new offsets to keep zoom centered on mouse pointer
const imageXUnderPointer = (mouseX - state.imageOffset.x) / state.zoomScale;
const imageYUnderPointer = (mouseY - state.imageOffset.y) / state.zoomScale;
const newOffsetX = mouseX - (imageXUnderPointer * newScale);
const newOffsetY = mouseY - (imageYUnderPointer * newScale);
state.imageOffset.x = newOffsetX;
state.imageOffset.y = newOffsetY;
state.zoomScale = newScale; // This will trigger the reactive 'zoomScale' callback
},
enforceBoundaries: (offsetX, offsetY, scale, containerRect, imageDOM) => { // imageDOM is DOM element
if (!imageDOM || !containerRect) return { x: offsetX, y: offsetY };
const imgWidth = imageDOM.naturalWidth * scale;
const imgHeight = imageDOM.naturalHeight * scale;
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
if (imgWidth <= containerWidth) {
offsetX = (containerWidth - imgWidth) / 2;
} else {
const maxX = (imgWidth - containerWidth) / 2;
const minX = -maxX;
if (offsetX > maxX) offsetX = maxX + ((offsetX - maxX) * CONFIG.PAN_RESISTANCE / scale);
else if (offsetX < minX) offsetX = minX - ((minX - offsetX) * CONFIG.PAN_RESISTANCE / scale);
}
if (imgHeight <= containerHeight) {
offsetY = (containerHeight - imgHeight) / 2;
} else {
const maxY = (imgHeight - containerHeight) / 2;
const minY = -maxY;
if (offsetY > maxY) offsetY = maxY + ((offsetY - maxY) * CONFIG.PAN_RESISTANCE / scale);
else if (offsetY < minY) offsetY = minY - ((minY - offsetY) * CONFIG.PAN_RESISTANCE / scale);
}
return { x: offsetX, y: offsetY };
},
startDrag: (event) => {
if (!galleryOverlay || !galleryOverlay.length) return;
if (event.button === 2 && event.type === 'mousedown') return; // Allow context menu on actual mousedown
if (event.preventDefault) event.preventDefault();
state.isDragging = true;
const clientX = event.clientX || (event.touches && event.touches[0].clientX);
const clientY = event.clientY || (event.touches && event.touches[0].clientY);
state.dragStartPosition = { x: clientX, y: clientY };
state.dragStartOffset = { x: state.imageOffset.x, y: state.imageOffset.y };
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if ($container.length) {
$container.addClass(CSS.GALLERY.GRABBING);
}
},
dragImage: (event) => {
if (!state.isDragging || !galleryOverlay || !galleryOverlay.length) return;
const clientX = event.clientX || (event.touches && event.touches[0].clientX);
const clientY = event.clientY || (event.touches && event.touches[0].clientY);
if (clientX === undefined || clientY === undefined) return;
const deltaX = clientX - state.dragStartPosition.x;
const deltaY = clientY - state.dragStartPosition.y;
state.imageOffset.x = state.dragStartOffset.x + deltaX;
state.imageOffset.y = state.dragStartOffset.y + deltaY;
Zoom.applyZoom();
},
endDrag: () => {
if (!state.isDragging || !galleryOverlay || !galleryOverlay.length) return;
state.isDragging = false;
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if ($container.length) {
$container.removeClass(CSS.GALLERY.GRABBING);
}
},
resetZoom: () => {
if (!galleryOverlay || !galleryOverlay.length) return;
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if ($container.length) {
$container.css('transition', 'transform 0.3s ease-out');
state.zoomScale = 1;
state.imageOffset = { x: 0, y: 0 };
Zoom.applyZoom();
setTimeout(() => $container.css('transition', ''), 300);
}
},
initializeImage: (imageDOM, containerDOM) => {
if (!imageDOM || !containerDOM) return;
$(imageDOM).css({width: '', height: '', maxWidth: '100%', maxHeight: '100%'});
const containerWidth = containerDOM.offsetWidth;
const containerHeight = containerDOM.offsetHeight;
const imageWidth = imageDOM.naturalWidth;
const imageHeight = imageDOM.naturalHeight;
if (imageWidth === 0 || imageHeight === 0) {
Zoom.resetZoom(); Zoom.applyZoom(); return;
}
const aspectRatio = imageWidth / imageHeight;
if (aspectRatio > containerWidth / containerHeight) {
$(imageDOM).css({width: '100%', height: 'auto'});
} else {
$(imageDOM).css({width: 'auto', height: '100%'});
}
state.zoomScale = 1;
state.imageOffset = { x: 0, y: 0 };
Zoom.applyZoom();
},
zoom: (step) => {
if (!galleryOverlay || !galleryOverlay.length) return;
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if (!$container.length) return;
const containerDOM = $container[0];
const rect = containerDOM.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const newScale = Math.max(CONFIG.MIN_SCALE, Math.min(state.zoomScale + step, CONFIG.MAX_SCALE));
if (state.zoomScale !== newScale) {
const imageX = (centerX - state.imageOffset.x) / state.zoomScale;
const imageY = (centerY - state.imageOffset.y) / state.zoomScale;
const newOffsetX = centerX - (imageX * newScale);
const newOffsetY = centerY - (imageY * newScale);
$container.css('transition', 'transform 0.2s ease-out');
state.imageOffset.x = newOffsetX;
state.imageOffset.y = newOffsetY;
state.zoomScale = newScale;
Zoom.applyZoom();
setTimeout(() => $container.css('transition', ''), 200);
}
},
setupTouchEvents: () => {
if (!galleryOverlay || !galleryOverlay.length) return;
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if (!$container.length) return;
const containerDOM = $container[0]; // Get DOM element for addEventListener
let initialTouchDistance = 0;
let initialScale = 1;
let longPressTimer = null;
const passiveSupported = Utils.supportsPassiveEvents();
const touchStart = (e) => {
if (e.touches.length === 1) {
clearTimeout(longPressTimer);
longPressTimer = setTimeout(() => {
$(e.target).addClass(CSS.LONG_PRESS);
if (state.isDragging) Zoom.endDrag();
}, 500);
}
if (e.touches.length === 1) {
const now = Date.now();
const timeSinceLastTap = now - state.lastTapTime;
if (timeSinceLastTap < CONFIG.DOUBLE_TAP_THRESHOLD && timeSinceLastTap > 0) {
if (state.zoomScale > 1) {
Zoom.resetZoom();
} else {
const touch = e.touches[0];
const rect = containerDOM.getBoundingClientRect();
const touchX = touch.clientX - rect.left;
const touchY = touch.clientY - rect.top;
state.zoomOrigin = { x: touchX, y: touchY };
const newScale = 2.5;
const imageX = (touchX - state.imageOffset.x) / state.zoomScale;
const imageY = (touchY - state.imageOffset.y) / state.zoomScale;
const newOffsetX = touchX - (imageX * newScale);
const newOffsetY = touchY - (imageY * newScale);
const imageDOM = $container.find(`.${CSS.GALLERY.MAIN_IMG}`)[0];
if (!imageDOM) return;
const boundedOffset = Zoom.enforceBoundaries(newOffsetX, newOffsetY, newScale, rect, imageDOM);
$container.css('transition', 'transform 0.3s ease-out');
state.imageOffset.x = boundedOffset.x;
state.imageOffset.y = boundedOffset.y;
state.zoomScale = newScale;
Zoom.applyZoom();
setTimeout(() => $container.css('transition', ''), 300);
}
state.lastTapTime = 0; e.preventDefault(); return;
}
state.lastTapTime = now;
Zoom.startDrag({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY, button: 0, preventDefault: () => e.preventDefault(), touches: e.touches });
} else if (e.touches.length === 2) {
clearTimeout(longPressTimer); e.preventDefault();
initialTouchDistance = Utils.getDistance(e.touches[0], e.touches[1]);
initialScale = state.zoomScale;
const rect = containerDOM.getBoundingClientRect();
const midPointScreen = Utils.getMidpoint(e.touches[0], e.touches[1]);
state.zoomOrigin = { x: midPointScreen.x - rect.left, y: midPointScreen.y - rect.top };
state.pinchZoomActive = true;
if (state.isDragging) Zoom.endDrag();
}
};
const touchMove = (e) => {
clearTimeout(longPressTimer);
if (e.touches.length === 1 && state.isDragging) {
e.preventDefault();
Zoom.dragImage({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY, touches: e.touches });
} else if (e.touches.length === 2 && state.pinchZoomActive) {
e.preventDefault();
const currentDistance = Utils.getDistance(e.touches[0], e.touches[1]);
if (initialTouchDistance === 0) return;
const scaleFactor = currentDistance / initialTouchDistance;
const newScale = Math.max(CONFIG.MIN_SCALE, Math.min(initialScale * scaleFactor, CONFIG.MAX_SCALE));
const rect = containerDOM.getBoundingClientRect();
const imageDOM = $container.find(`.${CSS.GALLERY.MAIN_IMG}`)[0];
if (Math.abs(newScale - state.zoomScale) > 0.01 || e.touches.length !== 2) {
const imageX = (state.zoomOrigin.x - state.imageOffset.x) / state.zoomScale;
const imageY = (state.zoomOrigin.y - state.imageOffset.y) / state.zoomScale;
const newOffsetX = state.zoomOrigin.x - (imageX * newScale);
const newOffsetY = state.zoomOrigin.y - (imageY * newScale);
if (!imageDOM) return;
const boundedOffset = Zoom.enforceBoundaries(newOffsetX, newOffsetY, newScale, rect, imageDOM);
state.imageOffset.x = boundedOffset.x;
state.imageOffset.y = boundedOffset.y;
state.zoomScale = newScale;
Zoom.applyZoom();
}
}
};
const touchEnd = (e) => {
clearTimeout(longPressTimer);
$container.find(`.${CSS.LONG_PRESS}`).removeClass(CSS.LONG_PRESS);
if (e.touches.length < 2 && state.pinchZoomActive) {
state.pinchZoomActive = false; initialTouchDistance = 0;
}
if (e.touches.length === 0 && state.isDragging) {
Zoom.endDrag();
}
};
const eventOptions = passiveSupported ? { passive: false } : false;
containerDOM.removeEventListener('touchstart', touchStart, eventOptions); // Try removing first
containerDOM.removeEventListener('touchmove', touchMove, eventOptions);
containerDOM.removeEventListener('touchend', touchEnd);
containerDOM.removeEventListener('touchcancel', touchEnd);
containerDOM.addEventListener('touchstart', touchStart, eventOptions);
containerDOM.addEventListener('touchmove', touchMove, eventOptions);
containerDOM.addEventListener('touchend', touchEnd);
containerDOM.addEventListener('touchcancel', touchEnd);
}
};
// ====================================================
// UI Component Module
// ====================================================
const UI = {
createToggleButton: (name, action, disabled = false) => {
const btn = document.createElement('a');
btn.textContent = name;
btn.addEventListener('click', action);
btn.style.cursor = 'pointer';
btn.classList.add(CSS.BTN);
if (disabled) {
btn.disabled = true;
btn.classList.add('disabled');
}
return btn;
},
createLoadingOverlay: (text = 'Loading...') => {
const overlay = document.createElement('div');
overlay.className = CSS.LOADING;
const loadingText = document.createElement('div');
loadingText.textContent = text;
overlay.appendChild(loadingText);
return overlay;
},
createStatusElement: () => {
const containerStatus = document.createElement('div');
containerStatus.style.display = 'inline-flex';
const statusElement = document.createElement('span');
statusElement.id = 'Status';
statusElement.style.marginLeft = '10px';
containerStatus.append(statusElement);
return { container: containerStatus, element: statusElement };
},
createButtonGroup: (buttonsConfig) => {
const div = document.createElement('div');
div.classList.add(CSS.BTN_CONTAINER);
buttonsConfig.forEach(config => {
let createThisButton = true;
// Check settings to determine if a button should be hidden
switch(config.name) {
case 'REMOVE': if (state.hideRemoveButton) createThisButton = false; break;
case 'FULL': if (state.hideFullButton) createThisButton = false; break;
case 'DOWNLOAD': if (state.hideDownloadButton) createThisButton = false; break;
case 'HEIGHT': if (state.hideHeightButton) createThisButton = false; break;
case 'WIDTH': if (state.hideWidthButton) createThisButton = false; break;
}
if (!createThisButton) {
return;
}
const button = UI.createToggleButton(config.text, config.action);
div.append(button);
button.classList.add(CSS.BTN);
});
return div;
},
createNavigationButton: (direction) => {
const btn = document.createElement('button');
btn.textContent = direction === 'prev' ? '←' : '→';
btn.className = `${CSS.GALLERY.NAV} ${direction === 'prev' ? CSS.GALLERY.PREV : CSS.GALLERY.NEXT}`;
btn.addEventListener('click', direction === 'prev' ? Gallery.prevImage : Gallery.nextImage);
btn.setAttribute('aria-label', direction === 'prev' ? 'Previous Image' : 'Next Image');
return btn;
},
showLoadingOverlay: (text) => {
if (!elements.loadingOverlay) {
elements.loadingOverlay = UI.createLoadingOverlay(text);
document.body.appendChild(elements.loadingOverlay);
} else {
UI.updateLoadingOverlayText(text);
}
},
updateLoadingOverlayText: (text) => {
if (elements.loadingOverlay) {
const loadingText = elements.loadingOverlay.querySelector('div');
if (loadingText) loadingText.textContent = text;
}
},
hideLoadingOverlay: () => {
if (elements.loadingOverlay) {
elements.loadingOverlay.remove();
elements.loadingOverlay = null;
}
},
createNotificationArea: () => {
const area = document.createElement('div');
area.id = CSS.NOTIF_AREA;
area.classList.add(CSS.NOTIF_AREA);
// Position based on user preference
area.style.top = state.notificationPosition === 'top' ? '10px' : 'auto';
area.style.bottom = state.notificationPosition === 'bottom' ? '10px' : 'auto';
document.body.appendChild(area);
return area;
},
createNotification: () => {
let area = document.getElementById(CSS.NOTIF_AREA);
if (!area) area = UI.createNotificationArea();
const container = document.createElement('div');
container.id = CSS.NOTIF_CONTAINER;
container.classList.add(CSS.NOTIF_CONTAINER);
const text = document.createElement('div');
text.id = CSS.NOTIF_TEXT;
container.appendChild(text);
const closeBtn = document.createElement('button');
closeBtn.id = CSS.NOTIF_CLOSE;
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
state.notification = null; // Clicking 'x' dismisses notification
});
container.appendChild(closeBtn);
const reportBtn = document.createElement('a');
reportBtn.id = CSS.NOTIF_REPORT;
reportBtn.textContent = 'Report Issue';
reportBtn.href = 'https://github.com/TearTyr/Ultra-Galleries/issues';
reportBtn.target = '_blank';
container.appendChild(reportBtn);
area.appendChild(container);
return container;
},
// A timeout ID for auto-hiding notifications
_notificationTimeoutId: null,
showNotification: (message, type = 'info') => {
if (!state.notificationsEnabled && type !== 'error') return; // Do not show non-error notifications if disabled
let area = document.getElementById(CSS.NOTIF_AREA);
if (!area) area = UI.createNotificationArea();
let container = area.querySelector(`.${CSS.NOTIF_CONTAINER}`);
if (!container) container = UI.createNotification();
if (area) area.style.display = state.notificationAreaVisible ? 'flex' : 'none';
const text = container.querySelector(`#${CSS.NOTIF_TEXT}`);
text.textContent = message;
container.classList.remove('info', 'success', 'error');
container.classList.add(type);
if (state.animationsEnabled) {
container.classList.add('ug-slide-in');
container.classList.remove('ug-slide-out');
} else {
container.classList.remove('ug-slide-in', 'ug-slide-out');
}
container.style.display = 'flex';
// Clear any existing auto-hide timeout
if (UI._notificationTimeoutId) {
clearTimeout(UI._notificationTimeoutId);
UI._notificationTimeoutId = null;
}
// Auto-hide after a delay for 'info' and 'success' notifications
if (type === 'info' || type === 'success') {
UI._notificationTimeoutId = setTimeout(() => {
state.notification = null; // This will trigger hideNotification via state proxy
}, 5000); // Hide after 5 seconds
}
},
hideNotification: () => {
const container = document.getElementById(CSS.NOTIF_CONTAINER);
if (!container) return;
// Clear any pending auto-hide timeout if manually hidden
if (UI._notificationTimeoutId) {
clearTimeout(UI._notificationTimeoutId);
UI._notificationTimeoutId = null;
}
if (state.animationsEnabled) {
container.classList.add('ug-slide-out');
container.classList.remove('ug-slide-in');
setTimeout(() => container.style.display = 'none', 500);
} else {
container.classList.remove('ug-slide-in', 'ug-slide-out');
container.style.display = 'none';
}
},
createSettingsUI: () => {
const $overlay = $('<div>').attr('id', 'ug-settings-overlay').addClass(CSS.SETTINGS.OVERLAY);
const $container = $('<div>').addClass(CSS.SETTINGS.CONTAINER);
$overlay.append($container);
const $header = $('<div>').addClass(CSS.SETTINGS.HEADER);
$container.append($header);
const $headerText = $('<h2>').text('Ultra Galleries Settings');
$header.append($headerText);
const $closeBtn = $('<button>').addClass(CSS.SETTINGS.CLOSE_BTN)
.text(BUTTONS.CLOSE)
.on('click', () => state.settingsOpen = false);
$header.append($closeBtn);
const $body = $('<div>').addClass(CSS.SETTINGS.BODY);
$container.append($body);
function createSection($parent, title) {
const $section = $('<div>').addClass(CSS.SETTINGS.SECTION);
$section.append($('<h3>').addClass(CSS.SETTINGS.SECTION_HEADER).text(title));
$parent.append($section);
return $section;
}
function addCheckbox($parent, id, label, checked, onChange) {
const $div = $('<div>').addClass(CSS.SETTINGS.CHECKBOX_LABEL);
const $input = $('<input type="checkbox">').attr('id', id).prop('checked', checked).addClass(CSS.SETTINGS.INPUT)
.on('change', e => onChange($(e.target).prop('checked')));
const $label = $('<label>').attr('for', id).text(label).addClass(CSS.SETTINGS.LABEL);
$div.append($input, $label);
$parent.append($div);
return $div;
}
function addTextInput($parent, id, label, value, maxLength, onChange) {
const $div = $('<div>').addClass(CSS.SETTINGS.LABEL);
$div.html(`
<label class="${CSS.SETTINGS.LABEL}" for="${id}">${label}</label>
<input type="text" id="${id}" value="${value}" maxlength="${maxLength}"
style="width: 2em;" class="${CSS.SETTINGS.INPUT}">
`);
$div.find('input').on('change', e => onChange($(e.target).val()));
$parent.append($div);
return $div;
}
function addTextAreaInput($parent, id, label, value, onChange) {
const $div = $('<div>').addClass(CSS.SETTINGS.LABEL);
$div.html(`
<label class="${CSS.SETTINGS.LABEL}" for="${id}">${label}</label>
<input type="text" id="${id}" value="${value}" style="width: 100%;" class="${CSS.SETTINGS.INPUT}">
`);
$div.find('input').on('change', e => onChange($(e.target).val()));
$parent.append($div);
return $div;
}
function addNumberInput($parent, id, label, value, min, max, step, onChange) {
const $div = $('<div>').addClass(CSS.SETTINGS.LABEL);
$div.html(`
<label for="${id}">${label}</label>
<input type="number" id="${id}" value="${value}" min="${min}" max="${max}"
step="${step}" class="${CSS.SETTINGS.INPUT}">
`);
$div.find('input').on('change', e => onChange(parseFloat($(e.target).val())));
$parent.append($div);
return $div;
}
const sections = {
general: createSection($body, 'General Settings'),
keys: createSection($body, 'Keyboard Shortcuts'),
notifications: createSection($body, 'Notifications'),
formatting: createSection($body, 'File Formatting'),
optimizations: createSection($body, 'Download Optimizations'),
buttonVisibility: createSection($body, 'Button Visibility'),
panZoom: createSection($body, 'Pan & Zoom Settings')
};
addCheckbox(sections.general, 'dynamicResizingToggle', 'Dynamic Resizing',
state.dynamicResizing, val => {
state.dynamicResizing = val;
GM_setValue('dynamicResizing', val);
});
addCheckbox(sections.general, 'animationsToggle', 'Enable Animations',
state.animationsEnabled, val => {
state.animationsEnabled = val;
GM_setValue('animationsEnabled', val);
});
addCheckbox(sections.general, 'bottomStripeToggle', 'Show Thumbnail Strip',
state.bottomStripeVisible, val => {
state.bottomStripeVisible = val;
GM_setValue('bottomStripeVisible', val);
});
addCheckbox(sections.panZoom, 'zoomEnabledToggle', 'Enable Zoom & Pan',
state.zoomEnabled, val => {
state.zoomEnabled = val;
GM_setValue('zoomEnabled', val);
});
addCheckbox(sections.panZoom, 'inertiaEnabledToggle', 'Enable Smooth Pan Inertia',
state.inertiaEnabled, val => {
state.inertiaEnabled = val;
GM_setValue('inertiaEnabled', val);
});
addNumberInput(sections.panZoom, 'maxZoomInput', 'Maximum Zoom Level:',
CONFIG.MAX_SCALE, 2, 10, 0.5, val => {
if (val >= 2 && val <= 10) {
CONFIG.MAX_SCALE = val;
GM_setValue('maxZoomScale', val);
}
});
const addButtonVisibility = (id, label, prop) => {
addCheckbox(sections.buttonVisibility, id, label, state[prop], val => {
state[prop] = val;
GM_setValue(prop, val);
if (galleryOverlay) {
Gallery.closeGallery();
Gallery.createGallery();
}
});
};
addButtonVisibility('hideRemoveBtn', 'Hide Remove Button', 'hideRemoveButton');
addButtonVisibility('hideFullBtn', 'Hide Full Size Button', 'hideFullButton');
addButtonVisibility('hideDownloadBtn', 'Hide Download Button', 'hideDownloadButton');
addButtonVisibility('hideHeightBtn', 'Hide Fill Height Button', 'hideHeightButton');
addButtonVisibility('hideWidthBtn', 'Hide Fill Width Button', 'hideWidthButton');
addButtonVisibility('hideNavArrows', 'Hide Navigation Arrows', 'hideNavArrows');
addTextInput(sections.keys, 'galleryKeyInput', 'Gallery Key:',
state.galleryKey, 1, val => {
state.galleryKey = val;
GM_setValue('galleryKey', val);
});
addTextInput(sections.keys, 'prevImageKeyInput', 'Previous Image Key:',
state.prevImageKey, 1, val => {
state.prevImageKey = val;
GM_setValue('prevImageKey', val);
});
addTextInput(sections.keys, 'nextImageKeyInput', 'Next Image Key:',
state.nextImageKey, 1, val => {
state.nextImageKey = val;
GM_setValue('nextImageKey', val);
});
addCheckbox(sections.notifications, 'notificationsEnabledToggle', 'Enable Notifications',
state.notificationsEnabled, val => {
state.notificationsEnabled = val;
GM_setValue('notificationsEnabled', val);
});
addCheckbox(sections.notifications, 'notificationAreaVisibleToggle', 'Show Notification Area',
state.notificationAreaVisible, val => {
state.notificationAreaVisible = val;
GM_setValue('notificationAreaVisible', val);
const area = document.getElementById(CSS.NOTIF_AREA);
if (area) area.style.display = val ? 'flex' : 'none';
});
const $posDiv = $('<div>').addClass(CSS.SETTINGS.LABEL).html(`
<label class="${CSS.SETTINGS.LABEL}">Notification Position:</label>
<select id="notificationPosition" class="${CSS.SETTINGS.INPUT}">
<option value="top" ${state.notificationPosition === 'top' ? 'selected' : ''}>Top</option>
<option value="bottom" ${state.notificationPosition === 'bottom' ? 'selected' : ''}>Bottom</option>
</select>
`);
$posDiv.find('select').on('change', e => {
state.notificationPosition = e.target.value;
});
sections.notifications.append($posDiv);
addCheckbox(sections.optimizations, 'optimizePngToggle', 'Optimize PNGs in ZIP (Smaller files, Slower zipping)',
state.optimizePngInZip, val => {
state.optimizePngInZip = val;
});
addCheckbox(sections.optimizations, 'persistentCachingToggle', 'Enable Persistent Image Caching (Faster revisit load times)',
state.enablePersistentCaching, val => {
state.enablePersistentCaching = val;
});
const $clearCacheButton = $('<button class="ug-button ug-settings-input" style="margin-top: 10px; display: block;">Clear Persistent Cache</button>');
$clearCacheButton.on('click', async () => {
if (!db && state.enablePersistentCaching) {
initDexie();
}
if (!db) {
Swal.fire('Cache Not Ready', 'The persistent cache system is not currently active.', 'info');
return;
}
const result = await Swal.fire({
title: 'Clear Cache?',
text: "This will remove all persistently cached images. Are you sure?",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, clear it!',
cancelButtonText: 'No, cancel'
});
if (result.isConfirmed) {
clearDexieCache();
}
});
sections.optimizations.append($clearCacheButton);
addTextAreaInput(sections.formatting, 'zipFileNameFormatInput', 'Zip File Name Format:',
state.zipFileNameFormat, val => {
state.zipFileNameFormat = val;
GM_setValue('zipFileNameFormat', val);
});
addTextAreaInput(sections.formatting, 'imageFileNameFormatInput', 'Image File Name Format:',
state.imageFileNameFormat, val => {
state.imageFileNameFormat = val;
GM_setValue('imageFileNameFormat', val);
});
$('body').append($overlay);
},
showSettings: () => {
UI.createSettingsUI();
const overlay = document.getElementById('ug-settings-overlay');
if (overlay) {
overlay.classList.remove('closing');
overlay.classList.add('opening');
overlay.style.width = '100%';
overlay.style.height = '100%';
}
},
closeSettings: () => {
const overlay = document.getElementById('ug-settings-overlay');
if (overlay) {
overlay.classList.add('closing');
setTimeout(() => overlay.remove(), 300);
}
}
};
// ====================================================
// Gallery Module
// ====================================================
let galleryOverlay = null;
const Gallery = {
_preloadedImageCache: {},
_preloadingInProgress: {},
_clearPreloadCache: function() {
for (const index in Gallery._preloadedImageCache) {
const cachedItem = Gallery._preloadedImageCache[index];
if (typeof cachedItem === 'string' && cachedItem.startsWith('blob:')) {
try { URL.revokeObjectURL(cachedItem); } catch (e) { /* silent */ }
}
}
Gallery._preloadedImageCache = {};
Gallery._preloadingInProgress = {};
// Also revoke blob URLs from the global loadedBlobUrls map
for (const blobUrl of loadedBlobUrls.values()) {
if (typeof blobUrl === 'string' && blobUrl.startsWith('blob:')) {
try { URL.revokeObjectURL(blobUrl); } catch (e) { /* silent */ }
}
}
loadedBlobUrls.clear(); // Clear the map after revoking
loadedBlobs.clear(); // Clear the blobs map
},
_fetchAndCacheImage: async function(indexToPreload) {
if (indexToPreload < 0 || indexToPreload >= state.originalImageSrcs.length) return;
if (Gallery._preloadedImageCache[indexToPreload] || Gallery._preloadingInProgress[indexToPreload]) return;
const originalImageUrl = state.originalImageSrcs[indexToPreload];
if (!originalImageUrl) return;
Gallery._preloadingInProgress[indexToPreload] = true;
let blobToCache = null;
let loadedFromPersistentCache = false;
try {
if (state.enablePersistentCaching && db) {
const cachedBlob = await getImageFromDexie(originalImageUrl);
if (cachedBlob) {
blobToCache = cachedBlob;
loadedFromPersistentCache = true;
}
}
if (!blobToCache) {
blobToCache = await new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET', url: originalImageUrl, responseType: 'blob',
onload: r => (r.status === 200 || r.status === 206) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}`)),
onerror: reject
});
});
if (blobToCache && state.enablePersistentCaching && db) {
await storeImageInDexie(originalImageUrl, blobToCache);
}
}
if (blobToCache) {
Gallery._preloadedImageCache[indexToPreload] = URL.createObjectURL(blobToCache);
} else {
Gallery._preloadedImageCache[indexToPreload] = 'failed_preload';
}
} catch (error) {
console.error(`Ultra Galleries: Error preloading image ${originalImageUrl}:`, error);
Gallery._preloadedImageCache[indexToPreload] = 'failed_preload';
} finally {
delete Gallery._preloadingInProgress[indexToPreload];
}
},
_preloadAdjacentImages: function(currentIndex) {
Gallery._fetchAndCacheImage(currentIndex + 1);
Gallery._fetchAndCacheImage(currentIndex - 1);
},
_createGalleryOverlayAndContainer: function() {
// Ensure galleryOverlay is initialized as a jQuery object
galleryOverlay = $('<div>').attr('id', 'gallery-overlay').addClass(CSS.GALLERY.OVERLAY);
const $container = $('<div>').addClass(CSS.GALLERY.CONTAINER).appendTo(galleryOverlay);
return $container;
},
_createBaseViews: function($galleryContentContainer) {
const $gridView = $('<div>').addClass(CSS.GALLERY.GRID_VIEW).appendTo($galleryContentContainer);
const $expandedView = $('<div>').addClass(CSS.GALLERY.EXPANDED_VIEW).addClass(CSS.GALLERY.HIDE).appendTo($galleryContentContainer);
return { $gridView, $expandedView };
},
_createGridViewContent: function($gridViewElement) {
const $thumbnailGrid = $('<div>').addClass(CSS.GALLERY.THUMBNAIL_GRID).appendTo($gridViewElement);
$('<button>')
.text(BUTTONS.CLOSE).addClass(CSS.GALLERY.GRID_CLOSE)
.attr('aria-label', 'Close Gallery').on('click', Gallery.closeGallery)
.appendTo($gridViewElement);
return $thumbnailGrid;
},
_createExpandedViewToolbar: function($expandedViewElement) {
const $toolbar = $('<div>').addClass(CSS.GALLERY.TOOLBAR).on('mousedown', e => e.stopPropagation());
$('<button>').addClass(CSS.GALLERY.TOOLBAR_BTN).text(BUTTONS.CLOSE)
.attr('aria-label', 'Close Expanded View').on('click', Gallery.showGridView)
.appendTo($toolbar);
const $zoomControls = $('<div>').addClass('zoom-controls').appendTo($toolbar);
$('<button>').attr({id: 'zoom-out-btn', title: 'Zoom Out'}).addClass(CSS.GALLERY.TOOLBAR_BTN)
.html('<img src="https://www.svgrepo.com/show/263638/zoom-out-search.svg" alt="Zoom Out" style="filter: invert(100%); pointer-events: none;">')
.on('click', () => Zoom.zoom(-CONFIG.ZOOM_STEP)).appendTo($zoomControls);
$('<span>').attr('id', 'zoom-level').addClass('zoom-level').text('100%').appendTo($zoomControls);
$('<button>').attr({id: 'zoom-in-btn', title: 'Zoom In'}).addClass(CSS.GALLERY.TOOLBAR_BTN)
.html('<img src="https://www.svgrepo.com/show/263635/zoom-in.svg" alt="Zoom In" style="filter: invert(100%); pointer-events: none;">')
.on('click', () => Zoom.zoom(CONFIG.ZOOM_STEP)).appendTo($zoomControls);
$('<button>').attr({id: 'reset-btn', title: 'Reset Zoom & Position'}).addClass(CSS.GALLERY.TOOLBAR_BTN)
.text('Reset').on('click', Zoom.resetZoom).appendTo($zoomControls);
$('<button>').text(BUTTONS.FULLSCREEN).addClass(CSS.GALLERY.FULLSCREEN).addClass(CSS.GALLERY.TOOLBAR_BTN)
.attr('aria-label', 'Toggle Fullscreen').on('click', Gallery.toggleFullscreen)
.appendTo($toolbar);
$expandedViewElement.append($toolbar);
},
_createExpandedViewMainImageArea: function($expandedViewElement) {
const $zoomContainer = $('<div>').addClass(CSS.GALLERY.ZOOM_CONTAINER).appendTo($expandedViewElement);
const $mainImageContainer = $('<div>').addClass(CSS.GALLERY.MAIN_IMG_CONTAINER).addClass('image-container').appendTo($zoomContainer);
$('<div>').addClass('pan-indicator')
.css({position:'absolute',top:'15px',left:'15px',zIndex:'10',opacity:'0',transition:'opacity 0.3s ease',pointerEvents:'none'})
.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>`)
.appendTo($mainImageContainer);
const $mainImage = $('<img>').addClass(CSS.GALLERY.MAIN_IMG).addClass('gallery-image').appendTo($mainImageContainer);
return { $mainImageContainer, $mainImage };
},
_createExpandedViewNavigationAndCounter: function($expandedViewElement) {
const $navContainer = $('<div>').addClass(CSS.GALLERY.NAV_CONTAINER).on('mousedown', e => e.stopPropagation());
if (!state.hideNavArrows) {
$navContainer.append(UI.createNavigationButton('prev'), UI.createNavigationButton('next'));
}
$expandedViewElement.append($navContainer);
$('<div>').addClass(CSS.GALLERY.COUNTER).addClass(CSS.GALLERY.HIDE).appendTo($expandedViewElement);
},
_createExpandedViewThumbnailStrip: function($expandedViewElement) {
const $thumbnailStripContainer = $('<div>').addClass(CSS.GALLERY.STRIP_CONTAINER)
.css('display', state.bottomStripeVisible ? 'flex' : 'none')
.on('mousedown', e => e.stopPropagation()).appendTo($expandedViewElement);
const $thumbnailStrip = $('<div>').addClass(CSS.GALLERY.THUMBNAIL_STRIP).appendTo($thumbnailStripContainer);
return $thumbnailStrip;
},
_populateAllThumbnails: function($gridThumbnailsContainer, $stripThumbnailsContainer) {
// Populate gallery with unique images from state.fullSizeImageSrcs
state.fullSizeImageSrcs.forEach((src, index) => {
if (src) { // Only add if source exists (not null from failed loads)
const $gridThumbImg = $('<img>').attr('src', src).addClass(CSS.GALLERY.THUMBNAIL)
.data('index', index).on('click', () => Gallery.showExpandedView(index))
.attr('aria-label', `Open image ${index + 1}`);
$('<div>').addClass(CSS.GALLERY.THUMBNAIL_CONTAINER).append($gridThumbImg).appendTo($gridThumbnailsContainer);
$('<img>').attr('src', src).addClass(CSS.GALLERY.THUMBNAIL_ITEM)
.data('index', index).on('click', () => Gallery.showExpandedView(index))
.attr('aria-label', `Thumbnail ${index + 1}`).appendTo($stripThumbnailsContainer);
}
});
},
_setupGalleryInteractions: function($expandedViewElement, $mainImageContainerElement) {
$mainImageContainerElement.on('wheel', Zoom.handleWheelZoom);
$expandedViewElement.on('mousedown', e => {
if ($(e.target).closest(`.${CSS.GALLERY.TOOLBAR}, .${CSS.GALLERY.NAV_CONTAINER}, .${CSS.GALLERY.STRIP_CONTAINER}`).length || e.button === 2) {
return;
}
Zoom.startDrag(e);
});
$mainImageContainerElement.on('dblclick', e => {
if (e.button !== 0) return;
if (state.zoomScale > 1) {
Zoom.resetZoom();
} else {
const rect = $mainImageContainerElement[0].getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
state.zoomOrigin = { x: clickX, y: clickY };
const newScale = 2.5;
const imageX = (clickX - state.imageOffset.x) / state.zoomScale;
const imageY = (clickY - state.imageOffset.y) / state.zoomScale;
const newOffsetX = clickX - (imageX * newScale);
const newOffsetY = clickY - (imageY * newScale);
const mainImageDOM = $mainImageContainerElement.find(`.${CSS.GALLERY.MAIN_IMG}`)[0];
if (!mainImageDOM) return;
const boundedOffset = Zoom.enforceBoundaries(newOffsetX, newOffsetY, newScale, rect, mainImageDOM);
$mainImageContainerElement.css('transition', 'transform 0.3s ease-out');
state.imageOffset.x = boundedOffset.x;
state.imageOffset.y = boundedOffset.y;
state.zoomScale = newScale;
Zoom.applyZoom();
setTimeout(() => $mainImageContainerElement.css('transition', ''), 300);
}
});
let controlsTimeout;
$expandedViewElement.on('mousemove', () => {
state.controlsVisible = true;
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(() => {
if (!state.isDragging && !state.pinchZoomActive) state.controlsVisible = false;
}, 3000);
});
state.controlsVisible = true;
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(() => {
if (!state.isDragging && !state.pinchZoomActive) state.controlsVisible = false;
}, 3000);
Zoom.setupTouchEvents();
$(document).on('mousemove.galleryDrag', Zoom.dragImage);
$(document).on('mouseup.galleryDrag', Zoom.endDrag);
},
createGallery: function() {
if (galleryOverlay && galleryOverlay.length) {
// If gallery already exists, just show grid view or toggle
Gallery.showGridView();
state.isGalleryMode = true;
return;
}
const $galleryContentContainer = Gallery._createGalleryOverlayAndContainer();
const { $gridView, $expandedView } = Gallery._createBaseViews($galleryContentContainer);
const $gridThumbnailsContainer = Gallery._createGridViewContent($gridView);
Gallery._createExpandedViewToolbar($expandedView);
const { $mainImageContainer, $mainImage } = Gallery._createExpandedViewMainImageArea($expandedView);
Gallery._createExpandedViewNavigationAndCounter($expandedView);
const $stripThumbnailsContainer = Gallery._createExpandedViewThumbnailStrip($expandedView);
$('body').append(galleryOverlay);
Gallery._populateAllThumbnails($gridThumbnailsContainer, $stripThumbnailsContainer);
Gallery._setupGalleryInteractions($expandedView, $mainImageContainer);
Gallery.showGridView();
state.isGalleryMode = true;
},
showGridView: function() {
if (!galleryOverlay || !galleryOverlay.length) return;
galleryOverlay.find(`.${CSS.GALLERY.GRID_VIEW}`).removeClass(CSS.GALLERY.HIDE);
galleryOverlay.find(`.${CSS.GALLERY.EXPANDED_VIEW}`).addClass(CSS.GALLERY.HIDE);
Zoom.resetZoom();
state.isZoomed = false;
state.controlsVisible = true;
},
showExpandedView: function(index) {
if (!galleryOverlay || !galleryOverlay.length) return;
const $mainImage = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG}`);
const $mainImageContainer = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
const $counter = galleryOverlay.find(`.${CSS.GALLERY.COUNTER}`);
const $prevButton = galleryOverlay.find(`.${CSS.GALLERY.PREV}`);
const $nextButton = galleryOverlay.find(`.${CSS.GALLERY.NEXT}`);
const $thumbnailStrip = galleryOverlay.find(`.${CSS.GALLERY.THUMBNAIL_STRIP}`);
if (!$mainImage.length || !$mainImageContainer.length || !$counter.length) return;
if (index < 0 || index >= state.fullSizeImageSrcs.length) return;
state.currentGalleryIndex = index;
let imageUrlToLoad = null;
let usingPreloadedMemoryCache = false;
const originalHttpUrl = state.originalImageSrcs[index];
if (Gallery._preloadedImageCache[index] && Gallery._preloadedImageCache[index] !== 'failed_preload') {
imageUrlToLoad = Gallery._preloadedImageCache[index];
usingPreloadedMemoryCache = true;
} else if (state.fullSizeImageSrcs[index] && state.fullSizeImageSrcs[index] !== 'failed_preload') {
imageUrlToLoad = state.fullSizeImageSrcs[index];
} else {
imageUrlToLoad = originalHttpUrl;
if (!imageUrlToLoad) {
$mainImage.attr({src: '', alt: "Image not available"});
$counter.text(`${index + 1} / ${state.fullSizeImageSrcs.length}`);
Gallery._preloadAdjacentImages(index);
return;
}
}
$mainImage.addClass('loading').removeClass('error');
Zoom.resetZoom();
$mainImageContainer.css({width:'100%',height:'100%',display:'flex',justifyContent:'center',alignItems:'center',overflow:'hidden'});
$mainImage.css({maxWidth:'100%',maxHeight:'100%',objectFit:'contain',position:'relative'});
$mainImage.off('load error').on('load', () => {
$mainImage.removeClass('loading');
Zoom.initializeImage($mainImage[0], $mainImageContainer[0]);
Gallery._preloadAdjacentImages(index);
}).on('error', () => {
$mainImage.removeClass('loading').addClass('error').attr({src:'', alt:"Error loading image"});
if (usingPreloadedMemoryCache && imageUrlToLoad.startsWith('blob:') && Gallery._preloadedImageCache[index]) {
try { URL.revokeObjectURL(imageUrlToLoad); } catch (e) { /* silent */ }
delete Gallery._preloadedImageCache[index];
}
Gallery._preloadAdjacentImages(index);
});
$mainImage.attr({src: imageUrlToLoad, alt: `Image ${index + 1} of ${state.fullSizeImageSrcs.length}`});
$counter.text(`${index + 1} / ${state.fullSizeImageSrcs.length}`);
galleryOverlay.find(`.${CSS.GALLERY.GRID_VIEW}`).addClass(CSS.GALLERY.HIDE);
galleryOverlay.find(`.${CSS.GALLERY.EXPANDED_VIEW}`).removeClass(CSS.GALLERY.HIDE);
$counter.removeClass(CSS.GALLERY.HIDE);
if ($thumbnailStrip.length) {
$thumbnailStrip.find(`.${CSS.GALLERY.THUMBNAIL_ITEM}.selected`).removeClass('selected');
const $currentThumbInStrip = $thumbnailStrip.find(`.${CSS.GALLERY.THUMBNAIL_ITEM}[data-index="${index}"]`);
if ($currentThumbInStrip.length) {
$currentThumbInStrip.addClass('selected');
const stripWidth = $thumbnailStrip.width();
const thumbOffsetLeft = $currentThumbInStrip[0].offsetLeft;
const thumbWidth = $currentThumbInStrip.outerWidth();
$thumbnailStrip.scrollLeft(thumbOffsetLeft - (stripWidth / 2) + (thumbWidth / 2));
}
}
if (!state.hideNavArrows && $prevButton.length && $nextButton.length) {
$prevButton.toggleClass(CSS.GALLERY.HIDE, index === 0);
$nextButton.toggleClass(CSS.GALLERY.HIDE, index === state.fullSizeImageSrcs.length - 1);
}
state.controlsVisible = true;
},
closeGallery: function() {
if (!galleryOverlay || !galleryOverlay.length) return;
Gallery._clearPreloadCache();
galleryOverlay.remove();
galleryOverlay = null;
$(document.body).removeClass('ug-fullscreen');
state.isGalleryMode = false;
state.isFullscreen = false;
$(document).off('.galleryDrag'); // Remove namespaced events
},
toggleGallery: function() {
if (state.isGalleryMode) {
Gallery.closeGallery();
} else {
if (state.galleryReady && state.fullSizeImageSrcs.length > 0) {
Gallery.createGallery();
} else if (!state.galleryReady) {
state.notification = "Gallery is still loading images."; state.notificationType = "info";
} else {
state.notification = "No images to display in gallery."; state.notificationType = "info";
}
}
},
toggleFullscreen: function() {
state.isFullscreen = !state.isFullscreen;
},
nextImage: function() {
if (state.fullSizeImageSrcs.length === 0) return;
let newIndex = (state.currentGalleryIndex + 1) % state.fullSizeImageSrcs.length;
Gallery.showExpandedView(newIndex);
},
prevImage: function() {
if (state.fullSizeImageSrcs.length === 0) return;
let newIndex = (state.currentGalleryIndex - 1 + state.fullSizeImageSrcs.length) % state.fullSizeImageSrcs.length;
Gallery.showExpandedView(newIndex);
},
createVirtualGallery: function() { // This was mostly vanilla, should be fine
Gallery.cleanupVirtualGallery();
elements.virtualGalleryContainer = document.createElement('div');
elements.virtualGalleryContainer.style.display = 'none';
state.virtualGallery.forEach(mediaSrc => {
if (mediaSrc) {
const mediaElement = document.createElement('img');
mediaElement.src = mediaSrc;
mediaElement.className = CSS.VIRTUAL_IMAGE;
elements.virtualGalleryContainer.appendChild(mediaElement);
}
});
document.body.appendChild(elements.virtualGalleryContainer);
},
cleanupVirtualGallery: function() { // This was vanilla, should be fine
if (elements.virtualGalleryContainer) {
elements.virtualGalleryContainer.remove();
elements.virtualGalleryContainer = null;
}
}
};
// ====================================================
// Image Loading Module
// ====================================================
let loadedBlobUrls = new Map();
let loadedBlobs = new Map();
const ImageLoader = {
imageActions: {
height: img => Utils.setImageStyle(img, { maxHeight: '100vh', maxWidth: '100%', width: 'auto', height: 'auto' }),
width: img => Utils.setImageStyle(img, { maxHeight: '100%', maxWidth: '100vw', width: 'auto', height: 'auto' }),
full: img => Utils.setImageStyle(img, { maxHeight: 'none', maxWidth: 'none', height: 'auto', width: 'auto' }),
},
simulateScrollDown: async () => {
return new Promise(resolve => {
const images = document.querySelectorAll(`${SELECTORS.IMAGE_LINK} img, ${SELECTORS.MAIN_THUMBNAIL} img`);
if (images.length === 0) {
resolve();
return;
}
let loadedCount = 0;
const checkAllLoaded = () => {
loadedCount++;
if (loadedCount >= images.length) resolve();
};
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
observer.unobserve(entry.target);
checkAllLoaded();
}
});
});
images.forEach(img => observer.observe(img));
setTimeout(resolve, 2000); // Fallback timeout in case images are not intersecting
});
},
handleImageError: (mediaSrc, reject) => {
console.error(`Image failed to load: ${mediaSrc}`);
reject(new Error(`Failed to load image: ${mediaSrc}`));
},
handleImageFetchError: (mediaSrc, status, reject, error = null) => {
console.error(`Failed to fetch image (status ${status}): ${mediaSrc}`, error);
reject(new Error(`Failed to fetch image (${status}): ${mediaSrc}`));
},
retryWithBackoff: async (loadFn, retries = CONFIG.MAX_RETRIES, delay = CONFIG.RETRY_DELAY, mediaSrc) => {
try {
return await loadFn();
} catch (err) {
if (retries <= 0) {
console.error(`Failed to load ${mediaSrc} after ${CONFIG.MAX_RETRIES} retries.`);
throw err;
}
console.log(`Retrying load for ${mediaSrc}, ${retries} attempts remaining`);
await Utils.delay(delay);
return ImageLoader.retryWithBackoff(loadFn, retries - 1, delay * 1.5, mediaSrc);
}
},
// New helper function to handle loading/applying a single image to a page element
loadImageAndApplyToPage: async (linkElement, galleryIndex, originalHref, isUniqueForGallery) => {
const imgElement = linkElement.querySelector('img.post__image');
if (!imgElement) {
console.warn(`ImageLoader: No img.post__image found for linkElement:`, linkElement);
// Still increment loadedImages for the counter to be accurate for page elements
state.loadedImages++;
return;
}
let blobUrlToUse = loadedBlobUrls.get(originalHref);
let blobToStore = loadedBlobs.get(originalHref);
let loadedFromCache = false;
if (!blobUrlToUse) { // If not already in our in-memory cache (meaning it's a new unique URL or first encounter)
try {
// 1. Try to get from Dexie cache
if (state.enablePersistentCaching && db) {
const cachedBlob = await getImageFromDexie(originalHref);
if (cachedBlob) {
blobToStore = cachedBlob;
loadedFromCache = true;
}
}
// 2. If not in Dexie, fetch via GM.xmlHttpRequest
if (!blobToStore) {
blobToStore = await ImageLoader.retryWithBackoff(async () => {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url: originalHref,
responseType: 'blob',
onload: function(response) {
if (response.status === 200 || response.status === 206) {
resolve(response.response);
} else {
ImageLoader.handleImageFetchError(originalHref, response.status, reject);
}
},
onerror: (error) => {
ImageLoader.handleImageFetchError(originalHref, 'Network Error', reject, error);
},
});
});
}, CONFIG.MAX_RETRIES, CONFIG.RETRY_DELAY, originalHref);
// 3. If fetched successfully, store in Dexie
if (blobToStore && state.enablePersistentCaching && db) {
await storeImageInDexie(originalHref, blobToStore);
}
}
if (blobToStore) {
blobUrlToUse = URL.createObjectURL(blobToStore);
loadedBlobUrls.set(originalHref, blobUrlToUse); // Store in our in-memory cache
loadedBlobs.set(originalHref, blobToStore);
if (loadedFromCache) console.log(`Ultra Galleries: Image ${originalHref} loaded from persistent cache for page display.`);
} else {
throw new Error("Blob could not be obtained for " + originalHref);
}
} catch (error) {
console.error(`Ultra Galleries: Failed to load media for page display: ${originalHref}`, error);
state.errorCount++;
// If loading fails, keep original thumbnail src, don't prevent click
imgElement.src = imgElement.dataset.src || originalHref; // Fallback to thumbnail or original
linkElement.classList.remove(CSS.NO_CLICK);
state.loadedImages++; // Still count as processed for total progress
return; // Exit this function, don't update gallery arrays below
}
}
// Apply the loaded blob URL to the image element on the page
imgElement.src = blobUrlToUse;
imgElement.dataset.originalSrc = originalHref; // Keep original HTTP src for reference
linkElement.classList.add(CSS.NO_CLICK); // Add no-click to the parent link
// If this is a unique item for the gallery, populate gallery arrays
if (isUniqueForGallery) {
// Ensure the gallery arrays are large enough for the unique index
if (galleryIndex >= state.fullSizeImageSrcs.length) {
state.fullSizeImageSrcs.length = galleryIndex + 1;
state.originalImageSrcs.length = galleryIndex + 1;
state.virtualGallery.length = galleryIndex + 1;
}
state.fullSizeImageSrcs[galleryIndex] = blobUrlToUse;
state.originalImageSrcs[galleryIndex] = originalHref;
state.virtualGallery[galleryIndex] = blobUrlToUse;
}
state.loadedImages++; // Increment for each page element successfully processed
state.mediaLoaded[galleryIndex] = true; // This tracks unique items for gallery, fine as is
},
loadImages: async () => {
if (!Utils.isPostPage() || state.galleryReady || state.isLoading) return;
try {
state.isLoading = true;
state.loadingMessage = 'Loading Media...';
// Clear previous session's in-memory caches and state
loadedBlobUrls.clear();
loadedBlobs.clear();
state.fullSizeImageSrcs = []; // Gallery unique items
state.originalImageSrcs = []; // Gallery unique items
state.virtualGallery = []; // Gallery unique items
state.loadedImages = 0;
state.mediaLoaded = {};
state.errorCount = 0;
const allPageImageLinks = []; // Store all <a> elements that are image links on the page
const uniqueGalleryUrls = new Set(); // Track unique URLs for the gallery itself
// Collect all image links on the page (including duplicates)
document.querySelectorAll(SELECTORS.IMAGE_LINK).forEach(linkElement => {
const fullUrl = Utils.handleMediaSrc(linkElement);
if (fullUrl) {
allPageImageLinks.push({ linkElement: linkElement, originalUrl: fullUrl });
uniqueGalleryUrls.add(fullUrl); // Add to unique set for gallery
}
});
document.querySelectorAll(SELECTORS.ATTACHMENT_LINK).forEach(linkElement => {
const attachmentUrl = Utils.handleMediaSrc(linkElement);
const fileName = linkElement.getAttribute('download') || attachmentUrl || "";
const isLikelyImage = /\.(jpe?g|png|gif|webp)$/i.test(fileName);
if (attachmentUrl && isLikelyImage) {
allPageImageLinks.push({ linkElement: linkElement, originalUrl: attachmentUrl });
uniqueGalleryUrls.add(attachmentUrl); // Add to unique set for gallery
}
});
document.querySelectorAll(SELECTORS.VIDEO_LINK).forEach(videoLink => {
const video = videoLink.querySelector('video');
if (video && video.hasAttribute('poster')) {
const posterSrc = video.getAttribute('poster');
if (posterSrc) {
allPageImageLinks.push({ linkElement: videoLink, originalUrl: posterSrc });
uniqueGalleryUrls.add(posterSrc); // Add to unique set for gallery
}
}
});
// Set totalImages to the count of ALL image elements on the page for the counter.
state.totalImages = allPageImageLinks.length;
state.hasImages = state.totalImages > 0;
// Pre-fill gallery arrays with nulls, to be populated only by unique images
// The size of gallery arrays is based on unique URLs.
state.fullSizeImageSrcs = Array(uniqueGalleryUrls.size).fill(null);
state.originalImageSrcs = Array(uniqueGalleryUrls.size).fill(null);
state.virtualGallery = Array(uniqueGalleryUrls.size).fill(null);
// Ensure thumbnails exist and apply initial styles (if needed)
await ImageLoader.simulateScrollDown();
Utils.ensureThumbnailsExist();
// Create an ordered array of unique URLs to map them to gallery indices
const orderedUniqueGalleryUrls = Array.from(uniqueGalleryUrls);
// Process all image links on the page in batches
const processingPromises = [];
for (let i = 0; i < allPageImageLinks.length; i++) {
const item = allPageImageLinks[i];
// Determine if this specific item's URL is unique for the gallery.
// If it is, get its index in the ordered unique list.
const isUniqueForGallery = uniqueGalleryUrls.has(item.originalUrl);
const galleryIndex = isUniqueForGallery ? orderedUniqueGalleryUrls.indexOf(item.originalUrl) : -1;
processingPromises.push(
ImageLoader.loadImageAndApplyToPage(item.linkElement, galleryIndex, item.originalUrl, isUniqueForGallery)
);
}
// Execute all loading/applying promises concurrently.
// The loadImageAndApplyToPage function already increments state.loadedImages.
await Promise.all(processingPromises);
// After all processing, update notification status
if (state.loadedImages === state.totalImages && state.totalImages > 0 && state.errorCount === 0) {
state.notification = `Images Done Loading! Total: ${state.totalImages}`;
state.notificationType = 'success';
} else if (state.errorCount > 0) {
state.notification = `Gallery: ${state.errorCount} error(s). Loaded: ${state.loadedImages}/${state.totalImages} items.`;
state.notificationType = 'warning';
} else if (state.loadedImages < state.totalImages && state.totalImages > 0) {
state.notification = `Gallery: Partially loaded (${state.loadedImages}/${state.totalImages} items).`;
state.notificationType = 'warning';
} else if (state.totalImages === 0) {
state.notification = 'No images found for gallery.';
state.notificationType = 'info';
}
// Filter out nulls from gallery arrays if some unique items failed to load
state.fullSizeImageSrcs = state.fullSizeImageSrcs.filter(src => src !== null);
state.originalImageSrcs = state.originalImageSrcs.filter(src => src !== null);
state.virtualGallery = state.virtualGallery.filter(src => src !== null);
state.galleryReady = true;
state.isLoading = false;
state.loadingMessage = null;
} catch (error) {
console.error('Critical Error in ImageLoader.loadImages:', error);
state.notification = 'Critical error loading images for gallery. Please refresh.';
state.notificationType = 'error';
state.isLoading = false;
state.loadingMessage = null;
state.galleryReady = false;
}
},
};
// ====================================================
// Download Management
// ====================================================
const DownloadManager = {
downloadAllImages: async () => {
try {
const imageLinks = document.querySelectorAll(SELECTORS.IMAGE_LINK);
const attachmentLinks = document.querySelectorAll(SELECTORS.ATTACHMENT_LINK);
const title = document.querySelector(SELECTORS.POST_TITLE)?.textContent?.trim() || "Untitled";
const artistName = document.querySelector(SELECTORS.POST_USER_NAME)?.textContent?.trim() || "Unknown Artist";
const itemsToProcess = [];
const processedDownloadUrls = new Set();
imageLinks.forEach((imgLink, index) => {
const fullImageUrl = imgLink.href.split("?")[0];
if (fullImageUrl && !processedDownloadUrls.has(fullImageUrl)) {
const originalFileName = imgLink.getAttribute("download") || `image-${index + 1}.jpg`;
itemsToProcess.push({
url: fullImageUrl,
originalName: originalFileName,
itemType: 'image',
originalIndex: index
});
processedDownloadUrls.add(fullImageUrl);
}
});
attachmentLinks.forEach((link, index) => {
const attachmentUrl = link.href;
if (attachmentUrl && !processedDownloadUrls.has(attachmentUrl)) {
const originalFileName = link.textContent.trim().replace("Download ", "") || `attachment-${index + 1}`;
itemsToProcess.push({
url: attachmentUrl,
originalName: originalFileName,
itemType: 'attachment',
originalIndex: index
});
processedDownloadUrls.add(attachmentUrl);
}
});
if (itemsToProcess.length === 0) {
state.notification = "No items to download.";
state.notificationType = "info";
return;
}
if (state.isGalleryMode || !state.isDownloading) {
const result = await Swal.fire({
title: 'Download All?',
text: `You are about to download ${itemsToProcess.length} item(s) as a ZIP. Proceed?`,
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Download',
cancelButtonText: 'Cancel',
});
if (!result.isConfirmed) return;
}
state.isDownloading = true;
state.totalImages = itemsToProcess.length;
state.downloadedCount = 0;
state.loadingMessage = "Preparing download...";
const sanitizedTitle = Utils.sanitizeFileName(title);
const sanitizedArtistName = Utils.sanitizeFileName(artistName);
const zip = new JSZip();
const downloadPromises = [];
for (let i = 0; i < itemsToProcess.length; i++) {
const item = itemsToProcess[i];
downloadPromises.push(DownloadManager.processFileForZip(
zip, item.url, item.originalName, item.itemType, item.originalIndex,
sanitizedTitle, sanitizedArtistName
));
}
await Promise.all(downloadPromises);
if (state.downloadedCount === 0 && itemsToProcess.length > 0) {
state.notification = "Failed to prepare any files for download.";
state.notificationType = 'error';
state.isDownloading = false;
state.loadingMessage = null;
return;
}
state.loadingMessage = "Generating ZIP...";
const zipBlob = await zip.generateAsync(
{ type: 'blob' },
(metadata) => {
state.notification = `Zipping... ${Math.round(metadata.percent)}%`;
}
);
const zipFileNameFormat = GM_getValue('zipFileNameFormat', '{title}-{artistName}.zip');
let zipFileName = zipFileNameFormat
.replace("{artistName}", sanitizedArtistName)
.replace("{title}", sanitizedTitle);
if (!zipFileName.toLowerCase().endsWith('.zip')) {
zipFileName += '.zip';
}
saveAs(zipBlob, zipFileName);
state.isDownloading = false;
state.loadingMessage = null;
} catch (error) {
console.error('Error in downloadAllImages:', error);
Swal.fire('Error!', `Failed to create zip file: ${error.message}`, 'error');
state.isDownloading = false;
state.loadingMessage = null;
state.notification = `Zip creation failed: ${error.message}`;
state.notificationType = 'error';
}
},
processFileForZip: async (zip, url, originalName, itemType, itemIndex,
sanitizedPostTitle, sanitizedPostArtistName) => {
try {
// Ensure UPNG is loaded if optimization is enabled and it hasn't been loaded yet
if (state.optimizePngInZip && !loadedUPNG) {
loadedUPNG = await loadResourceScript('upngJsRaw', 'UPNG');
if (!loadedUPNG) {
console.warn('Ultra Galleries: UPNG.js could not be loaded. PNG optimization will be skipped.');
// Optionally notify user or disable setting state.optimizePngInZip = false;
}
}
await DownloadManager.retryWithBackoff(async () => {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: url,
headers: { referer: `https://${window.location.hostname.split('.')[0]}.su/` },
responseType: 'blob',
onload: async function(response) { // Make this onload async for UPNG processing
if (response.status === 200 || response.status === 206) {
let fileBlob = response.response;
const contentTypeHeader = response.responseHeaders.match(/content-type:\s*([^;]+)/i);
const contentType = contentTypeHeader ? contentTypeHeader[1].trim().toLowerCase() : (fileBlob.type || '').toLowerCase();
let baseName = Utils.sanitizeFileName(originalName.replace(/\.[^/.]+$/, ""));
let ext = Utils.getExtension(originalName);
if (!ext || ['tmp', 'file', ''].includes(ext.toLowerCase())) {
if (contentType && contentType.startsWith('image/')) {
const imageExt = contentType.split('/')[1].replace('jpeg', 'jpg');
if (imageExt && !['octet-stream', 'x-icon'].includes(imageExt.toLowerCase())) {
ext = imageExt;
}
}
}
ext = ext || 'bin';
// PNG Optimization Step
if (state.optimizePngInZip && loadedUPNG && (contentType === 'image/png' || ext.toLowerCase() === 'png')) {
try {
console.log(`Ultra Galleries: Optimizing PNG: ${originalName}`);
const arrayBuffer = await fileBlob.arrayBuffer();
const decodedPng = loadedUPNG.decode(arrayBuffer);
// UPNG.encode(frames, w, h, cnum, dels)
// For single frame PNGs, frames is an array with one element: decodedPng.data
// cnum = 0 aims for lossless compression, choosing smallest representation
const optimizedPngArrayBuffer = loadedUPNG.encode([decodedPng.data], decodedPng.width, decodedPng.height, 0);
fileBlob = new Blob([optimizedPngArrayBuffer], { type: 'image/png' });
console.log(`Ultra Galleries: PNG optimized: ${originalName}. Original: ${arrayBuffer.byteLength}, Optimized: ${optimizedPngArrayBuffer.byteLength}`);
} catch (upngError) {
console.error(`Ultra Galleries: Error optimizing PNG ${originalName}:`, upngError);
// Proceed with the original blob if optimization fails
}
}
const imageFileNameFormat = GM_getValue('imageFileNameFormat', '{title}-{artistName}-{fileName}-{index}');
let pathInZip = imageFileNameFormat
.replace("{title}", sanitizedPostTitle)
.replace("{artistName}", sanitizedPostArtistName)
.replace("{fileName}", baseName)
.replace("{index}", itemIndex + 1)
.replace("{ext}", ext);
if (!pathInZip.toLowerCase().endsWith(`.${ext.toLowerCase()}`)) {
pathInZip = `${pathInZip}.${ext}`;
}
pathInZip = pathInZip.replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
if (pathInZip.startsWith('../') || pathInZip.startsWith('/')) {
pathInZip = pathInZip.replace(/^(\.\.\/)+|^\/+/g, '');
}
zip.file(pathInZip, fileBlob); // Use the (potentially optimized) fileBlob
state.downloadedCount++;
resolve();
} else {
console.error('Ultra Galleries: Error downloading file for zip:', response.status, originalName);
reject(new Error(`Failed to fetch ${originalName}: ${response.status}`));
}
},
onerror: function(error) {
console.error('Ultra Galleries: Network error downloading file for zip:', error, originalName);
reject(error);
}
});
});
}, CONFIG.MAX_RETRIES, CONFIG.RETRY_DELAY, url);
} catch (error) {
console.error(`Ultra Galleries: Failed to process ${originalName} for zip after retries:`, error);
}
},
downloadImageByIndex: async (index) => {
const galleryItemSrc = state.fullSizeImageSrcs[index];
if (!galleryItemSrc) {
console.error("Individual Download: Image source not found for index:", index);
Swal.fire('Error!', `Image source not found.`, 'error');
return;
}
let originalFileName;
const urlParts = galleryItemSrc.split('?')[0].split('/');
originalFileName = urlParts[urlParts.length - 1] || `image_${index + 1}.jpg`;
// To get the true original filename, state.fullSizeImageSrcs would need to store objects
// e.g., { src: 'url', originalName: 'name.jpg' }
try {
await DownloadManager.retryWithBackoff(async () => {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: galleryItemSrc,
headers: { referer: `https://${window.location.hostname.split('.')[0]}.su/` },
responseType: 'blob',
onload: function(response) {
if (response.status === 200 || response.status === 206) {
saveAs(response.response, Utils.sanitizeFileName(originalFileName));
resolve();
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: function(error) { reject(error); }
});
});
}, CONFIG.MAX_RETRIES, CONFIG.RETRY_DELAY, galleryItemSrc);
state.notification = `Downloaded: ${Utils.sanitizeFileName(originalFileName)}`;
state.notificationType = 'success';
} catch (error) {
console.error('Error downloading individual image:', error);
Swal.fire('Error!', `Failed to download image: ${error.message}`, 'error');
}
},
retryWithBackoff: async (downloadFn, retries = CONFIG.MAX_RETRIES, delay = CONFIG.RETRY_DELAY, url) => {
try {
return await downloadFn();
} catch (err) {
if (retries <= 0) {
console.error(`Failed to download/process ${url} after ${CONFIG.MAX_RETRIES} retries.`);
throw err;
}
console.log(`Retrying download/process for ${url}, ${retries} attempts remaining`);
await Utils.delay(delay);
return DownloadManager.retryWithBackoff(downloadFn, retries - 1, delay * 1.5, url);
}
}
};
// ====================================================
// Post Actions Management
// ====================================================
// Global UI elements
let elements = {
loadingOverlay: null,
virtualGalleryContainer: null,
postActions: null,
statusContainer: null,
statusElement: null,
galleryButton: null,
settingsButton: null,
};
let galleryKeyListenerAttached = false;
let previousPageUrl = null;
let uiCache = {};
const PostActions = {
initPostActions: () => {
try {
state.postActionsInitialized = true;
if (!Utils.isPostPage() || state.currentPostUrl === window.location.href) {
if (state.currentPostUrl === window.location.href && elements.postActions) {
// Potentially just re-check if global buttons are there if some other script removed them
} else {
return;
}
} else {
// URL has changed to a new post page, or first time initialization on a post page
PostActions.cleanupPostActions();
}
const currentPageUrl = window.location.href;
document.querySelectorAll(SELECTORS.IMAGE_LINK + ' img').forEach(img => img.classList.add('post__image'));
document.querySelectorAll(SELECTORS.ATTACHMENT_LINK).forEach(link => link.dataset.fileName = link.getAttribute('download'));
elements.postActions = document.querySelector(SELECTORS.POST_ACTIONS);
if (!elements.postActions) {
console.warn("PostActions: elements.postActions not found with selector:", SELECTORS.POST_ACTIONS);
return; // Cannot proceed without the main actions container
}
const hasMediaLinksOnPage = document.querySelectorAll(SELECTORS.IMAGE_LINK).length > 0;
// Setup global action buttons (FILL HEIGHT, FILL WIDTH, FULL, DL ALL, GALLERY)
if (hasMediaLinksOnPage) {
if (!elements.statusContainer) {
const { container, element } = UI.createStatusElement();
elements.statusContainer = container;
elements.statusElement = element;
elements.postActions.appendChild(container);
}
// Check if global buttons are already present to avoid duplicates on minor re-runs
if (!elements.postActions.querySelector('a.ug-button[data-action="gallery"]')) {
const heightButton = UI.createToggleButton(BUTTONS.HEIGHT, () => PostActions.resizeAllImages('height'));
const widthButton = UI.createToggleButton(BUTTONS.WIDTH, () => PostActions.resizeAllImages('width'));
const fullButton = UI.createToggleButton(BUTTONS.FULL, () => PostActions.resizeAllImages('full'));
const downloadAllButton = UI.createToggleButton(BUTTONS.DOWNLOAD_ALL, DownloadManager.downloadAllImages);
const galleryButton = UI.createToggleButton('Loading Gallery...', Gallery.toggleGallery, true);
galleryButton.dataset.action = "gallery";
elements.galleryButton = galleryButton;
elements.postActions.append(heightButton, widthButton, fullButton, downloadAllButton, galleryButton);
}
}
if (!elements.settingsButton) {
const settingsButton = document.createElement('button');
settingsButton.textContent = BUTTONS.SETTINGS;
settingsButton.className = `${CSS.SETTINGS_BTN} ${CSS.BTN}`;
settingsButton.addEventListener('click', () => { state.settingsOpen = !state.settingsOpen; });
document.body.appendChild(settingsButton);
elements.settingsButton = settingsButton;
}
// Setup per-image buttons
const filesArea = document.querySelector('div.post__files');
if (filesArea) {
const imageElementsOnPage = Array.from(filesArea.querySelectorAll(SELECTORS.IMAGE_LINK + ' > img.post__image'));
state.displayedImages = imageElementsOnPage;
imageElementsOnPage.forEach((imgElement, loopIndex) => {
if (!imgElement) return;
ImageLoader.imageActions.height(imgElement);
const thumbnailDiv = imgElement.closest(SELECTORS.THUMBNAIL);
if (!thumbnailDiv) {
console.warn('PostActions: Could not find thumbnailDiv for imgElement:', imgElement);
return;
}
if (thumbnailDiv.previousElementSibling && thumbnailDiv.previousElementSibling.classList.contains(CSS.BTN_CONTAINER)) {
thumbnailDiv.previousElementSibling.remove();
}
const buttonGroupConfig = [
// Make sure config.name matches what UI.createButtonGroup expects for hide checks
{ text: BUTTONS.HEIGHT, action: PostActions.resizeImage, name: 'HEIGHT' },
{ text: BUTTONS.WIDTH, action: PostActions.resizeImage, name: 'WIDTH' },
{ text: BUTTONS.FULL, action: () => ImageLoader.imageActions.full(imgElement), name: 'FULL' },
{ text: BUTTONS.DOWNLOAD, action: () => {
// Find index based on what ImageLoader.loadImage stored
const originalSrcForDownload = imgElement.dataset.originalSrc || Utils.handleMediaSrc(imgElement.closest(SELECTORS.IMAGE_LINK));
const downloadIndex = state.fullSizeImageSrcs.findIndex(src => state.originalImageSrcs[state.fullSizeImageSrcs.indexOf(src)] === originalSrcForDownload); // Find index based on original URL
if (downloadIndex > -1) {
DownloadManager.downloadImageByIndex(downloadIndex);
} else {
console.error("Download (per-image): Could not find image index for src:", originalSrcForDownload, "Available:", state.originalImageSrcs);
}
}, name: 'DOWNLOAD'},
];
const buttonGroupElement = UI.createButtonGroup(buttonGroupConfig);
if (buttonGroupElement.childElementCount > 0 && thumbnailDiv.parentNode) {
thumbnailDiv.parentNode.insertBefore(buttonGroupElement, thumbnailDiv);
}
});
// Add delegated click handler to the parent of all file thumbnails
if (!filesArea.dataset.ugClickHandlerAttached) {
filesArea.addEventListener('click', PostActions.delegatedImageClickHandler);
filesArea.dataset.ugClickHandlerAttached = "true";
}
}
state.currentPostUrl = currentPageUrl;
} catch (error) {
console.error('Error initializing post actions:', error);
state.notification = 'Error initializing UI. Try refreshing the page.';
state.notificationType = 'error';
}
},
cleanupPostActions: () => {
if (elements.postActions) {
elements.postActions.querySelectorAll('a.ug-button').forEach(button => button.remove());
}
if (elements.settingsButton && elements.settingsButton.parentNode) {
elements.settingsButton.remove();
elements.settingsButton = null;
}
const filesArea = document.querySelector('div.post__files');
if (filesArea) {
filesArea.removeEventListener('click', PostActions.delegatedImageClickHandler);
filesArea.removeAttribute('data-ug-click-handler-attached');
filesArea.querySelectorAll(`.${CSS.BTN_CONTAINER}`).forEach(bc => bc.remove());
// Remove CSS.NO_CLICK from any remaining image links
filesArea.querySelectorAll(SELECTORS.IMAGE_LINK).forEach(link => {
link.classList.remove(CSS.NO_CLICK);
});
}
if (elements.statusContainer && elements.statusContainer.parentNode) {
elements.statusContainer.remove();
elements.statusContainer = null;
elements.statusElement = null;
}
elements.postActions = null;
for (const blobUrl of loadedBlobUrls.values()) {
if (typeof blobUrl === 'string' && blobUrl.startsWith('blob:')) {
try { URL.revokeObjectURL(blobUrl); } catch (e) { /* silent */ }
}
}
loadedBlobUrls.clear();
loadedBlobs.clear();
if (Array.isArray(state.fullSizeImageSrcs)) {
state.fullSizeImageSrcs.forEach(src => {
if (typeof src === 'string' && src.startsWith('blob:')) {
try { URL.revokeObjectURL(src); } catch (e) { /* Silent error */ }
}
});
}
state.fullSizeImageSrcs = [];
state.originalImageSrcs = [];
state.virtualGallery = [];
state.currentPostUrl = null;
state.galleryReady = false;
state.loadedImages = 0;
state.totalImages = 0;
state.mediaLoaded = {};
state.errorCount = 0;
state.postActionsInitialized = false;
},
clickAllImageButtons: actionKey => {
const targetButtonText = BUTTONS[actionKey.toUpperCase()];
if (!targetButtonText) {
console.error("clickAllImageButtons: Invalid actionKey", actionKey);
return;
}
const filesArea = document.querySelector('div.post__files');
if (!filesArea) return;
// Find all relevant button containers and then the specific button
filesArea.querySelectorAll(`.${CSS.BTN_CONTAINER}`).forEach(buttonGroup => {
const button = Array.from(buttonGroup.querySelectorAll(`.${CSS.BTN}`))
.find(btn => btn.textContent === targetButtonText);
if (button) {
button.click();
}
});
},
resizeAllImages: action => {
if (!ImageLoader.imageActions[action]) {
console.error('PostActions.resizeAllImages: Invalid action:', action);
return;
}
document.querySelectorAll(`${SELECTORS.IMAGE_LINK} img.post__image`).forEach(img => {
ImageLoader.imageActions[action](img);
});
},
resizeImage: evt => {
const actionText = evt.currentTarget.textContent;
const action = Object.keys(BUTTONS)
.find(key => BUTTONS[key] === actionText)
?.toLowerCase();
if (!action || !ImageLoader.imageActions[action]) {
console.error('PostActions.resizeImage: Invalid action or action not found in ImageLoader.imageActions:', action);
return;
}
const buttonContainer = evt.currentTarget.closest(`.${CSS.BTN_CONTAINER}`);
if (!buttonContainer) {
console.error('PostActions.resizeImage: Could not find button container.');
return;
}
const imageOwningThumbnailDiv = buttonContainer.nextElementSibling;
if (!imageOwningThumbnailDiv || !imageOwningThumbnailDiv.matches(SELECTORS.THUMBNAIL)) {
console.error('PostActions.resizeImage: Could not find image-owning thumbnail div, or it does not match selector:', SELECTORS.THUMBNAIL, imageOwningThumbnailDiv);
return;
}
const displayedImage = imageOwningThumbnailDiv.querySelector('img.post__image');
if (!displayedImage) {
console.error('PostActions.resizeImage: Could not find img.post__image within thumbnail div.');
return;
}
ImageLoader.imageActions[action](displayedImage);
},
delegatedImageClickHandler: event => {
if (event.button === 2) return;
const clickedImageLink = event.target.closest(SELECTORS.IMAGE_LINK);
if (clickedImageLink) {
event.preventDefault(); // ALWAYS prevent default navigation on image link click
if (state.galleryReady) {
// Determine which image was clicked to open the gallery at that index
const originalSrcElement = clickedImageLink.querySelector('img.post__image');
const originalSrcClicked = originalSrcElement ? originalSrcElement.dataset.originalSrc : Utils.handleMediaSrc(clickedImageLink);
const galleryIndex = state.originalImageSrcs.indexOf(originalSrcClicked); // Find index in unique gallery array
if (galleryIndex !== -1) {
Gallery.createGallery(); // Ensure gallery structure exists
Gallery.showExpandedView(galleryIndex); // Open at the clicked image's index
} else {
console.warn("Ultra Galleries: Clicked image not found in gallery index, opening gallery to grid view.");
Gallery.toggleGallery(); // Fallback to just opening the gallery (grid view)
}
} else {
state.notification = "Gallery content is still loading or not available.";
state.notificationType = "info";
}
}
},
};
// ====================================================
// Event Handlers
// ====================================================
const EventHandlers = {
handleGalleryKey: event => {
if (!Utils.isPostPage()) return; // Only on post pages
// Toggle gallery with configured key
if (event.key.toLowerCase() === state.galleryKey.toLowerCase() && !event.altKey && !event.ctrlKey && !event.metaKey) {
if (state.galleryReady) {
event.preventDefault();
Gallery.toggleGallery();
return; // Exclusive action for gallery toggle
} else if (Utils.isPostPage() && !state.isGalleryMode) {
// If on a post page, gallery not ready, and not in gallery mode, notify user
state.notification = "Gallery content is still loading or not available.";
state.notificationType = "info";
return;
}
}
if (state.isGalleryMode && galleryOverlay && galleryOverlay.length) {
const $gridView = galleryOverlay.find(`.${CSS.GALLERY.GRID_VIEW}`);
const $expandedView = galleryOverlay.find(`.${CSS.GALLERY.EXPANDED_VIEW}`);
if (!$gridView.length || !$expandedView.length) {
// This might happen if the gallery structure is unexpectedly missing these elements
console.warn("Ultra Galleries: handleGalleryKey - Grid or Expanded view not found in galleryOverlay.");
return;
}
// --- Escape Key Logic ---
if (event.key === 'Escape') {
event.preventDefault();
if (!$expandedView.hasClass(CSS.GALLERY.HIDE)) {
Gallery.showGridView();
} else if (!$gridView.hasClass(CSS.GALLERY.HIDE)) {
Gallery.closeGallery();
}
return; // Escape key action is exclusive
}
// --- Other Key Logic (Navigation, Zoom) - Only in Expanded View ---
if ($gridView.hasClass(CSS.GALLERY.HIDE) && !$expandedView.hasClass(CSS.GALLERY.HIDE)) {
const keyLower = event.key.toLowerCase();
let actionTaken = false;
// Define relevant keys for expanded view actions
const prevKeys = [state.prevImageKey.toLowerCase(), 'arrowleft', 'k'];
const nextKeys = [state.nextImageKey.toLowerCase(), 'arrowright', 'l'];
const zoomInKeys = ['+', '='];
const zoomOutKeys = ['-'];
const resetZoomKeys = ['0'];
if (prevKeys.includes(keyLower)) {
Gallery.prevImage();
actionTaken = true;
} else if (nextKeys.includes(keyLower)) {
Gallery.nextImage();
actionTaken = true;
} else if (zoomInKeys.includes(keyLower)) {
Zoom.zoom(CONFIG.ZOOM_STEP);
actionTaken = true;
} else if (zoomOutKeys.includes(keyLower)) {
Zoom.zoom(-CONFIG.ZOOM_STEP);
actionTaken = true;
} else if (resetZoomKeys.includes(keyLower)) {
Zoom.resetZoom();
actionTaken = true;
}
if (actionTaken) {
event.preventDefault(); // Prevent default browser action if a script action was taken
}
}
}
},
handleSettingsKey: event => {
if (state.settingsOpen && event.key === 'Escape') {
state.settingsOpen = false;
}
},
handleWindowResize: Utils.debounce(() => {
if (!galleryOverlay || !galleryOverlay.length || !state.isGalleryMode) return;
const $container = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG_CONTAINER}`);
if (!$container.length) return; // Ensure container was found
const newWidth = $container.width();
const newHeight = $container.height();
if (newWidth !== state.lastWidth || newHeight !== state.lastHeight) {
state.lastWidth = newWidth;
state.lastHeight = newHeight;
const $expandedView = galleryOverlay.find(`.${CSS.GALLERY.EXPANDED_VIEW}`);
const $mainImage = galleryOverlay.find(`.${CSS.GALLERY.MAIN_IMG}`); // Find within overlay
// Check if in expanded view and an image is loaded
if ($expandedView.length && !$expandedView.hasClass(CSS.GALLERY.HIDE) &&
$mainImage.length && $mainImage.attr('src')) {
// Zoom.initializeImage expects DOM elements
Zoom.initializeImage($mainImage[0], $container[0]);
} else {
Zoom.resetZoom(); // Fallback reset
}
}
}, CONFIG.DEBOUNCE_DELAY),
toggleControlsVisibility: () => {
state.controlsVisible = !state.controlsVisible;
},
handleGlobalError: event => {
if (state.isGalleryMode || state.isLoading) {
console.error('Script error:', event.error);
state.notification = 'Encountered an error. Try refreshing the page.';
state.notificationType = 'error';
state.isLoading = false;
state.loadingMessage = null;
}
}
};
// ====================================================
// UI Injection
// ====================================================
const updateGalleryButton = enabled => {
if (elements.galleryButton) {
elements.galleryButton.textContent = enabled ? BUTTONS.GALLERY : 'Loading Gallery...';
elements.galleryButton.disabled = !enabled;
elements.galleryButton.classList.toggle('disabled', !enabled);
}
};
const injectUI = () => {
try {
if (!Utils.isPostPage()) {
// Reset state when leaving a post page
state.postActionsInitialized = false;
state.notification = null;
state.loadingMessage = null;
state.isLoading = false;
state.galleryReady = false;
state.hasImages = false;
state.totalImages = 0;
PostActions.cleanupPostActions();
uiCache = {};
previousPageUrl = null;
return;
}
const mediaLinks = [...document.querySelectorAll(SELECTORS.IMAGE_LINK)];
const currentTotalImages = mediaLinks.length;
const currentPageUrl = window.location.href;
const postSection = document.querySelector('.site-section.site-section--post');
if (!state.postActionsInitialized && postSection) {
// Initialize new post page
state.galleryReady = false;
state.loadedImages = 0;
state.hasImages = false;
state.totalImages = currentTotalImages;
const hasMedia = document.querySelectorAll(SELECTORS.IMAGE_LINK).length > 0;
if (hasMedia) {
// Add status container
if (!elements.statusContainer) {
const { container, element } = UI.createStatusElement();
elements.statusContainer = container;
elements.statusElement = element;
const actionsContainer = document.querySelector(SELECTORS.POST_ACTIONS);
if (actionsContainer) {
actionsContainer.appendChild(container);
}
}
state.notification = `Loading media (${state.loadedImages}/${state.totalImages})...`;
}
// Generate missing thumbnails and load images
Utils.ensureThumbnailsExist();
ImageLoader.loadImages();
PostActions.initPostActions();
state.currentPostUrl = currentPageUrl;
previousPageUrl = currentPageUrl;
} else if (currentPageUrl !== state.currentPostUrl) {
// Handle URL change to a different post
PostActions.cleanupPostActions();
state.totalImages = currentTotalImages;
state.galleryReady = false;
state.loadedImages = 0;
state.hasImages = false;
state.notification = null;
state.loadingMessage = null;
state.isLoading = false;
const hasMedia = document.querySelectorAll(SELECTORS.IMAGE_LINK).length > 0;
if (hasMedia) {
if (!elements.statusContainer) {
const { container, element } = UI.createStatusElement();
elements.statusContainer = container;
elements.statusElement = element;
const actionsContainer = document.querySelector(SELECTORS.POST_ACTIONS);
if (actionsContainer) {
actionsContainer.appendChild(container);
}
}
state.notification = `Loading media (${state.loadedImages}/${state.totalImages})...`;
} else if (elements.statusContainer) {
elements.statusContainer.remove();
elements.statusContainer = null;
elements.statusElement = null;
}
Utils.ensureThumbnailsExist();
ImageLoader.loadImages();
PostActions.initPostActions();
state.currentPostUrl = currentPageUrl;
previousPageUrl = currentPageUrl;
}
} catch (error) {
console.error('Error in injectUI:', error);
state.notification = 'Error initializing UI. Try refreshing the page.';
state.notificationType = 'error';
}
};
// ====================================================
// Initialization
// ====================================================
const init = async () => {
try {
// Load CSS
GM.xmlHttpRequest({
method: 'GET',
url: 'https://raw.githubusercontent.com/TearTyr/Ultra-Galleries/TestingBranch/Ultra-Galleries.css',
onload: function(response) {
if (response.status === 200) {
GM_addStyle(response.responseText);
} else {
console.error('Error loading CSS:', response.status);
}
},
onerror: function(error) {
console.error('Error loading CSS:', error);
},
});
if (!loadedPako) {
loadedPako = await loadResourceScript('pakoJsRaw', 'pako');
if (loadedPako) {
console.log("Ultra Galleries: Pako.js loaded and available.");
} else {
console.warn("Ultra Galleries: Pako.js could not be loaded.");
}
}
if (state.enablePersistentCaching) {
initDexie();
}
// Load saved settings
CONFIG.MAX_SCALE = GM_getValue('maxZoomScale', CONFIG.MAX_SCALE);
// Add mobile right-click handling CSS
GM_addStyle(`
.${CSS.LONG_PRESS} { cursor: context-menu !important; }
.${CSS.NOTIF_AREA} {
top: ${state.notificationPosition === 'top' ? '10px' : 'auto'};
bottom: ${state.notificationPosition === 'bottom' ? '10px' : 'auto'};
}
`);
// Attach event listeners
if (!galleryKeyListenerAttached) {
window.addEventListener('keydown', EventHandlers.handleGalleryKey);
window.addEventListener('keydown', EventHandlers.handleSettingsKey);
galleryKeyListenerAttached = true;
}
window.addEventListener('error', EventHandlers.handleGlobalError);
window.addEventListener('resize', EventHandlers.handleWindowResize);
// Create notification area
if (!document.getElementById(CSS.NOTIF_AREA) && state.notificationAreaVisible) {
UI.createNotificationArea();
}
// Setup mutation observer for DOM changes
const observer = new MutationObserver(injectUI);
observer.observe(document.body, { childList: true, subtree: true });
// Initial UI state
if (Utils.isPostPage()) {
Utils.ensureThumbnailsExist();
if (state.loadedImages === state.totalImages && state.totalImages > 0) {
state.notification = `Images Done Loading! Total: ${state.totalImages}`;
state.notificationType = 'success';
} else if (state.notificationType === 'error') {
state.notification = 'Error loading some media.';
}
} else {
state.notification = null;
state.isLoading = false;
state.galleryReady = false;
}
} catch (error) {
console.error('Error in init:', error);
state.notification = 'Error initializing script. Check console for details.';
state.notificationType = 'error';
}
};
// Initialize the script
init();
})();