// ==UserScript==
// @name Yet Another E-hentai Viewer
// @description Fullscreen Image Viewer for E-hentai/Exhentai with a sidebar, preloading, dual page mode, and other stuff.
// @namespace Violentmonkey Scripts
// @match https://exhentai.org/g/*
// @match https://exhentai.org/s/*
// @match https://e-hentai.org/g/*
// @match https://e-hentai.org/s/*
// @icon https://e-hentai.org/favicon.ico
// @version 1.12
// @author shlsdv
// @license MIT
// For configuration managment
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// Optional, can be removed. Removing breaks save image command (Shift+S).
// @grant GM_download
// Optional, can be removed. Removing breaks Ctrl+V image paste if url is cross-origin.
// @grant GM_xmlhttpRequest
// @connect *
// @homepageURL https://greasyfork.org/en/scripts/531375-yet-another-e-hentai-viewer
// ==/UserScript==
(function () {
"use strict";
// ----------------------------------------------------------------------------------------------
// thumbCollection.js
// ----------------------------------------------------------------------------------------------
/**
* Represents an ordered collection of objects (typically representing thumbnails)
* or null values. Objects are expected to potentially have a unique `href`.
*
* This collection provides features of both an Array and a Map:
* - **Ordered Elements:** Maintains the insertion order of elements (including nulls),
* accessible via index (primarily through iteration or methods like `forEach`).
* Standard array manipulation methods like `push`, `unshift`, `splice`, and index-based
* removal are provided. Supports storing `null` or `undefined` values directly
* in the collection.
* - **Fast Key Lookup:** Offers efficient O(1) average time lookup of non-null elements
* using their unique `href` (via `getByHref`). Null/undefined elements or elements
* without an `href` are not included in this lookup map.
* - **Fast First/Last Item Access:** Provides O(1) access to the first and last
* non-null/undefined items in the collection via `first()` and `last()`.
*
* It synchronizes an internal array (`items`) for order and iteration with an internal
* Map (`lookup` for href) for fast key-based access for applicable elements. It also
* maintains indices (`_firstValidIndex`, `_lastValidIndex`) pointing to the first
* and last non-null/undefined items for efficient retrieval.
* All modification methods ensure this synchronization is maintained. Null/undefined
* values are preserved in the `items` array but ignored by the `lookup` map and
* the first/last item tracking.
*
* Note: While the class itself doesn't directly support bracket notation access
* (e.g., `collection[0]`), it's designed to be easily wrapped by a Proxy
* (e.g., via the `createThumbCollection` factory function)
* to enable such syntax transparently while preserving the lookup and first/last item functionality.
*/
class ThumbCollection {
/**
* @param {Array<object|null|undefined>} [initialItems=[]] - Optional initial array of items.
* Object items are expected to potentially have an `href` property (any type,
* used as key). `null` or `undefined` values are preserved in the order.
*/
constructor(initialItems = []) {
this.items = []; // The array storing the items (including nulls) in order
this.lookup = new Map(); // Map<href, itemObject> for fast href lookup (non-null items only)
this._firstValidIndex = -1; // Index of the first non-null/undefined item, or -1 if none
this._lastValidIndex = -1; // Index of the last non-null/undefined item, or -1 if none
// Use internal helper to populate initial state
this._initializeItems(initialItems);
}
/**
* @private Internal helper to populate initial items and state.
* @param {Array<object|null|undefined>} initialItems
*/
_initializeItems(initialItems) {
initialItems.forEach((item) => {
// Always add the item (or null/undefined) to the main array
this.items.push(item);
const index = this.items.length - 1;
// Handle valid items (non-null/undefined)
if (item != null) {
// Update first/last valid indices
if (this._firstValidIndex === -1) {
this._firstValidIndex = index;
}
this._lastValidIndex = index; // Always update last valid index seen so far
// Add to href lookup if href exists
if (item.href != null) {
if (this.lookup.has(item.href)) {
console.warn(`[Constructor] Duplicate href "${item.href}" detected. Lookup will point to the last instance encountered.`);
}
this.lookup.set(item.href, item);
}
}
});
}
// --- Core Accessors ---
/** Get item by its unique href. Returns undefined if href not found or item associated with href is null. */
getByHref(href) {
return this.lookup.get(href);
}
/** Get item by its array index (less common if using Proxy) */
get(index) {
// Handle potential sparse arrays if 'delete' was used unwisely, though splice is preferred.
if (index >= 0 && index < this.items.length) {
return this.items[index];
}
return undefined; // Consistent with array behavior for out-of-bounds
}
/** Get the first non-null/undefined item in the collection. O(1). */
first() {
return this._firstValidIndex !== -1 ? this.items[this._firstValidIndex] : undefined;
}
/** Get the last non-null/undefined item in the collection. O(1). */
last() {
return this._lastValidIndex !== -1 ? this.items[this._lastValidIndex] : undefined;
}
/** Get the internal array (use with caution, direct modification bypasses lookups and index tracking) */
get array() {
return this.items;
}
/** Get the number of items in the collection (including nulls/undefined) */
get length() {
return this.items.length;
}
// --- Modification Methods (Updating array, lookup map, and first/last indices) ---
push(...itemsToAdd) {
const originalLength = this.items.length;
itemsToAdd.forEach((item, i) => {
const currentIndex = originalLength + i;
// Always add the item to the array
this.items.push(item);
// Handle valid items
if (item != null) {
// Update first/last valid indices
if (this._firstValidIndex === -1) {
this._firstValidIndex = currentIndex;
}
this._lastValidIndex = currentIndex; // Pushed item is always the new last valid
// Update lookup map if href exists
if (item.href != null) {
if (this.lookup.has(item.href)) {
console.warn(`[push] Item with href "${item.href}" already exists or was added multiple times. Overwriting in lookup.`);
}
this.lookup.set(item.href, item);
}
}
});
return this.items.length;
}
unshift(...itemsToAdd) {
const numAdded = itemsToAdd.length;
if (numAdded === 0) return this.items.length;
const originalLength = this.items.length;
const wasEmptyOrAllNull = this._firstValidIndex === -1;
// Add all items to the beginning of the array first
// We need to iterate *backwards* through itemsToAdd to add them correctly to the lookup
// and maintain the 'last one wins' logic for duplicate hrefs within the added items.
// However, Array.prototype.unshift does this automatically. Let's rebuild after.
this.items.unshift(...itemsToAdd);
// --- Rebuild Lookups and Boundaries ---
// Unshift potentially changes many indices and can introduce duplicate hrefs
// in complex ways. Rebuilding is the most reliable way to ensure correctness.
this.rebuildLookupsAndBoundaries();
return this.items.length;
}
removeByHref(href) {
const itemToRemove = this.lookup.get(href);
if (itemToRemove) { // Check if the href exists in the lookup
// Find the *specific instance* in the array that the lookup points to
// Use findIndex for potentially better performance than indexOf with objects
const index = this.items.findIndex(item => item === itemToRemove);
if (index > -1) {
// Now delegate to removeByIndex internal logic
this._removeIndexInternal(index, itemToRemove);
return true; // Successfully removed
} else {
// This indicates an inconsistency: item was in lookup but not found in array.
console.warn(`[removeByHref] Item with href "${href}" found in lookup but corresponding instance not found in array. Removing from lookup only.`);
this.lookup.delete(href);
// Note: first/last indices might be inaccurate now, consider rebuildLookupsAndBoundaries() if this happens often.
return false; // Indicate potential inconsistency
}
}
return false; // Not found by href in the lookup map
}
removeByIndex(index) {
if (index < 0 || index >= this.items.length) {
return false; // Index out of bounds
}
const itemToRemove = this.items[index];
this._removeIndexInternal(index, itemToRemove);
return true;
}
/**
* Removes elements from the collection and optionally inserts new elements in their place,
* returning the deleted elements. Updates lookups and boundaries.
* Mimics Array.prototype.splice.
*
* @param {number} start - The zero-based index at which to start changing the array.
* If < 0, it will begin that many elements from the end.
* @param {number} [deleteCount] - An integer indicating the number of elements in the array
* to remove from `start`. If omitted, or if >= number of elements from start to end,
* all elements from `start` to the end of the array will be deleted.
* @param {...any} itemsToAdd - The elements to add to the array, beginning from `start`.
* If you don't specify any elements, splice() will only remove elements from the array.
* @returns {Array<object|null|undefined>} An array containing the deleted elements.
*/
splice(start, deleteCount, ...itemsToAdd) {
// 1. Normalize start index (handle negative values like Array.splice)
let actualStart = start;
if (actualStart < 0) {
actualStart = Math.max(this.items.length + actualStart, 0);
} else {
actualStart = Math.min(actualStart, this.items.length); // Clamp to length
}
// 2. Normalize deleteCount
// If deleteCount is undefined, delete all from start
let actualDeleteCount = (deleteCount === undefined)
? this.items.length - actualStart
: Math.max(0, deleteCount);
// Clamp deleteCount to number of available items from start
actualDeleteCount = Math.min(actualDeleteCount, this.items.length - actualStart);
// 3. Perform the splice on the internal array and get deleted items
const deletedItems = this.items.splice(actualStart, actualDeleteCount, ...itemsToAdd);
// 4. Update lookup map: Remove deleted items
// We need to be careful if duplicate hrefs existed. Rebuilding the whole state
// is the safest approach after a complex operation like splice.
// deletedItems.forEach(item => {
// if (item != null && item.href != null) {
// // Only delete from lookup if THIS specific instance was mapped
// if (this.lookup.get(item.href) === item) {
// this.lookup.delete(item.href);
// // If another item with same href exists, rebuild will find it.
// }
// }
// });
// 5. Update lookup map: Add new items (will be handled by rebuild)
// itemsToAdd.forEach(item => { ... });
// 6. Update boundaries and lookup map
// Rebuilding ensures consistency after complex removals/insertions/shifts.
this.rebuildLookupsAndBoundaries();
// 7. Return deleted items
return deletedItems;
}
/**
* @private Internal helper for removing an item by index and updating state.
* @param {number} index - The index to remove.
* @param {object|null|undefined} itemToRemove - The item being removed (pre-fetched).
*/
_removeIndexInternal(index, itemToRemove) {
// 1. Remove from array
this.items.splice(index, 1);
// 2. Remove from lookup if applicable
if (itemToRemove && itemToRemove.href != null && this.lookup.get(itemToRemove.href) === itemToRemove) {
this.lookup.delete(itemToRemove.href);
// If a duplicate href exists earlier in the array, a rebuild might be needed
// to restore the lookup correctly. Let's rely on rebuild for now.
// Consider adding a targeted lookup fix here later if needed.
}
// 3. Update first/last valid indices
// This requires careful handling of edge cases. Rebuilding is simpler.
// this._updateBoundariesAfterRemoval(index, itemToRemove != null); // Old way
this.rebuildLookupsAndBoundaries(); // Safer after removal, especially with duplicate hrefs
}
// --- Iteration ---
forEach(callbackFn, thisArg) {
this.items.forEach(callbackFn, thisArg);
}
map(callbackFn, thisArg) {
return this.items.map(callbackFn, thisArg);
}
filter(callbackFn, thisArg) {
// Note: filter creates a standard array, not a new ThumbCollection
return this.items.filter(callbackFn, thisArg);
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]();
}
// --- Maintenance ---
/** Rebuilds the href lookup map AND first/last valid indices based on the current state of the items array. */
rebuildLookupsAndBoundaries() {
this.lookup.clear();
this._firstValidIndex = -1;
this._lastValidIndex = -1;
this.items.forEach((item, index) => {
if (item != null) {
// Update boundaries
if (this._firstValidIndex === -1) {
this._firstValidIndex = index;
}
this._lastValidIndex = index;
// Update lookup
if (item.href != null) {
// Allow overwrites, last one wins during rebuild
this.lookup.set(item.href, item);
}
}
});
}
/** Alias for potential backward compatibility (if needed) */
rebuildLookups() {
this.rebuildLookupsAndBoundaries();
}
// --- PRIVATE HELPER METHODS ---
/**
* @private Internal helper to find and update the _firstValidIndex.
* @param {number} [startIndex=0] - Index to start searching from.
*/
_findAndUpdateFirstValidIndex(startIndex = 0) {
for (let i = startIndex; i < this.items.length; i++) {
if (this.items[i] != null) {
this._firstValidIndex = i;
return;
}
}
// If no valid item found from startIndex onwards
this._firstValidIndex = -1;
// If first becomes -1, last must also be -1 if the search covered the whole array
if (startIndex === 0) {
this._lastValidIndex = -1;
}
}
/**
* @private Internal helper to find and update the _lastValidIndex.
* @param {number} [startIndex=this.items.length - 1] - Index to start searching from (backwards).
*/
_findAndUpdateLastValidIndex(startIndex = this.items.length - 1) {
for (let i = startIndex; i >= 0; i--) {
if (this.items[i] != null) {
this._lastValidIndex = i;
return;
}
}
// If no valid item found from startIndex backwards
this._lastValidIndex = -1;
// If last becomes -1, first must also be -1 if the search covered the whole array
if (startIndex === this.items.length - 1) {
this._firstValidIndex = -1;
}
}
/**
* @private Updates lookup map and potentially first/last indices when an item is set via index (Proxy).
* Called *after* the item has been set in the `items` array.
* @param {number} index - The index being modified.
* @param {object | null | undefined} newItem - The new item now at the index.
* @param {object | null | undefined} oldItem - The item previously at the index.
*/
_updateLookupsAndBoundaries(index, newItem, oldItem) {
const oldItemWasValid = oldItem != null;
const newItemIsValid = newItem != null;
let needsBoundaryRebuild = false;
// 1. Update href lookup map
// Remove old item from lookup if it was the one mapped
if (oldItemWasValid && oldItem.href != null && this.lookup.get(oldItem.href) === oldItem) {
this.lookup.delete(oldItem.href);
// Check if another item with the same href exists now needs to be in the lookup
const existingItemWithSameHrefIndex = this.items.findLastIndex(item => item != null && item.href === oldItem.href);
if (existingItemWithSameHrefIndex !== -1 && existingItemWithSameHrefIndex !== index) {
this.lookup.set(oldItem.href, this.items[existingItemWithSameHrefIndex]);
}
}
// Add new item to lookup if it's valid and has an href
if (newItemIsValid && newItem.href != null) {
const existingHrefItem = this.lookup.get(newItem.href);
if (existingHrefItem && existingHrefItem !== newItem) {
console.warn(`[_updateLookupsAndBoundaries] Overwriting href lookup for "${newItem.href}" which pointed to a different instance.`);
}
this.lookup.set(newItem.href, newItem);
}
// 2. Update first/last valid indices - check if boundaries might have changed
if (oldItemWasValid !== newItemIsValid) {
// Item changed validity status
needsBoundaryRebuild = true;
} else if (newItemIsValid) {
// Item remained valid, check if it's at a boundary index
if (index <= this._firstValidIndex || index >= this._lastValidIndex) {
needsBoundaryRebuild = true;
}
} else {
// Item remained null/undefined, no boundary change possible from this specific index
}
// If the change *might* have affected boundaries, rebuild them.
// It's simpler and safer than complex incremental updates for set().
if (needsBoundaryRebuild || this.items.length <= 1) {
this._findAndUpdateFirstValidIndex();
// Only update last if first was found (avoids extra loop if empty)
if (this._firstValidIndex !== -1) {
this._findAndUpdateLastValidIndex();
} else {
this._lastValidIndex = -1; // Ensure last is also -1 if first is
}
}
// If no rebuild needed (e.g. valid->valid in middle), indices remain correct.
}
// _updateBoundariesAfterRemoval is removed as _removeIndexInternal now uses rebuildLookupsAndBoundaries()
}
// --- Proxy Factory Function (No changes needed for splice, relies on method forwarding) ---
/**
* Creates a ThumbCollection instance wrapped in a Proxy for array-like bracket access.
* Handles null/undefined values and updates the href lookup map and first/last item
* tracking correctly.
* @param {Array<object|null|undefined>} [initialItems=[]] - Optional initial array of items.
* @returns {ThumbCollection & Proxy} - The proxied collection instance.
*/
function createThumbCollection(initialItems = []) {
const collectionInstance = new ThumbCollection(initialItems);
const handler = {
get(target, prop, receiver) {
// Handle symbols (like Symbol.iterator) correctly
if (typeof prop === 'symbol') {
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'function') {
return value.bind(target);
}
return value;
}
// Check if prop is a valid array index (numeric string)
const index = parseInt(prop, 10);
// Check if it parses cleanly and the string representation matches
if (String(prop) === String(index) && index >= 0) {
// Access the item directly from the internal array
// Use Reflect.get for consistency and potential future features
return Reflect.get(target.items, prop, receiver);
}
// Access other properties or methods (like getByHref, first, last, push, length etc.)
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'function') {
// Bind methods to the collection instance
return value.bind(target);
}
return value; // Return other properties like length
},
set(target, prop, value, receiver) {
// Check if prop is a valid array index (numeric string)
const index = parseInt(prop, 10);
if (String(prop) === String(index) && index >= 0) {
// Allow setting one past the end like arrays (causes length increase)
if (index > target.items.length) {
console.warn(`[Proxy set] Index ${index} is out of bounds (length is ${target.items.length}). Setting will create undefined holes.`);
// Allow Reflect.set to handle potential sparseness if desired
}
// Get the item currently at the index *before* overwriting it
const oldItem = target.items[index]; // Will be undefined if index >= target.items.length
// Set the value in the internal array. Use Reflect.set on the array itself.
const success = Reflect.set(target.items, prop, value, target.items);
if (success) {
// Update the lookup map and first/last indices based on the old and new items
// Pass the actual index used, the new value, and the old value
target._updateLookupsAndBoundaries(index, value, oldItem);
}
return success;
}
// Allow setting other properties directly on the ThumbCollection instance
// Note: This bypasses _updateLookupsAndBoundaries; only intended for internal props if any
// or potentially custom properties added outside the collection's core data.
return Reflect.set(target, prop, value, target);
},
deleteProperty(target, prop) {
// Prevent deleting symbol properties
if (typeof prop === 'symbol') {
console.warn(`Attempted to delete symbol property: ${prop.toString()}`);
return false;
}
// Check if prop is a valid array index (numeric string)
const index = parseInt(prop, 10);
if (String(prop) === String(index) && index >= 0) {
// Check if the index is within the current bounds of the array
if (index < target.items.length) {
// Use the public removeByIndex method which handles everything
return target.removeByIndex(index);
} else {
// Index out of bounds, standard array delete returns true but does nothing
// For consistency with potential strict modes or expectations, return false.
return false;
}
}
// Prevent deletion of non-numeric properties (methods, internal properties) by default
console.warn(`Attempted to delete non-index property: ${prop}. Deletion disallowed.`);
return false;
},
has(target, prop) {
// Handle symbols
if (typeof prop === 'symbol') {
return Reflect.has(target, prop);
}
// Check if prop is a valid array index (numeric string)
const index = parseInt(prop, 10);
if (String(prop) === String(index) && index >= 0) {
// Standard array 'has' check (index must be within bounds and not an empty slot)
// Note: `target.items.hasOwnProperty(prop)` might be more accurate for sparse arrays
// but `index < target.items.length` is typical array behavior for `in`.
return index < target.items.length;
}
// Check for methods/properties defined on the ThumbCollection instance itself
return Reflect.has(target, prop);
},
// --- Other traps (optional but good for robustness) ---
ownKeys(target) {
// Combine array indices with the target object's own keys
const itemKeys = Object.keys(target.items);
const targetKeys = Reflect.ownKeys(target);
// Filter out duplicates ('length' might be in both) and non-enumerable if needed
// Simple approach: combine and create a Set to deduplicate
return [...new Set([...itemKeys, ...targetKeys])];
},
getOwnPropertyDescriptor(target, prop) {
// Check if prop is an index
const index = parseInt(prop, 10);
if (String(prop) === String(index) && index >= 0 && index < target.items.length) {
// Get descriptor from the items array
return Reflect.getOwnPropertyDescriptor(target.items, prop);
}
// Otherwise, get descriptor from the target object itself
return Reflect.getOwnPropertyDescriptor(target, prop);
}
};
return new Proxy(collectionInstance, handler);
}/**
* Manages running asynchronous tasks (functions returning Promises) with a limited concurrency.
*/
class PromisePool {
/**
* Creates an instance of PromisePool.
* @param {number} maxConcurrency - The maximum number of tasks to run concurrently. Must be at least 1.
*/
constructor(maxConcurrency = 5) {
this.maxConcurrency = Math.max(1, Math.floor(maxConcurrency)); // Ensure positive integer >= 1
this.activePromises = new Set(); // Stores the wrapper promises of active tasks
this.queuedTasks = []; // Stores tasks waiting for a slot { taskFactory: Function, resolve: Function, reject: Function }
}
/**
* Waits until a concurrency slot is available.
* Should only be called when the pool is actually full.
* @private
*/
async #waitForSlot() {
// This promise resolves when any active task finishes, freeing a slot.
// We race against all active promises. The first one to settle (resolve/reject)
// will cause Promise.race to settle. The finally block on that task's
// wrapper promise will remove it from activePromises.
try {
await Promise.race(this.activePromises);
} catch (err) {
// Ignore errors here. Promise.race rejects if the first promise to settle rejects.
// The error is handled by the individual task's catch block (or should be).
// We only care that *a* slot became free.
}
// After race settles, a slot *should* be free due to the finally() block
// in the wrapper promise created in _runTask. We might need to loop
// in run(), but let's try without first. A check before adding is safer.
}
/**
* Internal method to actually execute a task and manage its lifecycle within the pool.
* @param {Function} taskFactory - A function that returns the Promise for the task.
* @private
*/
#runTask(taskFactory) {
const taskPromise = taskFactory(); // Execute the function to get the actual promise
// Create a wrapper promise that handles removing itself from the active set
// regardless of whether the original task resolves or rejects.
const managedPromise = taskPromise
.catch(err => {
// Catch errors from the taskFactory's promise here
// to prevent unhandled rejections potentially crashing Promise.race
// or Promise.allSettled if the caller doesn't handle them.
// The caller should still handle errors on the promise returned by run().
// We don't re-throw here to allow other tasks to continue smoothly.
// console.error("PromisePool: Task encountered an error:", err); // Optional internal logging
})
.finally(() => {
this.activePromises.delete(managedPromise); // Remove itself from active set
// Check if there are queued tasks waiting
if (this.queuedTasks.length > 0) {
const nextTaskInfo = this.queuedTasks.shift();
// Run the next queued task directly since we know a slot is free
const nextTaskPromise = this.#runTask(nextTaskInfo.taskFactory);
// Link the original promise resolves/rejects
nextTaskPromise.then(nextTaskInfo.resolve, nextTaskInfo.reject);
}
});
this.activePromises.add(managedPromise); // Add the wrapper promise to the active set
return taskPromise; // Return the *original* task's promise to the caller
}
/**
* Submits a task to the pool. The task will run when a concurrency slot is available.
*
* @param {Function} taskFactory - A function that returns a Promise when called.
* Example: () => fetch(url)
* @returns {Promise<any>} A Promise that resolves or rejects with the result of the taskFactory's promise.
*/
run(taskFactory) {
return new Promise((resolve, reject) => {
if (this.activePromises.size < this.maxConcurrency) {
// Enough slots, run immediately
const taskPromise = this.#runTask(taskFactory);
// Link the resolution/rejection back to the promise we return
taskPromise.then(resolve, reject);
} else {
// Pool is full, queue the task
// console.log(`PromisePool: Pool full (${this.activePromises.size}). Queuing task.`);
this.queuedTasks.push({ taskFactory, resolve, reject });
}
});
// ---- Simpler approach without explicit queue ----
// Need async here to use await
/* async run(taskFactory) {
// Wait if the pool is full
while (this.activePromises.size >= this.maxConcurrency) {
await this.#waitForSlot();
}
// Now a slot is free, run the task
return this.#runTask(taskFactory);
} */
// Chose the queue approach as it feels slightly more robust in managing
// the exact start time rather than potentially multiple checks after Promise.race.
}
/**
* Returns a Promise that resolves when all currently active and queued tasks have settled (completed or failed).
* @returns {Promise<void>}
*/
async waitAll() {
// Wait until both the active set AND the queue are empty.
while (this.activePromises.size > 0 || this.queuedTasks.length > 0) {
if (this.activePromises.size > 0) {
try {
// Wait for *all* currently active promises to settle.
// Using allSettled ensures we wait even if some fail.
await Promise.allSettled(this.activePromises);
} catch (e) { /* Should not happen with allSettled */ }
} else {
// If active is empty but queue is not, it might mean tasks finished
// very quickly. Add a small delay to allow the event loop to potentially
// process the next queued item triggered by a finally() block.
await new Promise(resolve => setTimeout(resolve, 0));
}
// Loop continues check in case new items were queued while waiting
}
}
}
// ----------------------------------------------------------------------------------------------
// config.js
// ----------------------------------------------------------------------------------------------
function sanitizeKeyForHtmlId(key) {
// Replace spaces, parentheses, and dots with underscores
return key.replace(/[\s().]/g, '_');
}
class Config {
constructor(title, configDefinitions = {}, closeOnClickAway = true, requiresReload = false) {
this.title = title;
this.originalDefinitions = configDefinitions;
this.data = {}; // Will hold the nested configuration data
this.flatDefinitions = {}; // Will hold the flattened definitions map (flatKey -> definition)
this._reloadListeners = []; // Array of reload listeners
this.hiddenTabs = new Set(); // Stores names of tabs explicitly marked as hidden
this._listeners = []; // Array of save click listeners
this._buildDataAndFlatDefinitions(this.originalDefinitions, this.data);
// Load needs the data structure and flat definitions
this.load();
// Accessors need the loaded data
this._createAccessors(); // Modified call
this.registerMenuCommand();
this.overlay = null;
this.boundEscapeHandler = this.handleEscape.bind(this);
this.boundClickAwayHandler = this.handleClickAway.bind(this);
this.activeTab = null;
this.closeOnClickAway = closeOnClickAway;
this.requiresReload = requiresReload;
}
onSaveClick(callback) {
if (typeof callback === 'function') {
this._listeners.push(callback);
}
// return unsubscribe function
return () => { this.offSaveClick(callback); };
}
offSaveClick(callback) {
this._listeners = this._listeners.filter(listener => listener !== callback);
}
_notifyListeners() {
this._listeners.forEach((listener, index) => {
try {
listener();
} catch (error) {
console.error(`Config: Error executing save listener at index ${index}:`, error);
}
});
}
onReload(callback) {
if (typeof callback === 'function') {
this._reloadListeners.push(callback);
}
return () => { this.offReload(callback); }; // Return unsubscribe function
}
offReload(callback) {
this._reloadListeners = this._reloadListeners.filter(listener => listener !== callback);
}
_notifyReloadListeners() {
this._reloadListeners.forEach((listener, index) => {
try {
listener();
} catch (error) {
console.error(`Config: Error executing reload listener at index ${index}:`, error);
}
});
}
_buildDataAndFlatDefinitions(sourceDefinitions, currentDataLevel, pathParts = [], currentTab = 'General', hideAll = false) {
for (const key in sourceDefinitions) {
if (!Object.prototype.hasOwnProperty.call(sourceDefinitions, key)) continue;
// Skip special metadata keys
if (key.startsWith('_')) continue;
const value = sourceDefinitions[key];
const newPathParts = [...pathParts, key];
if (typeof value === 'object' && value !== null) {
// Check if it's a leaf setting (has 'default') OR a description text (has 'text: true')
const isLeafSetting = Object.prototype.hasOwnProperty.call(value, 'default');
const isDescriptionText = value.text === true; // Check for the description flag
if (isLeafSetting || isDescriptionText) {
// Leaf definition (setting or description)
const flatKey = newPathParts.join('.');
const definition = { ...value, tab: currentTab, originalKey: key }; // Store original key
if (hideAll) definition.hidden = true;
this.flatDefinitions[flatKey] = definition;
// Assign a default value: use definition.default if present, otherwise null (for description)
currentDataLevel[key] = isLeafSetting ? definition.default : null;
} else {
// It's a nested sub-config object (potentially a Tab Group definition, but
// structure in `this.data` should always reflect the definition nesting).
// The _subgroup flag is ignored for data structure purposes here.
// Flat keys in flatDefinitions will still retain the full path.
// Check for _tabName in the group definition
const newTabName = value._tabName || ((pathParts.length === 0) ? key : currentTab);
if (!Object.prototype.hasOwnProperty.call(currentDataLevel, key)) {
currentDataLevel[key] = {};
}
// Check if this group itself should be hidden
if (value._hidden === true) {
// Mark the determined tab name as hidden
this.hiddenTabs.add(newTabName);
// This will set the "hidden" property of all children to true
hideAll = true;
}
// Pass the determined tab name down in the recursive call
this._buildDataAndFlatDefinitions(value, currentDataLevel[key], newPathParts, newTabName, hideAll);
}
} else {
console.warn(`Config: Invalid definition format for key "${key}" at path "${newPathParts.join('.')}"`, value);
}
}
}
_getValueByFlatKey(flatKey) {
const keys = flatKey.split('.');
let current = this.data;
for (const key of keys) {
if (current === null || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, key)) {
return undefined;
}
current = current[key];
}
return current;
}
_setValueByFlatKey(flatKey, value) {
const keys = flatKey.split('.');
let current = this.data;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (current === null || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, key)) {
console.error(`Config: Cannot set value for key "${flatKey}". Invalid path segment "${key}".`);
return false;
}
current = current[key];
}
if (current === null || typeof current !== 'object') {
console.error(`Config: Cannot set value for key "${flatKey}". Final path segment parent is not an object.`);
return false;
}
current[keys[keys.length - 1]] = value;
return true;
}
_updateDataFromForm(formElement) {
console.log("Config: Reading values from form to update internal data.");
Object.entries(this.flatDefinitions).forEach(([flatKey, definition]) => {
if (definition.hidden === true || definition.text === true) return; // Skip hidden and description text
const sanitizedKey = sanitizeKeyForHtmlId(flatKey);
const input = formElement.querySelector(`#config-${sanitizedKey}`);
if (!input) {
console.warn(`Config: Could not find input element for key ${flatKey}`);
return;
}
let newValue;
if (input.tagName.toLowerCase() === "select") {
newValue = input.value;
} else if (input.type === "checkbox") {
newValue = input.checked;
} else if (input.type === "number") {
newValue = input.valueAsNumber;
if (isNaN(newValue)) newValue = definition.default; // Revert to default if invalid number
} else { // text, etc.
newValue = input.value;
}
this._setValueByFlatKey(flatKey, newValue); // Update internal data
});
}
_createAccessors() {
const accessorRoot = this;
for (const flatKey in this.flatDefinitions) {
if (!Object.prototype.hasOwnProperty.call(this.flatDefinitions, flatKey)) continue;
const pathParts = flatKey.split('.');
const accessorKey = pathParts.pop(); // The leaf key name, e.g., "numColumns" or "replaceDefaultGridView"
let currentTargetForProp = accessorRoot; // Where the final property 'accessorKey' will be defined
let currentTargetForNesting = accessorRoot; // Tracks the object needed for the *next* level down (if not flattening)
let currentDefinitionLevel = this.originalDefinitions;
// Determine the target object based on _subgroup flags in the *original* definition structure
for (const part of pathParts) { // Iterate parent path segments e.g., "Gallery (Embedded)", "embeddedGridViewConfig"
if (!currentDefinitionLevel || typeof currentDefinitionLevel !== 'object') {
console.error(`Config: Invalid definition structure encountered while processing "${part}" for flat key "${flatKey}"`);
currentTargetForProp = null; // Mark as invalid target
break;
}
let nextDefinitionLevel = currentDefinitionLevel[part];
const isFlattened = nextDefinitionLevel && typeof nextDefinitionLevel === 'object' && nextDefinitionLevel._subgroup === false;
// This logic determines where the *final* property (accessorKey) should live
if (!isFlattened) {
// If the *current* level definition (nextDefinitionLevel) is NOT flattened,
// the final property will live *inside* an object corresponding to 'part'.
// Ensure this nesting object exists on the *previous* target (currentTargetForNesting).
let nestingObject;
if (!Object.prototype.hasOwnProperty.call(currentTargetForNesting, part)) {
nestingObject = {};
Object.defineProperty(currentTargetForNesting, part, {
value: nestingObject,
enumerable: true,
configurable: false, // Prevent deletion of structure
writable: false
});
} else {
nestingObject = currentTargetForNesting[part];
// Check if it's a valid object for nesting
if (typeof nestingObject !== 'object' || nestingObject === null) {
console.error(`Config: Structure conflict. Expected object at "${pathParts.slice(0, pathParts.indexOf(part) + 1).join('.')}" but found non-object when defining "${flatKey}".`);
currentTargetForProp = null; // Mark as invalid target
break; // Stop processing this flatKey
}
}
currentTargetForProp = nestingObject; // The prop will live here
currentTargetForNesting = nestingObject; // Next potential nesting starts from here
} else {
// If the *current* level definition IS flattened (_subgroup: false),
// the final property lives directly on the *previous* target (currentTargetForNesting).
currentTargetForProp = currentTargetForNesting; // Reaffirm target is the parent level
// currentTargetForNesting technically stays the same for the next iteration of this loop,
// as the flattened group doesn't introduce a new level in the *accessor* hierarchy.
}
// Move down the definition structure for the next iteration's check
currentDefinitionLevel = nextDefinitionLevel;
} // End loop through parent path parts
// Define the final leaf accessor property if the target is valid
if (currentTargetForProp) {
if (!Object.prototype.hasOwnProperty.call(currentTargetForProp, accessorKey)) {
Object.defineProperty(currentTargetForProp, accessorKey, {
get: () => this._getValueByFlatKey(flatKey),
set: (newValue) => {
// Optional: Add type validation/coercion based on definition.default type here?
if (this._setValueByFlatKey(flatKey, newValue)) {
this.save();
}
},
enumerable: true,
configurable: true // Allows user to modify/delete later if needed
});
} else {
// Property already exists. This could be due to multiple flatKeys attempting
// to define the same property path (e.g., an error in definitions or complex flattening).
console.warn(`Config: Accessor key "${accessorKey}" already exists on target for flat key "${flatKey}". Check for definition conflicts.`);
}
} else {
// Error occurred during path traversal, logged above.
// console.error(`Config: Could not define accessor for "${flatKey}" due to structure issues.`);
}
} // End loop through flatKeys
}
load() {
console.log("Config: Loading config values from local storage");
for (const flatKey in this.flatDefinitions) {
const storedValue = GM_getValue(flatKey);
if (typeof storedValue !== 'undefined') {
this._setValueByFlatKey(flatKey, storedValue);
}
}
}
save() {
console.log("Config: Saving config values to local storage");
for (const flatKey in this.flatDefinitions) {
const definition = this.flatDefinitions[flatKey];
const currentValue = this._getValueByFlatKey(flatKey);
if (currentValue !== undefined && currentValue !== definition.default) {
GM_setValue(flatKey, currentValue);
} else {
GM_deleteValue(flatKey);
}
}
}
handleEscape(e) {
if (e.key === "Escape" && this.overlay) {
this.closeUI();
}
}
handleClickAway(e) {
if (this.closeOnClickAway && this.overlay) {
const configContainer = this.overlay.querySelector('div');
if (configContainer && !configContainer.contains(e.target)) {
this.closeUI();
}
}
}
closeUI() {
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
this.overlay = null;
document.removeEventListener("keydown", this.boundEscapeHandler);
document.removeEventListener("click", this.boundClickAwayHandler);
}
}
showingUI() {
return !!this.overlay;
}
_updateElementDisabledState(elementWrapper, inputElement, isDisabled, definition) {
const onConditionFail = definition.onConditionFail;
if (!onConditionFail || onConditionFail == "disable") {
inputElement.disabled = isDisabled;
elementWrapper.style.opacity = isDisabled ? '0.5' : '1';
elementWrapper.style.pointerEvents = isDisabled ? 'none' : 'auto';
} else if (typeof onConditionFail === 'object') {
if (onConditionFail.label) {
const labelElement = elementWrapper.querySelector('label');
if (isDisabled) {
labelElement.textContent = onConditionFail.label;
} else {
labelElement.textContent = definition.label;
}
}
} else {
console.warn("Invalid onConditionFail: ", onConditionFail);
}
}
_createTabButton(tabName, tabContainer, contentContainer) {
const button = document.createElement("button");
button.textContent = tabName;
button.type = "button";
button.style.padding = "0.5em 1em";
button.style.marginRight = "0.5em";
button.style.marginBottom = "-1px";
button.style.border = "1px solid #555";
button.style.borderRadius = "3px 3px 0 0";
button.style.cursor = "pointer";
button.style.backgroundColor = "#444";
button.style.color = "#ccc";
button.addEventListener("click", () => {
Array.from(tabContainer.children).forEach(btn => {
btn.style.backgroundColor = "#444";
btn.style.color = "#ccc";
btn.style.borderBottom = "1px solid #555";
});
Array.from(contentContainer.children).forEach(pane => pane.style.display = "none");
button.style.backgroundColor = "#333";
button.style.color = "#fff";
button.style.borderBottom = "1px solid #333";
const contentPane = contentContainer.querySelector(`[data-tab-content="${tabName}"]`);
if (contentPane) contentPane.style.display = "block";
this.activeTab = tabName;
});
return button;
}
showUI(parent) {
if (this.showingUI()) return;
if (!parent) parent = document.body;
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
overlay.style.zIndex = 1000000;
overlay.style.display = "flex";
overlay.style.justifyContent = "center";
this.overlay = overlay;
const configContainer = document.createElement("div");
configContainer.style.padding = "1em";
configContainer.style.backgroundColor = "#333";
configContainer.style.color = "#fff";
configContainer.style.maxHeight = "80vh";
configContainer.style.alignSelf = "flex-start";
configContainer.style.margin = "15vh auto 0 auto";
configContainer.style.overflowY = "auto";
configContainer.style.border = "1px solid #555";
configContainer.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.3)";
configContainer.style.width = "500px";
configContainer.style.borderRadius = "4px";
configContainer.style.fontFamily = "Arial, sans-serif";
if (this.title) {
const titleElement = document.createElement("h2");
titleElement.textContent = this.title;
titleElement.style.marginTop = 0;
titleElement.style.marginBottom = "1em";
titleElement.style.textAlign = "center";
configContainer.appendChild(titleElement);
}
const tabButtonContainer = document.createElement("div");
tabButtonContainer.style.borderBottom = "1px solid #555";
tabButtonContainer.style.marginBottom = "1em";
const tabContentContainer = document.createElement("div");
const tabs = {};
// Map to store dependencies: controllerKey -> [dependentKey1, dependentKey2, ...]
const dependencyMap = {};
Object.entries(this.flatDefinitions).forEach(([flatKey, definition]) => {
if (definition.hidden === true) return;
const tabName = definition.tab || 'General';
// --- Build Dependency Map ---
if (definition.condition && Array.isArray(definition.condition) && definition.condition.length === 2) {
const [controllerKey, /* requiredValue */] = definition.condition;
if (!dependencyMap[controllerKey]) {
dependencyMap[controllerKey] = [];
}
dependencyMap[controllerKey].push(flatKey); // Store the dependent key
}
// --- End Build Dependency Map ---
if (!tabs[tabName]) {
tabs[tabName] = [];
}
tabs[tabName].push(flatKey);
});
const allTabNames = Object.keys(tabs);
const visibleTabNames = allTabNames.filter(name => !this.hiddenTabs.has(name));
this.activeTab = visibleTabNames.length > 0 ? visibleTabNames[0] : null;
const form = document.createElement("form");
let previousFieldType = null; // Track the type of the previous field
// --- Create Save & Reload button upfront if needed ---
let saveReloadButton = null; // Reference to the Save & Reload button
const anySettingRequiresReload = Object.values(this.flatDefinitions).some(def => def.requiresReload === true);
const needsReloadButton = this.requiresReload || anySettingRequiresReload;
if (needsReloadButton) {
saveReloadButton = document.createElement("button");
saveReloadButton.textContent = "Save & Reload";
saveReloadButton.type = "button"; // Important: prevent form submission
saveReloadButton.style.marginRight = "0.5em";
saveReloadButton.style.padding = "0.5em 1em";
saveReloadButton.style.backgroundColor = "#2196F3";
saveReloadButton.style.border = "none";
saveReloadButton.style.borderRadius = "3px";
saveReloadButton.style.color = "#fff";
saveReloadButton.style.cursor = "pointer";
saveReloadButton.style.display = 'none'; // Initially hidden
}
const createFieldElement = (key) => {
const self = this; // Capture 'this' for use in listeners
const definition = this.flatDefinitions[key];
const value = this._getValueByFlatKey(key);
const fieldWrapper = document.createElement("div");
fieldWrapper.style.marginBottom = "0.75em";
fieldWrapper.style.display = "flex";
fieldWrapper.style.alignItems = "center";
// Add extra spacing only when switching between checkbox/description and other fields
const currentIsCheckbox = typeof value === "boolean";
const isDescription = definition.text === true;
if (previousFieldType !== null && (currentIsCheckbox || isDescription) !== previousFieldType) {
fieldWrapper.style.marginTop = "2em";
}
previousFieldType = (currentIsCheckbox || isDescription);
const labelElement = document.createElement("label");
const sanitizedKey = sanitizeKeyForHtmlId(key); // Use the helper function
labelElement.textContent = definition.label || key.split('.').pop();
labelElement.htmlFor = `config-${sanitizedKey}`; // Use sanitized key
labelElement.style.marginRight = "0.5em";
labelElement.style.fontWeight = "bold";
labelElement.style.textAlign = "left";
let input;
// Only set fixed width for non-checkbox inputs
if (typeof value !== "boolean") {
labelElement.style.width = "200px";
}
// Check if it's a description text
if (definition.text === true) {
const descriptionElement = document.createElement('p');
descriptionElement.textContent = definition.label || definition.originalKey || "Description text missing"; // Use label or original key
descriptionElement.style.margin = "0 0 0.75em 0"; // Adjust margin as needed
descriptionElement.style.fontStyle = "italic";
descriptionElement.style.color = "#bbb"; // Lighter color for description
return descriptionElement; // Return only the description paragraph
}
if (definition.choices && Array.isArray(definition.choices)) {
input = document.createElement("select");
input.id = `config-${sanitizedKey}`; // Use sanitized key
input.style.flexGrow = "1";
input.style.padding = "0.3em";
input.style.border = "1px solid #777";
input.style.borderRadius = "2px";
input.style.backgroundColor = "#555";
input.style.color = "#fff";
definition.choices.forEach((choice) => {
const option = document.createElement("option");
option.value = choice;
option.textContent = choice;
if (choice === value) {
option.selected = true;
}
input.appendChild(option);
});
}
else if (typeof value === "boolean") {
input = document.createElement("input");
input.id = `config-${sanitizedKey}`; // Use sanitized key
input.type = "checkbox";
input.checked = value;
input.style.transform = "scale(1.2)";
} else if (typeof value === "number") {
input = document.createElement("input");
input.id = `config-${sanitizedKey}`; // Use sanitized key
input.type = "number";
input.value = value;
input.style.flexGrow = "1";
input.style.padding = "0.3em";
input.style.border = "1px solid #777";
input.style.borderRadius = "2px";
input.style.backgroundColor = "#555";
input.style.color = "#fff";
} else {
input = document.createElement("input");
input.id = `config-${sanitizedKey}`; // Use sanitized key
input.type = "text";
input.value = value;
input.style.flexGrow = "1";
input.style.padding = "0.3em";
input.style.border = "1px solid #777";
input.style.borderRadius = "2px";
input.style.backgroundColor = "#555";
input.style.color = "#fff";
}
// Append elements in the correct order (input first for checkbox visual)
if (input.type === 'checkbox') {
input.style.marginRight = "0.5em"; // Add margin to separate from label
fieldWrapper.appendChild(input);
fieldWrapper.appendChild(labelElement);
} else {
fieldWrapper.appendChild(labelElement);
fieldWrapper.appendChild(input);
}
// --- Initial Condition Check ---
if (definition.condition && Array.isArray(definition.condition) && definition.condition.length === 2) {
const [controllerKey, requiredValue] = definition.condition;
const controllerDefinition = this.flatDefinitions[controllerKey];
if (controllerDefinition) {
const controllerValue = this._getValueByFlatKey(controllerKey);
const isDisabled = controllerValue !== requiredValue;
this._updateElementDisabledState(fieldWrapper, input, isDisabled, definition);
} else {
console.warn(`Config: Controller key "${controllerKey}" for dependent key "${key}" not found in definitions.`);
}
}
// --- End Initial Condition Check ---
// Add change listener to potentially show the Save & Reload button
// AND add listener if this element is a *controller* for other elements
if (input) { // Check if input exists (not a description text)
// Use 'click' for checkboxes for immediate feedback, 'input' for others
const eventType = (input.type === 'checkbox') ? 'click' : 'input';
// --- Listener for CONTROLLER elements ---
if (dependencyMap[key]) { // Check if this key controls others
input.addEventListener(eventType, (event) => {
let newValue;
if (input.type === 'checkbox') newValue = event.target.checked;
else if (input.type === 'number') newValue = event.target.valueAsNumber;
else newValue = event.target.value; // string, select
// Update dependent elements
dependencyMap[key].forEach(dependentKey => {
const dependentDefinition = self.flatDefinitions[dependentKey];
const requiredValue = dependentDefinition.condition[1];
const depSanitizedKey = sanitizeKeyForHtmlId(dependentKey);
const depInput = form.querySelector(`#config-${depSanitizedKey}`);
const depWrapper = depInput ? depInput.closest('div') : null; // Find the wrapper div
if (depWrapper && depInput) {
const shouldBeDisabled = newValue !== requiredValue;
self._updateElementDisabledState(depWrapper, depInput, shouldBeDisabled, dependentDefinition);
}
});
});
}
// --- End Listener for CONTROLLER elements ---
input.addEventListener(eventType, () => {
const settingDefinition = this.flatDefinitions[key];
const settingRequiresReload = settingDefinition.requiresReload === true ||
(settingDefinition.requiresReload !== false && this.requiresReload === true);
if (settingRequiresReload && saveReloadButton) { // saveReloadButton reference is now correct
saveReloadButton.style.display = ''; // Show the button
}
});
}
return fieldWrapper;
};
visibleTabNames.forEach(tabName => {
const button = this._createTabButton(tabName, tabButtonContainer, tabContentContainer);
tabButtonContainer.appendChild(button);
const contentPane = document.createElement("div");
contentPane.dataset.tabContent = tabName;
contentPane.style.display = "none";
tabs[tabName].forEach(key => {
contentPane.appendChild(createFieldElement(key));
});
tabContentContainer.appendChild(contentPane);
});
const buttonsWrapper = document.createElement("div");
buttonsWrapper.style.marginTop = "1.5em";
buttonsWrapper.style.paddingTop = "1em";
buttonsWrapper.style.textAlign = "right";
const saveButton = document.createElement("button");
saveButton.textContent = "Save Config";
saveButton.type = "submit";
saveButton.style.marginRight = "0.5em";
saveButton.style.padding = "0.5em 1em";
saveButton.style.backgroundColor = "#4CAF50";
saveButton.style.border = "none";
saveButton.style.borderRadius = "3px";
saveButton.style.color = "#fff";
saveButton.style.cursor = "pointer";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.type = "button";
cancelButton.style.padding = "0.5em 1em";
cancelButton.style.backgroundColor = "#f44336";
cancelButton.style.border = "none";
cancelButton.style.borderRadius = "3px";
cancelButton.style.color = "#fff";
cancelButton.style.cursor = "pointer";
cancelButton.addEventListener("click", () => {
this.closeUI();
});
// Append the Save & Reload button if it was created
if (saveReloadButton) {
saveReloadButton.addEventListener("click", () => {
this._updateDataFromForm(form);
this.save();
this._notifyListeners();
this._notifyReloadListeners();
this.closeUI();
window.location.reload();
});
buttonsWrapper.appendChild(saveReloadButton);
}
buttonsWrapper.appendChild(saveButton);
buttonsWrapper.appendChild(cancelButton);
form.appendChild(tabContentContainer);
form.appendChild(buttonsWrapper);
configContainer.appendChild(tabButtonContainer);
configContainer.appendChild(form);
form.addEventListener("submit", (e) => {
e.preventDefault();
this._updateDataFromForm(form);
this.save();
this._notifyListeners();
this.closeUI();
});
overlay.appendChild(configContainer);
const firstTabButton = tabButtonContainer.querySelector('button');
if (firstTabButton) firstTabButton.click();
parent.appendChild(overlay);
document.addEventListener("keydown", this.boundEscapeHandler);
// Add slight delay to prevent instant close when opening
setTimeout(() => {
document.addEventListener("click", this.boundClickAwayHandler);
}, 50);
}
registerMenuCommand(name = "Configuration") {
GM_registerMenuCommand(name, () => this.showUI());
}
}
// ----------------------------------------------------------------------------------------------
// utils.js
// ----------------------------------------------------------------------------------------------
// Determine the current page index from URL parameter.
function getPageIndexFromUrl(url) {
const params = new URL(url).searchParams;
const p = params.get("p");
return p ? parseInt(p, 10) : 0;
}
function getTotalPages() {
const pageLinks = Array.from(document.querySelectorAll('table.ptt a'))
.filter(a => /^\d+$/.test(a.textContent));
if (pageLinks.length === 0) return 1;
const lastPageLink = pageLinks[pageLinks.length - 1];
const pParam = getPageIndexFromUrl(lastPageLink.href);
return pParam ? pParam + 1 : parseInt(lastPageLink.textContent, 10);
}
function getTotalImages() {
const elements = document.querySelectorAll('td.gdt2');
let pageCount = null;
elements.forEach(el => {
const text = el.textContent.trim();
const match = text.match(/(\d+)\s*pages/i);
if (match) {
pageCount = parseInt(match[1], 10);
}
});
if (pageCount !== null) {
return pageCount;
} else {
console.warn('Image count not found.');
}
}
function getThumbOffset(backgroundString, itemWidth) {
const sanitizedBackground = backgroundString.replace(/url\([^)]+\)/, '');
const tempDiv = document.createElement('div');
tempDiv.style.cssText = `
position: absolute; visibility: hidden; width: 0; height: 0;
background: ${sanitizedBackground};
`;
document.body.appendChild(tempDiv);
let backgroundPositionX = window.getComputedStyle(tempDiv).backgroundPositionX;
document.body.removeChild(tempDiv);
backgroundPositionX = parseFloat(backgroundPositionX);
if (itemWidth) {
const scale = itemWidth / THUMB_WIDTH;
return scale * backgroundPositionX;
}
return backgroundPositionX;
}
function imageIndexToPageIndex(imageIndex) {
return Math.floor(imageIndex / PAGINATION);
}
function getGalleryId() {
try {
const pathname = window.location.pathname;
return pathname.split('/')[2];
} catch {
console.warn(`Error extracting gallery id from url ${window.location.pathname}`);
return null;
}
}
// requires thumbs.length global variable to be set
function getSpritesheetWidthForPage(pageIndex) {
const startIndex = pageIndex * PAGINATION;
if (startIndex >= thumbs.length) {
return 0;
}
const numImagesOnPage = Math.min(PAGINATION, thumbs.length - startIndex);
return THUMB_WIDTH * numImagesOnPage;
}
function determineIfSpritesheets(initialThumbs) {
if (!initialThumbs?.length) {
return true;
}
const firstBg = initialThumbs[0].background;
if (firstBg.includes(baseUrl) || firstBg.includes(".jpg")) {
return false;
}
return true;
}
async function fetchDocument(url, signal = null) {
try {
const fetchOptions = {};
if (signal instanceof AbortSignal) { // Only add signal if it's a valid AbortSignal
fetchOptions.signal = signal;
}
const response = await fetch(url, fetchOptions);
// Check if the request was aborted *after* the fetch promise resolves
// Although fetch throws AbortError, this is an extra check in some edge cases.
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const htmlText = await response.text();
const parser = new DOMParser();
return parser.parseFromString(htmlText, "text/html");
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Fetch aborted for ${url}`);
} else {
console.error("Error fetching document:", error);
}
throw error; // Re-throw the error after logging
}
}
function getFirstByXpath(xpath, doc = document) {
return doc.evaluate(
xpath,
doc,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue;
}
const FULL_URL_XPATH = '//img[@id="img"]';
const ORIGINAL_URL_XPATH = '//a[starts-with(normalize-space(text()), "Download original")]';
async function extractImageUrls(url, signal = null) {
const doc = await fetchDocument(url, signal); // Pass signal down
if (!doc) return [null, null]; // fetchDocument might throw or return null/undefined on error
const imageElem = getFirstByXpath(FULL_URL_XPATH, doc);
const imageUrl = imageElem ? imageElem.src : null;
const linkElem = getFirstByXpath(ORIGINAL_URL_XPATH, doc);
// Not all images have a button do download the original image
// If no button is present, then imageUrl already points to the original.
const downloadHref = linkElem ? linkElem.getAttribute("href") : imageUrl;
return [imageUrl, downloadHref];
}
// Return objects that include the link element and the page index.
function extractThumbnailLinks(doc, pageIndex) {
const gdtDiv = doc.getElementById("gdt");
if (!gdtDiv) return [];
const array = Array.from(gdtDiv.querySelectorAll(`a[href^="${baseUrl}/s/"]`)).map((a, index) => {
const href = a.href;
const divElement = a.querySelector('div');
let thumbUrl = null;
let background = null;
let width = null;
let height = null;
if (divElement) {
background = divElement.style.background;
thumbUrl = background.match(/url\(["']?(.*?)["']?\)/)?.[1] || null;
width = divElement.offsetWidth;
height = divElement.offsetHeight;
}
return {
link: a,
page: pageIndex,
imageIndex: PAGINATION * pageIndex + index,
fullImageUrl: null,
originalImageUrl: null,
href,
background,
width,
height
};
});
return array;
}
function extractHashFromEhentaiUrl(imageUrl) {
// Parse the URL; throws if invalid
const url = new URL(imageUrl);
// Split the pathname into segments
// e.g. "/h/abc123-foo/bar" → ["", "h", "abc123-foo", "bar"]
const segments = url.pathname.split('/');
// Find the "h" segment and the next segment
const idx = segments.indexOf('h');
if (idx === -1 || segments.length <= idx + 1) {
return null;
}
const hashSegment = segments[idx + 1];
const [hash] = hashSegment.split('-');
return hash;
}
function showGalleriesWithThisImage(imageUrl) {
const hash = extractHashFromEhentaiUrl(imageUrl);
if (!hash) {
console.error("Could not extract a valid hash from url: " + imageUrl);
return;
}
const searchUrl = `${baseUrl}/?f_shash=${hash}`;
window.open(searchUrl, '_blank', 'noopener,noreferrer');
}
function createNotification(text, duration = 2) {
const existingNotification = document.getElementById('Notification');
if (existingNotification) {
existingNotification.remove();
}
const notificationContainer = document.createElement('div');
notificationContainer.id = 'Notification';
notificationContainer.style.zIndex = 999999;
notificationContainer.style.position = 'fixed';
notificationContainer.style.bottom = '30px';
notificationContainer.style.left = '50%';
notificationContainer.style.transform = 'translateX(-50%)';
const notificationButton = document.createElement('input');
notificationButton.type = 'button';
notificationButton.value = text;
notificationButton.style.padding = '10px';
notificationButton.style.fontSize = '16px';
notificationContainer.appendChild(notificationButton);
document.body.appendChild(notificationContainer);
setTimeout(() => {
if (notificationContainer.parentElement) {
notificationContainer.parentElement.removeChild(notificationContainer);
}
}, duration * 1000);
}
function ensureElement(element, name) {
if (!element) {
console.error(`${name} element not found!`);
return false;
}
return true;
}
function saveImage(url) {
console.log("Saving image from url " + url);
let filename = url.split('/').pop();
// for some reason downloading a webp image opens it in a new tab
if (filename.endsWith('.webp')) {
filename += '.png';
}
GM_download({
url: url,
name: filename,
saveAs: true
});
}
let styleElement = null;
const NO_SCROLL_CLASS = 'disable-no-scroll';
function lockPageScroll() {
function createNoScrollStyle() {
if (styleElement) return;
const css = `
.${NO_SCROLL_CLASS}, .${NO_SCROLL_CLASS} body {
overflow: hidden !important;
}
/* Optional: Add padding rule here too */
.${NO_SCROLL_CLASS} body.userscript-compensate-scrollbar {
/* padding-right will be set via JS */
}
`;
styleElement = document.createElement('style');
styleElement.type = 'text/css';
styleElement.appendChild(document.createTextNode(css));
(document.head || document.documentElement).appendChild(styleElement);
}
createNoScrollStyle();
if (document.documentElement.classList.contains(NO_SCROLL_CLASS)) return;
document.documentElement.classList.add(NO_SCROLL_CLASS);
}
function unlockPageScroll() {
if (!document.documentElement.classList.contains(NO_SCROLL_CLASS)) return;
document.documentElement.classList.remove(NO_SCROLL_CLASS);
}
let hideStyleElement = null;
const HIDE_CLASS = 'y_hidden';
function hideElement(element) {
function createHideStyle() {
if (hideStyleElement) return; // only create the style element once
const css = `
.${HIDE_CLASS} {
display: none !important;
}
`;
hideStyleElement = document.createElement('style');
hideStyleElement.type = 'text/css';
hideStyleElement.appendChild(document.createTextNode(css));
(document.head || document.documentElement).appendChild(hideStyleElement);
}
if (!element) {
console.error('hideElement: No element provided.');
return;
}
createHideStyle();
element.classList.add(HIDE_CLASS);
}
function unhideElement(element) {
if (!element) {
console.error('unhideElement: No element provided.');
return;
}
element.classList.remove(HIDE_CLASS);
}
function isElementHidden(element) {
if (!element) {
console.error('unhideElement: No element provided.');
return;
}
return element.classList.contains(HIDE_CLASS);
}
function goToPageAnywhere(index) {
console.log('Going to page index ' + index);
if (imageViewer.isActive()) {
imageViewer.goOrScrollToIndex(index);
} else if (embeddedGridView) {
if (config.embeddedGridGotoOpensViewer) {
imageViewer.loadAndShowIndex(index);
} else {
embeddedGridView.scrollToIndex(index);
}
} else { // main index view without embedded custom grid view
imageViewer.loadAndShowIndex(index);
}
}
function isFullscreen() {
return (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement
);
}
function requestFullscreen(element) {
if (!isFullscreen()) {
element.requestFullscreen?.() ||
element.webkitRequestFullscreen?.() ||
element.msRequestFullscreen?.();
}
}
function exitFullscreen() {
if (isFullscreen()) {
document.exitFullscreen?.() ||
document.webkitExitFullscreen?.() ||
document.msExitFullscreen?.();
}
}
/**
* Inserts one or more items into an array *after* a specified index,
* modifying the original array in place.
* @param {Array<any>} array - The array to modify.
* @param {number} index - The index *after* which items should be inserted.
* - If index is -1, items are inserted at the beginning.
* - If index is >= array.length - 1, items are appended to the end.
* - Behavior for index < -1 is to insert at the beginning (same as index = -1).
* @param {...any} itemsToInsert - The item(s) to insert. Can be zero or more arguments.
* @returns {Array<any>} The modified original array.
* @throws {TypeError} If the first argument is not an array.
* @throws {TypeError} If the index is not an integer.
*/
function insertAfterIndex(array, index, ...itemsToInsert) {
if (itemsToInsert.length === 0) {
return array;
}
const targetInsertionIndex = Math.max(0, Math.min(index + 1, array.length));
array.splice(targetInsertionIndex, 0, ...itemsToInsert);
return array;
}
/**
* Fetches media (image/video) from a potentially cross-origin URL using GM_xmlhttpRequest
* and returns a Promise that resolves with an object containing the temporary blob: URL
* and the detected Content-Type.
* IMPORTANT: The caller is responsible for calling URL.revokeObjectURL() on the
* returned blob URL when it's no longer needed to prevent memory leaks.
*
* Requires @grant GM_xmlhttpRequest and appropriate @connect directives.
*
* @param {string} mediaUrl The URL of the media to fetch.
* @returns {Promise<{ blobUrl: string, type: string | null }>} A Promise that resolves with an object
* containing the blobUrl and the Content-Type string (e.g., 'image/jpeg', 'video/mp4').
* The 'type' property might be null if the Content-Type header is missing or unparsable.
* Rejects with an Error object on failure.
*/
function fetchMediaBlobUrl(mediaUrl) { // Renamed imageUrl to mediaUrl for clarity
return new Promise((resolve, reject) => {
console.log(`Initiating GM fetch for media: ${mediaUrl}`);
GM_xmlhttpRequest({
method: "GET",
url: mediaUrl,
responseType: "blob", // Fetch as a Blob
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
try {
const mediaBlob = response.response;
if (!mediaBlob || mediaBlob.size === 0) {
reject(new Error(`Received empty blob for ${mediaUrl}`));
return;
}
// --- Extract Content-Type ---
let contentType = null;
if (response.responseHeaders) {
// Match 'content-type:' case-insensitive, trim whitespace
const match = response.responseHeaders.match(/^content-type:\s*(.*)$/im);
if (match && match[1]) {
contentType = match[1].trim().split(';')[0]; // Get primary type (e.g., 'image/jpeg') before any params like charset
console.log(`Detected Content-Type for ${mediaUrl}: ${contentType}`);
} else {
console.warn(`Could not find Content-Type header for ${mediaUrl}. Headers:\n${response.responseHeaders}`);
}
} else {
console.warn(`No responseHeaders found for ${mediaUrl}.`);
}
// --- End Extract Content-Type ---
// --- Create Blob URL ---
const blobUrl = URL.createObjectURL(mediaBlob);
console.log(`Blob URL created for ${mediaUrl}: ${blobUrl.substring(0, 100)}...`);
// --- End Create Blob URL ---
// Resolve with the object containing both blobUrl and type
resolve({ blobUrl: blobUrl, type: contentType });
} catch (e) {
reject(new Error(`Error processing response for ${mediaUrl}: ${e.message}`));
}
} else {
console.error(`GM_xmlhttpRequest failed for ${mediaUrl}: Status ${response.status} ${response.statusText}`);
reject(new Error(`Failed to fetch ${mediaUrl}: Server responded with status ${response.status} ${response.statusText}`));
}
},
onerror: function (response) {
console.error(`GM_xmlhttpRequest network error for ${mediaUrl}:`, response.error);
reject(new Error(`Network error fetching ${mediaUrl}: ${response.error || 'Unknown error'}`));
},
ontimeout: function () {
console.error(`GM_xmlhttpRequest timed out for ${mediaUrl}`);
reject(new Error(`Timeout fetching ${mediaUrl}`));
},
onabort: function () {
console.warn(`GM_xmlhttpRequest aborted for ${mediaUrl}`);
reject(new Error(`Request aborted for ${mediaUrl}`));
}
});
});
}
/**
* Fetches the Content-Type header for a potentially cross-origin URL
* using GM_xmlhttpRequest with a HEAD request, without downloading the full file.
*
* Requires @grant GM_xmlhttpRequest and appropriate @connect directives.
*
* @param {string} mediaUrl The URL to check.
* @returns {Promise<string | null>} A Promise that resolves with the primary Content-Type
* string (e.g., 'image/jpeg', 'video/mp4', 'text/html') or null if the
* header is missing, couldn't be parsed, or the request failed.
* Rejects with an Error object on network/request errors.
*/
function getMediaContentType(mediaUrl) {
return new Promise((resolve, reject) => {
console.log(`Initiating GM HEAD request for type: ${mediaUrl}`);
GM_xmlhttpRequest({
method: "HEAD", // Use HEAD request!
url: mediaUrl,
// responseType: "blob", // Not needed for HEAD, we only care about headers
headers: {
// You might include standard headers if needed, but often not necessary for HEAD
// 'Accept': '*/*'
},
onload: function (response) {
// Even errors (like 404) might have headers, but we usually only care about success
if (response.status >= 200 && response.status < 300) {
let contentType = null;
if (response.responseHeaders) {
// Match 'content-type:' case-insensitive, trim whitespace
const match = response.responseHeaders.match(/^content-type:\s*(.*)$/im);
if (match && match[1]) {
// Get primary type (e.g., 'image/jpeg') before any params like charset
contentType = match[1].trim().split(';')[0].trim();
console.log(`Detected Content-Type for ${mediaUrl}: ${contentType} (from full header: ${match[1].trim()})`);
resolve(contentType); // Resolve with the found type
} else {
console.warn(`Could not find Content-Type header for ${mediaUrl}. Status: ${response.status}. Headers:\n${response.responseHeaders}`);
resolve(null); // Successfully got headers, but Content-Type is missing
}
} else {
console.warn(`No responseHeaders found for ${mediaUrl}, Status: ${response.status}.`);
resolve(null); // Successfully completed request, but no headers (unlikely for 2xx)
}
} else {
// Handle non-2xx responses (404, 500, etc.)
// You might still want to check headers even on error in some cases,
// but generally, a non-2xx means the resource isn't available as expected.
console.warn(`GM_xmlhttpRequest (HEAD) non-2xx status for ${mediaUrl}: Status ${response.status} ${response.statusText}`);
// Decide if a non-2xx should reject or resolve with null
// Rejecting might be better to indicate failure accessing the resource.
reject(new Error(`Failed to get headers for ${mediaUrl}: Server responded with status ${response.status} ${response.statusText}`));
}
},
onerror: function (response) {
console.error(`GM_xmlhttpRequest (HEAD) network error for ${mediaUrl}:`, response.error);
reject(new Error(`Network error checking type for ${mediaUrl}: ${response.error || 'Unknown error'}`));
},
ontimeout: function () {
console.error(`GM_xmlhttpRequest (HEAD) timed out for ${mediaUrl}`);
reject(new Error(`Timeout checking type for ${mediaUrl}`));
},
onabort: function () {
console.warn(`GM_xmlhttpRequest (HEAD) aborted for ${mediaUrl}`);
reject(new Error(`Request aborted checking type for ${mediaUrl}`));
}
});
});
}
function createGoToPageInput() {
const input = document.createElement('input');
input.type = 'text';
input.id = 'GoToPageInput';
input.inputMode = 'numeric';
input.pattern = '[0-9]*';
input.placeholder = `Go to page 1-${thumbs.length}...`;
Object.assign(input.style, {
position: 'fixed',
bottom: '80px',
left: '50%',
transform: 'translateX(-50%)',
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #ccc',
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
zIndex: '99999',
fontSize: '16px',
outline: 'none',
display: 'none',
WebkitAppearance: 'none',
MozAppearance: 'textfield'
});
// Remove spinner buttons
input.style.WebkitAppearance = 'none';
input.style.MozAppearance = 'textfield';
const handleClose = () => {
input.style.display = 'none';
input.value = '';
// Only remove the document-level click listener
document.removeEventListener('click', handleClickOutside);
};
const handleInputKeydown = (e) => {
// Allow browser shortcuts with modifiers (Ctrl+C, Ctrl+R, etc.)
if (e.ctrlKey || e.altKey || e.metaKey) {
return;
}
// *** Crucial: Stop the event from bubbling up to the document keydown listener ***
// This prevents keys like Backspace from triggering document-level actions (like showing grid view)
// when the input is focused.
e.stopPropagation();
if (e.key === 'g' || e.key === 'G') {
handleClose();
return;
}
if (e.key === 'Enter') {
e.preventDefault(); // Prevent form submission if it were in a form
const pageNumStr = input.value;
// Allow negative numbers for parsing, use regex for validation
if (/^-?\d+$/.test(pageNumStr)) {
const pageNum = parseInt(pageNumStr, 10);
// Convert page number (1-based, potentially negative) to 0-based index
let targetIndex = (pageNum >= 1) ? pageNum - 1 : thumbs.length + pageNum; // Negative wraps from end (-1 goes to last image)
if (targetIndex >= 0 && targetIndex < thumbs.length) {
goToPageAnywhere(targetIndex);
} else {
// Provide more specific feedback if the index is out of range
alert(`Invalid input: Effective index ${targetIndex} is out of range [0, ${thumbs.length - 1}]`);
}
} else {
alert("Invalid input: Please enter an integer.");
}
handleClose();
} else if (e.key === 'Escape') {
handleClose();
// Allow '-' only as the first character. Allow all digits. Prevent other single characters.
} else if (e.key.length === 1 && !/\d/.test(e.key) && !(e.key === '-' && input.value.length === 0)) {
// Prevent single non-numeric characters from being entered into the input
e.preventDefault();
// Do not return here. Let the event bubble up if necessary,
// although preventDefault might stop other handlers.
// The main goal is browser shortcuts are already allowed by the modifier check above.
}
// Allow numbers, Backspace, Delete, Arrow Keys, etc. by default
};
// Add click outside handler
const handleClickOutside = (e) => {
if (!input.contains(e.target)) {
handleClose();
}
};
// Attach keydown listener directly to the input and keep it
// This listener doesn't need to be removed and re-added.
input.addEventListener('keydown', handleInputKeydown);
// Store the handler reference on the element for easy access later
input.__handleClickOutside = handleClickOutside;
input.__handleClose = handleClose;
document.body.appendChild(input);
return input;
}
let goToPageInput = null;
function showGotoPageInput() {
if (!goToPageInput || goToPageInput.style.display === "none") {
if (!goToPageInput) {
goToPageInput = createGoToPageInput();
}
goToPageInput.style.display = 'block';
goToPageInput.focus();
// Add the click-outside listener when the input is shown
setTimeout(() => {
document.addEventListener('click', goToPageInput.__handleClickOutside);
}, 100);
} else {
goToPageInput.__handleClose();
}
}
// ----------------------------------------------------------------------------------------------
// fetching.js
// ----------------------------------------------------------------------------------------------
async function fetchGalleryPage(pageIndex) {
if (pageIndex < 0 || pageIndex >= totalPages)
return null;
const url = new URL(window.location.href);
url.searchParams.set("p", pageIndex);
console.log(`Fetching page: ${url.href}`);
const doc = await fetchDocument(url.href);
return doc ? extractThumbnailLinks(doc, pageIndex) : [];
}
function populateThumbsOnPage(pageIndex, newThumbs) {
if (!newThumbs || newThumbs.length === 0)
return;
let start = pageIndex * PAGINATION;
for (let i = 0; i < newThumbs.length; i++) {
thumbs[start + i] = newThumbs[i];
}
}
function getPageIndexByImageIndex(imageIndex) {
return Math.floor(imageIndex / PAGINATION);
}
const pageLocks = {};
async function fetchAndPopulateThumbsOnPage(pageIndex) {
if (pageLocks[pageIndex]) {
return pageLocks[pageIndex];
}
const lockPromise = (async () => {
const start = pageIndex * PAGINATION;
const end = Math.min(start + PAGINATION, thumbs.length);
let needFetch = false;
for (let i = start; i < end; i++) {
if (!thumbs[i]) {
needFetch = true;
break;
}
}
if (!needFetch) {
return null;
}
const newThumbs = await fetchGalleryPage(pageIndex);
if (!newThumbs || newThumbs.length === 0) {
return newThumbs;
}
populateThumbsOnPage(pageIndex, newThumbs);
return newThumbs;
})();
pageLocks[pageIndex] = lockPromise;
try {
return await lockPromise;
} finally {
delete pageLocks[pageIndex];
}
}
function preloadRange(start, end, reverse = false, useOriginalImages = false, maxWorkers = 5) {
if (start < 0) start = 0;
if (end >= thumbs.length) end = thumbs.length - 1;
const indices = [];
if (reverse) {
for (let i = end; i >= start; i--) {
indices.push(i);
}
} else {
for (let i = start; i <= end; i++) {
indices.push(i);
}
}
return preloadIndices(indices, useOriginalImages, maxWorkers);
}
async function preloadIndices(indicesToPreload, useOriginalImages = false, maxWorkers = 5) {
console.log(`Preloading indices: [${indicesToPreload.join(', ')}] with maxWorkers=${maxWorkers}`);
const mediaLoadPool = new PromisePool(maxWorkers); // Renamed for clarity
for (const index of indicesToPreload) {
if (index < 0 || index >= thumbs.length) {
console.log(`Preload: Index ${index} is out of bounds [0, ${thumbs.length - 1}]. Skipping.`);
continue;
}
// --- Sequential Part: Load Media Metadata/URL ---
let item;
try {
// Ensure the item data (including URL and isVideo flag) is available
await loadImageUrlAtIndex(index); // Fetches metadata/URL if not already present
item = thumbs[index]; // Get the potentially updated item
} catch (error) {
console.error(`Preload: Error fetching metadata/URL for index ${index}:`, error);
continue; // Skip this index if metadata loading fails
}
// --- End Sequential Part ---
// Check if item is valid and determine media type
if (!item || item === 'deleted') {
console.log(`Preload: Item at index ${index} is invalid or deleted. Skipping.`);
continue;
}
const isVideo = item.isVideo === true;
if (isVideo) {
// For videos, preload metadata
// Hint to the browser to fetch metadata by creating a temporary video element.
const videoUrl = item.fullImageUrl;
if (videoUrl) {
const tempVideo = document.createElement('video');
tempVideo.referrerPolicy = "no-referrer";
tempVideo.preload = 'metadata';
tempVideo.src = videoUrl;
// Don't append to DOM, don't wait. Just setting src triggers the browser based on 'preload'.
console.log(`Preload: Index ${index} is a video. Hinting browser to preload metadata for ${videoUrl}`);
} else {
console.log(`Preload: Index ${index} is a video, but no URL found.`);
}
// No need to add to the pool, the browser handles this asynchronously.
} else {
// --- Handle Image Preloading ---
let imageUrl = null;
// Determine the correct image URL based on configuration
if (item.originalImageUrl && (useOriginalImages || item.originalImageIsCached)) {
imageUrl = item.originalImageUrl;
} else {
imageUrl = item.fullImageUrl;
}
if (imageUrl) {
// --- Concurrent Part: Submit Image Data Load Task to Pool ---
// Define the *task function* that the pool will execute later
const imageLoadTaskFactory = async () => {
// console.log(`Preload Pool: Starting image data load for index ${index} (URL: ${imageUrl})`);
const imgElement = new Image();
imgElement.referrerPolicy = "no-referrer";
imgElement.src = imageUrl; // Start loading
try {
await waitForMediaLoad(imgElement);
// console.log(`Preload Pool: Successfully loaded image data for index ${index}`);
} catch (err) {
// Handle/log errors for *individual* image loads within the task
console.error(`Preload Pool: Error loading image data for index ${index} (URL: ${imageUrl}):`, err);
// Do not re-throw here if you want other preloads to continue even if one fails
}
};
// Submit the task factory to the pool.
mediaLoadPool.run(imageLoadTaskFactory);
} else {
console.log(`Preload: No valid image URL found for index ${index} after metadata load. Skipping image data load.`);
}
}
}
// After iterating through all indices and submitting tasks,
// wait for all tasks managed by the pool (now only image loads) to complete.
// console.log(`Preload: All indices processed. Waiting for ${mediaLoadPool.activePromises.size} active + ${mediaLoadPool.queuedTasks.length} queued image loads to complete...`);
await mediaLoadPool.waitAll();
// console.log("Preload: All requested indices processed and all image loading tasks finished.");
}
const indexLocks = {};
async function loadImageUrlAtIndex(index, signal = null) { // Accept signal
if (indexLocks[index]) {
return indexLocks[index];
}
const lockPromise = _loadImageUrlAtIndexInternal(index, signal); // Pass signal
indexLocks[index] = lockPromise;
try {
return await lockPromise;
} finally {
delete indexLocks[index];
}
}
async function _loadImageUrlAtIndexInternal(index, signal = null) { // Accept signal
console.log(`Loading image URL at index ${index}`);
// 1. Check if the requested index is valid within the total possible range.
if (index < 0 || index >= thumbs.length) {
console.log(`Image index ${index} is out of range [0, ${thumbs.length - 1}]`);
return;
}
// 2. Check if the data for this index needs to be fetched.
if (thumbs[index] === null) {
const pageIndex = Math.floor(index / PAGINATION);
console.log(`Data for index ${index} is null. Fetching page ${pageIndex}.`);
// Ensure the calculated page index is valid (should be due to initial bounds check, but good safety)
if (pageIndex < 0 || pageIndex >= totalPages) {
console.error(`Calculated invalid page index ${pageIndex} for image index ${index}.`);
return; // Should not happen if thumbs.length is correct
}
// Fetch the page and let fetchAndPopulateThumbsOnPage update the global thumbs array.
const newThumbs = await fetchAndPopulateThumbsOnPage(pageIndex);
// Check if fetching was successful and if the specific index was populated.
if (!newThumbs || newThumbs.length === 0) {
console.error(`Failed to fetch or populate page ${pageIndex} containing index ${index}.`);
// Decide how to handle: retry? show error? For now, just return.
return;
}
// Verify that the specific index we need is now populated
if (thumbs[index] === null) {
console.error(`Index ${index} remains null after fetching page ${pageIndex}. Population failed.`);
return;
}
}
// 3. Check if the item at the index is marked as deleted.
// (Using 'deleted' string as per original code example)
if (thumbs[index] === 'deleted') {
console.log(`Image at index ${index} is marked as deleted.`);
return;
}
// 4. We should now have valid thumb data at thumbs[index].
const currentThumb = thumbs[index]; // Use a local variable for clarity
// Ensure the thumb object and required properties exist
if (!currentThumb || !currentThumb.link || !currentThumb.link.href) {
console.error(`Thumb data at index ${index} is invalid or missing link.href.`, currentThumb);
return;
}
const thumbHref = currentThumb.link.href;
// 5. Check if the full image URL is already cached for this thumb.
if (currentThumb.fullImageUrl) {
console.log(`Image URL(s) for index ${index} already cached.`);
// Even if fullImageUrl exists, we might still need originalImageUrl
if (!currentThumb.originalImageUrl) {
// Continue to fetch potentially missing original URL
} else {
return; // Both URLs likely present or handled
}
}
// 6. Full image URL is not cached, fetch it.
// console.log(`Fetching full image URL for index ${index}`);
let fullUrl = null;
let originalUrl = null;
try {
[fullUrl, originalUrl] = await extractImageUrls(thumbHref, signal); // Pass signal
} catch (error) {
// Check if the error is due to the operation being aborted
if (error.name === 'AbortError') {
console.log(`Fetch aborted for image URL at index ${index} (${thumbHref})`);
} else {
console.error(`Error fetching image URL for index ${index} (${thumbHref}):`, error);
}
return;
}
// 7. Process the fetched URL.
// Check if the item still exists at the index before assigning
// (it might have been marked 'deleted' concurrently)
if (thumbs[index] && thumbs[index] !== 'deleted') {
if (thumbs[index] && thumbs[index] !== 'deleted') {
thumbs[index].fullImageUrl = fullUrl;
// If originalUrl wasn't found via separate link, assume fullUrl is the original
if (!originalUrl && fullUrl) {
thumbs[index].originalImageUrl = fullUrl;
} else if (originalUrl) {
thumbs[index].originalImageUrl = originalUrl;
}
}
} else {
console.error(`Could not extract any image URL for index ${index} from ${thumbHref}.`);
// Optional: Mark as failed?
// if (thumbs[index] && thumbs[index] !== 'deleted') {
// thumbs[index].loadError = true;
// }
}
}
/**
* Returns a Promise that resolves when the media element has loaded sufficiently
* (image fully loaded, video metadata loaded for dimensions), or rejects on error/timeout.
* Handles cases where the media might already be ready.
*
* @param {HTMLImageElement | HTMLVideoElement} element The media element to monitor.
* @param {number | null} [timeout=null] Optional timeout in milliseconds.
* @returns {Promise<void>} A Promise that resolves on success, rejects on error/timeout.
*/
async function waitForMediaLoad(element, timeout = null) {
if (element instanceof HTMLImageElement) {
// --- Initial Synchronous Checks ---
if (element.complete && element.naturalWidth > 0) {
return Promise.resolve();
}
if (!element.src || element.src === window.location.href) {
// console.warn("Image src invalid or missing, resolving early:", element.src);
return Promise.resolve();
}
// --- Asynchronous Waiting ---
return new Promise((resolve, reject) => {
const currentSrc = element.src;
let timeoutId = null;
const cleanup = () => {
element.removeEventListener('load', loadHandler);
element.removeEventListener('error', errorHandler);
if (timeoutId) clearTimeout(timeoutId);
};
const loadHandler = () => { cleanup(); resolve(); };
const errorHandler = (event) => {
console.error("Image failed to load:", currentSrc, event);
cleanup();
reject(new Error(`Failed to load image: ${currentSrc}`));
};
element.addEventListener('load', loadHandler);
element.addEventListener('error', errorHandler);
if (typeof timeout === 'number' && timeout > 0) {
timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Image load timed out after ${timeout}ms: ${currentSrc}`));
}, timeout);
}
if (element.complete && element.naturalWidth > 0) {
cleanup();
resolve();
}
});
} else if (element instanceof HTMLVideoElement) {
// --- Implement video logic (wait for metadata) ---
// --- Initial Synchronous Checks ---
// video.readyState >= 1 means metadata (including dimensions) is loaded.
if (element.readyState >= 1) {
// console.log("Video metadata already available:", element.src);
return Promise.resolve(); // Already ready
}
if (!element.src || element.src === window.location.href) {
// console.warn("Video src invalid or missing, resolving early:", element.src);
// Resolve like the image case; allows Promise.all to proceed.
return Promise.resolve();
}
// --- Asynchronous Waiting ---
return new Promise((resolve, reject) => {
const currentSrc = element.src;
let timeoutId = null;
// Define cleanup logic
const cleanup = () => {
// console.log("Cleaning up video listeners/timeout for:", currentSrc);
element.removeEventListener('loadedmetadata', metadataHandler);
element.removeEventListener('error', errorHandler);
// Videos can also emit 'stalled' or 'suspend' which might be relevant
// but 'error' usually covers critical load failures.
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
// Define event handlers
const metadataHandler = () => {
// console.log("Video metadata loaded:", currentSrc);
cleanup();
resolve(); // Signal success (metadata ready)
};
const errorHandler = (event) => {
// The event itself might contain a MediaError object with more details
const error = element.error;
console.error("Video failed to load:", currentSrc, error ? `Code: ${error.code}, Message: ${error.message}` : 'Unknown error', event);
cleanup();
reject(new Error(`Failed to load video metadata: ${currentSrc}${error ? ` (${error.message})` : ''}`));
};
// Attach event listeners
element.addEventListener('loadedmetadata', metadataHandler);
element.addEventListener('error', errorHandler);
// Set up timeout if specified
if (typeof timeout === 'number' && timeout > 0) {
timeoutId = setTimeout(() => {
// console.warn(`Video metadata load timed out (${timeout}ms):`, currentSrc);
cleanup();
reject(new Error(`Video metadata load timed out after ${timeout}ms: ${currentSrc}`));
}, timeout);
}
// Double-check readyState *after* adding listeners.
// This catches cases where metadata loaded synchronously between the initial
// check and listener attachment.
if (element.readyState >= 1) {
// console.log("Video became ready just after listeners were added:", currentSrc);
cleanup();
resolve();
}
// Note: If the video 'src' was set *before* this function was called,
// and loading is in progress but not yet complete, the attached
// listeners will catch the eventual 'loadedmetadata' or 'error' event.
// Browsers might start loading metadata as soon as 'preload="metadata"' and 'src' are set.
});
} else {
// Handle unsupported element types
console.error("Unsupported element type for waitForMediaLoad:", element);
return Promise.reject(new Error('Unsupported element type for waitForMediaLoad'));
}
}
// ----------------------------------------------------------------------------------------------
// imageViewer.js
// ----------------------------------------------------------------------------------------------
class ImageViewer {
parent = null;
buttonContainer = null;
// Toolbar buttons
helpButton = null;
chaptersButton = null;
settingsButton = null;
exitButton = null;
prevButton = null;
nextButton = null;
rotateLeftButton = null;
rotateRightButton = null;
zoomInButton = null;
zoomOutButton = null;
galleryViewButton = null;
dualPageButton = null;
fullscreenButton = null;
downloadButton = null;
findGalleriesButton = null;
gotoPageButton = null;
imgContainer = null;
imgDisplay = null;
imgDisplay2 = null;
isNavigating = false;
sidebarVisible = false;
useFullscreen = false;
hasEnteredFullscreenOnce = false;
sidebarHiddenTransform = null;
sidebar = null; // GridView instance
gridView = null; // GridView instance
onExit = null; // Function accepting bool exitToPage
pinSidebar = false;
backwardNavigationCount = 0;
currentRotation = 0;
currentIndex = 0;
currentAbortController = null; // Controller for the latest load operation
currentLoadToken = null; // Add this to track the latest load request
fitMode = "fit-window";
currentZoom = 1;
userChangedZoom = false;
config;
UI_TRANSPARENCY = 0.6;
constructor(config, onExit = null) {
this.config = config;
this.onExit = onExit;
this.fitMode = config.fitMode;
this.pinSidebar = config.pinSidebar;
this.useFullscreen = config.useFullscreen;
this.rightToLeftMode = config.openInRightToLeftMode;
this.dualPageMode = config.openInDualPageMode;
this.dualLayout = config.dualLayout;
this.autoplayEnabled = config.videoConfig.autoplay;
this.loopEnabled = config.videoConfig.loop;
this.blobCache = new Map(); // Stores { blob, size, type, lastAccessed }
this.pendingFetches = new Map(); // Stores Promises for ongoing fetches
this.currentCacheSize = 0; // In bytes
this.cacheLimitMB = 50;
this._createImageViewer();
this._initializeEventListeners();
this.inputHandler = new ViewerInputHandler(this, this.config);
this._initializeHelpOverlay();
this._initializeSidebar();
this.parent.appendChild(this.gridParent);
this.gridView = new GridView(this.gridParent, config.gridViewConfig, false);
}
isActive() {
return this.parent.style.display === "block";
}
/**
* Checks if a video element is currently being displayed in the specified slot.
* @param {'primary' | 'secondary' | null} [position=null] - The display slot to check ('left' for primary, 'right' for secondary).
* If null, checks if *any* video is displayed.
* @returns {boolean} True if a video is displayed in the specified slot(s), false otherwise.
*/
isShowingVideo(position = null) {
const isPrimaryVideoShowing = this.videoDisplay.style.display === 'block';
const isSecondaryVideoShowing = this.dualPageMode && this.videoDisplay2.style.display === 'block';
if (position === 'primary') {
return isPrimaryVideoShowing;
} else if (position === 'secondary') {
return isSecondaryVideoShowing;
} else if (position === null) {
return isPrimaryVideoShowing || isSecondaryVideoShowing;
} else {
console.warn(`isShowingVideo called with invalid position: ${position}`);
return false; // Invalid position provided
}
}
/**
* Toggles the playback state (play/pause) of the specified video display(s).
* If targetDisplay is null (default), toggles both visible videos, ensuring the secondary
* video's state matches the primary video's resulting state.
*
* @param {'primary' | 'secondary' | null} [targetDisplay=null] - Which video display to toggle.
* 'primary', 'secondary', or null (default) for both.
*/
toggleVideoPlayback(targetDisplay = null) { // Default set to null here
const primaryVisible = this.isShowingVideo('primary');
const secondaryVisible = this.isShowingVideo('secondary'); // Checks dualPageMode internally
// Determine the target action (play or pause)
// Priority: Primary state if primary is targeted (or null target),
// otherwise Secondary state if only secondary is targeted.
let shouldPlay = false;
if (primaryVisible && (targetDisplay === 'primary' || targetDisplay === null)) {
shouldPlay = this.videoDisplay.paused;
} else if (secondaryVisible && targetDisplay === 'secondary') {
shouldPlay = this.videoDisplay2.paused;
} else if (secondaryVisible && targetDisplay === null && !primaryVisible) {
// Toggling both, but primary isn't visible, use secondary's state
shouldPlay = this.videoDisplay2.paused;
}
// Helper to apply the action and handle errors
const applyState = (videoElement, name) => {
if (shouldPlay) {
videoElement.play()?.catch(error => {
// Simplified notification - avoid flooding if both fail
if (name === 'primary' || !primaryVisible || targetDisplay !== null) {
console.error(`Error playing video (${name}):`, error);
createNotification(`Could not play video (${name}).`);
}
});
} else {
videoElement.pause();
}
};
// Apply to primary if it's targeted and visible
if (primaryVisible && (targetDisplay === 'primary' || targetDisplay === null)) {
applyState(this.videoDisplay, 'primary');
} else if (targetDisplay === 'primary') {
console.log("Primary video not visible, cannot toggle.");
}
// Apply to secondary if it's targeted and visible
// Note: The *same* `shouldPlay` action is applied for synchronization when targetDisplay is null.
if (secondaryVisible && (targetDisplay === 'secondary' || targetDisplay === null)) {
applyState(this.videoDisplay2, 'secondary');
} else if (targetDisplay === 'secondary') {
console.log("Secondary video not visible, cannot toggle.");
}
if (targetDisplay !== 'primary' && targetDisplay !== 'secondary' && targetDisplay !== null) {
console.warn(`Invalid targetDisplay specified for toggleVideoPlayback: ${targetDisplay}`);
}
}
/**
* Seeks the specified video display(s) by a given amount of time.
* If targetDisplay is null (default), seeks both visible videos by the same amount.
*
* @param {number} time - The amount of time in seconds to seek.
* Positive values seek forward, negative values seek backward.
* @param {'primary' | 'secondary' | null} [targetDisplay=null] - Which video display to seek.
* 'primary', 'secondary', or null (default) for both.
*/
seekVideo(time, targetDisplay = null) {
if (typeof time !== 'number' || isNaN(time)) {
console.warn(`seekVideo called with invalid time value: ${time}`);
return;
}
const primaryVisible = this.isShowingVideo('primary');
const secondaryVisible = this.isShowingVideo('secondary'); // Checks dualPageMode internally
// Helper function to apply the seek operation safely
const applySeek = (videoElement, name) => {
// Check if duration is available and valid before seeking
if (videoElement.duration && !isNaN(videoElement.duration)) {
// currentTime assignment automatically clamps between 0 and duration
videoElement.currentTime += time;
// console.log(`Seeking video (${name}) by ${time}s. New time: ${videoElement.currentTime}`);
} else {
console.log(`Cannot seek video (${name}): duration not available.`);
}
};
// Seek primary if it's targeted and visible
if (primaryVisible && (targetDisplay === 'primary' || targetDisplay === null)) {
applySeek(this.videoDisplay, 'primary');
} else if (targetDisplay === 'primary') {
console.log("Primary video not visible, cannot seek.");
}
// Seek secondary if it's targeted and visible
if (secondaryVisible && (targetDisplay === 'secondary' || targetDisplay === null)) {
applySeek(this.videoDisplay2, 'secondary');
} else if (targetDisplay === 'secondary') {
console.log("Secondary video not visible, cannot seek.");
}
if (targetDisplay !== 'primary' && targetDisplay !== 'secondary' && targetDisplay !== null) {
console.warn(`Invalid targetDisplay specified for seekVideo: ${targetDisplay}`);
}
}
isGridViewActive() {
return this.gridParent.style.display === "block";
}
toggleGridView(goToIndex = false) {
if (this.isGridViewActive()) {
this.gridParent.style.display = "none";
this.gridView.stopLoading();
if (goToIndex) this.loadAndShowIndex(this.savedIndexBeforeGridView);
this.savedIndexBeforeGridView = null;
} else {
this.savedIndexBeforeGridView = this.currentIndex;
this.gridParent.style.display = "block";
if (!this.gridView.showCalled) {
this.gridView.showGridView();
} else {
this.gridView.enableLoading();
}
this.gridView.scrollToIndex(this.currentIndex, false);
}
}
rotateImage(delta) {
this.currentRotation = (this.currentRotation + delta) % 360;
if (this.currentRotation < 0) {
this.currentRotation += 360;
}
this.updateTransforms();
}
/**
* Zooms the image.
* @param {number} direction - Positive to zoom in, negative to zoom out.
* @param {number} [zoomFactor=1.1] - Zoom factor
* @param {boolean} [centerX=false] - Keep the horizontal center of the viewport anchored.
* @param {boolean} [centerY=false] - Keep the vertical center of the viewport anchored.
*/
zoom(direction, zoomFactor = 1.1, centerX = false, centerY = false) {
const oldZoom = this.currentZoom;
if (oldZoom <= 0) return;
let newZoom = direction > 0 ? oldZoom * zoomFactor : oldZoom / zoomFactor;
// Clamp zoom level
newZoom = Math.max(this.minZoomLevel ?? 1, newZoom);
if (Math.abs(newZoom - oldZoom) < 0.001) {
return; // No significant change
}
// --- Store pre-zoom state for potential anchor calculation ---
const parent = this.parent;
const oldScrollLeft = parent.scrollLeft;
const oldScrollTop = parent.scrollTop;
const viewportWidth = parent.clientWidth;
const viewportHeight = parent.clientHeight;
// Calculate the coordinates of the viewport center relative to the scrollable content's top-left
// This represents the "anchor point" we *might* want to keep centered.
const centerX_coord = oldScrollLeft + viewportWidth / 2;
const centerY_coord = oldScrollTop + viewportHeight / 2;
const zoomRatio = newZoom / oldZoom;
this.currentZoom = newZoom;
// --- Update Visuals (applies new scale and container size/pos) ---
this.updateTransforms();
// --- Adjust Scroll Position Conditionally to Maintain Center Anchor ---
if (this.currentZoom > 1) {
// Use requestAnimationFrame to ensure scroll update happens *after* the browser
requestAnimationFrame(() => {
const maxScrollLeft = parent.scrollWidth - viewportWidth;
const maxScrollTop = parent.scrollHeight - viewportHeight;
let targetScrollLeft = parent.scrollLeft;
let targetScrollTop = parent.scrollTop;
// --- Horizontal Anchoring ---
if (centerX) {
const newAnchorX = centerX_coord * zoomRatio;
const newScrollLeftTarget = newAnchorX - viewportWidth / 2;
targetScrollLeft = Math.max(0, Math.min(newScrollLeftTarget, maxScrollLeft));
}
// --- Vertical Anchoring ---
if (centerY) {
const newAnchorY = centerY_coord * zoomRatio;
const newScrollTopTarget = newAnchorY - viewportHeight / 2;
targetScrollTop = Math.max(0, Math.min(newScrollTopTarget, maxScrollTop));
}
if (parent.scrollLeft !== targetScrollLeft) {
parent.scrollLeft = targetScrollLeft;
}
if (parent.scrollTop !== targetScrollTop) {
parent.scrollTop = targetScrollTop;
}
});
} else { // Zooming out to 1 or less. Reset scroll.
requestAnimationFrame(() => {
if (this.currentZoom <= 1) {
parent.scrollTop = 0;
parent.scrollLeft = 0;
}
});
}
}
/**
* Marks an item as deleted, cleans up associated resources, refreshes grids,
* and navigates away if the currently viewed item was deleted.
* @param {number} index The index of the item to delete.
*/
delete(index) {
// 1. Validate Index
if (index < 0 || index >= thumbs.length || thumbs[index] === 'deleted') {
console.warn(`ImageViewer.delete: Invalid or already deleted index ${index}.`);
return;
}
console.log(`ImageViewer.delete: Deleting index ${index}.`);
const itemToDelete = thumbs[index]; // Get item data before marking as deleted
// 2. Mark as Deleted
thumbs[index] = 'deleted';
// 3. Clean Up Blob Cache (if item existed and fallback was used)
if (itemToDelete) {
// Determine the URL that *might* be cached (prefer original if available)
const potentialCachedUrl = itemToDelete.originalImageUrl || itemToDelete.fullImageUrl;
if (potentialCachedUrl) {
this._evictCacheEntry(potentialCachedUrl); // Call the helper
}
}
// 4. Refresh Grids
this.refreshGrids();
// 5. Handle Current Index Deletion & Navigation
const displayedIndices = this._getDualPageIndices(this.currentIndex);
const wasDisplayed = index === this.currentIndex || index === displayedIndices.primary || index === displayedIndices.secondary;
if (wasDisplayed) {
if (index < thumbs.length - 1) {
this.navigateForward();
} else if (index > 0) {
this.navigateBack();
}
}
// If the deleted index wasn't the current one, no navigation is needed.
}
refreshGrids() {
this.sidebar?.refreshAll(); // Refresh sidebar grid
this.gridView?.refreshAll(); // Refresh main gallery grid (when active)
}
_getOneToOneZoom() {
// --- Determine Active Element and Get Dimensions ---
const primaryElement = (this.videoDisplay.style.display === 'block') ? this.videoDisplay : this.imgDisplay;
const primaryIsVideo = primaryElement === this.videoDisplay && primaryElement.style.display === 'block';
let naturalW = 0, naturalH = 0;
const MIN_ERROR_DIM = 100; // Consistent min dimension
if (primaryElement.style.display === 'block') {
naturalW = primaryIsVideo ? primaryElement.videoWidth : primaryElement.naturalWidth;
naturalH = primaryIsVideo ? primaryElement.videoHeight : primaryElement.naturalHeight;
// Apply MIN_ERROR_DIM logic for invalid or error states
if (!primaryIsVideo && primaryElement.classList.contains('viewer-error-state')) {
naturalW = Math.max(naturalW, MIN_ERROR_DIM);
naturalH = Math.max(naturalH, MIN_ERROR_DIM);
} else if (!primaryIsVideo && (!naturalW || naturalW <= 0 || !naturalH || naturalH <= 0)) {
// console.warn("1:1 Zoom: Primary image dimensions invalid, using min error dim.");
naturalW = MIN_ERROR_DIM; naturalH = MIN_ERROR_DIM;
} else if (primaryIsVideo && (!naturalW || naturalW <= 0 || !naturalH || naturalH <= 0)) {
// console.warn("1:1 Zoom: Primary video dimensions invalid, using min error dim.");
naturalW = MIN_ERROR_DIM; naturalH = MIN_ERROR_DIM;
}
} else { // If primary not visible, use min dim as fallback
// console.warn("1:1 Zoom: Primary element not visible, using min error dim.");
naturalW = MIN_ERROR_DIM; naturalH = MIN_ERROR_DIM;
}
// Ensure not zero if still possible
if (naturalW <= 0) naturalW = MIN_ERROR_DIM;
if (naturalH <= 0) naturalH = MIN_ERROR_DIM;
// --- Get Rotation and Viewport ---
const currentRotation = this.currentRotation;
const rotIs90 = currentRotation % 180 !== 0;
const viewportWidth = this.parent.clientWidth;
const viewportHeight = this.parent.clientHeight;
if (viewportWidth <= 0 || viewportHeight <= 0) {
console.warn("Cannot calculate 1:1 zoom: Viewport dimensions not available.");
return 1; // Fallback
}
// Effective dimensions after rotation
const effectiveW = rotIs90 ? naturalH : naturalW;
const effectiveH = rotIs90 ? naturalW : naturalH;
// --- Calculate baseScale (fit-to-screen scale) ---
let baseScale = 1;
const secondaryElement = (this.videoDisplay2.style.display === 'block') ? this.videoDisplay2 : this.imgDisplay2;
// Check if secondary is *actually* displayed for dual page calculations
const isDualPageVisible = this.dualPageMode && secondaryElement.style.display === 'block';
let naturalW2 = 0, naturalH2 = 0;
if (isDualPageVisible) {
const secondaryIsVideo = secondaryElement === this.videoDisplay2;
naturalW2 = secondaryIsVideo ? secondaryElement.videoWidth : secondaryElement.naturalWidth;
naturalH2 = secondaryIsVideo ? secondaryElement.videoHeight : secondaryElement.naturalHeight;
// Apply MIN_ERROR_DIM logic for invalid or error states
if (!secondaryIsVideo && secondaryElement.classList.contains('viewer-error-state')) {
naturalW2 = Math.max(naturalW2, MIN_ERROR_DIM);
naturalH2 = Math.max(naturalH2, MIN_ERROR_DIM);
} else if (!secondaryIsVideo && (!naturalW2 || naturalW2 <= 0 || !naturalH2 || naturalH2 <= 0)) {
// Use primary's dimensions if valid, else use min error dim.
if (naturalW > 0 && naturalH > 0 && naturalW !== MIN_ERROR_DIM) { // Check if primary has real dimensions
// console.warn("1:1 Zoom: Secondary dimensions invalid, using primary fallback.");
naturalW2 = naturalW; naturalH2 = naturalH;
} else {
// console.warn("1:1 Zoom: Secondary dimensions invalid, using min error dim.");
naturalW2 = MIN_ERROR_DIM; naturalH2 = MIN_ERROR_DIM;
}
} else if (secondaryIsVideo && (!naturalW2 || naturalW2 <= 0 || !naturalH2 || naturalH2 <= 0)) {
// console.warn("1:1 Zoom: Secondary video dimensions invalid, using min error dim.");
naturalW2 = MIN_ERROR_DIM; naturalH2 = MIN_ERROR_DIM;
}
// Ensure W2/H2 are not zero if dual page is visible
if (naturalW2 <= 0) naturalW2 = MIN_ERROR_DIM;
if (naturalH2 <= 0) naturalH2 = MIN_ERROR_DIM;
}
// Calculate base scale based on whether dual page is effectively active
let useDualForScale = isDualPageVisible;
if (useDualForScale && (naturalW2 <= 0 || naturalH2 <= 0)) {
// This check might be redundant due to above assignment, but safe
useDualForScale = false;
}
if (useDualForScale) {
let requiredWidthPerScale, requiredHeightPerScale;
if (!rotIs90) { // Side-by-side
requiredWidthPerScale = naturalW + naturalW2;
requiredHeightPerScale = Math.max(naturalH, naturalH2);
} else { // Top-and-bottom
requiredWidthPerScale = Math.max(naturalH, naturalH2);
requiredHeightPerScale = naturalW + naturalW2;
}
// Check for zero denominators
const scaleLimitW = requiredWidthPerScale > 0 ? viewportWidth / requiredWidthPerScale : Infinity;
const scaleLimitH = requiredHeightPerScale > 0 ? viewportHeight / requiredHeightPerScale : Infinity;
baseScale = Math.min(scaleLimitW, scaleLimitH);
} else { // Single page mode (or fallback)
// effectiveW/H derived from naturalW/H which are guaranteed > 0 here
const scaleLimitW = effectiveW > 0 ? viewportWidth / effectiveW : Infinity;
const scaleLimitH = effectiveH > 0 ? viewportHeight / effectiveH : Infinity;
baseScale = Math.min(scaleLimitW, scaleLimitH);
}
baseScale = Math.max(baseScale, 0); // Ensure non-negative
// --- Calculate 1:1 Zoom Factor ---
if (baseScale > 0) {
return 1 / baseScale;
} else {
console.warn("Cannot calculate 1:1 zoom: baseScale is zero.");
return 1; // Fallback
}
}
_getFitWidthZoom() {
// --- Determine Active Elements and Get Dimensions ---
const primaryElement = (this.videoDisplay.style.display === 'block') ? this.videoDisplay : this.imgDisplay;
const primaryIsVideo = primaryElement === this.videoDisplay && primaryElement.style.display === 'block';
let naturalW1 = 0, naturalH1 = 0;
const MIN_ERROR_DIM = 100; // Consistent min dimension
if (primaryElement.style.display === 'block') {
naturalW1 = primaryIsVideo ? primaryElement.videoWidth : primaryElement.naturalWidth;
naturalH1 = primaryIsVideo ? primaryElement.videoHeight : primaryElement.naturalHeight;
// Apply MIN_ERROR_DIM logic
if (!primaryIsVideo && primaryElement.classList.contains('viewer-error-state')) {
naturalW1 = Math.max(naturalW1, MIN_ERROR_DIM);
naturalH1 = Math.max(naturalH1, MIN_ERROR_DIM);
} else if (!primaryIsVideo && (!naturalW1 || naturalW1 <= 0 || !naturalH1 || naturalH1 <= 0)) {
// console.warn("Fit Width: Primary image dimensions invalid, using min error dim.");
naturalW1 = MIN_ERROR_DIM; naturalH1 = MIN_ERROR_DIM;
} else if (primaryIsVideo && (!naturalW1 || naturalW1 <= 0 || !naturalH1 || naturalH1 <= 0)) {
// console.warn("Fit Width: Primary video dimensions invalid, using min error dim.");
naturalW1 = MIN_ERROR_DIM; naturalH1 = MIN_ERROR_DIM;
}
} else {
// console.warn("Fit Width: Primary element not visible, using min error dim.");
naturalW1 = MIN_ERROR_DIM; naturalH1 = MIN_ERROR_DIM;
}
// Ensure not zero
if (naturalW1 <= 0) naturalW1 = MIN_ERROR_DIM;
if (naturalH1 <= 0) naturalH1 = MIN_ERROR_DIM;
// --- Get Rotation and Viewport ---
const currentRotation = this.currentRotation;
const rotIs90 = currentRotation % 180 !== 0;
const viewportWidth = this.parent.clientWidth;
const viewportHeight = this.parent.clientHeight;
if (viewportWidth <= 0 || viewportHeight <= 0) {
console.warn("Cannot calculate fit-width zoom: Viewport dimensions not available.");
return 1; // Fallback
}
// --- Calculate the baseScale (fit-to-viewport scale) ---
let baseScale = 1;
const secondaryElement = (this.videoDisplay2.style.display === 'block') ? this.videoDisplay2 : this.imgDisplay2;
let isDualPageVisible = this.dualPageMode && secondaryElement.style.display === 'block';
let naturalW2 = 0, naturalH2 = 0;
if (isDualPageVisible) {
const secondaryIsVideoCheck = secondaryElement === this.videoDisplay2;
naturalW2 = secondaryIsVideoCheck ? secondaryElement.videoWidth : secondaryElement.naturalWidth;
naturalH2 = secondaryIsVideoCheck ? secondaryElement.videoHeight : secondaryElement.naturalHeight;
// Apply MIN_ERROR_DIM logic
if (!secondaryIsVideoCheck && secondaryElement.classList.contains('viewer-error-state')) {
naturalW2 = Math.max(naturalW2, MIN_ERROR_DIM);
naturalH2 = Math.max(naturalH2, MIN_ERROR_DIM);
} else if (!secondaryIsVideoCheck && (!naturalW2 || naturalW2 <= 0 || !naturalH2 || naturalH2 <= 0)) {
if (naturalW1 > 0 && naturalH1 > 0 && naturalW1 !== MIN_ERROR_DIM) {
// console.warn("Fit Width: Secondary dimensions invalid, using primary fallback.");
naturalW2 = naturalW1; naturalH2 = naturalH1;
} else {
// console.warn("Fit Width: Secondary dimensions invalid, using min error dim.");
naturalW2 = MIN_ERROR_DIM; naturalH2 = MIN_ERROR_DIM;
}
} else if (secondaryIsVideoCheck && (!naturalW2 || naturalW2 <= 0 || !naturalH2 || naturalH2 <= 0)) {
// console.warn("Fit Width: Secondary video dimensions invalid, using min error dim.");
naturalW2 = MIN_ERROR_DIM; naturalH2 = MIN_ERROR_DIM;
}
// Ensure W2/H2 are not zero if dual page is visible
if (naturalW2 <= 0) naturalW2 = MIN_ERROR_DIM;
if (naturalH2 <= 0) naturalH2 = MIN_ERROR_DIM;
// If dimensions are still invalid after checks, treat as single page for scaling
if (naturalW2 <= 0 || naturalH2 <= 0) {
isDualPageVisible = false;
// console.warn("Fit Width: Secondary dimensions invalid after fallback, treating as single for scale.");
}
}
// Calculate base scale
let useDualForScale = isDualPageVisible; // Use separate flag for clarity
if (useDualForScale) {
let requiredWidthPerScale, requiredHeightPerScale;
if (!rotIs90) { // Side-by-side
requiredWidthPerScale = naturalW1 + naturalW2;
requiredHeightPerScale = Math.max(naturalH1, naturalH2);
} else { // Top-and-bottom
requiredWidthPerScale = Math.max(naturalH1, naturalH2);
requiredHeightPerScale = naturalW1 + naturalW2;
}
const scaleLimitW = requiredWidthPerScale > 0 ? viewportWidth / requiredWidthPerScale : Infinity;
const scaleLimitH = requiredHeightPerScale > 0 ? viewportHeight / requiredHeightPerScale : Infinity;
baseScale = Math.min(scaleLimitW, scaleLimitH);
} else { // Single page mode (or fallback)
const effectiveW = rotIs90 ? naturalH1 : naturalW1; // Uses W1/H1 which are guaranteed > 0
const effectiveH = rotIs90 ? naturalW1 : naturalH1;
const scaleLimitW = effectiveW > 0 ? viewportWidth / effectiveW : Infinity;
const scaleLimitH = effectiveH > 0 ? viewportHeight / effectiveH : Infinity;
baseScale = Math.min(scaleLimitW, scaleLimitH);
}
baseScale = Math.max(baseScale, 0);
// --- Calculate the desired final scale to achieve fit-width ---
let desiredFinalScale = 1.0;
let targetWidth = 0; // The width we want the content to scale to (viewport width)
// Use potentially adjusted dimensions, use useDualForScale flag
if (useDualForScale) { // W2/H2 validity checked above
if (!rotIs90) { // Side-by-side: target width is W1+W2
targetWidth = naturalW1 + naturalW2;
} else { // Top-and-bottom: target width is max(H1, H2)
targetWidth = Math.max(naturalH1, naturalH2);
}
} else { // Single page: target width is effectiveW
targetWidth = rotIs90 ? naturalH1 : naturalW1; // Uses W1/H1 which are guaranteed > 0
}
if (targetWidth > 0) {
desiredFinalScale = viewportWidth / targetWidth;
} else {
console.warn("Cannot calculate fit-width zoom: Target width is zero.");
desiredFinalScale = baseScale > 0 ? baseScale : 1.0; // Fallback
}
desiredFinalScale = Math.max(desiredFinalScale, 0);
// --- Calculate the required currentZoom ---
let finalZoom = 1.0;
if (baseScale > 0) {
finalZoom = desiredFinalScale / baseScale;
} else {
console.warn("Cannot calculate fit-width zoom: baseScale is zero.");
finalZoom = 1.0; // Fallback
}
// Ensure zoom doesn't go below the base 'fit' level (zoom=1).
finalZoom = Math.max(1.0, finalZoom);
return finalZoom;
}
pan(deltaX, deltaY) {
if (this.currentZoom > 1) {
this.parent.scrollBy(deltaX, deltaY);
}
}
toggleDualPageMode() {
this.dualPageMode = !this.dualPageMode;
// Call loadAndShowIndex to refresh the view with the current index,
// applying the new dual page mode logic.
this.loadAndShowIndex(this.currentIndex);
}
toggleRightToLeftMode() {
if (!this.dualPageMode) {
this.toggleDualPageMode();
}
this.rightToLeftMode = !this.rightToLeftMode;
this.loadAndShowIndex(this.currentIndex);
}
enableDualPageModeOrCycleLayout() {
if (!this.dualPageMode) {
this.dualPageMode = true;
} else {
const { primary: currentPrimaryIndex } = this._getDualPageIndices(this.currentIndex);
if (currentPrimaryIndex === null) {
this.dualPageMode = false;
console.log("Could not determine left page, toggling back to Single Page Mode.");
} else {
// The logic toggles between 'odd-first' and 'even-first' layouts.
// In LTR, if the primary (left) index is even, we're in `odd-first` layout and switch to `even-first`.
// In RTL, page order is swapped, so if the primary (left) index is even, we're already in `even-first` layout
// and must switch to `odd-first`. This XOR condition correctly handles the toggle for both modes.
if ((currentPrimaryIndex % 2 === 0) !== this.rightToLeftMode) {
this.dualLayout = 'even-first';
} else {
this.dualLayout = 'odd-first';
}
}
}
this.loadAndShowIndex(this.currentIndex);
}
downloadCurrentImage() {
if (this.isGridViewActive()) return;
const { primary, secondary } = this._getDualPageIndices(this.currentIndex);
const sStr = secondary ? "s" : "";
createNotification(`Downloading original image${sStr}..`, 30);
const items = [thumbs[primary]];
if (secondary) items.push(thumbs[secondary]);
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.originalImageUrl) {
const imgElement = new Image();
imgElement.referrerPolicy = "no-referrer";
imgElement.src = item.originalImageUrl;
waitForMediaLoad(imgElement)
.then(() => {
saveImage(item.originalImageUrl);
})
.catch((error) => {
console.error("Failed to load original, downloading downscaled image:", error);
createNotification("Failed to load original, using downscaled.");
saveImage(item.fullImageUrl);
});
} else {
saveImage(item.fullImageUrl);
}
}
createNotification(`Image${sStr} saved`);
}
showGalleriesWithCurrentImage() {
showGalleriesWithThisImage(thumbs[this.currentIndex].fullImageUrl);
}
async loadAndShowIndex(index) {
const wasActive = this.isActive();
const gridViewWasActive = this.isGridViewActive();
if (this.isGridViewActive()) this.toggleGridView();
// Ensure viewer is active/visible
this._ensureViewerActive();
// Prepare and Validate
const prepResult = this._prepareLoadOperation(index);
if (!prepResult) return; // Invalid index or error during prep
const { controller, loadToken, targetIndex, navigatedBackwards } = prepResult;
// Determine Display Indices & Update Label
const { displayPrimaryIndex, displaySecondaryIndex } = this._getDisplayIndicesAndLabel(targetIndex);
// Initiate Dimming
const dimTimeoutId = this._initiateDimming(targetIndex, loadToken, wasActive, gridViewWasActive, displayPrimaryIndex, displaySecondaryIndex);
try {
// Fetch Required URLs
await this._fetchRequiredUrls([displayPrimaryIndex, displaySecondaryIndex], controller.signal, loadToken);
// Note: _fetchRequiredUrls throws AbortError if cancelled
// Display Loaded Images (This now handles un-dimming internally per image)
await this._displayLoadedImages(targetIndex, displayPrimaryIndex, displaySecondaryIndex, controller.signal, loadToken);
// Note: _displayLoadedImages calls _show which throws AbortError if cancelled during its await
// Post-Display Tasks (Sidebar, Scroll Anchor, Preload)
this._performPostDisplayTasks(targetIndex, displayPrimaryIndex, displaySecondaryIndex, navigatedBackwards, controller.signal, loadToken);
} catch (error) {
// Handle Errors (This also handles un-dimming via helper)
this._handleLoadError(error, targetIndex, loadToken);
} finally {
// Final Cleanup (This also handles final un-dimming via helper)
this._finalizeLoadAttempt(loadToken, dimTimeoutId);
}
}
navigateForward() {
this.backwardNavigationCount = 0;
// 1. Get current display state
const { primary: currentPrimary, secondary: currentSecondary } = this._getDualPageIndices(this.currentIndex);
// 2. Find the highest index currently displayed
let maxCurrent = -1;
if (currentPrimary !== null) {
maxCurrent = currentPrimary;
}
if (currentSecondary !== null && currentSecondary > maxCurrent) {
maxCurrent = currentSecondary;
}
if (maxCurrent === -1) {
// If nothing is displayed (error state?), try navigating from currentIndex
maxCurrent = this.currentIndex;
}
// 3. Calculate initial next target
let nextTargetIndex = maxCurrent + 1;
// 4. Skip deleted indices forward
while (nextTargetIndex < thumbs.length && thumbs[nextTargetIndex] === 'deleted') {
nextTargetIndex++;
}
// 5. Check bounds
if (nextTargetIndex >= thumbs.length) {
createNotification("Last page!");
return;
}
// 6. Load the determined target index
this.loadAndShowIndex(nextTargetIndex);
}
navigateBack() {
this.backwardNavigationCount++;
// 1. Get current display state
const { primary: currentPrimary, secondary: currentSecondary } = this._getDualPageIndices(this.currentIndex);
// 2. Find the lowest index currently displayed
let minCurrent = -1;
if (currentPrimary !== null) {
minCurrent = currentPrimary;
}
if (currentSecondary !== null && currentSecondary < minCurrent) {
minCurrent = currentSecondary;
}
if (minCurrent === -1) {
// If nothing is displayed (error state?), try navigating from currentIndex
minCurrent = this.currentIndex;
}
// 3. Calculate initial previous target
let prevTargetIndex = minCurrent - 1;
// 4. Skip deleted indices backward
while (prevTargetIndex >= 0 && thumbs[prevTargetIndex] === 'deleted') {
prevTargetIndex--;
}
// Needed for 'selected-first', as otherwise it would only go back one page
// at a time. It should not affect the other layouts in theory, but let's
// make it a special case anyways.
if (this.dualPageMode && prevTargetIndex > 0 && this.dualLayout === 'selected-first') {
prevTargetIndex--;
}
// 5. Check bounds
if (prevTargetIndex < 0) {
// Special case check for 'even-first' mode at index 1 or 0
// If mode is 'even-first' and current primary was 1 (showing 1,2), target is 0.
// If target 0 is valid, we should allow navigating to it.
// The _getDualPageIndices handles showing only 0 if needed.
if (this.dualLayout === 'even-first' && minCurrent === 1 && prevTargetIndex === -1 && thumbs.length > 0 && thumbs[0] !== 'deleted') {
prevTargetIndex = 0; // Allow navigating to index 0
} else {
createNotification("First page!");
return;
}
}
// 6. Load the determined target index
this.loadAndShowIndex(prevTargetIndex);
}
goOrScrollToIndex(index) {
if (this.isGridViewActive()) {
this.gridView.scrollToIndex(index);
} else {
this.loadAndShowIndex(index);
}
}
enterFullscreen() {
this.hasEnteredFullscreenOnce = true;
requestFullscreen(document.body);
}
exitFullscreen() {
exitFullscreen();
}
toggleFullscreen() {
if (isFullscreen()) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
}
toggleFullscreen() {
if (isFullscreen()) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
}
closeViewer(exitToPage) {
console.log('Closing viewer');
unlockPageScroll();
this.parent.style.display = "none";
// --- Pause Videos ---
if (!this.videoDisplay.paused) {
this.videoDisplay.currentTime = 0;
this.videoDisplay.pause();
}
if (!this.videoDisplay2.paused) {
this.videoDisplay2.currentTime = 0;
this.videoDisplay2.pause();
}
// Clean up blob URLs
this._revokeBlobUrl(this.videoDisplay);
this._revokeBlobUrl(this.videoDisplay2);
// Clear src to release resources
this.videoDisplay.removeAttribute('src');
this.videoDisplay2.removeAttribute('src');
this.imgDisplay.removeAttribute('src');
this.imgDisplay2.removeAttribute('src');
// Abort any ongoing load operation
if (this.currentAbortController) {
this.currentAbortController.abort("Viewer closing"); // Add reason
this.currentAbortController = null;
}
this.currentLoadToken = null; // Clear token as well
// Reset state (maybe?)
// Let's preserve zoom and rotation for now.
this.currentIndex = 0;
this.userChangedZoom = false;
// this.currentZoom = 1.0;
// this.currentRotation = 0;
const wasFullscreen = isFullscreen();
if (wasFullscreen) {
const onFullscreenChange = () => {
if (document.fullscreenElement) return; // Still in fullscreen (maybe entered again?)
// Ensure cleanup happens only once
document.removeEventListener("fullscreenchange", onFullscreenChange);
if (this.onExit) {
console.log("Calling onExit after fullscreen exit.");
this.onExit(exitToPage);
}
};
document.addEventListener("fullscreenchange", onFullscreenChange);
this.exitFullscreen();
}
if (this.isGridViewActive()) {
this.toggleGridView(); // Ensure grid view is hidden and stopped
}
if (this.sidebarIsActive() && !this.pinSidebar) {
this.sidebarHideInstant(); // Hide sidebar immediately if not pinned
}
if (this.chapterSidebarIsActive()) {
this.toggleChapterSidebar(); // Hide chapter sidebar
}
if (this.isHelpOverlayVisible()) {
this.toggleHelpOverlay(); // Hide help overlay
}
// Restore scroll position if we entered fullscreen
// Only restore if we actually *used* fullscreen during this session
if (this.hasEnteredFullscreenOnce && this.lastScrollPosition) {
// *** ADDED *** Revoke any leftover blob URLs
this._revokeBlobUrl(this.videoDisplay);
this._revokeBlobUrl(this.videoDisplay2);
// Delay slightly to allow fullscreen exit animation to complete?
requestAnimationFrame(() => {
window.scrollTo(this.lastScrollPosition.x, this.lastScrollPosition.y);
this.lastScrollPosition = null; // Clear after restoring
});
} else {
this.lastScrollPosition = null; // Clear if not used
}
this.hasEnteredFullscreenOnce = false; // Reset flag for next opening
if (this.config.helpAndSettingsButtons === 'proximity') {
this.buttonContainer.style.opacity = '0';
this.buttonContainer.style.pointerEvents = 'none';
}
// Call onExit callback immediately if not handling fullscreen exit
if (!wasFullscreen && this.onExit) {
console.log("Calling onExit (not fullscreen).");
this.onExit(exitToPage);
}
}
updateTransforms() {
// --- Determine Active Elements and Media Types ---
const primaryElement = (this.videoDisplay.style.display === 'block') ? this.videoDisplay : this.imgDisplay;
const primaryIsVideo = primaryElement === this.videoDisplay && primaryElement.style.display === 'block';
const secondaryElement = (this.videoDisplay2.style.display === 'block') ? this.videoDisplay2 : this.imgDisplay2;
// isDualPageVisible: Check if dual mode is on AND secondary element is actually being displayed
const isDualPageVisible = this.dualPageMode && secondaryElement.style.display === 'block';
const secondaryIsVideo = isDualPageVisible && secondaryElement === this.videoDisplay2;
// --- Handle Rotation ---
let currentRotation = this.currentRotation;
const rotIs90 = currentRotation % 180 !== 0;
// --- Get Natural Media Dimensions (from active elements) ---
let naturalW1 = 0, naturalH1 = 0;
const MIN_ERROR_DIM = 100; // Min width/height for error placeholder visual
if (primaryElement.style.display === 'block') {
naturalW1 = primaryIsVideo ? primaryElement.videoWidth : primaryElement.naturalWidth;
naturalH1 = primaryIsVideo ? primaryElement.videoHeight : primaryElement.naturalHeight;
// Check for error state or invalid dimensions on the *image* element specifically
// Videos have their own dimensions which should be valid if displayed.
if (!primaryIsVideo && primaryElement.classList.contains('viewer-error-state')) {
// Assign minimum dimensions for error placeholder
naturalW1 = Math.max(naturalW1, MIN_ERROR_DIM);
naturalH1 = Math.max(naturalH1, MIN_ERROR_DIM);
} else if (!primaryIsVideo && (!naturalW1 || naturalW1 <= 0 || !naturalH1 || naturalH1 <= 0)) {
// Treat other invalid image dimensions as error case for sizing.
// console.warn("Primary image dimensions invalid or zero, assigning min error dimensions.");
naturalW1 = MIN_ERROR_DIM;
naturalH1 = MIN_ERROR_DIM;
} else if (primaryIsVideo && (!naturalW1 || naturalW1 <= 0 || !naturalH1 || naturalH1 <= 0)) {
// If video dimensions are somehow invalid/zero after load, treat similarly?
// console.warn("Primary video dimensions invalid or zero after load, assigning min error dimensions.");
naturalW1 = MIN_ERROR_DIM;
naturalH1 = MIN_ERROR_DIM;
}
} else {
// If primary element isn't displayed at all, use MIN dimensions for robustness
// This case shouldn't ideally happen if _show logic is correct.
// console.warn("Primary element not displayed in updateTransforms, assigning min error dimensions.");
naturalW1 = MIN_ERROR_DIM;
naturalH1 = MIN_ERROR_DIM;
}
let naturalW2 = 0, naturalH2 = 0;
if (isDualPageVisible) { // isDualPageVisible already implies secondaryElement is set and display=block
naturalW2 = secondaryIsVideo ? secondaryElement.videoWidth : secondaryElement.naturalWidth;
naturalH2 = secondaryIsVideo ? secondaryElement.videoHeight : secondaryElement.naturalHeight;
// Check for error state or invalid dimensions on the *image* element specifically
if (!secondaryIsVideo && secondaryElement.classList.contains('viewer-error-state')) {
naturalW2 = Math.max(naturalW2, MIN_ERROR_DIM);
naturalH2 = Math.max(naturalH2, MIN_ERROR_DIM);
} else if (!secondaryIsVideo && (!naturalW2 || naturalW2 <= 0 || !naturalH2 || naturalH2 <= 0)) {
// Use primary's dimensions if valid, else use min error dim.
if (naturalW1 > 0 && naturalH1 > 0 && naturalW1 !== MIN_ERROR_DIM) { // Check if primary has real dimensions
// console.warn("Secondary image dimensions invalid, using primary as fallback.");
naturalW2 = naturalW1; naturalH2 = naturalH1;
} else {
// console.warn("Secondary image dimensions invalid, assigning min error dimensions.");
naturalW2 = MIN_ERROR_DIM; naturalH2 = MIN_ERROR_DIM;
}
} else if (secondaryIsVideo && (!naturalW2 || naturalW2 <= 0 || !naturalH2 || naturalH2 <= 0)) {
// console.warn("Secondary video dimensions invalid or zero after load, assigning min error dimensions.");
naturalW2 = MIN_ERROR_DIM;
naturalH2 = MIN_ERROR_DIM;
}
}
// --- Get Viewport ---
const viewportWidth = this.parent.clientWidth;
const viewportHeight = this.parent.clientHeight;
// If viewport is zero, calculations below are meaningless. Return early.
if (viewportWidth <= 0 || viewportHeight <= 0) {
console.warn("Viewport dimensions are zero or negative in updateTransforms.");
return;
}
// --- Set Fit Mode Zoom Level (if not manually zoomed) ---
if (!this.userChangedZoom) {
if (this.fitMode == "fit-width") {
this.currentZoom = this._getFitWidthZoom(); // Uses MIN_ERROR_DIM internally now
} else if (this.fitMode == "one-to-one") {
this.currentZoom = this._getOneToOneZoom(); // Uses MIN_ERROR_DIM internally now
} else { // Default to fit-window
this.currentZoom = 1.0;
}
// Set min zoom based on the calculated 'fit' zoom, but ensure it's not > 1
// Ensure minZoomLevel is calculated based on the potentially adjusted fit-mode zoom.
// The base 'fit-window' zoom (currentZoom=1) acts as the lower bound here.
this.minZoomLevel = Math.min(this.currentZoom, 1.0);
}
// --- Calculate Base Scale (Fit-to-Viewport) ---
let baseScale = 1;
// Use the potentially adjusted naturalW1/H1/W2/H2 (which now include MIN_ERROR_DIM)
// Ensure primary dimensions are positive before calculating scale
if (naturalW1 <= 0 || naturalH1 <= 0) {
console.error("Primary dimensions are still <= 0 before scale calculation. Setting scale to 0.");
baseScale = 0; // Avoid division by zero
} else {
// Check secondary dimensions validity only if dual page is intended
let useDualForScale = isDualPageVisible;
if (useDualForScale && (naturalW2 <= 0 || naturalH2 <= 0)) {
console.warn("Secondary dimensions are <= 0 during scale calc, treating as single page for scaling.");
useDualForScale = false;
}
if (useDualForScale) {
let requiredWidthPerScale, requiredHeightPerScale;
if (!rotIs90) { // Side-by-side
requiredWidthPerScale = naturalW1 + naturalW2;
requiredHeightPerScale = Math.max(naturalH1, naturalH2);
} else { // Top-and-bottom
requiredWidthPerScale = Math.max(naturalH1, naturalH2);
requiredHeightPerScale = naturalW1 + naturalW2;
}
// Check for zero denominator before division
const scaleLimitW = requiredWidthPerScale > 0 ? viewportWidth / requiredWidthPerScale : Infinity;
const scaleLimitH = requiredHeightPerScale > 0 ? viewportHeight / requiredHeightPerScale : Infinity;
baseScale = Math.min(scaleLimitW, scaleLimitH);
} else { // Single page mode (or fallback)
const effectiveW = rotIs90 ? naturalH1 : naturalW1;
const effectiveH = rotIs90 ? naturalW1 : naturalH1;
// Check for zero denominator before division
const scaleLimitW = effectiveW > 0 ? viewportWidth / effectiveW : Infinity;
const scaleLimitH = effectiveH > 0 ? viewportHeight / effectiveH : Infinity;
baseScale = Math.min(scaleLimitW, scaleLimitH);
}
}
baseScale = Math.max(baseScale, 0); // Ensure non-negative scale
// --- Final Scale incorporating User Zoom ---
const finalScale = baseScale * this.currentZoom;
// --- Calculate Final CSS Dimensions for Media ---
// Uses potentially adjusted W/H. If error, these will be MIN_ERROR_DIM * finalScale
const primaryCssWidth = naturalW1 * finalScale;
const primaryCssHeight = naturalH1 * finalScale;
let secondaryCssWidth = 0, secondaryCssHeight = 0;
// Use isDualPageVisible which confirms secondary is displayed and dimensions should be valid (>=MIN_ERROR_DIM)
if (isDualPageVisible) {
secondaryCssWidth = naturalW2 * finalScale;
secondaryCssHeight = naturalH2 * finalScale;
}
// --- Apply Styles to Visible Media Elements ---
const rotationTransform = `rotate(${currentRotation}deg)`;
const primaryTransition = this.config.enableAnimations ? 'transform 0.3s ease-out' : 'none'; // Smoother ease-out
const secondaryTransition = this.config.enableAnimations ? 'transform 0.3s ease-out' : 'none';
primaryElement.style.width = primaryCssWidth + 'px';
primaryElement.style.height = primaryCssHeight + 'px';
primaryElement.style.transform = rotationTransform;
// Apply transition only if animations are enabled AND it's not the error state
// (error state might look weird transforming)
primaryElement.style.transition = (this.config.enableAnimations && !primaryElement.classList.contains('viewer-error-state')) ? primaryTransition : 'none';
if (isDualPageVisible) {
secondaryElement.style.width = secondaryCssWidth + 'px';
secondaryElement.style.height = secondaryCssHeight + 'px';
secondaryElement.style.transform = rotationTransform;
secondaryElement.style.transition = (this.config.enableAnimations && !secondaryElement.classList.contains('viewer-error-state')) ? secondaryTransition : 'none';
} else {
// Ensure *both* potential secondary elements have styles cleared if not visible
this.imgDisplay2.style.width = 'auto'; this.imgDisplay2.style.height = 'auto';
this.imgDisplay2.style.transform = ''; this.imgDisplay2.style.transition = '';
this.videoDisplay2.style.width = 'auto'; this.videoDisplay2.style.height = 'auto';
this.videoDisplay2.style.transform = ''; this.videoDisplay2.style.transition = '';
}
// --- Calculate Container Dimensions (based on final scaled media) ---
let containerContentWidth, containerContentHeight;
if (isDualPageVisible) {
if (!rotIs90) { // Side-by-side
containerContentWidth = primaryCssWidth + secondaryCssWidth;
containerContentHeight = Math.max(primaryCssHeight, secondaryCssHeight);
this.imgContainer.style.flexDirection = "row";
} else { // Top-and-bottom
containerContentWidth = Math.max(primaryCssWidth, secondaryCssWidth);
containerContentHeight = primaryCssHeight + secondaryCssHeight;
this.imgContainer.style.flexDirection = "column";
}
} else { // Single page
containerContentWidth = primaryCssWidth;
containerContentHeight = primaryCssHeight;
this.imgContainer.style.flexDirection = "row"; // Default for single
}
// --- Calculate Container Visual Size and Position ---
const visualContainerWidth = rotIs90 ? containerContentHeight : containerContentWidth;
const visualContainerHeight = rotIs90 ? containerContentWidth : containerContentHeight;
// Avoid negative dimensions which can cause issues
const clampedVisualWidth = Math.max(0, visualContainerWidth);
const clampedVisualHeight = Math.max(0, visualContainerHeight);
const needsScrollX = clampedVisualWidth > viewportWidth;
const needsScrollY = clampedVisualHeight > viewportHeight;
// Center container if smaller than viewport, align top-left if larger
let containerTop = needsScrollY ? 0 : (viewportHeight - clampedVisualHeight) / 2;
let containerLeft = needsScrollX ? 0 : (viewportWidth - clampedVisualWidth) / 2;
// Ensure top/left are not negative if calculation somehow results in it
containerTop = Math.max(0, containerTop);
containerLeft = Math.max(0, containerLeft);
// Apply position and size to the container
this.imgContainer.style.top = `${containerTop}px`;
this.imgContainer.style.left = `${containerLeft}px`;
this.imgContainer.style.width = `${clampedVisualWidth}px`;
this.imgContainer.style.height = `${clampedVisualHeight}px`;
// --- Update Parent Overflow ---
const oldOverflowX = this.parent.style.overflowX;
const oldOverflowY = this.parent.style.overflowY;
const newOverflowX = needsScrollX ? "auto" : "hidden";
const newOverflowY = needsScrollY ? "auto" : "hidden";
if (newOverflowX !== oldOverflowX) {
this.parent.style.overflowX = newOverflowX;
// Reset scroll only if changing *to* hidden (prevents jumpiness if already hidden)
if (newOverflowX === 'hidden' && this.parent.scrollLeft > 0) {
// Use requestAnimationFrame to avoid potential race conditions with layout updates
requestAnimationFrame(() => { this.parent.scrollLeft = 0; });
}
}
if (newOverflowY !== oldOverflowY) {
this.parent.style.overflowY = newOverflowY;
if (newOverflowY === 'hidden' && this.parent.scrollTop > 0) {
requestAnimationFrame(() => { this.parent.scrollTop = 0; });
}
}
}
_revokeBlobUrl(videoElement) {
if (videoElement._currentBlobUrl) {
URL.revokeObjectURL(videoElement._currentBlobUrl);
videoElement._currentBlobUrl = null;
}
}
_createImageViewer() {
this.parent = document.createElement('div');
this.parent.id = "Overlay";
Object.assign(this.parent.style, {
position: "fixed",
top: "0",
left: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0, 0, 0, 1)",
display: "none", // Initially hidden
overflowX: "hidden",
overflowY: "hidden",
zIndex: "9999",
cursor: "default",
});
// imgContainer: Positioned absolutely, calculations done in JS
this.imgContainer = document.createElement("div");
Object.assign(this.imgContainer.style, {
position: "absolute", // Manual positioning
display: "flex", // Still use flex for internal layout (dual page)
alignItems: "center", // Align images/videos within the flex row/column
justifyContent: "center", // Center images/videos within the flex row/column
gap: "0",
margin: "",
transform: "", // Rotations applied to images/videos directly
transformOrigin: "",
// Top/Left will be set dynamically
});
// --- Add Basic CSS for Error State and Message ---
const styleSheet = document.createElement("style");
styleSheet.textContent = `
.viewer-error-state {
background-color: #222; /* Dark background for the placeholder */
border: 1px dashed #555; /* Dashed border */
padding: 20px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
object-fit: contain; /* Prevent tiny gif from stretching weirdly */
image-rendering: pixelated; /* Keep tiny gif sharp if scaled up */
/* position: relative; /* Needed if error message was child */
}
.viewer-error-message {
/* Position it relative to the imgContainer */
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.75);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
font-family: Arial, sans-serif;
z-index: 10; /* Ensure it's above the image */
text-align: center;
max-width: 90%;
word-break: break-word; /* Important for long URLs */
box-sizing: border-box;
pointer-events: all; /* Allow clicking links */
}
.viewer-error-message a {
color: #9cf; /* Lighter blue for links */
text-decoration: underline;
}
`;
// Prepend to head to allow user styles potentially override later
document.head.insertBefore(styleSheet, document.head.firstChild);
// --- Create Image Elements ---
this.imgDisplay = document.createElement("img");
Object.assign(this.imgDisplay.style, {
// transition: "transform 0.3s", // Apply transition dynamically if needed
transformOrigin: "center",
margin: "0",
padding: "0",
display: "none", // Initially hidden, shown based on media type
maxWidth: "none",
maxHeight: "none",
width: "auto",
height: "auto",
// Transform (rotation) set dynamically
});
this.imgDisplay2 = document.createElement("img");
Object.assign(this.imgDisplay2.style, {
// transition: "transform 0.3s",
transformOrigin: "center",
margin: "0",
padding: "0",
display: "none", // Initially hidden
maxWidth: "none",
maxHeight: "none",
width: "auto",
height: "auto",
// Transform (rotation) set dynamically
});
// --- Create Video Elements ---
// Helper function to add hover controls to video elements
const addVideoHoverControls = (videoElement) => {
// Initially hide controls
videoElement.controls = false;
// Show controls on mouse enter
videoElement.addEventListener('mouseenter', () => {
// Only show controls if the video element is currently displayed
if (videoElement.style.display === 'block') {
videoElement.controls = true;
}
});
// Hide controls on mouse leave
videoElement.addEventListener('mouseleave', () => {
videoElement.controls = false;
});
};
this.videoDisplay = document.createElement("video");
this.videoDisplay.playsInline = true; // Important for mobile & some desktop contexts
this.videoDisplay.preload = "metadata"; // Load dimensions without full video
this.videoDisplay.muted = false;
this.videoDisplay.loop = this.loopEnabled; // Set initial loop state
Object.assign(this.videoDisplay.style, {
// No transition on video transform by default
transformOrigin: "center",
margin: "0",
padding: "0",
display: "none", // Initially hidden, shown based on media type
maxWidth: "none", // Allow explicit sizing
maxHeight: "none",
width: "auto",
height: "auto",
// Transform (rotation) set dynamically, potentially disabled for video
});
addVideoHoverControls(this.videoDisplay);
this.videoDisplay2 = document.createElement("video");
this.videoDisplay2.playsInline = true;
this.videoDisplay2.preload = "metadata";
this.videoDisplay2.muted = false;
this.videoDisplay2.loop = this.loopEnabled; // Set initial loop state
Object.assign(this.videoDisplay2.style, {
// No transition on video transform by default
transformOrigin: "center",
margin: "0",
padding: "0",
display: "none", // Initially hidden
maxWidth: "none",
maxHeight: "none",
width: "auto",
height: "auto",
// Transform (rotation) set dynamically
});
addVideoHoverControls(this.videoDisplay2);
this.gridParent = document.createElement('div');
Object.assign(this.gridParent.style, {
position: "fixed",
top: "0",
left: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0, 0, 0, 1)",
display: "none",
zIndex: "99999",
cursor: "default",
overflow: "auto",
// display: "none", // Already set
});
// Create container for buttons
this.buttonContainer = document.createElement('div');
Object.assign(this.buttonContainer.style, {
position: 'fixed',
top: '10px',
right: '20px',
display: 'flex',
gap: '8px',
zIndex: '10000', // Ensure buttons are above media container
transition: 'opacity 0.3s ease',
});
this._initializeToolbarButtons();
this.parent.appendChild(this.buttonContainer);
this._updateToolbarButtons();
if (this.config.viewerLabels !== 'disabled') {
this.pageNumberLabel = document.createElement('span');
Object.assign(this.pageNumberLabel.style, {
position: 'fixed',
bottom: '10px',
right: '20px',
backgroundColor: `rgba(0, 0, 0, ${this.UI_TRANSPARENCY})`,
color: 'white',
padding: '6px',
paddingTop: '4px',
fontSize: '18px',
borderRadius: '6px',
fontWeight: 'bold',
zIndex: '10000',
pointerEvents: 'none',
transition: 'opacity 0.3s ease',
});
if (this.config.viewerLabels === 'proximity') {
this.pageNumberLabel.style.opacity = '0';
}
this.parent.appendChild(this.pageNumberLabel);
}
// Append images AND videos to container, container to parent
// Order might matter for flex layout if items have different dimensions before scaling
this.imgContainer.appendChild(this.imgDisplay);
this.imgContainer.appendChild(this.videoDisplay); // Append video counterpart
this.imgContainer.appendChild(this.imgDisplay2);
this.imgContainer.appendChild(this.videoDisplay2); // Append video counterpart
this.parent.appendChild(this.imgContainer);
document.body.appendChild(this.parent);
// Note: gridParent is appended separately when grid view is toggled.
}
_initializeToolbarButtons() {
// Defines all toolbar buttons and their actions.
this.buttonDefinitions = [
{
prop: 'exitButton',
text: '⤬',
onClick: () => this.closeViewer(false),
altText: 'Exit viewer (Esc)',
offsetY: -2
},
{
prop: 'prevButton',
text: '←',
onClick: () => this.navigateBack(),
altText: 'Previous Page (Left Arrow)',
offsetY: 0
},
{
prop: 'nextButton',
text: '→',
onClick: () => this.navigateForward(),
altText: 'Next Page (Right Arrow)',
offsetY: 0
},
{
prop: 'rotateLeftButton',
text: '↺',
onClick: () => this.rotateImage(-90),
altText: 'Rotate Left (L)',
offsetY: -2
},
{
prop: 'rotateRightButton',
text: '↻',
onClick: () => this.rotateImage(90),
altText: 'Rotate Right (R)',
offsetY: -2
},
{
prop: 'zoomOutButton',
text: '-',
onClick: () => this.zoom(-1),
altText: 'Zoom Out (-)',
offsetY: 0
},
{
prop: 'zoomInButton',
text: '+',
onClick: () => this.zoom(1),
altText: 'Zoom In (+)',
offsetY: 0
},
{
prop: 'galleryViewButton',
text: '▦',
onClick: () => this.toggleGridView(),
altText: 'Toggle Gallery View (Backspace)',
offsetY: -1
},
{
prop: 'dualPageButton',
text: '◫',
onClick: () => this.toggleDualPageMode(),
altText: 'Toggle Dual Page Mode (Shift+D)',
offsetY: -1
},
{
prop: 'fullscreenButton',
text: '⛶',
onClick: () => this.toggleFullscreen(),
altText: 'Toggle Fullscreen (F)',
offsetY: -1
},
{
prop: 'downloadButton',
text: '⤓',
onClick: () => this.downloadCurrentImage(),
altText: 'Download Original Image (Shift+S)',
offsetY: 0
},
{
prop: 'findGalleriesButton',
text: '∈',
onClick: () => this.showGalleriesWithCurrentImage(),
altText: 'Find other galleries with this image (Shift+F)',
offsetY: -1
},
{
prop: 'gotoPageButton',
text: '⌕',
onClick: () => showGotoPageInput(),
altText: 'Goto Page (G)',
offsetY: -1,
offsetX: 1,
},
{
prop: 'chaptersButton',
text: '☰',
onClick: () => { this.toggleChapterSidebar(); },
altText: "Toggle Chapters (c)",
offsetY: -1,
condition: () => typeof chapterList !== 'undefined' && chapterList?.length,
},
{
prop: 'settingsButton',
text: '⚙',
onClick: () => {
if (this.config.showingUI()) {
this.config.closeUI();
} else {
this.config.showUI();
}
},
altText: "Edit Preferences (p)",
offsetY: -1,
offsetX: 1
},
{
prop: 'helpButton',
text: '?',
onClick: () => { this.toggleHelpOverlay(); },
altText: "Show Help (h)",
offsetY: 0
}
];
}
_updateToolbarButtons() {
this.buttonDefinitions.forEach(def => {
if (def.condition === undefined || def.condition()) {
this[def.prop] = this._createButton(def.text, def.onClick, def.altText, def.offsetY, def.offsetX);
}
});
const mode = this.config.helpAndSettingsButtons;
if (mode === 'disabled') {
this.buttonContainer.style.display = 'none';
return;
}
this.buttonContainer.style.display = 'flex';
if (mode === 'always') {
this.buttonContainer.style.opacity = '1';
this.buttonContainer.style.pointerEvents = 'auto';
} else if (mode === 'proximity') {
// Set initial state for proximity; mousemove handler will take over
this.buttonContainer.style.opacity = '0';
this.buttonContainer.style.pointerEvents = 'none';
}
// Clear container
while (this.buttonContainer.firstChild) {
this.buttonContainer.removeChild(this.buttonContainer.firstChild);
}
const buttonOrder = [
{ config: 'showHelpButton', button: this.helpButton },
{ config: 'showSettingsButton', button: this.settingsButton },
{ config: 'showChaptersButton', button: this.chaptersButton },
{ config: 'showGalleryViewButton', button: this.galleryViewButton },
{ config: 'showDualPageButton', button: this.dualPageButton },
{ config: 'showDownloadButton', button: this.downloadButton },
{ config: 'showFindGalleriesButton', button: this.findGalleriesButton },
{ config: 'showGotoPageButton', button: this.gotoPageButton },
{ config: 'showRotateButtons', button: this.rotateLeftButton },
{ config: 'showRotateButtons', button: this.rotateRightButton },
{ config: 'showZoomButtons', button: this.zoomOutButton },
{ config: 'showZoomButtons', button: this.zoomInButton },
{ config: 'showNavButtons', button: this.prevButton },
{ config: 'showNavButtons', button: this.nextButton },
{ config: 'showFullscreenButton', button: this.fullscreenButton },
{ config: 'showExitButton', button: this.exitButton },
];
buttonOrder.forEach(item => {
if (this.config[item.config] && item.button) {
this.buttonContainer.appendChild(item.button);
}
});
}
_initializeEventListeners() {
this.parent.addEventListener("mousemove", (e) => {
this._handleToolbarProximity(e);
this._handlePageLabelProximity(e);
});
window.addEventListener("resize", () => {
if (this.isActive()) this.updateTransforms();
});
document.addEventListener("fullscreenchange", () => {
if (this.useFullscreen && !isFullscreen() && this.isActive()) {
this.closeViewer(this.config.exitToViewerPage);
}
});
this._onSaveUnsubscribe = this.config.onSaveClick(() => this._handleConfigSave());
}
_handleToolbarProximity(e) {
if (this.config.helpAndSettingsButtons !== 'proximity' || !this.isActive()) {
return;
}
const proximityThreshold = 120; // pixels from the top
if (e.clientY < proximityThreshold) {
this.buttonContainer.style.opacity = '1';
this.buttonContainer.style.pointerEvents = 'auto';
} else {
this.buttonContainer.style.opacity = '0';
this.buttonContainer.style.pointerEvents = 'none';
}
}
_handlePageLabelProximity(e) {
if (this.config.viewerLabels !== 'proximity' || !this.isActive() || !this.pageNumberLabel) {
return;
}
const verticalProximity = 120; // pixels from the bottom edge
const horizontalProximity = this.pageNumberLabel.offsetWidth + 120;
if (e.clientX > window.innerWidth - horizontalProximity && e.clientY > window.innerHeight - verticalProximity) {
this.pageNumberLabel.style.opacity = '1';
} else {
this.pageNumberLabel.style.opacity = '0';
}
}
// Applies new config values
// Currently does not fully update the UI
_handleConfigSave() {
this.fitMode = this.config.fitMode;
this.pinSidebar = this.config.pinSidebar;
this.useFullscreen = this.config.useFullscreen;
this.rightToLeftMode = this.config.openInRightToLeftMode;
this.dualPageMode = this.config.openInDualPageMode;
this.dualLayout = this.config.dualLayout;
this.autoplayEnabled = this.config.videoConfig.autoplay;
this.loopEnabled = this.config.videoConfig.loop;
this.currentZoom = 1;
this._updateToolbarButtons();
// Handle page number label update
if (this.pageNumberLabel) {
this.pageNumberLabel.remove();
this.pageNumberLabel = null;
}
if (this.config.viewerLabels !== 'disabled') {
this.pageNumberLabel = document.createElement('span');
Object.assign(this.pageNumberLabel.style, {
position: 'fixed',
bottom: '10px',
right: '20px',
backgroundColor: `rgba(0, 0, 0, ${this.UI_TRANSPARENCY})`,
color: 'white',
padding: '6px',
paddingTop: '4px',
fontSize: '18px',
borderRadius: '6px',
fontWeight: 'bold',
zIndex: '10000',
pointerEvents: 'none',
transition: 'opacity 0.3s ease',
});
if (this.config.viewerLabels === 'proximity') {
this.pageNumberLabel.style.opacity = '0';
}
this.parent.appendChild(this.pageNumberLabel);
}
if (!this.config.enableSidebar && this.sidebarIsActive()) {
this.sidebarHideInstant();
}
if (this.isActive()) {
this.loadAndShowIndex(this.currentIndex);
}
}
_createButton(text, onClick, altText = "", offsetY = 0, offsetX = 0) {
const button = document.createElement('button');
button.title = altText || text; // Fallback to button text if no altText provided
Object.assign(button.style, {
width: this.config.buttonSize + 'px',
height: this.config.buttonSize + 'px',
borderRadius: '50%',
border: '1px solid rgba(255,255,255,0.2)',
background: `rgba(0,0,0,${this.UI_TRANSPARENCY})`,
color: 'white',
cursor: 'pointer',
fontSize: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative'
});
// Create a span for the text content
const textSpan = document.createElement('span');
textSpan.textContent = text;
textSpan.style.display = 'block';
if (offsetX !== 0) {
textSpan.style.transform += `translateX(${offsetX}px) `;
}
if (offsetY !== 0) {
textSpan.style.transform += `translateY(${offsetY}px)`;
}
button.appendChild(textSpan);
button.addEventListener('click', onClick);
// Add hover feedback
button.addEventListener('mouseenter', () => {
button.style.background = 'rgba(255, 255, 255, 0.9)';
button.style.color = 'black';
});
button.addEventListener('mouseleave', () => {
button.style.background = `rgba(0,0,0,${this.UI_TRANSPARENCY})`;
button.style.color = 'white';
});
// Add button to tracked buttons array
if (!this._trackedButtons) {
this._trackedButtons = [];
}
this._trackedButtons.push(button);
return button;
}
_addClickAwayEventListener(element, activeCheckFunc, onClickAway, removeOnClickAway = true) {
const clickAwayHandler = (e) => {
if (activeCheckFunc() && !element.contains(e.target)) {
const isTrackedButton = this._trackedButtons?.some(button =>
button.contains(e.target)
);
if (!isTrackedButton) {
onClickAway();
if (removeOnClickAway) {
document.removeEventListener('click', clickAwayHandler);
}
}
}
};
document.addEventListener('click', clickAwayHandler);
// Return cleanup function
return () => {
document.removeEventListener('click', clickAwayHandler);
};
}
isHelpOverlayVisible() {
return this.helpOverlay.style.display === 'flex';
}
toggleHelpOverlay() {
const isVisible = this.isHelpOverlayVisible();
this.helpOverlay.style.display = isVisible ? 'none' : 'flex';
if (!isVisible) {
// Add click away listener when showing
this._helpOverlayClickAwayCleanup = this._addClickAwayEventListener(
this.helpBox,
() => this.isHelpOverlayVisible(),
() => this.toggleHelpOverlay()
);
} else if (this._helpOverlayClickAwayCleanup) {
this._helpOverlayClickAwayCleanup();
this._helpOverlayClickAwayCleanup = null;
}
}
_initializeHelpOverlay() {
// Create overlay element with styles
this.helpOverlay = document.createElement("div");
this.helpOverlay.id = 'HelpOverlay';
Object.assign(this.helpOverlay.style, {
position: "fixed",
top: "0px", // Use strings for CSS values
left: "0px",
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.8)",
display: "none", // Initially hidden
alignItems: "center",
justifyContent: "center",
zIndex: "2147483647", // Max z-index is usually fine
padding: "14px",
boxSizing: "border-box"
});
// Create help box element - This is now a Flex Container
this.helpBox = document.createElement("div");
Object.assign(this.helpBox.style, {
backgroundColor: "rgb(34, 34, 34)", // Use rgb() format or hex #222
color: "rgb(241, 241, 241)", // Use rgb() format or hex #f1f1f1
borderRadius: "8px",
// Padding moved to inner elements or adjusted later if needed
// padding: "12px", // Remove outer padding if header/content manage their own
maxWidth: "600px",
width: "100%",
maxHeight: "100%", // Constrains the overall box size
// overflowY: "auto", // REMOVE - Scrolling handled by inner div
boxShadow: "0 6px 12px rgba(0, 0, 0, 0.5)",
fontFamily: "Arial, sans-serif",
lineHeight: "1.6",
fontSize: "13px",
// position: "relative", // Not strictly needed if not using absolute inside
display: "flex", // Use Flexbox
flexDirection: "column", // Stack children vertically
overflow: "hidden" // Hide any potential overflow from children before scroll kicks in
});
// --- Create Header Div ---
const headerDiv = document.createElement("div");
Object.assign(headerDiv.style, {
display: "flex",
justifyContent: "space-between", // Pushes title left, button right
alignItems: "center",
padding: "10px 12px", // Vertical and horizontal padding for the header
borderBottom: "1px solid #444", // Separator line
flexShrink: "0" // Prevent header from shrinking if content is large
});
// Create Title
const title = document.createElement("strong"); // Use <strong> or h2/h3
title.textContent = "Shortcuts";
Object.assign(title.style, {
fontSize: "1.1em", // Slightly larger than base font
fontWeight: "bold"
});
// Create Close Button (remove position: absolute)
const closeButton = document.createElement("button");
closeButton.textContent = "×";
Object.assign(closeButton.style, {
// position: "absolute", // REMOVED
// top: "8px", // REMOVED
// right: "8px", // REMOVED
background: "transparent",
border: "none",
color: "rgb(241, 241, 241)",
fontSize: "24px",
cursor: "pointer",
padding: "0 4px", // Adjust padding slightly if needed
lineHeight: "1", // Ensure it aligns nicely vertically
marginLeft: "10px" // Add some space between title and button
});
closeButton.addEventListener("click", () => {
this.toggleHelpOverlay(); // Assuming this function exists to hide the overlay
});
// Add title and button to header
headerDiv.appendChild(title);
headerDiv.appendChild(closeButton);
// --- Create Table Wrapper for Scrolling ---
const tableWrapper = document.createElement("div");
Object.assign(tableWrapper.style, {
overflowY: "auto", // Enable vertical scrolling ONLY for this div
flexGrow: "1", // Allow this div to take up remaining vertical space
minHeight: "0", // Crucial for flex-grow + overflow to work correctly
padding: "0 12px 12px 12px" // Add padding around the table (top padding from header)
});
const shortcuts = [
{ key: "Esc/Q", action: "Exit viewer (Shift+Q: exit to current page)" },
{ key: "Right/D/./Space", action: "Next Page (Shift+key: skip panning)" },
{ key: "Left/A/,", action: "Previous Page (Shift+key: skip panning)" },
{ key: "Shift+PageDown", action: "Next Chapter" },
{ key: "Shift+PageUp", action: "Previous Chapter" },
{ key: "Home", action: "First Page" },
{ key: "End", action: "Last Page" },
{ key: "+ / =", action: "Zoom In" },
{ key: "- / _", action: "Zoom Out (Min: Fit screen)" },
{ key: "R", action: "Rotate all 90°" },
{ key: "L", action: "Rotate all -90°" },
{ key: "S", action: "Toggle sidebar pin" },
{ key: "C", action: "Show/hide chapter list" },
{ key: "G", action: "Go to page (available everywhere)" },
{ key: "Backspace", action: "Show/Hide gallery view" },
{ key: "Shift+D", action: "Toggle dual page mode" },
{ key: "Shift+A", action: "Enable dual page mode or cycle layout" },
{ key: "Shift+R", action: "Toggle right to left mode" },
{ key: "F", action: "Toggle fullscreen" },
{ key: "Delete", action: "Remove image" },
{ key: "Ctrl+C", action: "Copy image url" },
{ key: "Ctrl+V", action: "Replace image with clipboard image or url" },
{ key: "Shift+S", action: "Save original image" },
{ key: "P", action: "Open preferences (available everywhere)" },
{ key: "1", action: "Reset zoom to fit screen" },
{ key: "2", action: "Set 1:1 pixel zoom" },
{ key: "3", action: "Zoom to fill view width" },
{ key: "H or ?", action: "Show/Hide this help overlay" },
];
// Build table inner HTML using a loop
let rows = "";
shortcuts.forEach((item, index) => {
// Remove bottom border from the very last row for cleaner look
const borderStyle = index === shortcuts.length - 1 ? "" : "border-bottom: 1px solid #333;";
rows += `<tr style="${borderStyle}">
<td style="padding: 8px; text-align: right; width: 30%; vertical-align: top; font-weight: bold;"><code>${item.key}</code></td>
<td style="padding: 8px; text-align: left; vertical-align: top;">${item.action}</td>
</tr>`;
});
// Use innerHTML on a new table element
const table = document.createElement('table');
Object.assign(table.style, {
width: "100%",
borderCollapse: "collapse"
// Removed overflow and height styles
});
table.innerHTML = `<tbody>${rows}</tbody>`;
// Add the table to the scrolling wrapper
tableWrapper.appendChild(table);
// --- Assemble the helpBox ---
this.helpBox.appendChild(headerDiv); // Add fixed header first
this.helpBox.appendChild(tableWrapper); // Add scrollable content below
// Add helpBox to the main overlay
this.helpOverlay.appendChild(this.helpBox);
// Append overlay to the parent element (ensure 'this.parent' is defined correctly)
if (this.parent) {
this.parent.appendChild(this.helpOverlay);
} else {
console.error("Parent element for HelpOverlay not found!");
// Fallback to body, though might not be ideal depending on context
document.body.appendChild(this.helpOverlay);
}
// Add event listener to close overlay when clicking the background
this.helpOverlay.addEventListener('click', (event) => {
if (event.target === this.helpOverlay) { // Only close if backdrop itself is clicked
this.toggleHelpOverlay();
}
});
}
_initializeSidebar() {
this._createSidebarParent();
this.sidebar = new GridView(this.sidebarParent, this.config.sidebarGridConfig, false);
document.addEventListener('mousemove', async (e) => {
this._sidebarShowOrHide(e);
});
}
_createSidebarParent() {
this.sidebarParent = document.createElement('div');
let posCss = "";
if (this.config.sidebarPosition === "right") {
this.sidebarHiddenTransform = "translateX(100%)";
posCss = `
top: 0;
right: 0;
width: ${this.config.sidebarWidth}px;
height: 100%;
overflow-y: auto;
box-shadow: -2px 0 5px rgba(0,0,0,0.2);
`;
} else if (this.config.sidebarPosition === "left") {
this.sidebarHiddenTransform = "translateX(-100%)";
posCss = `
top: 0;
left: 0;
width: ${this.config.sidebarWidth}px;
height: 100%;
overflow-y: auto;
box-shadow: 2px 0 5px rgba(0,0,0,0.2);
`;
}
this.sidebarTransition = "transform 0.3s";
const baseCss = `
position: fixed;
background: rgb(24,24,24);
transform: ${this.sidebarHiddenTransform};
transition: ${this.sidebarTransition};
z-index: 999999;
`;
// Apply the combined CSS
this.sidebarParent.style.cssText = baseCss + posCss;
if (config.sidebarPosition === "top" || config.sidebarPosition === "bottom") {
this.sidebarVisibleTransform = "translateY(0)";
} else {
this.sidebarVisibleTransform = "translateX(0)";
}
// Append to parent, not viewerContainer
this.parent.appendChild(this.sidebarParent);
}
sidebarIsActive() {
return this.sidebarVisible;
}
sidebarHideInstant() {
this.sidebarParent.style.transition = "none";
this.sidebarParent.style.transform = this.sidebarHiddenTransform;
this.sidebarVisible = false;
this.sidebar.stopLoading();
setTimeout(() => {
this.sidebarParent.style.transition = this.sidebarTransition;
}, 0);
}
_sidebarShowOrHide(e = null) {
// Only expand sidebar if the viewer is active AND grid view is not shown
if (!this.config.enableSidebar || !this.isActive() || this.isGridViewActive()) {
// Ensure sidebar is hidden if grid view is shown or sidebar is disabled
if (this.isGridViewActive() || !this.config.enableSidebar) {
this.sidebarParent.style.transform = this.sidebarHiddenTransform;
this.sidebarVisible = false;
this.sidebar.stopLoading();
}
return;
}
// Determine if the sidebar was hidden based on its transform value.
const sidebarWasHidden = this.sidebarParent.style.transform === this.sidebarHiddenTransform;
this.sidebarVisible = false;
if (e) {
let nearEdge = false;
let overSidebar = false;
if (config.sidebarPosition === "left") {
overSidebar = e.clientX <= config.sidebarWidth && !sidebarWasHidden;
nearEdge = e.clientX < 50;
} else if (config.sidebarPosition === "right") {
overSidebar = e.clientX >= (window.innerWidth - config.sidebarWidth) && !sidebarWasHidden;
nearEdge = e.clientX > (window.innerWidth - 50);
} else if (config.sidebarPosition === "top") {
overSidebar = e.clientY <= config.sidebarWidth && !sidebarWasHidden;
nearEdge = e.clientY < 50;
} else if (config.sidebarPosition === "bottom") {
overSidebar = e.clientY >= (window.innerHeight - config.sidebarWidth) && !sidebarWasHidden;
nearEdge = e.clientY > (window.innerHeight - 50);
}
this.sidebarVisible = nearEdge || overSidebar;
}
// config.pinSidebar can force the sidebar visible.
this.sidebarVisible |= this.pinSidebar;
if (this.sidebarVisible) {
this.sidebarParent.style.transform = this.sidebarVisibleTransform;
if (!this.sidebar.showCalled) {
this.sidebar.showGridView();
} else {
this.sidebar.enableLoading();
}
if (sidebarWasHidden) {
this.sidebar.scrollToIndex(this.currentIndex, false);
}
} else {
this.sidebarParent.style.transform = this.sidebarHiddenTransform;
this.sidebar.stopLoading();
}
}
toggleChapterSidebar() {
if (!chapterList || chapterList.length === 0) {
createNotification("No chapters available");
return;
}
if (!this.chapterSidebar) {
this._createChapterSidebar();
}
const isVisible = this.chapterSidebarIsActive();
this.chapterSidebar.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
this._chapterSidebarClickAwayCleanup = this._addClickAwayEventListener(
this.chapterSidebar,
() => this.chapterSidebarIsActive(),
() => this.toggleChapterSidebar()
);
} else if (this._chapterSidebarClickAwayCleanup) {
this._chapterSidebarClickAwayCleanup();
this._chapterSidebarClickAwayCleanup = null;
}
}
chapterSidebarIsActive() {
return this.chapterSidebar && this.chapterSidebar.style.display === 'block';
}
_createChapterSidebar() {
// Create sidebar container
this.chapterSidebar = document.createElement('div');
Object.assign(this.chapterSidebar.style, {
position: 'fixed',
top: '0',
left: '0',
width: `${this.config.sidebarWidth}px`,
height: '100vh',
backgroundColor: 'rgb(24,24,24)',
color: '#fff',
overflowY: 'auto',
padding: '20px',
boxSizing: 'border-box',
zIndex: '9999999',
boxShadow: '2px 0 5px rgba(0,0,0,0.2)',
display: 'none'
});
// Create title
const title = document.createElement('h3');
title.textContent = 'Chapters';
title.style.margin = '0 0 20px 0';
title.style.fontSize = '18px';
title.style.fontWeight = 'bold';
title.style.textAlign = 'center';
this.chapterSidebar.appendChild(title);
// Create a <style> element for our table CSS
const style = document.createElement('style');
style.textContent = `
/* Table for chapter sidebar */
.chapter-table {
width: 100%;
border-collapse: collapse;
}
.chapter-table tr {
border-bottom: 1px solid #333;
cursor: pointer;
}
.chapter-table td {
padding: 8px;
vertical-align: top;
}
.chapter-number {
text-align: right;
font-size: 13px;
font-weight: bold;
color: #f1f1f1;
white-space: nowrap;
width: 1%; /* Let the browser allocate enough width for the content */
}
.chapter-desc {
text-align: left;
font-size: 13px;
color: #aaa;
word-break: break-word;
}
.chapter-table tr:hover .chapter-desc {
color: #fff;
}
`;
this.chapterSidebar.appendChild(style);
// Create table container
const table = document.createElement('table');
table.className = 'chapter-table';
chapterList.forEach((chapter) => {
const tr = document.createElement('tr');
// Add click event so the whole row is clickable
tr.addEventListener('click', () => {
this.loadAndShowIndex(chapter.index);
});
// Create chapter number cell
const tdNumber = document.createElement('td');
tdNumber.className = 'chapter-number';
tdNumber.textContent = chapter.index + 1;
// Create description cell
const tdDesc = document.createElement('td');
tdDesc.className = 'chapter-desc';
// Create description text, remove any duplicate chapter number at the start
const first = (chapter.linkText || "").trim();
const second = (chapter.description || "").trim();
const sep = first.endsWith('.') && second.startsWith('.') ? "" : " ";
let text = (first + sep + second).trim();
// Remove a potential leading chapter number (with optional leading zeroes)
const pageNumber = (chapter.index + 1).toString();
const numberPattern = new RegExp(`^(?:P)?0*${pageNumber}`);
if (numberPattern.test(text)) {
text = text.replace(numberPattern, '').trim();
// Remove a following punctuation or whitespace if present:
if (text.startsWith('.') || text.startsWith('-') || text.startsWith(':') ||
text.startsWith('•') || text.startsWith('■') || text.startsWith('・')) {
text = text.slice(1).trim();
}
}
// Find longest section of dots (2 or more)
text = text.replaceAll('…', '...').replaceAll(' .', '.');
const dotSections = text.match(/\.{2,}|-{2,}/g) || [];
const longestDotSection = dotSections.reduce((longest, dots) =>
dots.length > longest.length ? dots : longest, '');
if (longestDotSection.length >= 2) {
// Split text by the longest dot section
const [firstPart, secondPart] = text.split(longestDotSection);
tdDesc.innerHTML = `${firstPart.trim()}<br>${secondPart.trim()}`;
} else {
tdDesc.textContent = text;
}
tr.appendChild(tdNumber);
tr.appendChild(tdDesc);
table.appendChild(tr);
});
this.chapterSidebar.appendChild(table);
this.parent.appendChild(this.chapterSidebar);
}
/**
* Determines the primary and secondary indices to display based on the target index and dual page mode setting.
* @param {number} targetIndex - The index the user intends to navigate to or view.
* @returns {{primary: number | null, secondary: number | null}} - The indices to display. Returns null for an index if it's invalid, out of bounds, or marked as 'deleted'.
*/
_getDualPageIndices(targetIndex) {
if (!this.dualPageMode || targetIndex < 0 || targetIndex >= thumbs.length) {
// Handle single page mode, or invalid target index
const isValid = targetIndex >= 0 && targetIndex < thumbs.length && thumbs[targetIndex] !== 'deleted';
return { primary: isValid ? targetIndex : null, secondary: null };
}
const layout = this.dualLayout;
let primary = null;
let secondary = null;
const lastIndex = thumbs.length - 1;
// --- Determine potential primary/secondary based on mode ---
if (layout === 'selected-first') {
// Always show targetIndex on left, targetIndex+1 on right if possible.
primary = targetIndex;
if (targetIndex < lastIndex) {
secondary = targetIndex + 1;
}
} else if (layout === 'odd-first') {
// Left page (primary) must have odd page number (index is even)
if (targetIndex % 2 === 0) { // Target is even (Page is odd) -> Target is primary
primary = targetIndex;
if (targetIndex < lastIndex) secondary = targetIndex + 1;
} else { // Target is odd (Page is even) -> Previous index is primary
primary = targetIndex - 1;
secondary = targetIndex;
}
} else if (layout === 'even-first') {
// Left page (primary) must have even page number (index is odd)
if (targetIndex % 2 !== 0) { // Target is odd (Page is even) -> Target is primary
primary = targetIndex;
if (targetIndex < lastIndex) secondary = targetIndex + 1;
} else { // Target is even (Page is odd) -> Previous index is primary (if possible)
if (targetIndex > 0) {
primary = targetIndex - 1;
secondary = targetIndex;
} else { // Target is 0 (Page 1) -> Show only page 1
primary = 0;
secondary = null;
}
}
}
// --- Validate and Clean Up Indices ---
const isValid = (idx) => idx !== null && idx >= 0 && idx < thumbs.length && thumbs[idx] !== 'deleted';
if (!isValid(primary)) primary = null;
if (!isValid(secondary)) secondary = null;
// If primary became invalid, but secondary is valid, maybe promote secondary?
// Or maybe the target was invalid to begin with. Let's keep it simple:
// If the intended primary (based on logic) is invalid, it's null.
if (primary === null && secondary !== null && this.dualPageMode) {
// If aiming for dual page, but primary is invalid, show secondary as single?
// This edge case depends on desired behavior. Showing only secondary might be confusing.
// Let's return null primary, valid secondary for now. loadAndShowIndex will handle it.
// console.warn(`_getDualPageIndices: Primary index ${primary} invalid, secondary ${secondary} valid.`);
}
// Ensure primary and secondary are not the same
if (primary === secondary) secondary = null;
// Interchange the indices in right-to-left mode *before* final promotion.
if (this.rightToLeftMode) {
[primary, secondary] = [secondary, primary];
}
// Final check: If we ended up with only a secondary index in dual mode, treat as single page secondary.
// This can happen if the pairing logic results in an invalid primary index (e.g., at the start of a book).
// It's also critical for RTL mode, where swapping might leave the primary slot empty.
// Enforce that the primary slot is filled if at least one page is available.
if (primary === null && secondary !== null) {
primary = secondary;
secondary = null;
}
return { primary, secondary };
}
/**
* Creates and displays an error message div for a specific slot.
* Removes any existing error message for that slot first.
* @param {'primary' | 'secondary'} slot - The slot ('primary' or 'secondary').
* @param {string} messageHtml - The HTML content for the error message.
*/
_displayErrorMessage(slot, messageHtml) {
// 1. Remove any existing error message specifically for this slot
const existingErrorMsg = this.parent.querySelector(`.viewer-error-message[data-slot="${slot}"]`);
if (existingErrorMsg) {
existingErrorMsg.remove();
}
// 2. Create the new error message div
const errorDiv = document.createElement('div');
errorDiv.className = 'viewer-error-message'; // Apply base class
errorDiv.setAttribute('data-slot', slot);
errorDiv.innerHTML = messageHtml;
// 3. Apply positioning styles based on mode
if (this.dualPageMode) {
errorDiv.style.maxWidth = '300px';
errorDiv.style.transform = 'translateY(-50%)';
errorDiv.style.left = (slot === 'primary') ? '0' : 'auto';
errorDiv.style.right = (slot === 'primary') ? 'auto' : '0';
} else {
// Non-dual mode: center it
errorDiv.style.maxWidth = '90%'; // Default max-width
errorDiv.style.transform = 'translate(-50%, -50%)';
errorDiv.style.left = '50%';
errorDiv.style.right = 'auto'; // Important to reset right
}
// 4. Append the newly created and styled div
this.parent.appendChild(errorDiv);
}
/**
* Core display function. Sets sources, handles visibility of img/video elements,
* waits for media load, and updates transforms. Handles null URLs by displaying an error state.
* Attempts to load a fallback URL if the primary URL fails and `config.useFallbackImages` is true.
* @param {object} urlObj - Object with primary and optional secondary URLs and their fallbacks.
* e.g., { primary: 'url1', primaryFallback: 'fallback1', secondary: 'url2', secondaryFallback: 'fallback2' }
* If urlObj.secondary is undefined, the secondary display is hidden.
* If a primary URL (e.g., urlObj.primary) is null, an error state is shown or fallback is attempted.
* @param {object} isVideoObj - Object indicating if primary/secondary are videos. e.g., { primary: false, secondary: true }
*/
async _show(urlObj, isVideoObj) {
if (!this.isActive() && this.parent.style.display !== "block") {
console.warn("_show called while viewer inactive, aborting.");
return;
}
const mediaSetups = [];
const ERROR_CLASS = 'viewer-error-state';
const removeDim = (element) => {
if (element && element.style.opacity !== '' && parseFloat(element.style.opacity) < 1) {
element.style.transition = 'none';
element.style.opacity = 1;
requestAnimationFrame(() => {
if (element.style.opacity === '1') {
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
}
});
} else if (element) {
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
}
};
// Helper to attempt loading a single URL (either primary or fallback)
const attemptLoadMediaInternal = async (urlToLoad, isVideo, imgElement, videoElement, slotName, attemptType /* 'initial' or 'fallback' */) => {
console.log(`_show: Attempting to load ${attemptType} URL for ${slotName}: ${urlToLoad}`);
if (urlToLoad === null) { // Should not happen if called correctly, but safety.
return Promise.reject(new Error(`URL for ${slotName} (${attemptType}) is null.`));
}
let loadPromise;
if (isVideo) {
imgElement.style.display = "none";
if (imgElement.src) imgElement.removeAttribute('src');
videoElement.referrerPolicy = "no-referrer";
this._revokeBlobUrl(videoElement);
if (videoElement.currentSrc !== urlToLoad) videoElement.src = urlToLoad;
videoElement.style.display = "block";
loadPromise = waitForMediaLoad(videoElement)
.catch(async (initialError) => {
const mediaErrorCode = videoElement.error?.code;
// This 'useFetchFallback' is for GM_xmlhttpRequest for videos, separate from image fallback.
if ((mediaErrorCode === 2 || mediaErrorCode === 4) && this.config.useFetchFallback) {
console.log(`Direct video load failed (Code: ${mediaErrorCode}). Attempting GM_xmlhttpRequest fallback for ${slotName} ${attemptType} URL: ${urlToLoad}`);
try {
const fetchResult = await this.getCachedMediaBlob(urlToLoad);
const blobUrl = fetchResult.blobUrl;
this._revokeBlobUrl(videoElement);
videoElement._currentBlobUrl = blobUrl;
videoElement.src = blobUrl;
return waitForMediaLoad(videoElement);
} catch (fetchError) {
console.error(`GM_xmlhttpRequest fallback failed for ${slotName} ${attemptType} URL (${urlToLoad}):`, fetchError);
throw initialError; // Propagate original error if GM_XHR fallback fails
}
}
throw initialError; // Propagate original error if not eligible for GM_XHR fallback
});
} else { // Handle Image
videoElement.style.display = "none";
if (videoElement.src) videoElement.removeAttribute('src');
imgElement.referrerPolicy = "no-referrer";
if (imgElement.src !== urlToLoad) imgElement.src = urlToLoad;
imgElement.style.display = "block";
loadPromise = waitForMediaLoad(imgElement);
}
return loadPromise;
};
// --- Helper to manage element visibility and src, now with fallback logic ---
const setupMediaElement = (imgElement, videoElement, initialAttemptUrl, fallbackAttemptUrl, isVideo, isPrimary) => {
const slot = isPrimary ? 'primary' : 'secondary';
const existingErrorMsg = this.parent.querySelector(`.viewer-error-message[data-slot="${slot}"]`);
if (existingErrorMsg) existingErrorMsg.remove();
imgElement.classList.remove(ERROR_CLASS);
imgElement.style.backgroundColor = ''; imgElement.style.border = '';
if (!videoElement.paused) { videoElement.loop = false; videoElement.pause(); videoElement.currentTime = 0; }
let loadSequencePromise = (() => {
if (initialAttemptUrl === null) {
if (this.config.useFallbackImages && fallbackAttemptUrl) {
// console.log(`Initial URL for ${slot} is null. Attempting fallback: ${fallbackAttemptUrl}`);
return attemptLoadMediaInternal(fallbackAttemptUrl, isVideo, imgElement, videoElement, slot, 'fallback (due to null initial)')
.catch(errorFromFallback => {
throw { type: 'load_failure', primaryError: new Error('Initial URL was null.'), fallbackError: errorFromFallback, initialUrl: null, fallbackUrl: fallbackAttemptUrl };
});
} else {
// No fallback or fallback not viable for an initially null URL
// console.log(`Initial URL for ${slot} is null, and no fallback specified or usable.`);
return Promise.reject({ type: 'explicit_null', url: null });
}
}
// Initial URL is not null, attempt to load it
return attemptLoadMediaInternal(initialAttemptUrl, isVideo, imgElement, videoElement, slot, 'initial')
.catch(errorFromPrimary => {
if (this.config.useFallbackImages && fallbackAttemptUrl && fallbackAttemptUrl !== initialAttemptUrl) {
console.warn(`Media load for ${slot} (${initialAttemptUrl}) failed. Attempting fallback: ${fallbackAttemptUrl}`);
return attemptLoadMediaInternal(fallbackAttemptUrl, isVideo, imgElement, videoElement, slot, 'fallback')
.catch(errorFromFallback => {
throw { type: 'load_failure', primaryError: errorFromPrimary, fallbackError: errorFromFallback, initialUrl: initialAttemptUrl, fallbackUrl: fallbackAttemptUrl };
});
}
// No fallback configured, or fallback URL is same/invalid
throw { type: 'load_failure', primaryError: errorFromPrimary, initialUrl: initialAttemptUrl, fallbackUrl: null };
});
})();
const loadCompletionPromise = loadSequencePromise
.then(result => ({
status: 'fulfilled',
value: result,
setup: { imgElement, videoElement, isPrimary, initialUrl: initialAttemptUrl, fallbackUrl: fallbackAttemptUrl }
}))
.catch(errorInfo => ({
status: 'rejected',
reason: errorInfo, // Contains type, errors, urls
setup: { imgElement, videoElement, isPrimary, initialUrl: initialAttemptUrl, fallbackUrl: fallbackAttemptUrl }
}))
.finally(() => {
removeDim(imgElement);
removeDim(videoElement);
});
// isErrorState for originalLoadPromise is now less relevant as completionPromise handles the outcome.
// The key is how 'reason' is structured in case of rejection.
return { originalLoadPromise: loadSequencePromise, loadCompletionPromise, isErrorState: false /* Deprecate this direct flag */, imgElement, videoElement, isPrimary, url: initialAttemptUrl };
};
// --- Setup Primary Media ---
mediaSetups.push(setupMediaElement(this.imgDisplay, this.videoDisplay, urlObj.primary, urlObj.primaryFallback, isVideoObj.primary, true));
// --- Setup Secondary Media ---
const shouldDisplaySecondary = urlObj.secondary !== undefined;
if (shouldDisplaySecondary) {
mediaSetups.push(setupMediaElement(this.imgDisplay2, this.videoDisplay2, urlObj.secondary, urlObj.secondaryFallback, isVideoObj.secondary, false));
} else {
this.imgDisplay2.style.display = "none";
this.imgDisplay2.classList.remove(ERROR_CLASS);
if (this.imgDisplay2.src) this.imgDisplay2.removeAttribute('src');
this.videoDisplay2.style.display = "none";
if (!this.videoDisplay2.paused) { this.videoDisplay2.pause(); this.videoDisplay2.currentTime = 0; }
if (this.videoDisplay2.src) this.videoDisplay2.removeAttribute('src');
const existingSecondaryErrorMsg = this.parent.querySelector('.viewer-error-message[data-slot="secondary"]');
if (existingSecondaryErrorMsg) existingSecondaryErrorMsg.remove();
}
const completionPromises = mediaSetups.map(setup => setup.loadCompletionPromise);
try {
const loadTimeout = 20000;
const settledResults = await Promise.race([
Promise.all(completionPromises),
new Promise((_, reject) => setTimeout(() => reject(new Error('Media load process timeout')), loadTimeout))
]);
if (Array.isArray(settledResults)) {
settledResults.forEach((result) => {
const { status, reason, setup } = result;
const { imgElement, videoElement, isPrimary } = setup; // initialUrl/fallbackUrl from setup can be used if needed
const slot = isPrimary ? 'primary' : 'secondary';
if (status === 'rejected') {
const errorDetails = reason; // This is our structured error object
videoElement.style.display = "none";
if (videoElement.src) videoElement.removeAttribute('src');
imgElement.style.display = "block";
imgElement.classList.add(ERROR_CLASS);
let messageHtml;
if (errorDetails.type === 'explicit_null') {
if (imgElement.src) imgElement.removeAttribute('src');
messageHtml = "Image not available";
console.warn(`_show: Explicit null URL for ${slot}, no viable fallback was found or used. URL was: ${errorDetails.url}`);
} else if (errorDetails.type === 'load_failure') {
const attemptedInitialUrl = errorDetails.initialUrl;
const attemptedFallbackUrl = errorDetails.fallbackUrl;
const displayInitial = typeof attemptedInitialUrl === 'string' ? attemptedInitialUrl : (attemptedInitialUrl === null ? '[Initial URL was null]' : '[Unknown Initial URL]');
const safeInitial = typeof attemptedInitialUrl === 'string' ? attemptedInitialUrl.replace(/"/g, '"').replace(/'/g, "'") : '#';
if (errorDetails.fallbackError && attemptedFallbackUrl) {
console.error(`_show: Media load failed for ${slot} (tried initial: ${attemptedInitialUrl}, then fallback: ${attemptedFallbackUrl}):`, errorDetails.primaryError, errorDetails.fallbackError);
const displayFallback = typeof attemptedFallbackUrl === 'string' ? attemptedFallbackUrl : '[Unknown Fallback URL]';
const safeFallback = typeof attemptedFallbackUrl === 'string' ? attemptedFallbackUrl.replace(/"/g, '"').replace(/'/g, "'") : '#';
messageHtml = `Failed to load: <a href="${safeInitial}" target="_blank" rel="noopener noreferrer">${displayInitial}</a>` +
`<br>and fallback: <a href="${safeFallback}" target="_blank" rel="noopener noreferrer">${displayFallback}</a>`;
} else {
console.error(`_show: Media load failed for ${slot} (tried initial: ${attemptedInitialUrl}, no fallback attempted or fallback failed silently/wasn't applicable):`, errorDetails.primaryError);
messageHtml = `Failed to load: <a href="${safeInitial}" target="_blank" rel="noopener noreferrer">${displayInitial}</a>`;
}
} else {
console.error(`_show: Unknown error structure in _show for ${slot}:`, errorDetails);
messageHtml = "An unexpected error occurred loading media.";
}
this._displayErrorMessage(slot, messageHtml);
} else if (status === 'fulfilled') {
if (this.autoplayEnabled && videoElement.style.display === 'block' && videoElement.paused) {
videoElement.play().catch(e => console.warn(`Autoplay ${slot} failed:`, e));
}
}
});
} else {
console.error("Media load process timed out.");
createNotification("Media timed out loading");
mediaSetups.forEach(({ imgElement, videoElement }) => {
removeDim(imgElement);
removeDim(videoElement);
});
}
this.updateTransforms();
} catch (error) {
console.error("Error during media loading phase (_show):", error);
createNotification("Error loading media");
mediaSetups.forEach(({ imgElement, videoElement }) => {
removeDim(imgElement);
removeDim(videoElement);
});
this.updateTransforms();
} finally {
clearTimeout(this._dimTimeout);
this.parent.style.cursor = "default";
}
}
_ensureViewerActive() {
if (!this.isActive()) {
console.log("Activating viewer in loadAndShowIndex");
this.hasEnteredFullscreenOnce = false; // Reset flag when activating
this.lastScrollPosition = { x: window.scrollX, y: window.scrollY };
lockPageScroll();
this.parent.style.display = "block";
// Initial fullscreen check on activation
if (this.useFullscreen && !isFullscreen()) {
this.enterFullscreen();
} else if (!this.useFullscreen && isFullscreen()) {
this.exitFullscreen();
}
// Ensure sidebar state is correct on activation
if (this.pinSidebar) {
this._sidebarShowOrHide(); // Make sure pinned sidebar shows
} else {
this.sidebarHideInstant(); // Ensure non-pinned sidebar is hidden initially
}
}
}
// Validates index, handles 'deleted', sets up cancellation, pauses existing video
_prepareLoadOperation(index) {
// --- Pause Currently Playing Videos ---
// Do this before validating the new index or aborting previous loads,
// to ensure the currently visible video stops.
if (this.videoDisplay.style.display === 'block' && !this.videoDisplay.paused) {
// console.log("Pausing primary video before navigation.");
this.videoDisplay.currentTime = 0;
this.videoDisplay.pause();
}
if (this.videoDisplay2.style.display === 'block' && !this.videoDisplay2.paused) {
// console.log("Pausing secondary video before navigation.");
this.videoDisplay2.currentTime = 0;
this.videoDisplay2.pause();
}
// --- Validate target index ---
if (index === null || index < 0 || index >= thumbs.length) { // Added null check
console.log(`Image index ${index} is out of range [0, ${thumbs.length - 1}]`);
// Adjust for dual mode start or return null if truly invalid
if (index === -1 && this.dualPageMode) index = 0;
else return null;
}
// Handle deleted target index
if (thumbs[index] === 'deleted') {
console.log(`Image index ${index} is marked as deleted. Trying next available.`);
let nextAvailable = index + 1;
while (nextAvailable < thumbs.length && thumbs[nextAvailable] === 'deleted') {
nextAvailable++;
}
if (nextAvailable < thumbs.length) {
index = nextAvailable;
} else {
let prevAvailable = index - 1;
while (prevAvailable >= 0 && thumbs[prevAvailable] === 'deleted') {
prevAvailable--;
}
if (prevAvailable >= 0) {
index = prevAvailable;
} else {
console.error("Cannot load index: Target and adjacent indices are deleted or out of bounds.");
createNotification("Cannot load image: Not available.");
return null; // No valid index found
}
}
}
// --- Cancellation of Previous Load ---
if (this.currentAbortController) {
console.log("Aborting previous load operation.");
this.currentAbortController.abort();
// *** ADDED *** Also revoke blob URLs from the operation being cancelled
this._revokeBlobUrl(this.videoDisplay);
this._revokeBlobUrl(this.videoDisplay2);
}
const controller = new AbortController();
this.currentAbortController = controller;
const loadToken = Symbol();
this.currentLoadToken = loadToken;
this.isNavigating = true;
const navigatedBackwards = this.backwardNavigationCount > 0; // Store direction before updating index
this.currentIndex = index; // Update the main current index state
return { controller, loadToken, targetIndex: index, navigatedBackwards };
}
// Determines display indices and updates label
_getDisplayIndicesAndLabel(targetIndex) {
const { primary: displayPrimaryIndex, secondary: displaySecondaryIndex } = this._getDualPageIndices(targetIndex);
if (this.config.viewerLabels !== 'disabled' && this.pageNumberLabel) {
if (displayPrimaryIndex !== null) {
let pageText = `${displayPrimaryIndex + 1}`;
if (displaySecondaryIndex !== null) {
pageText += `-${displaySecondaryIndex + 1}`;
}
pageText += ` / ${thumbs.length}`
this.pageNumberLabel.textContent = pageText;
} else {
this.pageNumberLabel.textContent = "?";
}
}
return { displayPrimaryIndex, displaySecondaryIndex };
}
// Sets up the dimming timeout
_initiateDimming(targetIndex, loadToken, wasActive, gridViewWasActive, displayPrimaryIndex, displaySecondaryIndex) {
const delayDimming = wasActive && !gridViewWasActive;
const dimDelay = delayDimming ? 10 : 0; // Short delay only if viewer was already active
const dimOpacity = 0.4;
const dimTransition = delayDimming ? 'opacity 0.15s ease-in' : 'none'; // Apply transition only if delaying
clearTimeout(this._dimTimeout); // Clear any previous dimming timeout
this._dimTimeout = setTimeout(() => {
// Check if the load operation associated with this timeout is still the current one
if (this.currentIndex === targetIndex && this.currentLoadToken === loadToken) {
// Set cursor for the parent element
this.parent.style.cursor = "progress";
// Helper to apply dimming styles
const applyDim = (element) => {
if (element && element.style.display !== 'none') { // Only dim visible elements
element.style.transition = dimTransition;
element.style.opacity = dimOpacity;
}
};
// Dim primary slot (image or video) if it exists
if (displayPrimaryIndex !== null) {
applyDim(this.imgDisplay);
applyDim(this.videoDisplay);
}
// Dim secondary slot (image or video) if it exists and we are in dual page mode
if (this.dualPageMode && displaySecondaryIndex !== null) {
applyDim(this.imgDisplay2);
applyDim(this.videoDisplay2);
}
}
}, dimDelay);
return this._dimTimeout; // Return the timeout ID
}
// Fetches required image URLs
async _fetchRequiredUrls(indices, signal, loadToken) {
const loadPromises = [];
const uniqueIndices = [...new Set(indices.filter(idx => idx !== null))]; // Get unique, non-null indices
for (const index of uniqueIndices) {
// Check cancellation before initiating each load request
if (this.currentLoadToken !== loadToken || signal.aborted) throw new DOMException('Aborted', 'AbortError');
loadPromises.push(loadImageUrlAtIndex(index, signal));
}
// Wait for all load attempts to settle (resolve or reject)
// Note: loadImageUrlAtIndex handles its internal errors and should resolve (e.g., with null)
// rather than rejecting the Promise.all promise unless an unexpected error occurs.
await Promise.all(loadPromises);
// Post-await validation (check if token/signal still valid after waiting)
if (this.currentLoadToken !== loadToken || signal.aborted) {
throw new DOMException('Aborted', 'AbortError'); // Throw abort error to be caught
}
// We no longer validate URLs here. _displayLoadedImages will call _getMediaUrlAtIndex
// which returns the result (URL or null) of the completed loadImageUrlAtIndex call.
}
// Adjusts scroll position before showing images
_updateScrollPosition(navigatedBackwards) {
if (this.currentZoom > 1) {
let targetScrollY = navigatedBackwards ? this.parent.scrollHeight : 0;
// TODO: Refine scroll anchoring if needed for specific dual modes.
this.parent.scrollTo({ top: targetScrollY });
}
}
// Displays the loaded images using _show*Page methods
async _displayLoadedImages(targetIndex, displayPrimaryIndex, displaySecondaryIndex, signal, loadToken) {
if (this.currentLoadToken !== loadToken || signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
// Fetch URL objects (which now include url and fallbackUrl)
const primaryMediaInfo = this._getMediaUrlAtIndex(displayPrimaryIndex);
const secondaryMediaInfo = this._getMediaUrlAtIndex(displaySecondaryIndex);
const primaryItem = (displayPrimaryIndex !== null && displayPrimaryIndex >= 0 && displayPrimaryIndex < thumbs.length) ? thumbs[displayPrimaryIndex] : null;
const secondaryItem = (displaySecondaryIndex !== null && displaySecondaryIndex >= 0 && displaySecondaryIndex < thumbs.length) ? thumbs[displaySecondaryIndex] : null;
const primaryIsVideo = primaryItem?.isVideo ?? false;
const secondaryIsVideo = secondaryItem?.isVideo ?? false;
// Log failures if primary URL is null but item was expected
if (displayPrimaryIndex !== null && primaryMediaInfo.url === null && primaryItem && primaryItem !== 'deleted') {
console.warn(`Primary media (index ${displayPrimaryIndex}) has no valid URL to attempt.`);
}
const showSecondary = this.dualPageMode && displaySecondaryIndex !== null && secondaryItem !== 'deleted';
if (showSecondary && displaySecondaryIndex !== null && secondaryMediaInfo.url === null && secondaryItem && secondaryItem !== 'deleted') {
console.warn(`Secondary media (index ${displaySecondaryIndex}) has no valid URL to attempt for dual page mode.`);
}
await this._show(
{
primary: primaryMediaInfo.url,
primaryFallback: primaryMediaInfo.fallbackUrl,
secondary: showSecondary ? secondaryMediaInfo.url : undefined,
secondaryFallback: showSecondary ? secondaryMediaInfo.fallbackUrl : undefined
},
{
primary: primaryIsVideo,
secondary: showSecondary ? secondaryIsVideo : false
}
);
if (this.currentLoadToken !== loadToken || signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
}
// Performs tasks after images are displayed
_performPostDisplayTasks(targetIndex, displayPrimaryIndex, displaySecondaryIndex, navigatedBackwards, signal, loadToken) {
if (this.currentLoadToken !== loadToken || signal.aborted) return; // Check before proceeding
if (this.currentLoadToken === loadToken) {
this.parent.style.cursor = "default"; // Reset cursor only if still current
}
// Update sidebar scroll position to the *target* index
if (this.sidebarVisible) {
this.sidebar.scrollToIndex(targetIndex, false);
}
// Set scroll position after layout update
this._updateScrollPosition(navigatedBackwards);
// Trigger preloading for neighbours
if (displayPrimaryIndex !== null && this.config.preloadCount > 0) {
const preloadCount = this.config.preloadCount;
const preloadStartIndex = displaySecondaryIndex !== null ? displaySecondaryIndex + 1 : displayPrimaryIndex + 1;
const preloadEndIndex = displayPrimaryIndex - 1;
const useOriginal = this.config.useOriginalImages;
const indicesToPreload = [];
if (!navigatedBackwards) {
// Preload forward range
const forwardEnd = Math.min(preloadStartIndex + preloadCount - 1, thumbs.length - 1);
for (let i = preloadStartIndex; i <= forwardEnd; i++) {
indicesToPreload.push(i);
}
// Append the previous index (opposite direction)
indicesToPreload.push(preloadEndIndex);
} else {
// Preload backward range
const backwardStart = Math.max(preloadEndIndex - preloadCount + 1, 0);
for (let i = preloadEndIndex; i >= backwardStart; i--) {
indicesToPreload.push(i);
}
// Append the next index (opposite direction)
indicesToPreload.push(preloadStartIndex);
}
preloadIndices(indicesToPreload, useOriginal);
}
}
// Handles errors during the load process
_handleLoadError(error, index, loadToken) {
if (error.name === 'AbortError') {
console.log(`Load operation for index ${index} aborted.`);
// Don't show notification for aborts
} else {
console.error(`Error during loadAndShowIndex for index ${index}:`, error);
createNotification(`Failed to load image ${index + 1}.`);
}
// Reset UI state only if this token is still current
if (this.currentLoadToken === loadToken) {
clearTimeout(this._dimTimeout); // Ensure dimming stops on error
this.parent.style.cursor = "default";
// Helper function to remove dimming effect from an element
const removeDim = (element) => {
if (element && element.style.opacity !== '' && element.style.opacity !== '1') {
element.style.transition = 'none';
element.style.opacity = 1;
requestAnimationFrame(() => {
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
});
} else if (element) {
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
}
};
// Remove dimming from all elements explicitly on error/abort
removeDim(this.imgDisplay);
removeDim(this.videoDisplay);
removeDim(this.imgDisplay2);
removeDim(this.videoDisplay2);
// *** ADDED *** Ensure final revoke just in case something failed silently
this._revokeBlobUrl(this.videoDisplay);
this._revokeBlobUrl(this.videoDisplay2);
// *** ADDED *** Also revoke potential blob URLs on error/abort
this._revokeBlobUrl(this.videoDisplay);
this._revokeBlobUrl(this.videoDisplay2);
}
}
// Finalizes the load attempt, resetting state
_finalizeLoadAttempt(loadToken, dimTimeoutId) {
// Helper function to remove dimming effect from an element
const removeDim = (element) => {
if (element && element.style.opacity !== '' && element.style.opacity !== '1') {
element.style.transition = 'none';
element.style.opacity = 1;
requestAnimationFrame(() => {
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
});
} else if (element) {
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
}
};
// Only reset state if this token is still the current one
if (this.currentLoadToken === loadToken) {
clearTimeout(dimTimeoutId); // Clear timeout regardless of success/fail
this.parent.style.cursor = "default";
this.currentAbortController = null; // Clear controller only if this was the last op
this.isNavigating = false;
// Final safety check: Ensure no dimming is left
removeDim(this.imgDisplay);
removeDim(this.videoDisplay);
removeDim(this.imgDisplay2);
removeDim(this.videoDisplay2);
} else {
// If a newer operation is already running, don't clear its state,
// but maybe ensure dimming is cleared for elements associated with *this* old token?
// The current approach handles this because removeDim is now called individually
// when promises settle in _show. This block primarily handles the state variables.
}
}
/**
* Gets the appropriate media URL (image or video) for a given index,
* considering configuration like useOriginalImages and useFallbackImages.
* @param {number} index - The index of the thumb item.
* @returns {{url: string | null, fallbackUrl: string | null}} - An object containing the primary URL to attempt (`url`)
* and a potential fallback URL (`fallbackUrl`).
* Both can be null if no suitable URL is found.
*/
_getMediaUrlAtIndex(index) {
if (index === null || index < 0 || index >= thumbs.length) {
return { url: null, fallbackUrl: null };
}
const item = thumbs[index];
if (!item || item === 'deleted') {
return { url: null, fallbackUrl: null };
}
let primaryUrl = null;
let fallbackUrl = null;
if (item.isVideo) {
primaryUrl = item.fullImageUrl || null;
// No image fallback logic for videos in this implementation
} else { // Is Image
const originalImg = item.originalImageUrl;
const fullImg = item.fullImageUrl;
// Determine primary URL based on config.useOriginalImages and caching status
if (this.config.useOriginalImages) {
if (originalImg && (item.originalImageIsCached || !fullImg)) {
// Prefer original if it exists AND (is cached OR fullImg is missing)
primaryUrl = originalImg;
} else if (fullImg) {
primaryUrl = fullImg;
} else { // Fallback to originalImg if fullImg was also missing
primaryUrl = originalImg || null;
}
} else { // Not using original images
if (fullImg) {
primaryUrl = fullImg;
} else { // Fallback to originalImg if fullImg is missing
primaryUrl = originalImg || null;
}
}
// Determine fallback URL if config.useFallbackImages is true
if (this.config.useFallbackImages) {
if (primaryUrl === originalImg && fullImg && fullImg !== originalImg) {
fallbackUrl = fullImg;
} else if (primaryUrl === fullImg && originalImg && originalImg !== fullImg) {
fallbackUrl = originalImg;
}
// If primaryUrl itself is null, but one of the image URLs exists,
// and wasn't chosen as primary (e.g., original not cached, full missing),
// it could potentially be a fallback if primaryUrl ended up null.
// However, the current logic aims to set primaryUrl if *any* URL is available.
// If primaryUrl is null, it means both originalImg and fullImg were likely null.
}
}
return { url: primaryUrl, fallbackUrl: fallbackUrl };
}
/**
* Retrieves media, utilizing an in-memory Blob cache with an LRU eviction strategy.
* Creates a blob: URL for the retrieved Blob (either cached or newly fetched).
* Handles concurrent requests for the same URL.
* IMPORTANT: The caller is responsible for revoking the returned blobUrl.
*
* @param {string} mediaUrl The URL of the media to retrieve.
* @returns {Promise<{ blobUrl: string, type: string | null }>} Resolves with blob URL and type.
*/
async getCachedMediaBlob(mediaUrl) {
// 1. Check for pending fetch
if (this.pendingFetches.has(mediaUrl)) {
console.log(`CACHE: Request for ${mediaUrl} already pending. Returning existing promise.`);
return this.pendingFetches.get(mediaUrl);
}
// 2. Check cache
if (this.blobCache.has(mediaUrl)) {
const entry = this.blobCache.get(mediaUrl);
console.log(`CACHE: Hit for ${mediaUrl} (Size: ${entry.size / 1024 / 1024} MB)`);
// Update LRU: Move entry to the end by deleting and re-inserting
this.blobCache.delete(mediaUrl);
entry.lastAccessed = Date.now(); // Update timestamp (optional but good practice)
this.blobCache.set(mediaUrl, entry);
// Create a NEW blob URL for the cached blob
const newBlobUrl = URL.createObjectURL(entry.blob);
return Promise.resolve({ blobUrl: newBlobUrl, type: entry.type });
}
// 3. Not in cache, not pending - Initiate fetch
console.log(`CACHE: Miss for ${mediaUrl}. Fetching...`);
const fetchPromise = (async () => { // IIAFE to handle async/await inside promise constructor pattern
try {
// Fetch the raw blob and type
const { blob, type } = await this._fetchMediaBlobAndType(mediaUrl);
// Check if blob exceeds total cache limit *before* eviction logic
const blobSize = blob.size;
const limitBytes = this.cacheLimitMB * 1024 * 1024;
if (blobSize > limitBytes) {
console.warn(`CACHE: Fetched blob size (${(blobSize / 1024 / 1024).toFixed(2)} MB) exceeds total cache limit (${this.cacheLimitMB} MB). Cannot cache ${mediaUrl}.`);
// Still create URL for immediate use, but don't cache
const blobUrl = URL.createObjectURL(blob);
return { blobUrl, type }; // Return directly without caching
}
// Ensure enough space in cache, evicting LRU if necessary
this._ensureCacheLimit(blobSize);
// Add the new blob to the cache
const newEntry = {
blob: blob,
size: blobSize,
type: type,
lastAccessed: Date.now()
};
this.blobCache.set(mediaUrl, newEntry);
this.currentCacheSize += blobSize;
console.log(`CACHE: Stored ${mediaUrl}. Cache size: ${(this.currentCacheSize / 1024 / 1024).toFixed(2)} MB / ${this.cacheLimitMB} MB`);
// Create blob URL for the newly cached blob
const blobUrl = URL.createObjectURL(blob);
return { blobUrl, type };
} catch (error) {
console.error(`CACHE: Failed to fetch/process ${mediaUrl}:`, error);
throw error; // Re-throw error to be caught by the caller
} finally {
// Remove from pending fetches once resolved or rejected
this.pendingFetches.delete(mediaUrl);
}
})(); // End IIAFE
// Store the promise for concurrent requests
this.pendingFetches.set(mediaUrl, fetchPromise);
return fetchPromise;
}
/**
* Internal function to fetch media using GM_xmlhttpRequest.
* Resolves with the raw Blob object and its Content-Type.
* NOTE: This does NOT create a blob: URL.
*
* @param {string} mediaUrl The URL of the media to fetch.
* @returns {Promise<{ blob: Blob, type: string | null }>} A Promise resolving with the Blob and type.
* @private
*/
async _fetchMediaBlobAndType(mediaUrl) {
// No config check here; the wrapper decides whether to call this.
return new Promise((resolve, reject) => {
console.log(`CACHE: Initiating GM fetch for: ${mediaUrl}`);
GM_xmlhttpRequest({
method: "GET",
url: mediaUrl,
responseType: "blob",
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
try {
const mediaBlob = response.response;
if (!mediaBlob || mediaBlob.size === 0) {
reject(new Error(`CACHE: Received empty blob for ${mediaUrl}`));
return;
}
let contentType = null;
if (response.responseHeaders) {
const match = response.responseHeaders.match(/^content-type:\s*(.*)$/im);
if (match && match[1]) {
contentType = match[1].trim().split(';')[0];
}
}
// Resolve with Blob and type
resolve({ blob: mediaBlob, type: contentType });
} catch (e) {
reject(new Error(`CACHE: Error processing response for ${mediaUrl}: ${e.message}`));
}
} else {
reject(new Error(`CACHE: Failed to fetch ${mediaUrl}: Server responded with status ${response.status} ${response.statusText}`));
}
},
onerror: (response) => {
reject(new Error(`CACHE: Network error fetching ${mediaUrl}: ${response.error || 'Unknown error'}`));
},
ontimeout: () => {
reject(new Error(`CACHE: Timeout fetching ${mediaUrl}`));
},
onabort: () => { // Handle abort if the outer operation is cancelled
reject(new Error(`CACHE: Request aborted for ${mediaUrl}`));
}
// We might need to pass the AbortSignal here if GM_xmlhttpRequest supports it
// signal: signal // If supported
});
});
}
/**
* Ensures the cache size doesn't exceed the limit after adding an item of proposedSize.
* Evicts items using an LRU strategy
*
* @param {number} proposedNewItemSize The size in bytes of the item about to be added.
* @private
*/
_ensureCacheLimit(proposedNewItemSize) {
const limitBytes = this.cacheLimitMB * 1024 * 1024;
// Calculate potential size if item is added
let potentialSize = this.currentCacheSize + proposedNewItemSize;
// Evict LRU items (first items in Map iteration) while over limit
const cacheIterator = this.blobCache.keys(); // Get iterator for keys
while (potentialSize > limitBytes && this.blobCache.size > 0) {
const keyToRemove = cacheIterator.next().value; // Get the first (oldest) key
if (!keyToRemove) break; // Should not happen if size > 0, but safety check
const evicted = this._evictCacheEntry(keyToRemove);
if (evicted) {
potentialSize = this.currentCacheSize + proposedNewItemSize; // Recalculate potential size
} else {
console.error(`CACHE: Inconsistency during eviction for key ${keyToRemove}`);
break;
}
}
// Optional: Final check if the new item *alone* is too big (already handled in getCachedMediaBlob)
// if (proposedNewItemSize > limitBytes && this.blobCache.size === 0) {
// console.warn(`CACHE: New item size (${proposedNewItemSize} bytes) exceeds limit (${limitBytes} bytes) even after clearing cache.`);
// }
}
/**
* Removes a specific entry from the blob cache and updates the total cache size.
* Logs the eviction.
*
* @param {string} mediaUrl The URL (key) of the cache entry to remove.
* @returns {boolean} True if an entry was found and removed, false otherwise.
* @private
*/
_evictCacheEntry(mediaUrl) {
if (this.blobCache.has(mediaUrl)) {
const entryToRemove = this.blobCache.get(mediaUrl);
if (entryToRemove) {
this.currentCacheSize -= entryToRemove.size;
// Ensure size doesn't go negative due to float precision or errors
if (this.currentCacheSize < 0) this.currentCacheSize = 0;
this.blobCache.delete(mediaUrl);
console.log(`CACHE: Evicted/Removed ${mediaUrl} (Size: ${(entryToRemove.size / 1024 / 1024).toFixed(2)} MB). New size: ${(this.currentCacheSize / 1024 / 1024).toFixed(2)} MB`);
return true;
} else {
// Should not happen if .has() was true, but handle defensively
console.error(`CACHE: Inconsistency - found key ${mediaUrl} with .has() but .get() failed.`);
this.blobCache.delete(mediaUrl); // Attempt deletion anyway
return false;
}
} else {
// console.warn(`CACHE: Attempted to evict non-existent key ${mediaUrl}`); // Optional warning
return false;
}
}
}// ----------------------------------------------------------------------------------------------
// imageViewerInputHandler.js
// ----------------------------------------------------------------------------------------------
class ViewerInputHandler {
constructor(imageViewerInstance, config) {
this.viewer = imageViewerInstance;
this.config = config;
// State for smooth scroll acceleration
this._lastSmoothScrollTime = 0;
this._lastSmoothScrollDirection = 0; // 1 for down/pageDown, -1 for up/pageUp, 0 for none
this._smoothScrollMultiplier = 1.0;
// Constants for tuning acceleration
this.SMOOTH_ACCEL_RESET_TIMEOUT_MS = 100; // Time in ms to reset multiplier
this.SMOOTH_ACCEL_FACTOR = 1.15; // Factor to increase multiplier by
this.SMOOTH_ACCEL_MAX_MULTIPLIER = 2.5; // Maximum speed multiplier
this.BASE_PAGE_SCROLL_FACTOR = 0.8; // Base scroll amount for PageUp/Down
this.BASE_SCROLL_STEP = 250; // Base scroll amount for Arrow keys
this._initializeListeners();
}
_initializeListeners() {
// Bind handlers to maintain 'this' context and allow removal
this._boundHandlePasteEvent = this._handlePasteEvent.bind(this);
this._boundHandleCopyEvent = this._handleCopyEvent.bind(this);
this._boundHandleKeyDown = this._handleKeyDown.bind(this);
this._boundHandleWheel = this._handleWheel.bind(this);
document.addEventListener("paste", this._boundHandlePasteEvent);
document.addEventListener("copy", this._boundHandleCopyEvent);
this.viewer.parent.addEventListener("wheel", this._boundHandleWheel, { passive: false }); // Listen on the parent div for wheel events
document.addEventListener("keydown", this._boundHandleKeyDown);
}
/**
* Performs a smooth scroll, increasing the distance if called
* repeatedly in the same direction within a short timeframe.
* @param {Element} element The element to scroll.
* @param {object} options Original scroll options (must include top and behavior: 'smooth').
*/
smoothScrollWithAcceleration(element, options) {
if (!element || typeof options?.top !== 'number' || options?.behavior !== 'smooth') {
console.warn("Invalid arguments for smoothScrollWithAcceleration");
if (element && options) element.scrollBy(options); // Try fallback
return;
}
const now = Date.now();
const intendedDeltaY = options.top;
const currentDirection = Math.sign(intendedDeltaY); // -1 for up, 1 for down
// --- Reset Check ---
// Reset multiplier if direction changes or too much time has passed
if (currentDirection !== this._lastSmoothScrollDirection ||
(now - this._lastSmoothScrollTime) > this.SMOOTH_ACCEL_RESET_TIMEOUT_MS) {
this._smoothScrollMultiplier = 1.0;
}
// --- Acceleration ---
else {
// Increase multiplier if conditions are met (same direction, quick succession)
this._smoothScrollMultiplier = Math.min(
this.SMOOTH_ACCEL_MAX_MULTIPLIER,
this._smoothScrollMultiplier * this.SMOOTH_ACCEL_FACTOR
);
}
const finalDeltaY = intendedDeltaY * this._smoothScrollMultiplier;
element.scrollBy({ top: finalDeltaY, behavior: 'smooth' });
// Update state for next call
this._lastSmoothScrollTime = now;
this._lastSmoothScrollDirection = currentDirection;
}
_navigateOrPan(direction, forceNavigate) {
if (direction === 1) {
const canScrollDown = this.viewer.currentZoom > 1 && this.viewer.parent.scrollHeight > this.viewer.parent.clientHeight;
const isAtBottom = this.viewer.parent.scrollTop >= this.viewer.parent.scrollHeight - this.viewer.parent.clientHeight - 5; // Allow 5px tolerance
if (this.config.panFirst && canScrollDown && !isAtBottom && !forceNavigate) {
// Pan down smoothly if panFirst is enabled and not at bottom
this.viewer.parent.scrollBy({ top: this.config.panStep * this.viewer.currentZoom, behavior: 'smooth' });
} else {
// Otherwise, navigate forward
this.viewer.navigateForward();
}
} else {
const canScrollUp = this.viewer.currentZoom > 1 && this.viewer.parent.scrollHeight > this.viewer.parent.clientHeight;
const isAtTop = this.viewer.parent.scrollTop <= 5;
if (this.config.panFirst && canScrollUp && !isAtTop && !forceNavigate) {
// Pan up smoothly if panFirst is enabled and not at top
this.viewer.parent.scrollBy({ top: -this.config.panStep * this.viewer.currentZoom, behavior: 'smooth' });
} else {
// Otherwise, navigate back
this.viewer.navigateBack();
}
}
}
_handleWheel(e) {
// Handles Ctrl+Mousewheel and Touchpad Pinch-to-Zoom (which often sends wheel events with ctrlKey=true)
// Only handle wheel events if the viewer is active, config UI isn't showing, and Ctrl key is pressed (for zoom)
if (!this.viewer.isActive() || this.config.showingUI() || !e.ctrlKey || this.viewer.isGridViewActive()) {
// Allow default scroll or other behaviors if Ctrl isn't pressed or viewer isn't right state
return;
}
// Prevent default browser zoom or scroll when Ctrl + Wheel is used for image zoom
e.preventDefault();
// Determine zoom direction based on deltaY
// Negative deltaY means scroll up or pinch out (zoom in)
// Positive deltaY means scroll down or pinch in (zoom out)
const zoomDirection = e.deltaY < 0 ? 1 : -1; // +1 for zoom in, -1 for zoom out
// Set zoomChanged flag and call zoom function, anchoring to the center
this.viewer.userChangedZoom = true;
this.viewer.zoom(zoomDirection, 1.07, true, true); // Use direction, anchor to center X and Y
}
_handleKeyDown(e) {
if (e.ctrlKey || e.altKey || e.metaKey || e.key === 'Alt') return;
if (!this.viewer.isActive() || this.config.showingUI() || this.mediaExtractorUi?.isShown()) {
return;
}
const targetElement = e.target;
if (targetElement && (
targetElement.tagName === 'INPUT' ||
targetElement.tagName === 'TEXTAREA' ||
targetElement.isContentEditable
)) {
return;
}
switch (e.key) {
case "Escape":
if (this.viewer.isHelpOverlayVisible()) {
this.viewer.toggleHelpOverlay();
} else if (this.config.showingUI()) {
this.config.closeUI();
} else {
this.viewer.closeViewer(this.config.exitToViewerPage ^ e.shiftKey);
}
break;
case "Backspace":
if (this.viewer.chapterSidebarIsActive()) this.viewer.toggleChapterSidebar();
if (this.viewer.sidebarIsActive()) this.viewer.sidebarHideInstant();
this.viewer.toggleGridView(true);
break;
case "Home":
if (this.viewer.isGridViewActive()) {
this.viewer.gridView.scrollToIndex(0, false);
} else {
this.viewer.loadAndShowIndex(0);
}
break;
case "End":
if (this.viewer.isGridViewActive()) {
this.viewer.gridView.scrollToIndex(thumbs.length - 1, false);
} else {
this.viewer.loadAndShowIndex(thumbs.length - 1);
}
break;
case "PageUp":
if (e.shiftKey) {
if (this.viewer.currentIndex == 0) {
createNotification("First page!");
break;
}
if (!chapterList?.length) {
createNotification("No chapters available");
break;
}
const prevChapter = chapterList.slice().reverse().find(ch => ch.index < this.viewer.currentIndex);
if (prevChapter) {
this.viewer.loadAndShowIndex(prevChapter.index);
} else {
this.viewer.loadAndShowIndex(0);
}
} else if (this.viewer.isGridViewActive()) {
this.smoothScrollWithAcceleration(this.viewer.gridView.getContainer(), { top: -this.viewer.gridView.getContainer().clientHeight * this.BASE_PAGE_SCROLL_FACTOR, behavior: 'smooth' });
e.preventDefault(); // Prevent default page scroll
} else {
const canScrollUp = this.viewer.currentZoom > 1 && this.viewer.parent.scrollHeight > this.viewer.parent.clientHeight;
const isAtTop = this.viewer.parent.scrollTop <= 5; // 5px tolerance
if (canScrollUp && !isAtTop) {
this.viewer.parent.scrollTo({ top: 0, behavior: 'smooth' });
} else {
this.viewer.navigateBack();
}
e.preventDefault(); // Prevent default page scroll
}
break;
case "PageDown":
if (e.shiftKey) {
if (this.viewer.currentIndex === thumbs.length - 1) {
createNotification("Last page!");
break;
}
if (!chapterList?.length) {
createNotification("No chapters available");
break;
}
const nextChapter = chapterList.find(ch => ch.index > this.viewer.currentIndex);
if (nextChapter) {
this.viewer.loadAndShowIndex(nextChapter.index);
} else {
this.viewer.loadAndShowIndex(thumbs.length - 1);
}
} else if (this.viewer.isGridViewActive()) {
this.smoothScrollWithAcceleration(this.viewer.gridView.getContainer(), { top: this.viewer.gridView.getContainer().clientHeight * this.BASE_PAGE_SCROLL_FACTOR, behavior: 'smooth' });
e.preventDefault(); // Prevent default page scroll
} else {
const canScrollDown = this.viewer.currentZoom > 1 && this.viewer.parent.scrollHeight > this.viewer.parent.clientHeight;
const isAtBottom = this.viewer.parent.scrollTop >= this.viewer.parent.scrollHeight - this.viewer.parent.clientHeight - 5; // Allow 5px tolerance
if (canScrollDown && !isAtBottom) {
this.viewer.parent.scrollTo({ top: this.viewer.parent.scrollHeight, behavior: 'smooth' });
} else {
this.viewer.navigateForward();
}
e.preventDefault(); // Prevent default page scroll
}
break;
case "q":
case "Q":
this.viewer.closeViewer(this.config.exitToViewerPage ^ e.shiftKey);
break;
case "d":
case ".":
case "ArrowRight":
case "Space":
case " ":
if (!this.viewer.isGridViewActive()) {
e.preventDefault();
if (e.shiftKey && e.key === "ArrowRight" && this.viewer.isShowingVideo()) {
this.viewer.seekVideo(5)
break;
}
if (this.viewer.rightToLeftMode && this.config.reverseNavigationInRtlMode) {
this._navigateOrPan(-1, e.shiftKey);
} else {
this._navigateOrPan(1, e.shiftKey);
}
}
break;
case "a":
case ",":
case "ArrowLeft":
if (!this.viewer.isGridViewActive()) {
e.preventDefault();
if (e.shiftKey && e.key === "ArrowLeft" && this.viewer.isShowingVideo()) {
this.viewer.seekVideo(-5)
break;
}
if (this.viewer.rightToLeftMode && this.config.reverseNavigationInRtlMode) {
this._navigateOrPan(1, e.shiftKey);
} else {
this._navigateOrPan(-1, e.shiftKey);
}
}
break;
case "ArrowUp":
if (!this.viewer.isGridViewActive() && this.viewer.currentZoom > 1 && this.viewer.parent.scrollTop > 0) {
this.smoothScrollWithAcceleration(this.viewer.parent, { top: -this.BASE_SCROLL_STEP, behavior: 'smooth' });
e.preventDefault(); // Prevent default page scroll
}
else if (this.viewer.isGridViewActive()) {
this.smoothScrollWithAcceleration(this.viewer.gridView.getContainer(), { top: -this.BASE_SCROLL_STEP, behavior: 'smooth' });
e.preventDefault(); // Prevent default page scroll
}
break;
case "ArrowDown":
if (!this.viewer.isGridViewActive() && this.viewer.currentZoom > 1 && this.viewer.parent.scrollTop < this.viewer.parent.scrollHeight - this.viewer.parent.clientHeight) {
this.smoothScrollWithAcceleration(this.viewer.parent, { top: this.BASE_SCROLL_STEP, behavior: 'smooth' });
e.preventDefault(); // Prevent default page scroll
}
else if (this.viewer.isGridViewActive()) {
this.smoothScrollWithAcceleration(this.viewer.gridView.getContainer(), { top: this.BASE_SCROLL_STEP, behavior: 'smooth' });
e.preventDefault(); // Prevent default page scroll
}
break;
case "+":
case "=": // Often shifted version of +
if (!this.viewer.isGridViewActive()) {
this.viewer.userChangedZoom = true;
this.viewer.zoom(1, 1.1, true, false);
}
break;
case "-":
case "_": // Often shifted version of -
if (!this.viewer.isGridViewActive()) {
this.viewer.userChangedZoom = true;
this.viewer.zoom(-1, 1.1, true, false);
}
break;
case "r":
if (!this.viewer.isGridViewActive()) {
this.viewer.rotateImage(90);
}
break;
case "l":
if (!this.viewer.isGridViewActive()) {
this.viewer.rotateImage(-90);
}
break;
case "s":
if (!this.viewer.isGridViewActive() && this.config.enableSidebar) {
this.viewer.pinSidebar = !this.viewer.pinSidebar;
this.viewer._sidebarShowOrHide();
}
break;
case "S":
this.viewer.downloadCurrentImage();
break;
case "F":
this.viewer.showGalleriesWithCurrentImage();
break;
case "D":
if (!this.viewer.isGridViewActive()) {
this.viewer.toggleDualPageMode();
}
break;
case "R":
if (!this.viewer.isGridViewActive()) {
this.viewer.toggleRightToLeftMode();
}
break;
case "A":
if (!this.viewer.isGridViewActive()) {
this.viewer.enableDualPageModeOrCycleLayout();
}
break;
case "f":
this.viewer.useFullscreen = !isFullscreen();
if (this.viewer.useFullscreen) {
this.viewer.enterFullscreen();
} else {
this.viewer.exitFullscreen();
}
break;
case "Delete":
if (!this.viewer.isGridViewActive()) {
this.viewer.delete(this.viewer.currentIndex);
embeddedGridView?.refreshAll();
}
break;
case "1": // Reset Zoom (Fit Screen)
if (!this.viewer.isGridViewActive()) {
this.viewer.userChangedZoom = false;
this.viewer.fitMode = "fit-window";
this.viewer.currentZoom = 1;
this.viewer.updateTransforms();
}
break;
case "2": // 1:1 Pixel Zoom
if (!this.viewer.isGridViewActive()) {
this.viewer.userChangedZoom = false;
this.viewer.fitMode = "one-to-one";
this.viewer.updateTransforms();
}
break;
case "3": // Zoom to Fill Width
if (!this.viewer.isGridViewActive()) {
this.viewer.userChangedZoom = false;
this.viewer.fitMode = "fit-width";
this.viewer.updateTransforms();
}
break;
case "h":
case "?": // Show/hide help overlay
this.viewer.toggleHelpOverlay();
break;
case "c": // Show/hide chapter sidebar
this.viewer.toggleChapterSidebar();
break;
case " ":
case "Spacebar":
this.viewer.toggleVideoPlayback();
break;
case "E":
if (!this.mediaExtractorUi) {
this.mediaExtractorUi = new MediaExtractorUI();
} else if (this.mediaExtractorUi.isShown()) {
this.mediaExtractorUi.close();
break;
}
this.mediaExtractorUi.onExtractionComplete((error, results) => {
if (error) {
return;
}
if (results?.length) {
for (let i = 0; i < results.length; i++) {
const result = results[i];
const index = i + this.viewer.currentIndex;
while (index >= thumbs.length) thumbs.push(null);
thumbs[index] = {
fullImageUrl: result.url,
isVideo: result.type === "video",
};
}
this.viewer.loadAndShowIndex(this.viewer.currentIndex);
this.viewer.refreshGrids();
embeddedGridView?.refreshAll();
}
});
this.mediaExtractorUi.render();
break;
case "N":
case "I":
console.log("Inserting a blank image at index", this.viewer.currentIndex + 1);
const item = {
fullImageUrl: null
};
insertAfterIndex(thumbs, this.viewer.currentIndex, item);
this.viewer.loadAndShowIndex(this.viewer.currentIndex + 1);
break;
}
}
// Add these inside the ViewerInputHandler class or where appropriate
// Regular expression to match common video file extensions
_isVideoExtension(filename = '') {
return /\.(mp4|webm|mov|ogg|avi|mkv)$/i.test(filename);
}
// Regular expression to match common image file extensions
_isImageExtension(filename = '') {
return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename);
}
// Function to parse MIME type from a Data URL
_getMimeTypeFromDataUrl(dataUrl = '') {
const match = dataUrl.match(/^data:([\w\/+-.]+)(?:;.*)?/);
return match ? match[1].toLowerCase() : null; // e.g., 'image/png' or 'video/mp4'
}
/**
* Tries to insert and load media at the given index.
* Updates the viewer's state and associated grid views.
*
* @param {string} url - The blob:, data:, or http(s): URL to load.
* @param {number} idx - The index in the thumbs array to update.
* @param {boolean} isVideo - Whether the URL represents a video.
* @returns {Promise<boolean>} - True if the media loaded successfully, false otherwise.
*/
async insertImage(url, idx, isVideo) {
const thumbItem = thumbs[idx];
if (!thumbItem || thumbItem === 'deleted') {
console.warn(`Cannot insert media at index ${idx}, item is invalid.`);
return false; // Indicate failure
}
console.log(`Attempting to insert ${isVideo ? 'video' : 'image'} from URL: ${url.substring(0, 100)}... at index ${idx}`);
// Update thumb data
thumbItem.isVideo = isVideo;
// For pasted content, we assume the pasted URL is both full and original for simplicity
thumbItem.fullImageUrl = url;
thumbItem.originalImageUrl = url; // Or null if we want to be strict? Let's keep it simple.
thumbItem.imageUrlToLoad = null; // Clear any cached URL from loadImageUrlAtIndex
thumbItem.imageElement = null; // Clear any cached element
// Force update in any visible grid views
this.viewer.sidebar?.forceSetFullImage(idx, url); // Pass URL for immediate update if needed
this.viewer.gridView?.forceSetFullImage(idx, url);
embeddedGridView?.forceSetFullImage(idx, url); // Assuming embeddedGridView might exist
// Reset viewer state before loading the new content
this.viewer.userChangedZoom = false; // Allow fitMode to apply initially
this.viewer.currentZoom = 1;
this.viewer.currentRotation = 0;
this.viewer.displayedRotation = 0;
// --- Load and Verify ---
try {
document.body.style.cursor = "progress";
// loadAndShowIndex will attempt to load the media using _show, which uses waitForMediaLoad
await this.viewer.loadAndShowIndex(idx);
// After loadAndShowIndex completes, check if the correct element loaded successfully
const primaryElement = isVideo ? this.viewer.videoDisplay : this.viewer.imgDisplay;
// Check if the primary display slot is showing the content we just set
// Note: src might be blob: even if original was http: after fetchImageBlobUrl
// So, checking dimensions is more reliable than src equality in some cases.
const isPrimaryVisible = primaryElement.style.display === 'block';
const hasValidDimensions = isVideo
? primaryElement.videoWidth > 0
: primaryElement.naturalWidth > 0;
// Check if the displayed src corresponds *roughly* to what we tried loading.
// For data/blob URLs, currentSrc will be the same. For http, it might differ if redirected.
// This check is less critical than dimensions.
const srcMatches = primaryElement.currentSrc === url || primaryElement.src === url;
if (isPrimaryVisible && hasValidDimensions) {
console.log(`Successfully loaded and displayed ${isVideo ? 'video' : 'image'} at index ${idx}.`);
// Optional: Force another transform update? Usually loadAndShowIndex handles it.
// this.viewer.updateTransforms();
return true; // Indicate success
} else {
console.warn(`Failed to verify load for ${isVideo ? 'video' : 'image'} at index ${idx}. Visible: ${isPrimaryVisible}, Valid Dims: ${hasValidDimensions}, Src Matches: ${srcMatches}, currentSrc: ${primaryElement.currentSrc.substring(0, 100)}`);
return false; // Indicate failure
}
} catch (error) {
console.error(`Error during loadAndShowIndex after paste for index ${idx}:`, error);
return false; // Indicate failure
} finally {
document.body.style.cursor = "default";
}
}
async _handlePasteEvent(e) {
const targetElement = e.target;
if (targetElement && (
targetElement.tagName === 'INPUT' ||
targetElement.tagName === 'TEXTAREA' ||
targetElement.isContentEditable
)) {
return;
}
if (!this.viewer.isActive()) return;
e.preventDefault();
const currentIdx = this.viewer.currentIndex;
if (!thumbs[currentIdx] || thumbs[currentIdx] === 'deleted') {
console.warn("Cannot paste as target thumb item is invalid or deleted.");
createNotification("Cannot paste here");
return;
}
const items = (e.clipboardData || window.clipboardData).items;
let foundMedia = false;
let potentialTextItem = null;
console.log(`Paste event detected with ${items.length} items.`);
// --- Try Pasted Files First (Images or Videos) ---
for (let i = 0; i < items.length; i++) {
const item = items[i];
// console.log(`Item ${i}: type = ${item.type}, kind = ${item.kind}`);
if (item.kind === 'file' && (item.type.startsWith("image/") || item.type.startsWith("video/"))) {
const blob = item.getAsFile();
if (blob) {
const isVideo = blob.type.startsWith("video/");
console.log(`Found ${isVideo ? 'video' : 'image'} blob:`, blob);
const reader = new FileReader();
reader.onload = async (event) => { // Make onload async
const pastedDataUrl = event.target.result;
console.log(`Pasted ${isVideo ? 'video' : 'image'} data loaded (Data URL).`);
const success = await this.insertImage(pastedDataUrl, currentIdx, isVideo);
if (success) {
console.log(`Pasted ${isVideo ? 'video' : 'image'} data`);
foundMedia = true; // Set flag on successful insertion *here*
} else {
// Notification handled by insertImage/loadAndShowIndex
}
};
reader.onerror = (err) => {
console.error("FileReader error:", err);
createNotification(`Error reading pasted ${isVideo ? 'video' : 'image'}`);
}
reader.readAsDataURL(blob);
// Don't set foundMedia here, wait for reader.onload confirmation
// foundMedia = true; // Removed from here
break; // Stop after finding the first file
}
} else if (item.kind === 'string' && item.type === 'text/plain' && !potentialTextItem) {
potentialTextItem = item;
}
}
// Await potential file processing before checking text
// (FileReader is async, but we broke the loop, so this is roughly okay)
// A better approach might involve Promise.all if multiple files were processed.
// For now, assuming we only process the first file:
// --- If No File Found *or file processing failed*, Try Pasted Text (URL) ---
if (!foundMedia && potentialTextItem) { // Check foundMedia status now
console.log("No media file found or loaded, checking potential text item...");
potentialTextItem.getAsString(async (pastedText) => { // Make callback async
pastedText = pastedText.trim();
if (!pastedText) {
console.log("Pasted text is empty.");
return;
}
console.log("Pasted text:", pastedText.substring(0, 100) + (pastedText.length > 100 ? '...' : ''));
let success = false;
let triedInsert = false;
// --- Handle data: URLs ---
if (pastedText.startsWith('data:')) {
console.log("Pasted text is a data: URL.");
triedInsert = true;
const mimeType = this._getMimeTypeFromDataUrl(pastedText);
if (mimeType) {
if (mimeType.startsWith('video/') || mimeType.startsWith('image/')) {
const isVideo = mimeType.startsWith('video/');
success = await this.insertImage(pastedText, currentIdx, isVideo);
if (success) {
console.log(`Pasted ${isVideo ? 'video' : 'image'} from data URL`);
}
} else {
console.log("Pasted data is not an image or video");
createNotification("Paste is not image or video");
}
} else {
console.warn("Could not parse MIME type from data: URL.");
createNotification("Invalid data URL format");
}
// --- Handle http/https: URLs ---
} else if (pastedText.startsWith('http')) {
let isVideo = this._isVideoExtension(pastedText);
let isImage = this._isImageExtension(pastedText);
// Basic check if it looks like any http URL, even without extension
const isLikelyHttpUrl = /^https?:\/\/[^\s]+$/i.test(pastedText);
// If it looks like an HTTP URL but has no recognized media extension, assume image
if (isLikelyHttpUrl && !isVideo && !isImage) {
console.log("Pasted HTTP URL has no recognized media extension, fetching type..");
const type = await getMediaContentType(pastedText);
if (!type) {
console.warn("Could not retrieve type, assuming image");
isImage = true;
}
isImage = type.startsWith("image/");
isVideo = type.startsWith("video/");
if (!isImage && !isVideo) {
console.log("Pasted URL does not point to an image or video");
createNotification("Paste is not an image or video URL");
return;
}
} else if (isLikelyHttpUrl) {
console.log(`Pasted text looks like a ${isVideo ? 'video' : 'image'} HTTP URL.`);
}
if (isVideo) isImage = false; // Video takes precedence
if (isImage || isVideo) {
triedInsert = true;
// --- Attempt Direct Load ---
success = await this.insertImage(pastedText, currentIdx, isVideo);
if (success) {
console.log(`Pasted ${isVideo ? 'video' : 'image'} from URL`);
} else {
console.warn(`Direct load failed for URL: ${pastedText}`);
// Notification handled internally, but now try cross-origin fetch
// --- If Direct Load Failed, Try Cross-Origin Fetch ---
const isPotentiallyCrossOrigin = !pastedText.startsWith(window.location.origin);
if (isPotentiallyCrossOrigin) {
console.log("Direct load failed, attempting cross-origin fetch...");
try {
document.body.style.cursor = "progress";
// fetchImageBlobUrl returns { blobUrl, type }
const result = await fetchMediaBlobUrl(pastedText);
// Check if we got the expected result object and a blobUrl
if (result && result.blobUrl) {
// Determine media type PRIMARILY from the fetched Content-Type header
let fetchedIsVideo = false;
if (result.type) { // Check if type was successfully extracted
fetchedIsVideo = result.type.startsWith('video/');
console.log(`Cross-origin fetch successful. Actual Type: ${result.type}. Blob URL: ${result.blobUrl.substring(0, 100)}...`);
} else {
// Fallback to guessing from original URL if Content-Type was missing
fetchedIsVideo = isVideo; // Use the 'isVideo' flag guessed before
console.warn(`Cross-origin fetch successful but Content-Type header was missing/unparsable. Falling back to guessed type (${fetchedIsVideo ? 'video' : 'image'}) from original URL.`);
}
// Try inserting the Blob URL using the DETECTED (or guessed) type
success = await this.insertImage(result.blobUrl, currentIdx, fetchedIsVideo);
if (success) {
console.log(`Pasted ${fetchedIsVideo ? 'video' : 'image'} via cross-origin fetch`);
} else {
// Load failed. Notification likely handled by insertImage/loadAndShowIndex.
console.log("Revoking unused/failed blob URL after failed load:", result.blobUrl.substring(0, 100));
URL.revokeObjectURL(result.blobUrl); // Clean up memory
}
} else {
// This case handles if fetchImageBlobUrl resolved without a blobUrl (shouldn't happen with current logic, but good check)
console.error("Cross-origin fetch did not return a blob URL in the result object.");
createNotification("Cross-origin fetch failed (no blob URL)");
}
} catch (error) {
// Handle potential rejections from fetchImageBlobUrl
console.error(`Cross-origin fetch failed for ${pastedText}:`, error);
let errorMsg = "Cross-origin fetch failed";
if (error instanceof Error && error.message) {
errorMsg += `: ${error.message}`;
} else if (typeof error === 'string') {
errorMsg += `: ${error}`;
}
createNotification(errorMsg);
// No blobUrl to revoke if fetchImageBlobUrl rejects
} finally {
document.body.style.cursor = "default";
}
} else {
console.log("URL is not cross-origin or direct load failed for other reasons.");
}
}
} else if (isLikelyHttpUrl) {
console.log("Pasted text looks like an HTTP URL, but not a recognized media type.");
createNotification("URL does not point to known media type");
} else {
console.log("Pasted text is not a data: or http(s): URL.");
createNotification("Paste does not contain media URL");
}
// --- Handle other text ---
} else {
console.log("Pasted text is not a data: or http(s): URL.");
createNotification("Paste does not contain media URL");
}
// Update foundMedia *after* all attempts for the text item
if (success) {
foundMedia = true;
}
});
// Need to wait for getAsString callback if it was called
// This requires more complex promise handling, or we accept that foundMedia
// might not be updated immediately if text pasting occurs.
// Let's simplify: the notification inside getAsString is the primary feedback.
}
// Final check after potential async operations (might be slightly delayed)
// This check is less reliable due to async nature of getAsString
/*
if (!foundMedia && !potentialTextItem && items.length > 0) {
console.log("No compatible media file or text URL found or loaded successfully.");
} else if (items.length === 0) {
console.log("Paste event had no items.");
createNotification("Nothing found in clipboard to paste");
}
*/
}
async _handleCopyEvent(e) {
const targetElement = e.target;
if (targetElement && (
targetElement.tagName === 'INPUT' ||
targetElement.tagName === 'TEXTAREA' ||
targetElement.isContentEditable
)) {
return;
}
if (!this.viewer.isActive()) return;
const item = thumbs[this.viewer.currentIndex];
if (!item) {
console.warn("Cannot copy as thumbs[currentIndex] is null");
return;
}
const url = this.config.useOriginalImages && item.originalImageUrl
? item.originalImageUrl
: item.fullImageUrl;
if (!url) {
console.warn("Cannot copy, missing url");
return;
}
if (e) e.preventDefault();
// Check if the Clipboard API (writeText) is supported
if (!navigator.clipboard || !navigator.clipboard.writeText) {
console.error("Clipboard API (writeText) not supported.");
// Fallback for older browsers (less reliable)
try {
const textArea = document.createElement("textarea");
textArea.value = url;
textArea.style.position = "fixed"; // Avoid scrolling
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
console.log("Image URL copied to clipboard (fallback method).");
createNotification("Copied image URL");
} else {
throw new Error('Fallback copy failed');
}
} catch (err) {
console.error("Failed to copy URL (fallback failed):", err);
createNotification("Error copying URL");
}
return;
}
// Use modern Clipboard API
try {
await navigator.clipboard.writeText(url);
console.log("Image URL copied to clipboard:", url);
createNotification("Copied image URL");
} catch (err) {
console.error("Failed to copy URL:", err);
createNotification("Error copying URL");
if (err.name === 'NotAllowedError') {
createNotification("Error: Clipboard permission denied.");
}
}
}
// Method to detach listeners
destroy() {
document.removeEventListener("paste", this._boundHandlePasteEvent);
document.removeEventListener("copy", this._boundHandleCopyEvent);
document.removeEventListener("keydown", this._boundHandleKeyDown);
this.viewer.parent.removeEventListener("wheel", this._boundHandleWheel);
}
}
// ----------------------------------------------------------------------------------------------
// gridView.js
// ----------------------------------------------------------------------------------------------
class GridView {
// --- Internal State and Calculated Values ---
gridContainer;
aspectRatio = 3 / 4;
disableOnImageSelect = false;
observeViaViewport = false;
minMargin = 10;
placeholderColor = '#cccccc';
observerRootMargin = '200px 0px';
itemPaddingBottom;
placeholderBorderStyle;
gridIntersectionObserver = null;
itemsToLoadQueue = [];
gridViewIsLoading = false;
showCalled = false;
disabled = false;
loadingEnabled = true;
initialIndex = 0;
loadingStatus = new Map(); // Tracks status: 'pending', 'queued', 'loading', 'loaded', 'failed'
_cachedGridItemRect = null; // Cache for grid item dimensions
fetchingThumbPages = new Set(); // Track pages currently fetching thumbnails
config; // To store passed configuration
/**
* Initializes the GridView component.
* @param {HTMLElement} parent The parent element to append the grid container to
* @param {object} config Configuration object containing grid settings
* @param {number} config.numColumns Number of columns in the grid
* @param {boolean} config.showPageNumbers Whether to show index labels on grid items
* @param {boolean} config.fetchFullImages Whether to actively fetch full images when items intersect.
* @param {boolean} [observeViaViewport] Determines the root for the IntersectionObserver.
* - If `false` (default): The observer monitors item visibility relative to the `gridContainer` itself. Use this when the `gridContainer` has a fixed height and internal scrolling (`overflow: auto/scroll`).
* - If `true`: The observer monitors item visibility relative to the browser's viewport. Use this when the `gridContainer` displays all its items at once and visibility changes occur due to page scrolling.
* @param {boolean} [disableOnImageSelect=false] Whether to disable grid view when an image is clicked.
*/
constructor(parent, config, observeViaViewport, disableOnImageSelect = false) {
if (!parent || !(parent instanceof Element)) {
throw new Error("GridView requires a valid parent HTMLElement.");
}
if (!config) {
throw new Error("GridView requires a config object.");
}
if (typeof config.numColumns !== 'number' || typeof config.showPageNumbers !== 'boolean' || typeof config.fetchFullImages !== 'boolean') {
throw new Error("GridView requires config object with 'numColumns' (number), 'gridLabels' (bool) and 'fetchFullImages' (bool).");
}
if (typeof loadImageUrlAtIndex !== 'function') {
throw new Error("GridView requires global 'loadImageUrlAtIndex' function.");
}
if (typeof getThumbOffset !== 'function') {
throw new Error("GridView requires global 'getThumbOffset' function.");
}
if (typeof thumbs === 'undefined') {
throw new Error("GridView requires global 'thumbs' collection.");
}
if (typeof getPageIndexByImageIndex !== 'function') {
throw new Error("GridView requires global 'getPageIndexByImageIndex' function.");
}
if (typeof fetchAndPopulateThumbsOnPage !== 'function') {
throw new Error("GridView requires global 'fetchAndPopulateThumbsOnPage' function.");
}
// --- Calculate derived values (Internal) ---
this.parent = parent;
this.config = config;
this.disableOnImageSelect = disableOnImageSelect;
this.observeViaViewport = observeViaViewport;
this.itemPaddingBottom = `${(1 / this.aspectRatio) * 100}%`;
this.placeholderBorderStyle = `1px solid ${this.placeholderColor}`;
this.initialIndex = 0;
// --- Create the main grid container element (Internal) ---
this.gridContainer = document.createElement('div');
// --- Initial setup using globals ---
this._initializeDOM();
}
/**
* Appends the gridContainer to the global parent.
* @private
*/
_initializeDOM() {
this.parent.appendChild(this.gridContainer);
this.gridContainer.style.display = 'none';
this.gridContainer.style.position = 'relative';
this.gridContainer.style.overflow = 'auto'; // Should be auto or scroll
this.gridContainer.style.height = '100%'; // Take full overlay height
this.gridContainer.style.width = '100%';
this.gridContainer.style.gap = `${this.minMargin}px`;
this.gridContainer.style.boxSizing = 'border-box'; // Include padding/border in size
// this.gridContainer.style.padding = `${this.minMargin}px`; // Add padding
}
/**
* Handles the click event on a grid item.
* @param {number} index - The index of the clicked image.
* @private
*/
_handleGridItemClick(index) {
if (!imageViewer) {
console.error("Global 'imageViewer' does not exist.");
return;
}
console.log(`GridView: Clicked index ${index}`);
this.stopLoading(); // Stop background loading
imageViewer.loadAndShowIndex(index) // Prioritize loading clicked item
.catch(err => {
console.error(`Error loading image on click for index ${index}:`, err);
})
.finally(() => {
if (this.disableOnImageSelect) {
console.log(`GridView: Disabling grid view`);
this.disable();
}
});
}
/**
* Cleans up observers/queues.
*/
stopLoading() {
if (!this.loadingEnabled) return;
console.log("GridView: Stopping loading and disconnecting observer");
this.loadingEnabled = false;
if (this.gridIntersectionObserver) {
this.gridIntersectionObserver.disconnect();
// Ensure all observed items are unobserved if observer is reused later
this.gridContainer.querySelectorAll('[data-index]').forEach(item => {
try {
this.gridIntersectionObserver.unobserve(item);
} catch (e) { /* Ignore errors if already unobserved */ }
});
}
this.itemsToLoadQueue.length = 0;
this.gridViewIsLoading = false;
// Optionally reset 'queued'/'loading' statuses back to 'pending'
// this.loadingStatus.forEach((status, index) => {
// if (status === 'queued' || status === 'loading') {
// this.loadingStatus.set(index, 'pending');
// }
// });
}
/**
* Checks if a full image is available for a grid item and updates it if necessary.
* @param {HTMLElement} gridItem The grid item element.
* @param {number} index The index of the grid item.
* @private
*/
_checkAndApplyFullImage(gridItem, index) {
if (!this.config.fetchFullImages) return; // Only proceed if configured to fetch full images
const thumb = thumbs[index];
const currentStatus = this.loadingStatus.get(index) || 'pending';
// Check if full image URL exists and the item isn't already loaded/failed or currently loading one
if (thumb && thumb.fullImageUrl && currentStatus !== 'loaded' && currentStatus !== 'failed' && currentStatus !== 'loading') {
// Check if the gridItem does *not* already have an <img> element (the full image)
if (!gridItem.querySelector('img')) {
console.log(`GridView [${index}]: Found available full image during enableLoading. Updating.`);
this._updateGridItemImage(gridItem, index); // This will handle showing the full image
}
}
}
/**
* Checks if a grid item element is currently visible within the relevant scroll container.
* @param {HTMLElement} element The grid item element to check.
* @returns {boolean} True if the element is at least partially visible, false otherwise.
* @private
*/
_isElementVisible(element) {
if (!element || !element.isConnected) {
return false;
}
const rect = element.getBoundingClientRect();
if (this.observeViaViewport) {
// Check against viewport
const viewHeight = window.innerHeight || document.documentElement.clientHeight;
const viewWidth = window.innerWidth || document.documentElement.clientWidth;
// Check if vertical bounds intersect
const vertInView = (rect.top <= viewHeight) && ((rect.top + rect.height) >= 0);
// Check if horizontal bounds intersect
const horzInView = (rect.left <= viewWidth) && ((rect.left + rect.width) >= 0);
return vertInView && horzInView;
} else {
// Check against gridContainer
if (!this.gridContainer) return false; // Should not happen if element exists
const containerRect = this.gridContainer.getBoundingClientRect();
// Check if vertical bounds intersect (relative to container)
const vertInView = (rect.bottom > containerRect.top) && (rect.top < containerRect.bottom);
// Check if horizontal bounds intersect (relative to container)
const horzInView = (rect.right > containerRect.left) && (rect.left < containerRect.right);
return vertInView && horzInView;
}
}
/**
* Iterates through grid items, checks which are currently visible,
* and updates those that are in 'pending' state and have thumbnail data available.
* @private
*/
_updateVisiblePendingItems() { // Currently unused. Kept around just in case.
// console.log("GridView: Running _updateVisiblePendingItems check.");
let totalUpdated = 0;
this.gridContainer.querySelectorAll('[data-index]').forEach(gridItem => {
const index = parseInt(gridItem.dataset.index, 10);
if (isNaN(index)) return;
const currentStatus = this.loadingStatus.get(index) || 'pending';
const thumb = thumbs.get(index); // Use .get() for safety
// Update only if: Pending status + Has thumb background + Currently visible
if (currentStatus === 'pending' && thumb?.background && this._isElementVisible(gridItem)) {
// console.log(`GridView [_updateVisiblePendingItems]: Updating visible pending item ${index}.`);
this._updateGridItemImage(gridItem, index); // This will update visuals and potentially status/observer
totalUpdated++;
}
});
console.log("GridView: _updateVisiblePendingItems: Checked", totalUpdated, "items.");
}
/**
* Reenables image loading after stopLoading was called.
*/
enableLoading() {
if (this.loadingEnabled) return;
console.log("GridView: Enabling loading and re-observing grid items");
this._ensureObserverInitialized();
this.loadingEnabled = true;
this.gridContainer.querySelectorAll('[data-index]').forEach(gridItem => {
const index = parseInt(gridItem.dataset.index, 10);
if (isNaN(index)) return;
const currentStatus = this.loadingStatus.get(index) || 'pending';
const isFinalStatus = currentStatus === 'loaded' || currentStatus === 'failed';
if (!isFinalStatus) {
if (this.gridIntersectionObserver) {
try {
this.gridIntersectionObserver.observe(gridItem);
} catch (e) {
console.warn(`GridView: Error observing item ${index} during enableLoading:`, e);
}
}
} // No need to explicitly unobserve final items
});
// Update currently visible items that might have received thumb data
// requestAnimationFrame(() => this._updateVisiblePendingItems());
}
/**
* Hides the grid view and cleans up observers/queues.
*/
disable() {
if (this.disabled) return;
console.log("GridView: Disabling");
this.gridContainer.style.display = 'none';
this.stopLoading();
this.disabled = true;
}
/**
* Gets whether the grid view is currently visible.
*/
isShown() {
return this.gridContainer.style.display !== 'none';
}
/**
* Forces the grid item at the specified index to display its full-resolution image.
* This will fetch the full image data if necessary, even if `config.fetchFullImages` is false.
* It updates the item's visual state and loading status accordingly.
*
* @param {number} index The index of the image item to update.
* @returns {Promise<void>} A promise that resolves when the update attempt is complete (either success or failure).
* @public
*/
async forceSetFullImage(index) {
if (typeof index !== 'number' || index < 0 || index >= thumbs.length) {
console.warn(`GridView: forceSetFullImage: Invalid index ${index}.`);
return;
}
const gridItem = this.gridContainer.querySelector(`[data-index="${index}"]`);
if (!gridItem) {
console.warn(`GridView: forceSetFullImage: Could not find grid item element for index ${index}. Grid might not be visible or item not rendered.`);
return;
}
let thumb = thumbs[index];
const currentStatus = this.loadingStatus.get(index) || 'pending';
// Check if the full image URL is already available
if (!thumb || !thumb.fullImageUrl) {
console.log(`GridView: forceSetFullImage [${index}]: Full image URL not found locally. Fetching data...`);
try {
this.loadingStatus.set(index, 'loading');
await loadImageUrlAtIndex(index);
thumb = thumbs[index];
if (!thumb || !thumb.fullImageUrl) {
throw new Error(`Full image URL still missing after fetch attempt for index ${index}.`);
}
console.log(`GridView: forceSetFullImage [${index}]: Full image data fetched successfully.`);
} catch (error) {
console.error(`GridView: forceSetFullImage [${index}]: Error fetching full image data:`, error);
this.loadingStatus.set(index, 'failed');
// Update visual to reflect failure (likely back to thumb/placeholder)
this._updateGridItemImage(gridItem, index);
if (this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(gridItem); } catch (e) { }
}
return; // Stop further processing
}
} else {
// console.log(`GridView: forceSetFullImage [${index}]: Full image URL already available.`);
// If URL exists but status isn't final, mark as loading before update
if (currentStatus !== 'loaded' && currentStatus !== 'failed') {
this.loadingStatus.set(index, 'loading');
}
}
// At this point, we expect thumb.fullImageUrl to exist.
// Call _updateGridItemImage to handle the visual update.
// This function will create/update the <img> element, set the src,
// handle onload/onerror, update the status to 'loaded' or 'failed',
// and unobserve the item via the observer.
// console.log(`GridView: forceSetFullImage [${index}]: Triggering visual update with full image URL.`);
this._updateGridItemImage(gridItem, index);
}
/**
* Calculates the expected width of a single grid item based on the container's
* current clientWidth, the number of columns, and the computed column gap.
* @returns {number | null} The calculated width in pixels, or null if calculation is not possible
* (e.g., container not ready).
* @private
*/
_calculateGridItemWidth() {
if (!this.gridContainer || !this.gridContainer.isConnected) {
console.warn("GridView: Cannot calculate item width, grid container not ready.");
return null;
}
const container = this.gridContainer;
const numColumns = this.config.numColumns;
// Get the actual computed gap value
const styles = window.getComputedStyle(container);
const gapStyle = styles.columnGap; // Could be '10px', 'normal', etc.
let gap = parseFloat(gapStyle);
// Handle non-pixel gaps or 'normal' - fallback to configured minMargin
// 'normal' usually computes to a specific value, but parseFloat fails.
// If parseFloat returns NaN, use the class's minMargin as the intended gap.
if (isNaN(gap)) {
gap = this.minMargin;
}
const paddingLeft = parseFloat(styles.paddingLeft) || 0;
const paddingRight = parseFloat(styles.paddingRight) || 0;
// container.clientWidth includes horizontal padding
const availableWidthForGrid = container.clientWidth - (paddingLeft + paddingRight);
if (availableWidthForGrid <= 0) {
console.warn(`GridView: Available width for grid (${availableWidthForGrid}) is <= 0 after accounting for padding. Item width will be 0.`);
return 0;
}
// Calculate total gap space: (N-1) gaps for N columns
// Use Math.max(0, ...) in case numColumns is 1 (no gaps)
const totalGap = Math.max(0, numColumns - 1) * gap;
// Calculate the width allocated to one '1fr' trac
const calculatedWidth = (availableWidthForGrid - totalGap) / numColumns;
if (calculatedWidth < 0) {
return 0;
}
return calculatedWidth;
}
/**
* Updates the grid item with the best available image: full image > thumbnail > placeholder.
* Handles transitions more smoothly by showing thumb first if available.
* @param {HTMLElement} gridItem - The grid item div.
* @param {number} index - The index of the image.
* @private
*/
_updateGridItemImage(gridItem, index) {
const thumb = thumbs[index];
const currentStatus = this.loadingStatus.get(index) || 'pending';
let thumbnailApplied = false;
// --- Special Handling for Videos ---
if (thumb && thumb.isVideo === true) {
// console.log(`GridView [${index}]: Item identified as video. Applying placeholder.`);
// Clear existing image/background
const existingImg = gridItem.querySelector('img');
if (existingImg) existingImg.remove();
gridItem.style.background = 'none';
// Apply placeholder styles
gridItem.style.border = this.placeholderBorderStyle;
gridItem.style.backgroundColor = this.placeholderColor;
gridItem.style.opacity = '1'; // Ensure visible
// Set final status and unobserve
this.loadingStatus.set(index, 'loaded'); // Treat as loaded for observer purposes
if (this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(gridItem); } catch (e) { /* Ignore */ }
}
return; // Stop further processing for video items
}
// --- Clear only the things we are definitely replacing ---
const existingImg = gridItem.querySelector('img');
if (existingImg) existingImg.remove();
// Reset core styles that might interfere
gridItem.style.background = 'none';
gridItem.style.border = 'none';
gridItem.style.backgroundColor = '';
gridItem.style.opacity = '1'; // Ensure visible by default
// --- Step 1: Try to Apply Thumbnail Background First ---
if (thumb && typeof thumb === 'object' && thumb.background && typeof getThumbOffset === 'function') {
try {
// console.log(`GridView [${index}]: Attempting to apply thumbnail background.`);
const currentWidth = this._calculateGridItemWidth();
const thumbOffset = getThumbOffset(thumb.background, currentWidth);
const scale = currentWidth / THUMB_WIDTH;
const currentPageIndex = imageIndexToPageIndex(index);
const thumbSpritesheetWidth = getSpritesheetWidthForPage(currentPageIndex);
// Apply background styles
gridItem.style.background = thumb.background;
gridItem.style.backgroundPosition = `${thumbOffset}px 0px`;
if (galleryUsesSpritesheets) {
gridItem.style.backgroundSize = `${thumbSpritesheetWidth * scale}px auto`;
} else {
gridItem.style.backgroundSize = `100% auto`;
}
thumbnailApplied = true;
} catch (e) {
console.error(`GridView [${index}]: Error applying thumbnail background:`, e);
gridItem.style.background = 'none'; // Clear potentially broken background
thumbnailApplied = false; // Ensure flag reflects failure
}
}
// --- Step 2: Try to Load Full Image (if URL exists and not failed) ---
if (thumb && typeof thumb === 'object' && thumb.fullImageUrl && currentStatus !== 'failed') {
// console.log(`GridView [${index}]: Found full image URL. Starting load overlaying thumbnail (if applied).`);
const img = document.createElement('img');
img.src = thumb.fullImageUrl;
img.referrerPolicy = "no-referrer";
img.alt = `Image ${index + 1}`;
img.style.position = 'absolute';
img.style.top = '0';
img.style.left = '0';
img.style.width = '100%';
img.style.objectFit = 'cover';
img.style.display = 'block';
img.style.opacity = '0'; // Start hidden
img.style.transition = 'opacity 0.3s ease-in-out';
img.style.zIndex = '1'; // Ensure it's above background
img.onload = () => {
// console.log(`GridView [${index}]: Full image loaded successfully.`);
gridItem.style.border = 'none'; // Ensure no placeholder border remains
// Start the fade-in transition for the full image
img.style.opacity = '1';
// Listener to remove background *after* fade completes, if a thumb was applied
if (thumbnailApplied) {
img.addEventListener('transitionend', () => {
// Check if opacity transition finished and it's fully opaque
if (img.style.opacity === '1') {
gridItem.style.background = 'none'; // Remove thumb background now
// console.log(`GridView [${index}]: Removed thumbnail background after full image fade-in.`);
}
}, { once: true }); // Ensure listener runs only once
}
// --- Status Update & Unobserve ---
this.loadingStatus.set(index, 'loaded');
if (this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(gridItem); } catch (e) { /* Ignore */ }
}
};
img.onerror = () => {
console.warn(`GridView [${index}]: Failed to load full image: ${thumb.fullImageUrl}`);
img.remove(); // Clean up the failed image element
this.loadingStatus.set(index, 'failed');
// If the thumbnail didn't apply OR failed earlier, we need the placeholder
if (!thumbnailApplied) {
// console.log(`GridView [${index}]: Full image failed, no thumbnail applied, falling back to placeholder.`);
gridItem.style.background = 'none';
gridItem.style.border = this.placeholderBorderStyle;
gridItem.style.backgroundColor = this.placeholderColor;
} else {
// console.log(`GridView [${index}]: Full image failed, leaving thumbnail background visible.`);
// Ensure border/bgcolor are not set if thumbnail is the fallback
gridItem.style.border = 'none';
gridItem.style.backgroundColor = '';
}
// Unobserve after failure
if (this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(gridItem); } catch (e) { /* Ignore */ }
}
};
// Append the image. It will sit transparently over the background (thumb or placeholder)
// until it loads and fades in, or fails.
gridItem.appendChild(img);
// --- Step 3: Fallback to Placeholder if nothing else worked ---
} else if (!thumbnailApplied) {
// Apply placeholder only if no full image is being loaded AND no thumb was successfully applied
// console.log(`GridView [${index}]: Applying placeholder (no full image or thumbnail applied).`);
gridItem.style.background = 'none'; // Clear any previous background attempt
gridItem.style.border = this.placeholderBorderStyle;
gridItem.style.backgroundColor = this.placeholderColor;
// Update status if it wasn't already set or finalized (and not 'failed' from img load)
if (currentStatus !== 'failed' && currentStatus !== 'loading' && currentStatus !== 'queued' && currentStatus !== 'loaded') {
this.loadingStatus.set(index, 'pending');
}
// Keep observing if pending or potentially retryable failed state (unless full image just failed)
// Note: Observing logic is primarily handled by the intersection handler now. We just ensure
// the final state leads to unobserving elsewhere.
}
// --- Final Observer State Check ---
// If the final state determined here is loaded or failed, ensure it's unobserved.
// (This adds redundancy but ensures correctness if called outside intersection flow)
const finalStatusCheck = this.loadingStatus.get(index);
if ((finalStatusCheck === 'loaded' || finalStatusCheck === 'failed') && this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(gridItem); } catch (e) { /* Ignore */ }
}
}
/**
* Efficiently removes an item from the queue.
* @param {number} indexToRemove - The index to remove.
* @private
*/
_removeFromQueue(indexToRemove) {
this.itemsToLoadQueue = this.itemsToLoadQueue.filter(index => index !== indexToRemove);
}
/**
* After loading an image, checks neighbors for newly available thumbnails and updates them.
* @param {number} centerIndex - The index of the image that was just loaded.
* @private
*/
/**
* Updates grid items on a specific page, typically after thumbnails have been fetched.
* Only updates items that are currently placeholders.
* @param {number} pageIndex - The index of the page whose items should be updated.
* @private
*/
_updateThumbnailsForPage(pageIndex) {
const startIndex = pageIndex * PAGINATION;
const endIndex = Math.min(startIndex + PAGINATION, thumbs.length);
// console.log(`GridView: Updating thumbnails visibility for page ${pageIndex} [${startIndex}-${endIndex-1}]`);
for (let i = startIndex; i < endIndex; i++) {
const thumb = thumbs[i];
const gridItem = this.gridContainer.querySelector(`[data-index="${i}"]`);
// Check if item exists, has a thumb now, and doesn't already have a full image or background image set
if (gridItem && thumb && thumb.background) {
const hasFullImg = gridItem.querySelector('img');
const hasThumbBg = gridItem.style.backgroundImage && gridItem.style.backgroundImage !== 'none';
if (!hasFullImg && !hasThumbBg) {
// console.log(`GridView: Applying newly fetched thumbnail to item ${i}`);
this._updateGridItemImage(gridItem, i); // Will apply the thumbnail
}
}
}
}
_updateNeighborThumbnails(centerIndex) {
let minUpdated = centerIndex;
let maxUpdated = centerIndex;
let updated = false;
const maxIndex = thumbs.length - 1; // Cache length
// Check backwards
for (let i = centerIndex - 1; i >= 0; i--) {
const thumb = thumbs[i];
if (!thumb) break; // Reached edge of loaded thumb data for this page
const status = this.loadingStatus.get(i);
if (status === 'loaded' || status === 'failed' || (thumb && thumb.fullImageUrl)) {
break; // Already has full image or failed, stop checking further back
}
if (thumb.background) {
const gridItem = this.gridContainer.querySelector(`[data-index="${i}"]`);
// Only update if the item exists and doesn't already have an <img> (full image) or a background image (thumb)
if (gridItem && !gridItem.querySelector('img') && (!gridItem.style.backgroundImage || gridItem.style.backgroundImage === 'none')) {
// console.log(`GridView: Updating neighbor ${i} with thumbnail.`);
this._updateGridItemImage(gridItem, i); // Will apply the thumb
minUpdated = i;
updated = true;
}
}
}
// Check forwards
for (let i = centerIndex + 1; i <= maxIndex; i++) {
const thumb = thumbs[i];
if (!thumb) break; // Reached edge of loaded thumb data for this page
const status = this.loadingStatus.get(i);
if (status === 'loaded' || status === 'failed' || (thumb && thumb.fullImageUrl)) {
break; // Already has full image or failed, stop checking further forward
}
if (thumb.background) {
const gridItem = this.gridContainer.querySelector(`[data-index="${i}"]`);
if (gridItem && !gridItem.querySelector('img') && (!gridItem.style.backgroundImage || gridItem.style.backgroundImage === 'none')) {
// console.log(`GridView: Updating neighbor ${i} with thumbnail.`);
this._updateGridItemImage(gridItem, i); // Will apply the thumb
maxUpdated = i;
updated = true;
}
}
}
if (updated && minUpdated <= maxUpdated && !(minUpdated === centerIndex && maxUpdated === centerIndex)) {
// Log only if actual neighbors were updated
console.log(`GridView: Found/updated thumbnails for neighbors in range [${minUpdated}, ${maxUpdated}] around loaded index ${centerIndex}.`);
}
}
/**
* Processes the queue of items needing their full image URL loaded.
* @private
*/
async _processLoadQueue() {
// Check gridViewIsLoading BEFORE checking queue length for slightly faster exit
if (this.gridViewIsLoading) return;
if (this.itemsToLoadQueue.length === 0) return;
if (!this.loadingEnabled) return;
this.gridViewIsLoading = true;
const indexToLoad = this.itemsToLoadQueue.shift(); // Get the next item
// --- Crucial Check: Is it still relevant? ---
const currentStatus = this.loadingStatus.get(indexToLoad);
if (currentStatus !== 'queued') {
// console.log(`GridView: Skipping load for index ${indexToLoad}, status is now ${currentStatus}.`);
this.gridViewIsLoading = false;
// Process next item if queue isn't empty
if (this.itemsToLoadQueue.length > 0) requestAnimationFrame(() => this._processLoadQueue());
return;
}
// Check if grid item exists *before* async call
const gridItem = this.gridContainer.querySelector(`[data-index="${indexToLoad}"]`);
if (!gridItem) {
console.warn(`GridView: Could not find grid item DOM element for index ${indexToLoad} before starting load.`);
this.loadingStatus.set(indexToLoad, 'failed'); // Mark as failed if DOM element is gone
this.gridViewIsLoading = false;
if (this.itemsToLoadQueue.length > 0) requestAnimationFrame(() => this._processLoadQueue());
return;
}
try {
// console.log(`GridView: Processing queue for index ${indexToLoad}`);
this.loadingStatus.set(indexToLoad, 'loading'); // Mark as loading *before* async call
const pageIndex = getPageIndexByImageIndex(indexToLoad);
if (pageIndex === null || pageIndex === undefined) {
throw new Error(`Could not determine page index for image index ${indexToLoad}`);
}
if (this.config.fetchFullImages) {
// --- Fetch Full Image Data (and neighbor thumbs) ---
if (typeof loadImageUrlAtIndex !== 'function') {
throw new Error("Global function 'loadImageUrlAtIndex' is not defined.");
}
await loadImageUrlAtIndex(indexToLoad, false); // Fetch full image data
// Re-select item after await, it might have been removed/changed
const currentGridItem = this.gridContainer.querySelector(`[data-index="${indexToLoad}"]`);
if (currentGridItem) {
// This call will attempt to display the full image if available
this._updateGridItemImage(currentGridItem, indexToLoad);
} else {
console.warn(`GridView: Grid item for index ${indexToLoad} disappeared before image update after full load.`);
// Status was 'loading'. Check thumb data to finalize status if item vanished.
const thumb = thumbs[indexToLoad];
if (thumb && thumb.fullImageUrl) {
// Only update status if it didn't already fail during load/update
if (this.loadingStatus.get(indexToLoad) !== 'failed') this.loadingStatus.set(indexToLoad, 'loaded');
} else if (this.loadingStatus.get(indexToLoad) === 'loading') {
this.loadingStatus.set(indexToLoad, 'failed'); // Only mark failed if it wasn't already 'failed' or didn't become 'loaded'
}
}
// Update neighbors which might have got thumbs from loadImageUrlAtIndex
this._updateNeighborThumbnails(indexToLoad);
} else {
// --- Fetch Only Thumbnail Data for the Page ---
if (typeof getPageIndexByImageIndex !== 'function' || typeof fetchAndPopulateThumbsOnPage !== 'function') {
throw new Error("Required global functions for thumbnail fetching are missing.");
}
console.log(`GridView: Fetching only thumbs for page ${pageIndex} (triggered by index ${indexToLoad})`);
// Note: fetchAndPopulateThumbsOnPage locks per page index, so it's okay to call it multiple times on the same page
await fetchAndPopulateThumbsOnPage(pageIndex);
// Re-select item after await
const currentGridItem = this.gridContainer.querySelector(`[data-index="${indexToLoad}"]`);
if (currentGridItem) {
// Update item - will now show thumbnail if available, or placeholder
this._updateGridItemImage(currentGridItem, indexToLoad);
} else {
console.warn(`GridView: Grid item for index ${indexToLoad} disappeared before image update after thumb fetch.`);
// Even if item is gone, the thumb data might be useful for others
}
// Update neighbors on the same page which might now have thumbs
this._updateNeighborThumbnails(indexToLoad);
// Mark as 'loaded' because the requested *thumbnail* fetch is complete.
// The observer should stop observing this item now.
// Only set to loaded if it didn't fail during the update process somehow
if (this.loadingStatus.get(indexToLoad) === 'loading') {
this.loadingStatus.set(indexToLoad, 'loaded');
// If we marked it loaded, ensure it's unobserved
if (currentGridItem && this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(currentGridItem); } catch (e) { }
}
}
}
} catch (error) {
console.error(`GridView: Error calling loadImageUrlAtIndex for index ${indexToLoad}:`, error);
this.loadingStatus.set(indexToLoad, 'failed');
// Re-select item before trying to update its state after error
const currentGridItem = this.gridContainer.querySelector(`[data-index="${indexToLoad}"]`);
if (currentGridItem) {
// Update to show thumb/placeholder on error
this._updateGridItemImage(currentGridItem, indexToLoad);
// Unobserve on catch failure (if observer exists)
if (this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(currentGridItem); } catch (e) { }
}
}
} finally {
this.gridViewIsLoading = false;
if (this.itemsToLoadQueue.length > 0) {
requestAnimationFrame(() => this._processLoadQueue());
}
}
}
/**
* The callback function for the IntersectionObserver.
* Handles both entering and leaving the viewport.
* @param {IntersectionObserverEntry[]} entries
* @param {IntersectionObserver} observer
* @private
*/
_handleIntersection(entries, observer) {
entries.forEach(entry => {
const gridItem = entry.target;
const index = parseInt(gridItem.dataset.index, 10);
if (isNaN(index)) {
console.warn("GridView: Could not determine index for intersecting grid item.", gridItem);
try { observer.unobserve(gridItem); } catch (e) { } // Unobserve problematic items
return;
}
if (entry.isIntersecting) {
// --- Item is entering the viewport ---
// **Step 1: Update visual based on current data**
// This applies thumb/placeholder/existing full image immediately.
// It also updates status if full image loads/fails synchronously or is already known.
// It also handles unobserving if status becomes loaded/failed.
this._updateGridItemImage(gridItem, index);
// **Step 2: Check if further network loading is needed**
const updatedStatus = this.loadingStatus.get(index) || 'pending'; // Get status *after* update attempt
const thumb = thumbs[index];
const hasFullImage = thumb && thumb.fullImageUrl; // We might not need this specific check now
const isFinalStatus = updatedStatus === 'loaded' || updatedStatus === 'failed';
// Needs network action if: status isn't final AND (we need full images OR thumb data might be missing)
const needsNetworkAction = !isFinalStatus && (this.config.fetchFullImages || !thumb?.background);
if (needsNetworkAction) {
const pageIndex = getPageIndexByImageIndex(index);
// --- Independent Thumbnail Fetch Trigger ---
if (pageIndex !== null && pageIndex !== undefined) {
const firstThumbIndexOnPage = pageIndex * PAGINATION;
// Check if thumbs for this page are likely missing and not already being fetched.
// A simple check on the first item of the page is usually sufficient.
const thumbsLikelyMissing = !thumbs.get(firstThumbIndexOnPage); // Using .get() for robustness
if (thumbsLikelyMissing && !this.fetchingThumbPages.has(pageIndex)) {
console.log(`GridView: Item ${index} intersected. Triggering thumb fetch for page ${pageIndex}.`);
this.fetchingThumbPages.add(pageIndex);
fetchAndPopulateThumbsOnPage(pageIndex)
.then(() => {
console.log(`GridView: Thumb fetch for page ${pageIndex} completed.`);
// Update visuals for items on this page that might now have thumbnails
this._updateThumbnailsForPage(pageIndex);
})
.catch(err => {
console.error(`GridView: Thumb fetch for page ${pageIndex} failed:`, err);
})
.finally(() => {
this.fetchingThumbPages.delete(pageIndex);
});
}
}
// --- End Independent Thumbnail Fetch ---
// --- Full Image Queueing (if applicable) ---
// Queue the specific item for full image loading if fetchFullImages is true.
// This happens regardless of the separate thumbnail fetch.
// console.log(`GridView: Intersecting item index ${index} (Status: ${currentStatus}). Adding to full image queue.`);
this.itemsToLoadQueue.push(index);
this.loadingStatus.set(index, 'queued');
this._processLoadQueue(); // Start processing queue if not already running
}
// Case: Item is intersecting, doesn't need a *network* load (full image URL exists),
// but hasn't reached a final visual state ('loaded'/'failed').
// This forces an update check if, e.g., the full image arrived via another mechanism (main viewer load)
// while this item was not visible or being processed.
else if (hasFullImage && !isFinalStatus) {
// console.log(`GridView [${index}]: Intersecting with existing full image data. Forcing update.`);
this._updateGridItemImage(gridItem, index);
}
// ---- END ADDED LOGIC ----
} else {
// --- Item is leaving the viewport ---
const currentStatus = this.loadingStatus.get(index) || 'pending';
if (currentStatus === 'queued') {
// console.log(`GridView: Item index ${index} left viewport while queued. Dequeuing.`);
this._removeFromQueue(index);
this.loadingStatus.set(index, 'pending');
}
// Keep observing otherwise; it will be unobserved on load/fail.
}
});
}
/**
* Creates a single grid item element, styles it, adds listeners.
* @param {number} index - The index of the image.
* @returns {HTMLElement} The configured grid item element.
* @private
*/
_createGridItem(index) {
const gridItem = document.createElement('div');
gridItem.style.position = 'relative';
gridItem.style.overflow = 'hidden';
gridItem.style.width = '100%';
gridItem.style.height = '0';
gridItem.style.paddingBottom = this.itemPaddingBottom;
gridItem.style.cursor = 'pointer';
gridItem.style.boxSizing = 'border-box';
gridItem.dataset.index = index;
// Set initial placeholder state directly
gridItem.style.backgroundColor = this.placeholderColor;
gridItem.style.border = this.placeholderBorderStyle;
if (this.config.showPageNumbers) {
const indexLabel = document.createElement('span');
indexLabel.textContent = index + 1;
indexLabel.style.position = 'absolute';
indexLabel.style.top = '2px';
indexLabel.style.left = '2px';
indexLabel.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
indexLabel.style.color = 'white';
indexLabel.style.padding = '3px 6px';
indexLabel.style.fontSize = '14px';
indexLabel.style.borderRadius = '4px';
indexLabel.style.fontWeight = 'bold';
indexLabel.style.zIndex = '10'; // Above image/thumb background but below potential UI elements
indexLabel.style.pointerEvents = 'none';
gridItem.appendChild(indexLabel);
}
gridItem.addEventListener('click', () => this._handleGridItemClick(index));
return gridItem;
}
/**
* Prepares the grid container for display.
* @private
*/
_prepareGridContainer() {
this.gridContainer.innerHTML = ''; // Clear previous items
// Disconnect observer, clear queue, reset loading flag
if (this.gridIntersectionObserver) {
this.gridIntersectionObserver.disconnect();
this.gridContainer.querySelectorAll('[data-index]').forEach(item => {
try {
this.gridIntersectionObserver.unobserve(item);
} catch (e) { /* Ignore errors if already unobserved */ }
});
}
this.itemsToLoadQueue.length = 0;
this.gridViewIsLoading = false;
this.loadingStatus.clear(); // Clear status map for fresh build
this._cachedGridItemRect = null; // Invalidate cached dimensions
// Re-initialize observer if it was destroyed or null
if (!this.gridIntersectionObserver) {
this._ensureObserverInitialized();
} else {
// If observer exists, ensure it's disconnected before adding new items
this.gridIntersectionObserver.disconnect();
}
this.gridContainer.style.display = 'grid';
this.gridContainer.style.gridTemplateColumns = `repeat(${this.config.numColumns}, 1fr)`;
}
/**
* Initializes intersection observer if needed.
* @private
*/
_ensureObserverInitialized() {
if (!this.gridIntersectionObserver) {
const observerRoot = this.observeViaViewport ? null : this.gridContainer;
const observerOptions = {
root: observerRoot,
rootMargin: this.observerRootMargin,
threshold: 0
};
this.gridIntersectionObserver = new IntersectionObserver(
this._handleIntersection.bind(this),
observerOptions
);
}
}
/**
* Scrolls the grid view to the specified image index.
* @param {number} index - The index of the image to scroll to.
* @param {boolean} [highlight=false] - Whether to temporarily highlight the scrolled-to item.
*/
scrollToIndex(index, highlight = true) {
if (index === null || index < 0 || index >= thumbs.length) {
console.warn(`GridView: scrollToIndex: Invalid index ${index}.`);
return;
}
// Ensure scrolling happens after the DOM might have updated
requestAnimationFrame(() => {
const targetItem = this.gridContainer.querySelector(`[data-index="${index}"]`);
if (targetItem) {
console.log(`GridView: Scrolling to index ${index}`);
targetItem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' });
// --- Add Highlighting Logic ---
if (highlight) {
const fadeInDuration = 200; // ms for fade-in
const holdDuration = 800; // ms to keep highlight fully visible
const fadeOutDuration = 800; // ms for fade-out
// Brighter white-yellow glow, more spread
const highlightBoxShadow = '0 0 30px 10px rgba(255, 255, 200, 1.0)';
// Function to clean up styles and timeouts for a specific item
const cleanupHighlight = (item) => {
if (item._highlightTimeout) clearTimeout(item._highlightTimeout);
if (item._cleanupTimeout) clearTimeout(item._cleanupTimeout);
item.style.removeProperty('outline');
item.style.removeProperty('box-shadow');
item.style.removeProperty('transition');
item.style.removeProperty('z-index'); // Clean up z-index
delete item._highlightTimeout; // Clean up custom property
delete item._cleanupTimeout; // Clean up custom property
};
// Clean up any previous highlight effect on this item immediately
cleanupHighlight(targetItem);
// Ensure item is visible above others if overlapping during scroll/highlight
targetItem.style.zIndex = '2';
// Set initial transition for fade-in
targetItem.style.transition = `box-shadow ${fadeInDuration}ms ease-in`;
// Ensure initial state (no shadow) is registered before applying the highlight
targetItem.style.boxShadow = '';
// Use requestAnimationFrame to apply the highlight *after* the initial styles are set
requestAnimationFrame(() => {
requestAnimationFrame(() => { // Double RAF for robustness in some browsers
targetItem.style.boxShadow = highlightBoxShadow; // Apply highlight, starts fade-in
// Set timeout to start the fade-out after fade-in + hold duration
targetItem._highlightTimeout = setTimeout(() => {
// Change transition for fade-out
targetItem.style.transition = `box-shadow ${fadeOutDuration}ms ease-out`;
targetItem.style.boxShadow = ''; // Start fade-out
// Set another timeout to clean up styles *after* the fade-out completes
targetItem._cleanupTimeout = setTimeout(() => {
cleanupHighlight(targetItem); // Use the cleanup function
}, fadeOutDuration);
}, fadeInDuration + holdDuration); // Wait for fade-in and hold
});
});
}
// --- End Highlighting Logic ---
} else {
console.warn(`GridView: scrollToIndex: Could not find grid item for index ${index} to scroll to.`);
}
});
}
/**
* Displays the grid view, creating items and setting up observers.
* @param {number | null} [initialIndex=null] - Optional index to scroll into view.
*/
showGridView(initialIndex = null) {
console.log(`GridView: showGridView called, initialIndex: ${initialIndex}`);
this.showCalled = true;
this.loadingEnabled = true;
this.initialIndex = initialIndex;
const currentTotalImages = thumbs.length;
this._prepareGridContainer(); // Sets up container, observer, clears state
if (currentTotalImages > 0) {
this.gridContainer.style.display = 'grid'; // Ensure display is grid
let itemsCreated = 0;
for (let i = 0; i < currentTotalImages; i++) {
const thumb = thumbs[i];
// --- Skip deleted items ---
if (thumb === 'deleted') {
continue;
}
// Always set initial status to pending
this.loadingStatus.set(i, 'pending');
const gridItem = this._createGridItem(i); // Creates item and sets initial visual
this.gridContainer.appendChild(gridItem);
itemsCreated++;
// Always observe the item initially. The intersection handler will manage updates.
if (this.gridIntersectionObserver) {
this.gridIntersectionObserver.observe(gridItem);
} else {
console.warn("GridView: Observer not initialized when trying to observe item", i);
}
}
// Initial update for items already visible after grid creation
requestAnimationFrame(() => {
// this._updateVisiblePendingItems();
// Scroll after the potential initial visible item update
if (initialIndex !== null && initialIndex >= 0 && initialIndex < currentTotalImages) {
this.scrollToIndex(initialIndex, false);
}
});
} else {
this.gridContainer.innerHTML = '<p style="text-align: center; grid-column: 1 / -1; color: #555; padding: 20px;">No images to display.</p>';
this.gridContainer.style.display = 'block'; // Use block for the message
}
this.disabled = false;
}
/**
* Scrolls the grid container by the specified amount.
* @param {number|ScrollToOptions} x The number of pixels to scroll horizontally, or a ScrollToOptions
object
* @param {number} [y] - The number of pixels to scroll vertically (if x is a number)
*/
scrollBy(x, y) {
if (typeof y === 'number') {
this.gridContainer.scrollBy(x, y);
} else {
this.gridContainer.scrollBy(x);
}
}
/**
* Returns the grid container element.
* @returns {HTMLElement} The grid container element
*/
getContainer() {
return this.gridContainer;
}
/**
* Refreshes all currently displayed grid items.
* It checks if any item in the `thumbs` collection has been marked as "deleted"
* and removes the corresponding grid item if found.
* For items that are not deleted, it updates their visual representation based
* on the latest available data (thumbnail or full image) and configuration,
* essentially calling `_updateGridItemImage` on them.
* Note: This function currently only updates or removes existing items. It does
* not add new grid items if the `thumbs` collection grew since the last show.
* For adding new items, consider calling `showGridView` again.
*/
refreshAll() {
console.log("GridView: Refreshing all grid items.");
const items = Array.from(this.gridContainer.querySelectorAll('[data-index]'));
items.forEach(gridItem => {
const index = parseInt(gridItem.dataset.index, 10);
if (isNaN(index)) {
console.warn("GridView [refreshAll]: Found grid item with invalid index.", gridItem);
return; // Skip this item
}
const thumb = thumbs.get(index); // Use .get() for safety if thumbs is proxy/collection
if (thumb === 'deleted') {
console.log(`GridView [refreshAll]: Item ${index} marked as deleted. Removing grid item.`);
// Unobserve before removing
if (this.gridIntersectionObserver) {
try { this.gridIntersectionObserver.unobserve(gridItem); } catch (e) { /* Ignore */ }
}
gridItem.remove();
this.loadingStatus.delete(index);
} else {
// Item is not deleted, update its visual state
// console.log(`GridView [refreshAll]: Updating item ${index}.`);
// _updateGridItemImage handles checking thumb/fullImage and applying appropriate visual
// It also manages observer status internally.
this._updateGridItemImage(gridItem, index);
}
});
}
}
/**
* @typedef {object} MediaResult
* @property {string} url - The final extracted media URL.
* @property {string} type - The determined media type ('image', 'video', or a custom type from config).
* @property {string} sourceUrl - The URL of the page where this media item was found.
* @property {number} depth - The recursion depth at which this was found (0 is the initial page).
* @property {Element} [element] - The specific element (img, video, etc.) if extracted directly (might be null/undefined if from deep recursion context).
*/
/**
* @typedef {object} CustomSelectorConfig
* @property {string} selector - The CSS selector or XPath expression.
* @property {'css' | 'xpath'} type - The type of the selector.
* @property {string} attribute - Attribute for direct extraction OR attribute with the link URL (if following).
* @property {string} [mediaType] - Optional: Explicitly define the type label (e.g., 'image', 'video', 'galleryThumb'). If omitted, the extractor attempts to auto-detect 'image' or 'video' based on element or URL; otherwise defaults to 'custom'. This label is applied *after* filtering.
* @property {RegExp|null} [styleUrlRegex=null] - Optional regex for extracting URL from style attribute. Must contain one capturing group for the URL.
* @property {boolean} [followLink=false] - If true, use 'attribute' value as URL and fetch recursively.
* @property {Partial<ExtractorConfig>|null} [nextConfig=null] - Config for the page loaded *after* following the link. Required if followLink is true.
* @property {Array<string>|null} [allowedMediaTypes=null] - Optional: If followLink is false, only extract if the determined media type (using _determineMediaType) is one of these (e.g., ['video']). If null, extract regardless of type.
*/
/**
* @typedef {object} ExtractorConfig
* @property {boolean} [extractImages=true] - Whether to extract <img> elements on the current level.
* @property {boolean} [extractVideos=true] - Whether to extract <video> elements on the current level.
* @property {Array<CustomSelectorConfig>} [customSelectors=[]] - Array of custom selectors.
* @property {number | null | undefined} [maxDepth] - Optional maximum recursion depth. If null/undefined/Infinity, depth is limited only by config structure and cycle detection. 0 means initial page only.
* @property {number} [concurrency=5] - How many links to follow in parallel.
*/
class MediaExtractor {
/** @type {Required<ExtractorConfig>} */
static DEFAULT_CONFIG = {
extractImages: true,
extractVideos: true,
customSelectors: [],
maxDepth: Infinity, // Default to no explicit depth limit
concurrency: 5
};
// Common media file extensions for auto-detection
static IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tif', 'tiff']);
static VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogg', 'mov', 'avi', 'wmv', 'flv', 'mkv', 'm4v']);
/**
* Merges user config with defaults or parent config. Ensures 'maxDepth' is a non-negative number or Infinity.
* Ignores 'maxDepth' properties within nested nextConfigs during resolution.
* Inherits 'concurrency' from parentConfig if not specified in userConfig.
* @param {Partial<ExtractorConfig>} [userConfig={}] The specific config for the current level (e.g., nextConfig).
* @param {Required<ExtractorConfig>} [parentConfig=null] The resolved config of the parent level (null for the initial call).
* @returns {Required<ExtractorConfig>} The fully resolved configuration for the current level.
* @private
*/
_resolveConfig(userConfig = {}, parentConfig = null) {
// Start with defaults
let resolved = { ...MediaExtractor.DEFAULT_CONFIG };
// 1. Inherit concurrency from parent if userConfig doesn't specify it
if (userConfig.concurrency === undefined || userConfig.concurrency === null || userConfig.concurrency <= 0) {
if (parentConfig && parentConfig.concurrency > 0) {
resolved.concurrency = parentConfig.concurrency;
}
// Otherwise, it keeps the DEFAULT_CONFIG.concurrency
} else {
resolved.concurrency = userConfig.concurrency;
}
// 2. Merge top-level simple properties (excluding concurrency)
if (userConfig.extractImages !== undefined) resolved.extractImages = userConfig.extractImages;
if (userConfig.extractVideos !== undefined) resolved.extractVideos = userConfig.extractVideos;
// 3. Handle maxDepth specifically (only initial top-level matters for limit)
if (parentConfig === null) {
if (userConfig.maxDepth !== undefined && userConfig.maxDepth !== null && userConfig.maxDepth >= 0) {
resolved.maxDepth = Number(userConfig.maxDepth);
} else if (userConfig.hasOwnProperty('maxDepth')) {
console.warn(`MediaExtractor: Invalid or null top-level 'maxDepth' provided (${userConfig.maxDepth}). Defaulting to Infinity (no limit).`);
resolved.maxDepth = Infinity;
}
// Otherwise, it keeps default Infinity
} else {
// Inherit parent's resolved maxDepth for structural consistency
resolved.maxDepth = parentConfig.maxDepth;
}
// 4. Merge customSelectors carefully
resolved.customSelectors = (userConfig.customSelectors || []).map(cs => {
// Basic structure with defaults
const resolvedCs = {
// No default mediaType here; it's determined later or falls back to 'custom'
followLink: false,
nextConfig: null,
styleUrlRegex: null,
allowedMediaTypes: null, // <<< Renamed and default added
...cs // User overrides
};
// Validate required fields
if (!resolvedCs.selector || !resolvedCs.type || !resolvedCs.attribute) {
console.error("MediaExtractor: Custom selector is missing required fields (selector, type, attribute):", cs);
return null; // Mark for filtering
}
// Validate/prepare nextConfig if following link
if (resolvedCs.followLink && !resolvedCs.nextConfig) {
console.warn(`MediaExtractor: Custom selector has followLink=true but no nextConfig. Recursion for this selector will use inherited/default config for the next level. Selector:`, resolvedCs.selector);
resolvedCs.nextConfig = {}; // Ensure object exists for next _resolveConfig call
}
// Validate styleUrlRegex if provided
if (resolvedCs.styleUrlRegex && !(resolvedCs.styleUrlRegex instanceof RegExp)) {
console.error(`MediaExtractor: Invalid styleUrlRegex (must be a RegExp instance) for selector: ${resolvedCs.selector}`);
resolvedCs.styleUrlRegex = null; // Invalidate it
}
return resolvedCs;
}).filter(cs => cs !== null); // Filter out invalid selectors
return resolved;
}
/**
* Fetches HTML content.
* @param {string} url
* @returns {Promise<string>}
* @private
*/
async _fetchHtml(url) {
// In a real userscript, use GM_xmlhttpRequest or GM.xmlHttpRequest
// For simulation/testing, you might use fetch if CORS allows or a proxy
console.debug(`MediaExtractor: Fetching ${url}`);
return new Promise((resolve, reject) => {
// Assuming GM_xmlhttpRequest is available in the execution context
if (typeof GM_xmlhttpRequest === 'undefined') {
console.error("MediaExtractor: GM_xmlhttpRequest is not available. Cannot fetch URL:", url);
return reject(new Error("GM_xmlhttpRequest is not defined. Ensure the script runs in a userscript manager environment (e.g., Tampermonkey, Greasemonkey) with appropriate @grant privileges."));
}
GM_xmlhttpRequest({
method: "GET",
url: url,
headers: { // Add some common headers to mimic a browser
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
timeout: 30000, // 30 second timeout
onload: function (response) {
if (response.status >= 200 && response.status < 400) { // Allow redirects (3xx) usually handled by browser/GM
resolve(response.responseText);
} else {
const errorDetails = response.responseText ? `: ${response.responseText.substring(0, 200)}` : '';
reject(new Error(`HTTP error! status: ${response.status} for ${url}${errorDetails}`));
}
},
onerror: function (response) {
reject(new Error(`Network error fetching ${url}: ${response.error || 'Unknown error'}`));
},
ontimeout: function () {
reject(new Error(`Request timed out for ${url}`));
},
onabort: function () {
reject(new Error(`Request aborted for ${url}`));
}
});
});
}
/**
* Extracts a URL from a DOM element attribute or style.
* @param {Element} element
* @param {string} attributeName
* @param {string} baseUrl
* @param {RegExp|null} [styleUrlRegex=null] - Regex for style attribute extraction. Must have one capturing group.
* @returns {string|null} The absolute URL or null.
* @private
*/
_extractUrlFromAttribute(element, attributeName, baseUrl, styleUrlRegex = null) {
let rawUrl = null;
if (attributeName.toLowerCase() === 'style' && styleUrlRegex instanceof RegExp) {
const styleContent = element.getAttribute('style');
if (styleContent) {
const match = styleContent.match(styleUrlRegex);
// Use the first capturing group (index 1)
if (match && match[1]) {
rawUrl = match[1].trim().replace(/&/g, '&'); // Decode entities just in case
}
}
} else {
// Handle data-* attributes correctly
if (attributeName.startsWith('data-')) {
// Convert kebab-case attribute name to camelCase dataset property name
const datasetName = attributeName.substring(5).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
rawUrl = element.dataset[datasetName];
// Fallback to getAttribute if dataset property is undefined (less common but possible)
if (rawUrl === undefined) {
rawUrl = element.getAttribute(attributeName);
}
} else {
rawUrl = element.getAttribute(attributeName);
}
}
if (rawUrl) {
rawUrl = rawUrl.trim();
if (rawUrl) {
// Skip javascript:, mailto:, #fragment links specifically for 'href'
if (attributeName.toLowerCase() === 'href' && /^(javascript:|mailto:|#)/i.test(rawUrl)) {
// console.debug(`MediaExtractor: Skipping non-media link: ${rawUrl}`);
return null;
}
try {
// Resolve relative URLs against the base URL
return new URL(rawUrl, baseUrl).href;
} catch (e) {
// Ignore invalid URLs
console.warn(`MediaExtractor: Skipping invalid URL "${rawUrl}" found in attribute "${attributeName}" on element <${element.tagName.toLowerCase()}> on page ${baseUrl}`, e.message);
return null;
}
}
}
return null;
}
/**
* Helper to run async tasks with controlled concurrency.
* @template T
* @param {Array<() => Promise<T>>} taskFns - Array of functions returning promises.
* @param {number} concurrency - Max tasks in parallel.
* @returns {Promise<Array<T | {error: Error}>>} - Promise with results or error objects.
* @private
*/
async _runWithConcurrency(taskFns, concurrency) {
const results = new Array(taskFns.length);
let taskIndex = 0;
const totalTasks = taskFns.length;
const workers = [];
const effectiveConcurrency = Math.max(1, Math.min(concurrency, totalTasks));
for (let i = 0; i < effectiveConcurrency; i++) {
workers.push((async () => {
while (taskIndex < totalTasks) {
const currentIndex = taskIndex++;
const taskFn = taskFns[currentIndex];
if (taskFn) {
try {
results[currentIndex] = await taskFn();
} catch (error) {
const err = (error instanceof Error) ? error : new Error(String(error || 'Unknown error in concurrent task'));
console.error(`MediaExtractor: Error in concurrent task index ${currentIndex}:`, err.message);
results[currentIndex] = { error: err }; // Store error marker
}
}
}
})());
}
await Promise.all(workers);
return results;
}
/**
* Determines the media type based on element tag, URL extension, or falls back.
* Respects the explicitly provided mediaType from the config.
* @param {Element} element The source DOM element.
* @param {string|null} url The extracted URL.
* @param {string|undefined} [configMediaType] The mediaType specified in the CustomSelectorConfig.
* @returns {string} The determined media type (e.g., 'image', 'video', 'custom', or configMediaType).
* @private
*/
_determineMediaType(element, url, configMediaType) {
// 1. Explicit Override: If a mediaType is provided in the config, use it.
if (configMediaType) {
return configMediaType;
}
// 2. Element Type Check: Infer from common media tags.
const tagName = element.tagName.toUpperCase();
if (tagName === 'IMG') return 'image';
if (tagName === 'VIDEO') return 'video';
// Check <source> specifically if its parent is <video>
if (tagName === 'SOURCE' && element.closest('video')) return 'video';
// Could add checks for AUDIO, PICTURE > SOURCE etc. if needed
// 3. URL Extension Check: Attempt to infer from the URL path.
if (url) {
try {
const parsedUrl = new URL(url);
// Get path without query string or hash
const pathname = parsedUrl.pathname;
// Extract the last part after '.'
const extensionMatch = pathname.match(/\.([^.?#]+)(?:[?#]|$)/);
if (extensionMatch && extensionMatch[1]) {
const ext = extensionMatch[1].toLowerCase();
if (MediaExtractor.IMAGE_EXTENSIONS.has(ext)) return 'image';
if (MediaExtractor.VIDEO_EXTENSIONS.has(ext)) return 'video';
}
} catch (e) {
// Ignore URL parsing errors; cannot determine type from extension.
console.warn(`MediaExtractor: Could not parse URL for extension check: ${url}`, e.message);
}
}
// 4. Fallback: If no type determined, default to 'custom'.
return 'custom';
}
/**
* Parses HTML, extracts direct media, and identifies tasks for recursive calls based on config structure.
* @param {string} htmlString
* @param {string} baseUrl
* @param {Required<ExtractorConfig>} config - The configuration resolved for THIS level.
* @param {number} currentDepth
* @param {number} initialMaxDepth - The maxDepth value set at the START of the crawl (can be Infinity).
* @param {Set<string>} visitedUrls - Set of URLs visited in the current crawl path.
* @returns {Promise<{directResults: Array<MediaResult>, followTasks: Array<() => Promise<Array<MediaResult>>>}>}
* @private
*/
async _parseHtmlAndIdentifyTasks(htmlString, baseUrl, config, currentDepth, initialMaxDepth, visitedUrls) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const directResults = [];
const followTasks = [];
// Use a Map to associate elements with the rules that matched them
// Key: Element, Value: Array<{source: 'img'|'video'|'custom', configIndex?: number}>
const candidateElements = new Map();
const addCandidate = (element, sourceInfo) => {
if (!candidateElements.has(element)) {
candidateElements.set(element, []);
}
candidateElements.get(element).push(sourceInfo);
};
// 1. Standard image/video extraction
if (config.extractImages) {
doc.querySelectorAll('img[src]').forEach(el => addCandidate(el, { source: 'img' }));
}
if (config.extractVideos) {
doc.querySelectorAll('video').forEach(el => {
// Check video[src] OR video > source[src]
if (el.hasAttribute('src') || el.querySelector('source[src]')) {
addCandidate(el, { source: 'video' });
}
});
}
// 2. Custom selector processing
config.customSelectors.forEach((customConfig, index) => {
if (!customConfig.selector || !customConfig.type || !customConfig.attribute) return; // Already validated in _resolveConfig, but check again
try {
let nodes = [];
if (customConfig.type === 'css') {
nodes = Array.from(doc.querySelectorAll(customConfig.selector));
} else if (customConfig.type === 'xpath') {
const xpathResult = doc.evaluate(customConfig.selector, doc, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
let node;
while ((node = xpathResult.iterateNext())) {
if (node instanceof Element) {
nodes.push(node);
}
}
}
nodes.forEach(el => {
if (el instanceof Element) { // Ensure it's an element
addCandidate(el, { source: 'custom', configIndex: index });
}
});
} catch (e) {
console.error(`MediaExtractor: Error processing custom selector ${customConfig.type}: "${customConfig.selector}" on ${baseUrl}`, e);
}
});
// 3. Sort elements by document order for predictable processing
const sortedElements = Array.from(candidateElements.keys()).sort((a, b) => {
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
// 4. Process sorted elements, prioritizing custom rules and avoiding duplicate processing per element
for (const element of sortedElements) {
const matchingSources = candidateElements.get(element);
let processedForDirectMedia = false; // Found media URL directly from this element?
let processedForLinkFollow = false; // Followed a link from this element?
// --- Check Custom Rules First ---
for (const sourceInfo of matchingSources) {
if (sourceInfo.source === 'custom') {
const customConfig = config.customSelectors[sourceInfo.configIndex];
if (!customConfig) continue; // Should not happen
// A. Handle Link Following
if (customConfig.followLink) {
if (processedForLinkFollow) continue; // Only follow one link per element
const linkUrl = this._extractUrlFromAttribute(element, customConfig.attribute, baseUrl);
if (linkUrl && !visitedUrls.has(linkUrl)) {
if (currentDepth + 1 >= initialMaxDepth) {
// Depth limit reached, don't queue
} else {
visitedUrls.add(linkUrl); // Mark visited *before* queueing task
processedForLinkFollow = true;
const taskFn = () => {
const resolvedNextConfig = this._resolveConfig(customConfig.nextConfig || {}, config);
return this._processUrlRecursive(
linkUrl,
resolvedNextConfig,
currentDepth + 1,
initialMaxDepth,
new Set(visitedUrls) // Pass a copy for branch isolation
);
};
followTasks.push(taskFn);
}
} else if (linkUrl && visitedUrls.has(linkUrl)) {
// Already visited on this path
}
// Link following typically takes precedence over direct extraction *from the same rule*.
// If a different rule extracts media directly, that's handled below.
}
// B. Handle Direct Media Extraction (Custom Rule)
else { // customConfig.followLink is false
if (processedForDirectMedia) continue; // Already got media from this element
const mediaUrl = this._extractUrlFromAttribute(element, customConfig.attribute, baseUrl, customConfig.styleUrlRegex);
if (mediaUrl) {
// Determine the potential media type *before* adding
const potentialType = this._determineMediaType(element, mediaUrl, customConfig.mediaType);
// <<< START: Apply the allowedMediaTypes filter >>>
let shouldExtract = true; // Assume yes by default
// Check if the filter is defined and is an array
if (customConfig.allowedMediaTypes && Array.isArray(customConfig.allowedMediaTypes)) {
// Check if the determined type is NOT in the allowed list
if (!customConfig.allowedMediaTypes.includes(potentialType)) {
// console.debug(`MediaExtractor: Skipping ${mediaUrl} - type '${potentialType}' not in allowedMediaTypes [${customConfig.allowedMediaTypes.join(', ')}] for selector ${customConfig.selector}`);
shouldExtract = false; // Type not allowed, skip it
}
}
// <<< END: Apply the allowedMediaTypes filter >>>
if (shouldExtract) {
directResults.push({
url: mediaUrl,
type: potentialType, // Use the type determined (respecting configMediaType override within _determineMediaType)
sourceUrl: baseUrl,
depth: currentDepth,
element: element
});
processedForDirectMedia = true;
}
}
}
}
} // End loop through custom sources for this element
// --- Standard Element Processing (if not already handled by custom rule) ---
if (!processedForDirectMedia) {
const isVideoTag = matchingSources.some(s => s.source === 'video');
const isImageTag = matchingSources.some(s => s.source === 'img');
// Standard Video: Check <video src> then <source src>
if (isVideoTag && config.extractVideos) { // Check config again
let videoUrl = this._extractUrlFromAttribute(element, 'src', baseUrl);
if (!videoUrl) {
const sourceElements = element.querySelectorAll('source[src]');
for (const sourceElement of sourceElements) {
videoUrl = this._extractUrlFromAttribute(sourceElement, 'src', baseUrl);
if (videoUrl) break; // Use first valid source
}
}
if (videoUrl) {
// Type is inherently 'video' here
directResults.push({ url: videoUrl, type: 'video', sourceUrl: baseUrl, depth: currentDepth, element: element });
processedForDirectMedia = true;
}
}
// Standard Image: Check <img src> (only if not already processed)
if (!processedForDirectMedia && isImageTag && config.extractImages) { // Check config again
const imgUrl = this._extractUrlFromAttribute(element, 'src', baseUrl);
if (imgUrl) {
// Type is inherently 'image' here
directResults.push({ url: imgUrl, type: 'image', sourceUrl: baseUrl, depth: currentDepth, element: element });
processedForDirectMedia = true;
}
}
}
} // End loop through sorted elements
return { directResults, followTasks };
}
/**
* Internal recursive method. Checks depth limit against initialMaxDepth.
* Uses a copy of visitedUrls for each branch.
* @param {string} url
* @param {Required<ExtractorConfig>} config - Config resolved for THIS level.
* @param {number} currentDepth
* @param {number} initialMaxDepth - The maxDepth from the initial call (can be Infinity).
* @param {Set<string>} visitedUrlsOnPath - Set of URLs visited on the *current specific branch* of the crawl.
* @returns {Promise<Array<MediaResult>>}
* @private
*/
async _processUrlRecursive(url, config, currentDepth, initialMaxDepth, visitedUrlsOnPath) {
// --- Depth Check ---
if (currentDepth >= initialMaxDepth) {
if (initialMaxDepth !== Infinity) {
console.debug(`MediaExtractor: Max depth (${initialMaxDepth}) reached at depth ${currentDepth}. Stopping branch at ${url}.`);
}
return [];
}
// --- Cycle Check --- (Handled before call via visitedUrls.add)
try {
console.log(`MediaExtractor: [Depth ${currentDepth}] Fetching and processing: ${url} (Concurrency: ${config.concurrency})`);
const htmlContent = await this._fetchHtml(url);
console.log(htmlContent);
// Parse, extract direct media, identify next tasks
const { directResults, followTasks } = await this._parseHtmlAndIdentifyTasks(
htmlContent,
url,
config,
currentDepth,
initialMaxDepth,
visitedUrlsOnPath // Pass the set for this path
);
// --- Execute recursive calls concurrently ---
let nestedResults = [];
if (followTasks.length > 0) {
const effectiveConcurrency = config.concurrency;
console.log(`MediaExtractor: [Depth ${currentDepth}] Following ${followTasks.length} links from ${url} (Concurrency: ${effectiveConcurrency})...`);
const taskResults = await this._runWithConcurrency(followTasks, effectiveConcurrency);
nestedResults = taskResults.filter(r => r && !r.error).flat(); // Flatten results, filter errors
console.log(`MediaExtractor: [Depth ${currentDepth}] Finished following ${followTasks.length} links from ${url}. Found ${nestedResults.length} nested items.`);
}
// Combine results from this page and nested calls
return [...directResults, ...nestedResults];
} catch (error) {
console.error(`MediaExtractor: [Depth ${currentDepth}] Failed to process URL ${url}:`, error.message);
// console.error(error); // Optional: Log full stack trace
return []; // Return empty for this failed branch
}
}
/**
* PUBLIC METHOD: Initiates the recursive extraction.
* @param {string} url - The initial URL.
* @param {Partial<ExtractorConfig>} [userConfig={}] - User configuration.
* @returns {Promise<Array<MediaResult>>} A promise resolving to an array of found media results.
*/
async extractFromUrl(url, userConfig = {}) {
console.log(`MediaExtractor: Starting extraction from ${url}`);
// 1. Resolve initial config and determine the effective max depth limit
const initialConfig = this._resolveConfig(userConfig, null);
const effectiveMaxDepth = initialConfig.maxDepth; // Number or Infinity
if (effectiveMaxDepth !== Infinity) {
console.log(`MediaExtractor: Configured with maxDepth limit: ${effectiveMaxDepth}`);
} else {
console.log(`MediaExtractor: No explicit maxDepth limit. Depth controlled by config structure and cycle detection.`);
}
// 2. Initialize visited set for the starting path
const initialVisitedUrls = new Set();
try {
// Normalize starting URL before adding
const startUrl = new URL(url).href;
initialVisitedUrls.add(startUrl);
// 3. Start the recursive process
const allMediaResults = await this._processUrlRecursive(
startUrl,
initialConfig,
0, // Start depth
effectiveMaxDepth,
initialVisitedUrls
);
console.log(`MediaExtractor: Extraction complete. Found ${allMediaResults.length} total media items starting from ${url}.`);
if (allMediaResults.length === 0 && effectiveMaxDepth !== 0) {
console.log("MediaExtractor: Hint - If 0 results, check config (selectors, attributes, followLink structure), network logs (F12) for fetch errors/redirects, or website structure changes.");
}
// Optional: Deduplicate results based on URL if needed
// const uniqueResults = Array.from(new Map(allMediaResults.map(item => [item.url, item])).values());
// return uniqueResults;
return allMediaResults;
} catch (error) {
// Catch errors during initial setup (e.g., invalid initial URL) or the top-level process call
console.error(`MediaExtractor: Critical failure during extraction starting from ${url}:`, error.message);
console.error(error);
return []; // Return empty on critical failure
}
}
} const CONFIG_STORAGE_PREFIX = 'mediaExtractorConfig_';
const CONFIG_LIST_KEY = 'mediaExtractorConfig_list';
const CONFIG_LAST_USED_KEY = 'mediaExtractorConfig_lastUsed';
class MediaExtractorUI {
/**
* @param {string} [containerId=null] - Optional ID of the HTML element to render into.
* If null, UI will be rendered in a centered modal overlay.
*/
constructor(containerId = null) {
this.targetElement = null;
if (containerId) {
this.targetElement = document.getElementById(containerId);
if (!this.targetElement) {
throw new Error(`MediaExtractorUI: Container element with ID "${containerId}" not found.`);
}
}
this.extractor = new MediaExtractor(); // Assuming MediaExtractor is available
this.uniqueIdCounter = 0;
this._isShown = false;
this.uiRoot = null; // Will hold the main UI container element (inside modal or target)
this.overlayElement = null; // For modal mode
this.modalElement = null; // For modal mode
this.boundKeyHandler = this._handleKeyPress.bind(this); // Bind escape handler
this.extractionCompleteCallbacks = []; // Initialize callbacks array
// --- Define the UI structure ---
// Definition for top-level ExtractorConfig fields
// NOTE: maxDepth was removed previously
this.TOP_LEVEL_CONFIG_FIELDS = [
{ key: 'extractImages', label: 'Extract <img> tags', type: 'checkbox', defaultValue: true },
{ key: 'extractVideos', label: 'Extract <video> tags', type: 'checkbox', defaultValue: true },
{ key: 'concurrency', label: 'Concurrency:', type: 'number', defaultValue: 5, min: 1 },
// The 'customSelectors' field is handled specially by _renderCustomSelectorList
];
this.CUSTOM_SELECTOR_FIELDS = [
{ key: 'selector', label: 'Selector (CSS or XPath):', type: 'text', placeholder: 'e.g., .my-image a', required: true },
{ key: 'type', label: 'Selector Type:', type: 'select', options: { css: 'CSS', xpath: 'XPath' }, defaultValue: 'css', required: true },
{ key: 'attribute', label: 'Attribute (for URL/Link):', type: 'text', placeholder: 'e.g., href, src, data-src, style', required: true },
{
key: 'styleUrlRegex', label: 'Style Regex (if attr=style):', type: 'text', placeholder: 'e.g., url\\(["\']?(.*?)["\']?\\)',
valueGetter: (input) => input.value.trim() ? new RegExp(input.value.trim()) : null,
valueSetter: (value) => value instanceof RegExp ? value.source : ''
},
// --- MOVED & MODIFIED ---
{
key: 'allowedMediaTypes',
label: 'Extract Only If URL is..',
type: 'select',
options: { // Key: value sent to valueGetter/received from valueSetter, Value: Display text
'all': 'Any',
'image': 'Image',
'video': 'Video'
},
defaultValue: 'all', // UI default corresponds to 'all'
// helpText: '...', // <<< Removed helpText
valueGetter: (selectElement) => {
switch (selectElement.value) {
case 'image': return ['image'];
case 'video': return ['video'];
case 'all':
default: return null; // null means no filtering
}
},
valueSetter: (value) => {
if (Array.isArray(value)) {
if (value.includes('image') && value.length === 1) return 'image';
if (value.includes('video') && value.length === 1) return 'video';
}
return 'all'; // Default to 'all' if null or unrecognized array
}
},
// --- Follow Link now comes after Allowed Types ---
{
key: 'followLink', label: 'Follow Link (Extract from linked page)', type: 'checkbox', defaultValue: false,
onChange: this._handleFollowLinkChange // Keep existing handler
}
// 'nextConfig' is handled implicitly when 'followLink' is true
];
}
// --- Core Methods (render, close, _addStyles) ---
_getNextId() {
return `me-ui-${this.uniqueIdCounter++}`;
}
_addStyles() {
const styleId = 'media-extractor-ui-styles';
if (document.getElementById(styleId)) return;
const css = `
/* Basic UI styles */
.media-extractor-ui {
font-family: sans-serif;
border: 1px solid #444; /* Darker border */
padding: 15px;
border-radius: 5px;
background-color: #333; /* Dark gray background */
color: #eee; /* Light text color */
margin: 0; /* Reset margin for modal context */
}
.media-extractor-ui fieldset { border: 1px solid #555; padding: 10px; margin-bottom: 15px; border-radius: 4px; }
.media-extractor-ui legend { font-weight: bold; padding: 0 5px; color: #eee; } /* Light legend color */
.media-extractor-ui label { display: block; margin-bottom: 3px; font-size: 0.9em; color: #ccc; } /* Lighter label color */
.media-extractor-ui .field-wrapper { margin-bottom: 0.75em; }
.media-extractor-ui .field-wrapper label { display: block; margin-bottom: 3px; font-size: 0.9em; color: #ccc;} /* Lighter label color */
.media-extractor-ui .field-wrapper.checkbox-field label { display: inline-block; margin-left: 5px; vertical-align: middle;}
/* Adjust alignment for checkbox */
.media-extractor-ui .field-wrapper.checkbox-field input[type="checkbox"] {
vertical-align: middle;
margin-right: 0; /* Remove default margin if label handles spacing */
}
.media-extractor-ui input[type="text"],
.media-extractor-ui input[type="number"],
.media-extractor-ui select,
.media-extractor-ui textarea {
width: 95%; padding: 6px; margin-bottom: 2px; /* Smaller bottom margin */
border: 1px solid #666; /* Slightly darker border */
border-radius: 3px; box-sizing: border-box;
background-color: #444; /* Darker input background */
color: #eee; /* Light input text */
}
/* Reduce bottom margin specifically for text/number/select/textarea inside the wrapper */
.media-extractor-ui .field-wrapper input[type="text"],
.media-extractor-ui .field-wrapper input[type="number"],
.media-extractor-ui .field-wrapper select,
.media-extractor-ui .field-wrapper textarea {
margin-bottom: 2px;
}
.media-extractor-ui .config-section { padding: 10px; margin-top: 10px; background-color: #3a3a3a; border-radius: 3px; } /* Slightly lighter section bg */
.media-extractor-ui .custom-selector-group { border: 1px solid #555; padding: 10px 10px 15px 10px; margin-bottom: 10px; background-color: #383838; position: relative; border-radius: 4px; } /* Slightly lighter group bg */
.media-extractor-ui .custom-selector-group legend { font-size: 0.95em; font-weight: normal; color: #ddd; } /* Lighter legend */
.media-extractor-ui .nested-config { margin-left: 20px; padding-left: 15px; border-left: 2px solid #007bff; margin-top: 10px; display: none; /* Hidden by default */ }
.media-extractor-ui button { padding: 8px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 5px; font-size: 1em; }
.media-extractor-ui button:hover { background-color: #0056b3; }
.media-extractor-ui .remove-button { background-color: #dc3545; font-size: 0.8em; padding: 3px 8px; position: absolute; top: 5px; right: 5px; }
.media-extractor-ui .remove-button:hover { background-color: #c82333; }
.media-extractor-ui .add-button { background-color: #28a745; font-size: 0.9em; padding: 5px 10px; margin-top: 10px; }
.media-extractor-ui .add-button:hover { background-color: #218838; }
.media-extractor-ui .output-area { margin-top: 15px; padding: 10px; background-color: #444; border: 1px solid #666; border-radius: 4px; font-family: monospace; white-space: pre-wrap; word-wrap: break-word; min-height: 50px; max-height: 200px; overflow-y: auto; color: #ccc; } /* Darker output, lighter text */
.media-extractor-ui .output-area.extracting { color: #66aaff; } /* Lighter blue */
.media-extractor-ui .output-area.error { color: #ff8080; font-weight: bold; } /* Lighter red */
.media-extractor-ui .output-area.success { color: #80ff80; } /* Lighter green */
.media-extractor-ui .config-options-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 10px; }
.media-extractor-ui .custom-selector-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
.media-extractor-ui .field-help-text { font-size: 0.8em; color: #aaa; margin-top: 2px; } /* Adjust help text color */
/* Config Management styles */
.media-extractor-ui .config-management {
display: flex;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
align-items: center; /* Align items vertically */
gap: 10px; /* Spacing between elements */
padding: 10px;
background-color: #3a3a3a; /* Match config section */
border-radius: 3px;
margin-bottom: 15px; /* Space below this section */
}
.media-extractor-ui .config-management label {
margin-bottom: 0; /* Remove default bottom margin */
margin-right: 5px; /* Space after label */
font-size: 0.9em;
color: #ccc;
}
.media-extractor-ui .config-management select,
.media-extractor-ui .config-management input[type="text"] {
flex-grow: 1; /* Allow select and input to take available space */
width: auto; /* Override default width */
min-width: 150px; /* Ensure minimum width */
padding: 5px; /* Slightly smaller padding */
margin-bottom: 0; /* Remove default bottom margin */
font-size: 0.9em;
}
.media-extractor-ui .config-management input[type="text"] {
/* Specific styling for save name input */
background-color: #444;
border: 1px solid #666;
color: #eee;
}
.media-extractor-ui .config-management button {
padding: 5px 10px; /* Smaller button padding */
font-size: 0.9em; /* Smaller font size */
margin: 0 2px; /* Adjust margin */
flex-shrink: 0; /* Prevent buttons from shrinking too much */
}
.media-extractor-ui .config-management .delete-config-button {
background-color: #dc3545;
}
.media-extractor-ui .config-management .delete-config-button:hover {
background-color: #c82333;
}
.media-extractor-ui .config-management .config-status {
font-size: 0.85em;
color: #aaa;
margin-left: 10px; /* Space from buttons */
flex-basis: 100%; /* Force status to new line if needed */
text-align: left; /* Align left */
}
/* Modal specific styles */
.media-extractor-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.7); /* Slightly darker overlay */
display: flex;
justify-content: center; align-items: center; z-index: 9999999;
padding: 20px; box-sizing: border-box;
}
.media-extractor-modal {
/* Background handled by inner .media-extractor-ui now */
padding: 0; /* Padding applied to inner */
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); /* Adjust shadow */
max-width: 95%;
width: 850px; max-height: 90vh; overflow-y: auto; position: relative;
background-color: #333; /* Set modal background same as UI */
border: 1px solid #444; /* Match border */
}
.media-extractor-modal-close {
position: absolute; top: 8px; right: 12px; font-size: 1.8em;
font-weight: bold; color: #aaa; background: none; border: none;
cursor: pointer; line-height: 1; padding: 0 5px; z-index: 1; /* Ensure button is clickable */
}
.media-extractor-modal-close:hover { color: #fff; } /* Brighter hover */
.media-extractor-ui .field-wrapper.field-disabled-by-follow label,
.media-extractor-ui .field-wrapper.field-disabled-by-follow select:disabled {
color: #777; /* Dim the label */
cursor: not-allowed;
}
.media-extractor-ui .field-wrapper.field-disabled-by-follow select:disabled {
background-color: #555; /* Darker background for disabled select */
opacity: 0.7;
}
`;
const styleElement = document.createElement('style');
styleElement.id = styleId;
styleElement.textContent = css;
document.head.appendChild(styleElement);
}
isShown() {
return this._isShown;
}
render() {
this._addStyles();
this.close(); // Clear previous modal if exists
this._isShown = true;
if (this.targetElement) {
// ... (rendering into target element, no escape listener needed)
this.targetElement.innerHTML = '';
this.uiRoot = document.createElement('div');
this.uiRoot.classList.add('media-extractor-ui');
this._buildUIContent(this.uiRoot);
this.targetElement.appendChild(this.uiRoot);
this._loadLastUsedConfig(); // Load last used config after building
} else { // Render into a modal
this.overlayElement = document.createElement('div');
this.overlayElement.classList.add('media-extractor-overlay');
this.overlayElement.addEventListener('click', (e) => {
if (e.target === this.overlayElement) this.close();
});
this.modalElement = document.createElement('div');
this.modalElement.classList.add('media-extractor-modal');
// uiRoot container now inside modal
this.uiRoot = document.createElement('div');
this.uiRoot.classList.add('media-extractor-ui');
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.classList.add('media-extractor-modal-close');
closeButton.innerHTML = '×';
closeButton.title = 'Close (Esc)'; // Hint for escape key
closeButton.onclick = () => this.close();
this.modalElement.appendChild(closeButton); // Append to modal, not uiRoot
this._buildUIContent(this.uiRoot); // Build inside uiRoot
this.modalElement.appendChild(this.uiRoot); // Add uiRoot content to modal
this.overlayElement.appendChild(this.modalElement);
document.body.appendChild(this.overlayElement);
// Add Escape key listener for modal mode
document.addEventListener('keydown', this.boundKeyHandler);
this._loadLastUsedConfig(); // Load last used config after building
}
}
close() {
if (this.overlayElement && this.overlayElement.parentNode) {
this.overlayElement.parentNode.removeChild(this.overlayElement);
// Remove Escape key listener when closing
document.removeEventListener('keydown', this.boundKeyHandler);
}
this.overlayElement = null;
this.modalElement = null;
this._isShown = false;
// If rendering into target, don't clear uiRoot unless desired
// If modal, clearing uiRoot isn't strictly necessary as it's removed with the modal
}
// --- UI Building ---
/**
* Builds the main form structure within the parent element.
* @param {HTMLElement} parentElement - Usually this.uiRoot.
* @private
*/
_buildUIContent(parentElement) {
parentElement.innerHTML = ''; // Clear previous content
const form = document.createElement('form');
form.addEventListener('submit', (e) => e.preventDefault());
// --- Configuration Management Section ---
const configMgmtFieldset = document.createElement('fieldset');
configMgmtFieldset.innerHTML = '<legend>Configuration Management</legend>';
const configMgmtDiv = document.createElement('div');
configMgmtDiv.classList.add('config-management');
configMgmtDiv.dataset.role = 'config-management-area';
// Dropdown for saved configs
const selectLabel = document.createElement('label');
selectLabel.htmlFor = this._getNextId() + '-config-select';
selectLabel.textContent = 'Saved Configs:';
const configSelect = document.createElement('select');
configSelect.id = selectLabel.htmlFor;
configSelect.dataset.role = 'config-select';
configSelect.innerHTML = '<option value="">--- Select Config ---</option>'; // Default option
// Input for naming config
const saveNameLabel = document.createElement('label');
saveNameLabel.htmlFor = this._getNextId() + '-config-save-name';
saveNameLabel.textContent = 'Save/Update As:';
const configSaveName = document.createElement('input');
configSaveName.type = 'text';
configSaveName.id = saveNameLabel.htmlFor;
configSaveName.dataset.role = 'config-save-name';
configSaveName.placeholder = 'Enter config name';
// Buttons
// --- REMOVED Load Button ---
// const loadButton = document.createElement('button');
// loadButton.type = 'button';
// loadButton.textContent = 'Load';
// loadButton.dataset.role = 'load-config-btn';
// loadButton.onclick = () => this._handleLoadConfigClick(); // Removed
const saveButton = document.createElement('button');
saveButton.type = 'button';
saveButton.textContent = 'Save';
saveButton.dataset.role = 'save-config-btn';
saveButton.onclick = () => this._handleSaveConfigClick();
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.textContent = 'Delete';
deleteButton.classList.add('delete-config-button');
deleteButton.dataset.role = 'delete-config-btn';
deleteButton.onclick = () => this._handleDeleteConfigClick();
// Status message area
const statusSpan = document.createElement('span');
statusSpan.classList.add('config-status');
statusSpan.dataset.role = 'config-status-message';
statusSpan.textContent = ''; // Initially empty
// --- MODIFIED: Add listener to load config instantly and update save name ---
configSelect.addEventListener('change', this._handleConfigSelectChange.bind(this));
configMgmtDiv.appendChild(selectLabel);
configMgmtDiv.appendChild(configSelect);
// configMgmtDiv.appendChild(loadButton); // Removed
configMgmtDiv.appendChild(saveNameLabel);
configMgmtDiv.appendChild(configSaveName);
configMgmtDiv.appendChild(saveButton);
configMgmtDiv.appendChild(deleteButton);
configMgmtDiv.appendChild(statusSpan);
configMgmtFieldset.appendChild(configMgmtDiv);
form.appendChild(configMgmtFieldset);
// --- Start URL Section (existing) ---
const urlFieldset = document.createElement('fieldset');
urlFieldset.innerHTML = '<legend>Start URL</legend>';
const urlLabel = document.createElement('label');
const urlInputId = this._getNextId() + '-start-url';
urlLabel.htmlFor = urlInputId;
urlLabel.textContent = 'Enter the initial URL to extract from:';
const urlInput = document.createElement('input');
urlInput.type = 'text';
urlInput.id = urlInputId;
urlInput.placeholder = 'https://example.com/page';
urlInput.required = true;
urlInput.dataset.role = 'start-url-input'; // Add role for easier selection
urlFieldset.appendChild(urlLabel);
urlFieldset.appendChild(urlInput);
form.appendChild(urlFieldset);
// --- Top Level Configuration Section (existing) ---
const topLevelFieldset = document.createElement('fieldset');
topLevelFieldset.innerHTML = '<legend>Extraction Configuration (Top Level)</legend>';
topLevelFieldset.dataset.role = 'top-level-config-fieldset'; // Add role
const topLevelConfigDiv = document.createElement('div');
topLevelConfigDiv.classList.add('config-section', 'config-options-grid'); // Use grid layout
topLevelConfigDiv.dataset.depth = '0'; // Mark depth
this._renderConfigSection(
topLevelConfigDiv,
this.TOP_LEVEL_CONFIG_FIELDS,
MediaExtractor.DEFAULT_CONFIG, // Initial defaults
0
);
topLevelFieldset.appendChild(topLevelConfigDiv);
form.appendChild(topLevelFieldset);
// --- Custom Selectors Section (existing) ---
const csFieldset = document.createElement('fieldset');
csFieldset.innerHTML = `<legend>Custom Selectors</legend>`;
csFieldset.dataset.role = 'custom-selectors-fieldset'; // Add role
this._renderCustomSelectorList(csFieldset, MediaExtractor.DEFAULT_CONFIG.customSelectors || [], 0);
form.appendChild(csFieldset);
// --- Controls & Output (existing) ---
const controlsFieldset = document.createElement('fieldset');
controlsFieldset.innerHTML = '<legend>Control & Output</legend>';
this._renderControls(controlsFieldset);
form.appendChild(controlsFieldset);
parentElement.appendChild(form);
// Populate the config dropdown after the UI is built
this._populateConfigDropdown();
// NOTE: Loading last used config is now handled in render() after this function completes
}
/**
* Renders a configuration section based on a field definition array.
* @param {HTMLElement} parentElement - The container element for this section.
* @param {Array<object>} fieldsDefinition - Array describing the fields.
* @param {object} currentConfigData - The current data object for this config level.
* @param {number} depth - The current nesting depth (0 for top level).
* @param {object} [sectionDataAttributes={}] - Optional data attributes for the section.
* @private
*/
_renderConfigSection(parentElement, fieldsDefinition, currentConfigData, depth, sectionDataAttributes = {}) {
// Add data attributes to the parent element itself
for (const attr in sectionDataAttributes) {
parentElement.dataset[attr] = sectionDataAttributes[attr];
}
fieldsDefinition.forEach(fieldDef => {
// Skip fields handled elsewhere (like customSelectors list itself)
if (fieldDef.key === 'customSelectors' || fieldDef.key === 'nextConfig') {
return;
}
// Skip concurrency field if rendering a nested config (depth > 0)
if (fieldDef.key === 'concurrency' && depth > 0) {
return;
}
// Get the current value for this field
const currentValue = currentConfigData?.[fieldDef.key] ?? fieldDef.defaultValue;
// Create and append the field
this._renderFormField(parentElement, fieldDef, currentValue, depth);
});
}
/**
* Renders a single form field based on its definition.
* @param {HTMLElement} parentElement - The container to append the field to.
* @param {object} fieldDef - The field definition object.
* @param {*} currentValue - The current value for the field.
* @param {number} depth - Current nesting depth.
* @private
*/
_renderFormField(parentElement, fieldDef, currentValue, depth) {
const wrapper = document.createElement('div');
wrapper.classList.add('field-wrapper');
const fieldId = this._getNextId() + '-' + fieldDef.key;
let inputElement;
const label = document.createElement('label');
label.htmlFor = fieldId;
label.textContent = fieldDef.label;
switch (fieldDef.type) {
case 'checkbox':
wrapper.classList.add('checkbox-field'); // Add class for specific styling
inputElement = document.createElement('input');
inputElement.type = 'checkbox';
inputElement.checked = currentValue === true;
// Place checkbox before label for common layout
wrapper.appendChild(inputElement);
wrapper.appendChild(label); // Label now appears after checkbox
break;
case 'select':
inputElement = document.createElement('select');
for (const value in fieldDef.options) {
const option = document.createElement('option');
option.value = value;
option.textContent = fieldDef.options[value];
if (value === currentValue) {
option.selected = true;
}
inputElement.appendChild(option);
}
wrapper.appendChild(label);
wrapper.appendChild(inputElement);
break;
case 'number':
inputElement = document.createElement('input');
inputElement.type = 'number';
if (fieldDef.min !== undefined) inputElement.min = fieldDef.min;
if (fieldDef.max !== undefined) inputElement.max = fieldDef.max;
if (fieldDef.step !== undefined) inputElement.step = fieldDef.step;
inputElement.placeholder = fieldDef.placeholder || '';
// Use valueSetter if provided, otherwise default behavior
inputElement.value = fieldDef.valueSetter ? fieldDef.valueSetter(currentValue) : (currentValue ?? '');
wrapper.appendChild(label);
wrapper.appendChild(inputElement);
break;
case 'textarea':
inputElement = document.createElement('textarea');
inputElement.rows = fieldDef.rows || 3;
inputElement.placeholder = fieldDef.placeholder || '';
inputElement.value = fieldDef.valueSetter ? fieldDef.valueSetter(currentValue) : (currentValue ?? '');
wrapper.appendChild(label);
wrapper.appendChild(inputElement);
break;
case 'text':
default:
inputElement = document.createElement('input');
inputElement.type = 'text';
inputElement.placeholder = fieldDef.placeholder || '';
inputElement.value = fieldDef.valueSetter ? fieldDef.valueSetter(currentValue) : (currentValue ?? '');
wrapper.appendChild(label);
wrapper.appendChild(inputElement);
break;
}
inputElement.id = fieldId;
inputElement.dataset.key = fieldDef.key; // Crucial for data collection
if (fieldDef.required) {
inputElement.required = true;
}
// Add help text if defined
if (fieldDef.helpText) {
const helpText = document.createElement('p');
helpText.classList.add('field-help-text');
helpText.textContent = fieldDef.helpText;
wrapper.appendChild(helpText);
}
// Attach change listener if defined
if (fieldDef.onChange && typeof fieldDef.onChange === 'function') {
inputElement.addEventListener('change', (event) => fieldDef.onChange(event, wrapper, depth));
}
// Append the whole wrapper to the parent
parentElement.appendChild(wrapper);
}
/**
* Renders the list of custom selectors and the "Add" button.
* @param {HTMLElement} parentElement - The fieldset element to contain the list.
* @param {Array<object>} currentSelectorsData - Array of custom selector config objects.
* @param {number} depth - The depth level this list belongs to.
* @private
*/
_renderCustomSelectorList(parentElement, currentSelectorsData, depth) {
const selectorsListDiv = document.createElement('div');
selectorsListDiv.classList.add('custom-selectors-list');
selectorsListDiv.dataset.depth = depth; // Store depth for context
// Render existing selectors
currentSelectorsData.forEach((csData, index) => {
this._addCustomSelector(selectorsListDiv, csData, depth, index);
});
parentElement.appendChild(selectorsListDiv);
// Add "Add Custom Selector" button
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.textContent = '+ Add Custom Selector';
addButton.classList.add('add-button');
addButton.onclick = () => {
// Add a new selector with default values
this._addCustomSelector(selectorsListDiv, {}, depth, selectorsListDiv.children.length);
};
parentElement.appendChild(addButton);
}
/**
* Renders a single custom selector group UI.
* @param {HTMLElement} listContainerElement - The div holding all selector groups.
* @param {object} selectorData - The configuration data for this specific selector.
* @param {number} listDepth - The depth of the list container itself.
* @param {number} index - The index of this selector in the list.
* @private
*/
_addCustomSelector(listContainerElement, selectorData, listDepth, index) {
const groupFieldset = document.createElement('fieldset');
groupFieldset.classList.add('custom-selector-group');
groupFieldset.dataset.isCustomSelector = 'true'; // Mark as a selector group
groupFieldset.dataset.depth = listDepth; // Carry over depth
groupFieldset.innerHTML = `<legend>Selector #${index + 1}</legend>`;
const gridContainer = document.createElement('div');
gridContainer.classList.add('custom-selector-grid'); // Apply grid layout
// Render fields for this custom selector using its definition and loaded data
this._renderConfigSection(
gridContainer,
this.CUSTOM_SELECTOR_FIELDS,
selectorData, // Pass the specific data for this selector
listDepth // Depth is the same as the list container
);
groupFieldset.appendChild(gridContainer);
// --- Nested Configuration Handling ---
const nestedConfigContainer = document.createElement('div');
nestedConfigContainer.classList.add('nested-config');
nestedConfigContainer.classList.add('config-options-grid');
nestedConfigContainer.dataset.role = 'nested-config-container'; // For easy selection
// Check the *loaded* data to determine initial state
const shouldShowNested = selectorData?.followLink === true && selectorData?.nextConfig;
if (shouldShowNested) {
const nextConfigData = selectorData.nextConfig || {};
const nextDepth = listDepth + 1;
nestedConfigContainer.style.display = 'block'; // Show it
// --- Recursive Update Logic integrated here for loading ---
// We need helper functions similar to those in _updateUIFromConfig,
// but adapted for rendering within this context. Let's reuse the main ones.
// Helper function (could be refactored out, but placed here for clarity)
const updateOrRenderSection = (parent, fieldsDef, configLevelData, depth, dataAttrs = {}) => {
// Clear existing content before rendering/updating
// parent.innerHTML = ''; // Don't clear here, _renderConfigSection appends
// Use _renderConfigSection to build the fields based on loaded data
this._renderConfigSection(parent, fieldsDef, configLevelData, depth, dataAttrs);
};
// Helper function
const updateOrRenderCustomSelectors = (listParent, selectorsData, depth) => {
// Clear existing selectors first
const existingList = listParent.querySelector('.custom-selectors-list');
if (existingList) existingList.remove();
const existingAddButton = listParent.querySelector('.add-button');
if (existingAddButton) existingAddButton.remove(); // Also remove old add button
// Use _renderCustomSelectorList to rebuild the list based on loaded data
this._renderCustomSelectorList(listParent, selectorsData || [], depth);
};
// --- End Helpers ---
// Render/Update the nested top-level fields
updateOrRenderSection(
nestedConfigContainer,
this.TOP_LEVEL_CONFIG_FIELDS,
nextConfigData,
nextDepth,
{ configType: 'nested-top-level' }
);
// Render/Update the nested custom selector list recursively
// We need a container *within* nestedConfigContainer for the nested list+button
const nestedCsListWrapper = document.createElement('div'); // Create a wrapper
nestedCsListWrapper.dataset.role = 'nested-custom-selectors-wrapper';
nestedConfigContainer.appendChild(nestedCsListWrapper); // Append wrapper
updateOrRenderCustomSelectors(
nestedCsListWrapper, // Pass the wrapper as the parent
nextConfigData.customSelectors || [],
nextDepth
);
} else {
// Ensure it's hidden if followLink is false or no nextConfig exists
nestedConfigContainer.style.display = 'none';
// Optionally render empty structure when hidden? Or leave empty?
// Leaving it empty is simpler and _handleFollowLinkChange will populate if needed.
}
// Append the (potentially populated or empty/hidden) container
groupFieldset.appendChild(nestedConfigContainer);
// Add Remove Button
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = 'Remove';
removeButton.classList.add('remove-button');
removeButton.onclick = () => {
groupFieldset.remove();
// Optional: Renumber legends after removal
this._renumberSelectorLegends(listContainerElement);
};
groupFieldset.appendChild(removeButton);
listContainerElement.appendChild(groupFieldset);
}
/** Helper to renumber selector legends after removal */
_renumberSelectorLegends(listContainerElement) {
const groups = listContainerElement.querySelectorAll('.custom-selector-group');
groups.forEach((group, index) => {
const legend = group.querySelector('legend');
if (legend) {
legend.textContent = `Selector #${index + 1}`;
}
});
}
/**
* Event handler for the 'followLink' checkbox change.
* Shows/hides the nested config section AND enables/disables the allowedMediaTypes dropdown.
* NOTE: Defined as an arrow function property to automatically bind 'this'.
* @param {Event} event - The change event object.
* @param {HTMLElement} fieldWrapper - The wrapper div of the checkbox field.
* @param {number} depth - The depth where this checkbox resides.
* @private
*/
_handleFollowLinkChange = (event, fieldWrapper, depth) => { // Arrow function syntax
const checkbox = event.target;
const groupFieldset = fieldWrapper.closest('.custom-selector-group');
if (!groupFieldset) return;
const nestedConfigContainer = groupFieldset.querySelector('.nested-config[data-role="nested-config-container"]');
// Find the allowedMediaTypes dropdown within the same group
const allowedTypesSelect = groupFieldset.querySelector('select[data-key="allowedMediaTypes"]');
if (!nestedConfigContainer) {
console.warn("Nested config container not found during followLink change.");
// Still proceed to enable/disable dropdown if found
}
if (checkbox.checked) {
// --- Nested Config Handling (Show) ---
if (nestedConfigContainer) {
nestedConfigContainer.style.display = 'block';
// Only render if it's empty (first time being checked)
if (nestedConfigContainer.children.length === 0) {
const nextDepth = depth + 1;
// 'this' here correctly refers to the MediaExtractorUI instance
this._renderConfigSection(
nestedConfigContainer,
this.TOP_LEVEL_CONFIG_FIELDS,
{}, // Start with defaults for nested config
nextDepth,
{ configType: 'nested-top-level' }
);
// Create a wrapper for the nested list and button
const nestedCsListWrapper = document.createElement('div');
nestedCsListWrapper.dataset.role = 'nested-custom-selectors-wrapper';
nestedConfigContainer.appendChild(nestedCsListWrapper);
this._renderCustomSelectorList(
nestedCsListWrapper, // Pass the wrapper
[], // Start with empty list for nested
nextDepth
);
}
}
// --- Allowed Types Handling (Disable) ---
if (allowedTypesSelect) {
allowedTypesSelect.disabled = true;
// Optional: Add a class for visual styling when disabled due to followLink
allowedTypesSelect.closest('.field-wrapper')?.classList.add('field-disabled-by-follow');
}
} else { // Checkbox is UNchecked
// --- Nested Config Handling (Hide) ---
if (nestedConfigContainer) {
nestedConfigContainer.style.display = 'none';
// Optional: Clear the content when hiding to reset state?
// nestedConfigContainer.innerHTML = '';
}
// --- Allowed Types Handling (Enable) ---
if (allowedTypesSelect) {
allowedTypesSelect.disabled = false;
allowedTypesSelect.closest('.field-wrapper')?.classList.remove('field-disabled-by-follow');
}
}
} // End of arrow function
/**
* Renders the control buttons and output area. (Largely unchanged)
* @param {HTMLElement} parentElement - The DOM element to append controls to.
* @private
*/
_renderControls(parentElement) {
const extractButton = document.createElement('button');
extractButton.type = 'button';
extractButton.textContent = 'Extract Media';
extractButton.dataset.role = 'extract-btn';
extractButton.onclick = () => this._handleExtractClick();
const outputArea = document.createElement('div');
outputArea.classList.add('output-area');
outputArea.dataset.role = 'output-area';
outputArea.textContent = 'Click "Extract Media" or press Enter to start.';
parentElement.appendChild(extractButton);
parentElement.appendChild(outputArea);
}
// --- Data Collection ---
/**
* Reads the configuration from the UI elements within a given section.
* Handles nested configurations recursively.
* @param {HTMLElement} sectionElement - The element containing the config section UI (e.g., config-section, custom-selector-group, nested-config).
* @param {Array<object>} fieldsDefinition - The definition array used to build this section (or a dummy definition for structural reads like customSelectors).
* @param {number} depth - The nesting depth of the section being read (0 for top-level).
* @returns {object} The config object derived from the UI fields.
* @private
*/
_getConfigFromUI(sectionElement, fieldsDefinition, depth) {
const config = {};
// --- 1. Read Simple Fields Defined by fieldsDefinition ---
fieldsDefinition.forEach(fieldDef => {
const key = fieldDef.key;
// Skip keys handled structurally (like the container for custom selectors or nested config itself)
if (key === 'customSelectors' || key === 'nextConfig') {
return;
}
// Skip reading concurrency if reading a nested config (depth > 0)
if (key === 'concurrency' && depth > 0) {
return;
}
// Find input element (checking direct children wrappers first)
let inputElement = null;
const wrapper = Array.from(sectionElement.querySelectorAll(':scope > .field-wrapper')).find(
wrap => wrap.querySelector(`[data-key="${key}"]`)
);
inputElement = wrapper ? wrapper.querySelector(`[data-key="${key}"]`) : null;
// Fallback: Check direct children if not in a wrapper (less common now)
if (!inputElement) {
inputElement = sectionElement.querySelector(`:scope > [data-key="${key}"]`);
}
// Fallback: Check any descendant (needed for custom selector fields within grid)
if (!inputElement) {
inputElement = sectionElement.querySelector(`[data-key="${key}"]`);
}
if (!inputElement) {
// console.warn(`_getConfigFromUI: Could not find input element for key "${key}" at depth ${depth} in section:`, sectionElement);
// Assign default value only if the field is expected at this depth
if (!(key === 'concurrency' && depth > 0)) {
config[key] = fieldDef.defaultValue;
}
return; // Skip to next field definition
}
// Extract value using getter or default logic
if (fieldDef.valueGetter) {
config[key] = fieldDef.valueGetter(inputElement);
} else {
switch (inputElement.type) {
case 'checkbox':
config[key] = inputElement.checked;
break;
case 'number':
const numVal = parseFloat(inputElement.value);
config[key] = isNaN(numVal) && !(key === 'concurrency' && depth > 0) ? fieldDef.defaultValue : numVal;
break;
case 'select-one':
// Assign default only if value is empty AND the field is expected
config[key] = inputElement.value === '' && !(key === 'concurrency' && depth > 0) ? fieldDef.defaultValue : inputElement.value;
if (config[key] === undefined && inputElement.value === '') {
config[key] = fieldDef.defaultValue;
}
break;
case 'textarea':
case 'text':
default:
const trimmedValue = inputElement.value.trim();
// Assign default only if value is empty AND the field is expected
config[key] = trimmedValue === '' && !(key === 'concurrency' && depth > 0) ? fieldDef.defaultValue : trimmedValue;
if (config[key] === undefined && trimmedValue === '') {
config[key] = fieldDef.defaultValue;
}
break;
}
}
}); // End of simple field processing
// --- 2. Handle Nested Structures (Custom Selectors List) ---
// This part runs regardless of the fieldsDefinition passed, but only populates
// config.customSelectors if a list is found within sectionElement.
// --- MODIFICATION START: Find the list container more flexibly ---
let selectorListDiv = sectionElement.querySelector(':scope > .custom-selectors-list'); // Check direct child
if (!selectorListDiv) {
// If not direct, check within the known nested wrapper structure
const nestedWrapper = sectionElement.querySelector(':scope > div[data-role="nested-custom-selectors-wrapper"]');
if (nestedWrapper) {
selectorListDiv = nestedWrapper.querySelector(':scope > .custom-selectors-list');
// console.log(`_getConfigFromUI (depth ${depth}): Found nested selector list inside wrapper.`, selectorListDiv);
}
}
/* else {
console.log(`_getConfigFromUI (depth ${depth}): Found direct selector list.`, selectorListDiv);
}
if (!selectorListDiv && sectionElement.dataset.role !== 'config-section') { // Don't log for sections not expected to have list
// console.log(`_getConfigFromUI (depth ${depth}): No selector list found in section.`, sectionElement);
} */
// --- MODIFICATION END ---
if (selectorListDiv) {
config.customSelectors = []; // Initialize array for this level
const selectorGroups = selectorListDiv.querySelectorAll(':scope > .custom-selector-group');
selectorGroups.forEach(group => {
// Get config for this specific selector group using its fields definition
// The depth passed here is the depth of the *list* container (selectorListDiv.dataset.depth or fallback to current depth)
const groupDepth = parseInt(selectorListDiv.dataset.depth, 10) || depth;
const csConfig = this._getConfigFromUI(group, this.CUSTOM_SELECTOR_FIELDS, groupDepth);
// --- 3. Handle Nested Config *within* a Custom Selector Group (Recursive Part) ---
if (csConfig.followLink) {
const nestedContainer = group.querySelector(':scope > .nested-config[data-role="nested-config-container"]');
const nestedDepth = groupDepth + 1; // Calculate nested depth
if (nestedContainer && nestedContainer.style.display !== 'none' && nestedContainer.children.length > 0) {
// console.log(`_getConfigFromUI (depth ${depth}): Reading nested config for group at depth ${groupDepth}. Nested container found.`, nestedContainer);
// Recursively get the *entire* nested config object (top-level fields and its own selectors)
// by calling _getConfigFromUI on the nestedContainer.
// Pass both field definitions so it reads everything inside.
// We don't need two separate calls anymore. _getConfigFromUI will handle reading
// both simple fields and the custom selector list *within* the nestedContainer.
const fullNestedConfig = this._getConfigFromUI(
nestedContainer,
this.TOP_LEVEL_CONFIG_FIELDS, // Provide definitions for simple fields inside
nestedDepth // Pass the correct nested depth
// The function will *also* look for a custom selector list inside nestedContainer
);
// The result 'fullNestedConfig' will have keys from TOP_LEVEL_CONFIG_FIELDS
// AND potentially a 'customSelectors' key if a list was found inside.
csConfig.nextConfig = fullNestedConfig;
// console.log(`_getConfigFromUI (depth ${depth}): Finished reading nested config. Result:`, JSON.parse(JSON.stringify(csConfig.nextConfig)));
} else {
// console.log(`_getConfigFromUI (depth ${depth}): Nested container for group at depth ${groupDepth} is hidden or empty. Setting nextConfig to empty object.`);
// If followLink is checked but container is hidden/empty, provide empty config or null?
// Let's use null for consistency with the 'else' branch.
csConfig.nextConfig = null; // Changed from {} to null
}
} else {
csConfig.nextConfig = null; // Ensure it's null if not following
}
// Add the complete custom selector config (with potential nextConfig) if valid
if (csConfig.selector && csConfig.type && csConfig.attribute) {
config.customSelectors.push(csConfig);
} else {
// Avoid logging warning for empty selector groups the user hasn't filled yet
const isEmpty = !(csConfig.selector || csConfig.type || csConfig.attribute || csConfig.followLink);
if (!isEmpty) {
console.warn("MediaExtractorUI: Skipping custom selector due to missing required fields (selector, type, or attribute):", group, csConfig);
}
}
}); // End loop through selector groups
// If no valid custom selectors were added, remove the empty array
if (config.customSelectors && config.customSelectors.length === 0) {
delete config.customSelectors;
}
} // End if (selectorListDiv)
return config;
} // End _getConfigFromUI
// --- Event Handling & Extraction ---
/**
* Handles the click event of the "Extract" button.
* @private
*/
async _handleExtractClick() {
if (!this.uiRoot) {
console.error("MediaExtractorUI: UI Root not found. Cannot extract.");
this._notifyExtractionComplete(new Error("UI Root not found"), null); // Notify listeners of failure
return;
}
const startUrlInput = this.uiRoot.querySelector('input[data-role="start-url-input"]');
const outputArea = this.uiRoot.querySelector('.output-area[data-role="output-area"]');
const extractButton = this.uiRoot.querySelector('button[data-role="extract-btn"]');
if (!startUrlInput || !outputArea || !extractButton) {
console.error("MediaExtractorUI: Core UI elements not found.");
if (outputArea) outputArea.textContent = "Error: UI elements missing.";
this._notifyExtractionComplete(new Error("Core UI elements not found"), null); // Notify listeners of failure
return;
}
const startUrl = startUrlInput.value.trim();
let urlError = null;
if (!startUrl) {
urlError = new Error('Please enter a valid Start URL.');
} else {
try { new URL(startUrl); } catch (e) {
urlError = new Error(`Invalid Start URL format: ${e.message}`);
}
}
if (urlError) {
outputArea.textContent = `Error: ${urlError.message}`;
outputArea.className = 'output-area error';
startUrlInput.focus();
this._notifyExtractionComplete(urlError, null); // Notify listeners of failure
return;
}
// Get config from the top-level section
const topLevelConfigDiv = this.uiRoot.querySelector('.config-section[data-depth="0"]');
if (!topLevelConfigDiv) {
const configError = new Error('Could not find top-level configuration section.');
outputArea.textContent = `Error: ${configError.message}`;
outputArea.className = 'output-area error';
this._notifyExtractionComplete(configError, null); // Notify listeners of failure
return;
}
// Read config using the new data-driven method
let userConfig;
try {
// Find the top-level custom selectors list container (usually inside the fieldset)
const topLevelCsListContainer = this.uiRoot.querySelector('fieldset > .custom-selectors-list[data-depth="0"]');
// Read top-level fields (passing depth 0)
const topLevelFields = this._getConfigFromUI(topLevelConfigDiv, this.TOP_LEVEL_CONFIG_FIELDS, 0);
// Read custom selectors (passing depth 0 for the list container)
// Pass the container's parent fieldset to find the list inside
const customSelectorsData = topLevelCsListContainer ? this._getConfigFromUI(topLevelCsListContainer.parentNode, [{ key: 'customSelectors' }], 0) : {};
userConfig = {
...topLevelFields,
// Make sure customSelectors is an array even if none were found
customSelectors: customSelectorsData.customSelectors || []
};
} catch (configError) {
console.error("MediaExtractorUI: Error reading configuration from UI.", configError);
outputArea.textContent = `Error reading UI configuration: ${configError.message}`;
outputArea.className = 'output-area error';
this._notifyExtractionComplete(configError, null); // Notify listeners of failure
return;
}
outputArea.scrollIntoView();
outputArea.textContent = `Starting extraction from: ${startUrl}...\nConfiguration:\n${JSON.stringify(userConfig, (key, value) => value instanceof RegExp ? value.toString() : value, 2)}`;
outputArea.className = 'output-area extracting';
extractButton.disabled = true;
extractButton.textContent = 'Extracting...';
console.log("MediaExtractorUI: Starting extraction with config:", JSON.parse(JSON.stringify(userConfig))); // Deep copy for logging
const startTime = performance.now();
let results = null;
let error = null;
try {
// Assuming this.extractor.extractFromUrl exists and works with the config structure
results = await this.extractor.extractFromUrl(startUrl, userConfig);
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
console.log(`MediaExtractorUI: Extraction finished in ${duration} seconds.`);
console.log("MediaExtractorUI: Results:", results);
if (results.length > 0) {
outputArea.textContent = `Extraction complete!\nFound ${results.length} media items in ${duration} seconds.\n\nResults logged to the browser console (Press F12).`;
outputArea.textContent += `\n\nFirst ${Math.min(results.length, 20)} results:\n` + results.slice(0, 20).map(r => `- [${r.type}@d${r.depth}] ${r.url}`).join('\n');
outputArea.className = 'output-area success';
} else {
outputArea.textContent = "Extractor found no results. Check console for details.";
outputArea.className = 'output-area error';
}
} catch (extractionError) {
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
console.error("MediaExtractorUI: Extraction failed.", extractionError);
outputArea.textContent = `Error during extraction after ${duration} seconds: ${extractionError.message}\nSee console for details.`;
outputArea.className = 'output-area error';
error = extractionError; // Store error
} finally {
extractButton.disabled = false;
extractButton.textContent = 'Extract Media';
// Notify listeners after everything is done
this._notifyExtractionComplete(error, results);
}
}
/**
* Handles key presses
* @param {KeyboardEvent} event
* @private
*/
_handleKeyPress(event) {
if (event.key === 'Escape') {
this.close();
} else if (event.key === 'Enter') {
this._handleExtractClick();
}
}
/**
* Registers a callback function to be executed when media extraction completes.
* @param {function(Error|null, Array<MediaResult>|null): void} callback - The function to call.
* It receives an error object (or null if successful) as the first argument,
* and the array of results (or null if an error occurred) as the second argument.
*/
onExtractionComplete(callback) {
if (typeof callback === 'function') {
this.extractionCompleteCallbacks.push(callback);
} else {
console.warn("MediaExtractorUI: Attempted to register a non-function as an extraction complete callback.");
}
}
/**
* Notifies all registered extraction complete listeners.
* @param {Error|null} error - The error object if extraction failed, otherwise null.
* @param {Array<MediaResult>|null} results - The array of results if extraction succeeded, otherwise null.
* @private
*/
_notifyExtractionComplete(error, results) {
this.extractionCompleteCallbacks.forEach((callback, index) => {
try {
callback(error, results);
} catch (e) {
console.error(`MediaExtractorUI: Error executing extraction complete callback at index ${index}:`, e);
}
});
}
/**
* Populates the saved configuration dropdown from GM storage.
* Handles potential errors during loading or parsing the list.
* @private
*/
_populateConfigDropdown() {
if (!this.uiRoot) {
console.error("MediaExtractorUI: Cannot populate dropdown, UI Root not found.");
return;
}
const selectElement = this.uiRoot.querySelector('select[data-role="config-select"]');
if (!selectElement) {
console.error("MediaExtractorUI: Cannot populate dropdown, select element not found.");
return;
}
// Clear existing options except the default placeholder
selectElement.length = 1; // Keep only the "--- Select Config ---" option
let configList = [];
try {
const configListJson = GM_getValue(CONFIG_LIST_KEY, '[]');
console.log("MediaExtractorUI: Raw config list JSON from storage:", configListJson); // Log raw value
if (configListJson) {
configList = JSON.parse(configListJson);
if (!Array.isArray(configList)) {
console.warn('MediaExtractorUI: Invalid format for config list in storage (not an array). Resetting.', configList);
configList = [];
GM_setValue(CONFIG_LIST_KEY, '[]'); // Reset if invalid format
}
} else {
console.warn('MediaExtractorUI: Config list key not found or empty in storage. Initializing.');
configList = [];
GM_setValue(CONFIG_LIST_KEY, '[]'); // Initialize if missing
}
console.log("MediaExtractorUI: Parsed config list:", configList); // Log parsed list
if (configList.length > 0) {
configList.sort(); // Sort names alphabetically
configList.forEach(name => {
if (typeof name === 'string' && name.trim() !== '') { // Ensure it's a non-empty string
const option = document.createElement('option');
option.value = name;
option.textContent = name;
selectElement.appendChild(option);
// console.log(`MediaExtractorUI: Added option: ${name}`); // Uncomment for verbose logging
} else {
console.warn("MediaExtractorUI: Skipping invalid entry in config list:", name);
}
});
} else {
console.log("MediaExtractorUI: Config list is empty. No options added.");
}
} catch (e) {
console.error('MediaExtractorUI: Error parsing config list from storage:', e);
// Optionally reset the list if parsing fails critically
try {
GM_setValue(CONFIG_LIST_KEY, '[]');
console.log("MediaExtractorUI: Reset config list due to parsing error.");
} catch (resetError) {
console.error("MediaExtractorUI: Failed to reset config list after parsing error:", resetError);
}
// Ensure dropdown is empty (except placeholder) after error
selectElement.length = 1;
}
console.log("MediaExtractorUI: Dropdown population complete. Current options:", selectElement.options.length);
}
/**
* Handles the click event for the "Save Config" button.
* Reads the current UI config, validates the name, and saves to GM storage.
* Stores the saved name as the last used config.
* @private
*/
_handleSaveConfigClick() {
if (!this.uiRoot) return;
const saveNameInput = this.uiRoot.querySelector('input[data-role="config-save-name"]');
const statusSpan = this.uiRoot.querySelector('.config-status[data-role="config-status-message"]');
const selectElement = this.uiRoot.querySelector('select[data-role="config-select"]');
const startUrlInput = this.uiRoot.querySelector('input[data-role="start-url-input"]'); // Get Start URL input
if (!saveNameInput || !statusSpan || !selectElement || !startUrlInput) { // Check for startUrlInput too
console.error("Config Management or Start URL UI elements not found for saving.");
if (statusSpan) statusSpan.textContent = "Error: UI elements missing.";
return;
}
const configName = saveNameInput.value.trim();
if (!configName) {
statusSpan.textContent = "Error: Please enter a name for the configuration.";
saveNameInput.focus();
return;
}
if (configName.includes('_') || configName.includes(' ')) {
statusSpan.textContent = "Error: Config name cannot contain spaces or underscores.";
saveNameInput.focus();
return;
}
let currentConfig;
try {
// Reuse the logic from _handleExtractClick to get the full config object
const topLevelConfigDiv = this.uiRoot.querySelector('.config-section[data-depth="0"]');
const topLevelCsFieldset = this.uiRoot.querySelector('fieldset[data-role="custom-selectors-fieldset"]'); // Get the fieldset
if (!topLevelConfigDiv || !topLevelCsFieldset) {
throw new Error('Could not find configuration sections.');
}
const topLevelFields = this._getConfigFromUI(topLevelConfigDiv, this.TOP_LEVEL_CONFIG_FIELDS, 0);
// Pass the fieldset containing the list to _getConfigFromUI for custom selectors
const customSelectorsData = this._getConfigFromUI(topLevelCsFieldset, [{ key: 'customSelectors' }], 0);
// --- Get Start URL ---
const startUrlValue = startUrlInput.value.trim();
currentConfig = {
startUrl: startUrlValue, // Add start URL here
...topLevelFields,
customSelectors: customSelectorsData.customSelectors || []
};
// Clean up potential empty nextConfig objects if followLink is false
const cleanConfig = (config) => {
if (config.customSelectors) {
config.customSelectors.forEach(cs => {
if (!cs.followLink) {
delete cs.nextConfig; // Remove nextConfig if followLink is false
} else if (cs.nextConfig) {
cleanConfig(cs.nextConfig); // Recursively clean nested configs
}
});
}
};
cleanConfig(currentConfig);
} catch (configError) {
console.error("MediaExtractorUI: Error reading configuration for saving.", configError);
statusSpan.textContent = `Error reading UI config: ${configError.message}`;
return;
}
try {
const configKey = CONFIG_STORAGE_PREFIX + configName;
const configJson = JSON.stringify(currentConfig, (key, value) => {
// Special handling for RegExp during serialization
if (value instanceof RegExp) {
return { __type: 'RegExp', source: value.source, flags: value.flags };
}
return value;
});
// Update the list of names
const configListJson = GM_getValue(CONFIG_LIST_KEY, '[]');
let configList = JSON.parse(configListJson);
if (!Array.isArray(configList)) configList = []; // Ensure it's an array
if (!configList.includes(configName)) {
configList.push(configName);
GM_setValue(CONFIG_LIST_KEY, JSON.stringify(configList));
}
// Save the actual config
GM_setValue(configKey, configJson);
// Refresh the dropdown
this._populateConfigDropdown();
// Ensure the newly saved/updated config is selected
selectElement.value = configName;
statusSpan.textContent = `Configuration "${configName}" saved successfully.`;
console.log(`MediaExtractorUI: Config "${configName}" saved.`, currentConfig);
// --- ADDED: Store as last used config ---
this._setLastUsedConfig(configName);
// --- END ADDED ---
} catch (e) {
console.error('MediaExtractorUI: Error saving configuration to storage:', e);
statusSpan.textContent = `Error saving config: ${e.message}`;
}
}
/**
* Handles the click event for the "Delete Config" button.
* Removes the selected configuration from GM storage, updates the list,
* refreshes the dropdown, and resets the selection.
* @private
*/
_handleDeleteConfigClick() {
if (!this.uiRoot) return;
const selectElement = this.uiRoot.querySelector('select[data-role="config-select"]');
const statusSpan = this.uiRoot.querySelector('.config-status[data-role="config-status-message"]');
const saveNameInput = this.uiRoot.querySelector('input[data-role="config-save-name"]');
if (!selectElement || !statusSpan || !saveNameInput) {
console.error("Config Management UI elements not found for deleting.");
if (statusSpan) statusSpan.textContent = "Error: UI elements missing.";
return;
}
const configName = selectElement.value;
if (!configName) {
statusSpan.textContent = "Please select a configuration to delete.";
return;
}
// Use a more specific confirmation message
if (!confirm(`Are you sure you want to permanently delete the configuration "${configName}"? This cannot be undone.`)) {
statusSpan.textContent = "Deletion cancelled.";
return;
}
try {
const configKey = CONFIG_STORAGE_PREFIX + configName;
// 1. Remove the config itself
GM_deleteValue(configKey);
console.log(`MediaExtractorUI: Deleted config data for key: ${configKey}`);
// 2. Update the list of names
const configListJson = GM_getValue(CONFIG_LIST_KEY, '[]');
let configList = [];
try {
configList = JSON.parse(configListJson);
if (!Array.isArray(configList)) { // Ensure it's an array
console.warn("MediaExtractorUI: Config list was not an array, resetting during delete.", configList);
configList = [];
}
} catch (parseError) {
console.error("MediaExtractorUI: Error parsing config list during delete, resetting.", parseError);
configList = [];
}
const originalLength = configList.length;
configList = configList.filter(name => name !== configName); // Filter out the deleted name
if (configList.length < originalLength) {
GM_setValue(CONFIG_LIST_KEY, JSON.stringify(configList));
console.log(`MediaExtractorUI: Updated config list in storage after deleting "${configName}". New list:`, configList);
} else {
console.warn(`MediaExtractorUI: Config name "${configName}" not found in the stored list during deletion.`);
}
// 3. Refresh the dropdown using the updated list from storage
this._populateConfigDropdown();
// --- IMPORTANT FIXES ---
// 4. Explicitly reset the dropdown selection to the default placeholder
selectElement.value = ''; // Set value to the placeholder's value
// 5. Clear the save name input, regardless of whether it matched the deleted item
saveNameInput.value = '';
// 6. Clear the last used config if the deleted one was the last used
const lastUsed = GM_getValue(CONFIG_LAST_USED_KEY);
if (lastUsed === configName) {
this._setLastUsedConfig(null); // Clear it
console.log(`MediaExtractorUI: Cleared last used config setting as "${configName}" was deleted.`);
}
// --- END FIXES ---
statusSpan.textContent = `Configuration "${configName}" deleted successfully.`;
console.log(`MediaExtractorUI: Config "${configName}" deleted.`);
// Optionally, trigger a UI reset to default state after deletion?
// this._handleConfigSelectChange({ target: { value: '' } }); // Simulate selecting "--- Select ---"
} catch (e) {
console.error(`MediaExtractorUI: Error deleting configuration "${configName}":`, e);
statusSpan.textContent = `Error deleting config: ${e.message}`;
}
}
/**
* Updates the UI elements to reflect the state of a loaded configuration object.
* @param {object} configData - The configuration object to load into the UI.
* @private
*/
_updateUIFromConfig(configData) {
if (!this.uiRoot || !configData) return;
console.log("Updating UI from config:", JSON.parse(JSON.stringify(configData))); // Log deep copy
// Helper function to update a single config section
const updateSection = (parentElement, fieldsDefinition, currentConfigLevelData, depth) => {
if (!currentConfigLevelData) {
console.warn(`_updateUIFromConfig: No config data provided for section at depth ${depth} in parent:`, parentElement);
return;
}
fieldsDefinition.forEach(fieldDef => {
const key = fieldDef.key;
if (key === 'customSelectors' || key === 'nextConfig') return;
if (key === 'concurrency' && depth > 0) return;
let inputElement = parentElement.querySelector(`[data-key="${key}"]`);
if (!inputElement) {
const wrapper = Array.from(parentElement.querySelectorAll(':scope > .field-wrapper')).find(
wrap => wrap.querySelector(`[data-key="${key}"]`)
);
inputElement = wrapper ? wrapper.querySelector(`[data-key="${key}"]`) : null;
}
if (inputElement) {
const valueToSet = currentConfigLevelData.hasOwnProperty(key) ? currentConfigLevelData[key] : fieldDef.defaultValue;
const displayValue = fieldDef.valueSetter ? fieldDef.valueSetter(valueToSet) : (valueToSet ?? '');
switch (inputElement.type) {
case 'checkbox':
inputElement.checked = valueToSet === true;
// --- MODIFICATION START ---
// ALWAYS call the onChange handler *after* setting the value
// if one is defined, to ensure dependent UI updates correctly on load.
if (fieldDef.onChange) {
// We need to simulate the event object minimally and find the wrapper.
const pseudoEvent = { target: inputElement };
const fieldWrapper = inputElement.closest('.field-wrapper');
if (fieldWrapper) {
// Call the handler directly with the necessary context.
// Assuming the handler is bound correctly (like _handleFollowLinkChange).
try {
fieldDef.onChange(pseudoEvent, fieldWrapper, depth);
} catch (e) {
console.error(`Error executing onChange handler for key "${key}" during config load:`, e);
}
} else {
console.warn(`Could not find field wrapper for key "${key}" during onChange trigger on load.`);
}
}
// --- MODIFICATION END ---
break;
case 'number':
case 'text':
case 'select-one':
case 'textarea':
default:
const previousValue = inputElement.value; // Store previous value
inputElement.value = displayValue;
// Trigger change event for select/text/etc. if value changed and handler exists
if (inputElement.value !== previousValue && fieldDef.onChange) {
// For non-checkboxes, dispatching event might still be okay,
// but manual call like above is also an option. Let's keep dispatch for now.
const event = new Event('change', { bubbles: true });
inputElement.dispatchEvent(event);
}
break;
}
} else if (!(key === 'concurrency' && depth > 0)) {
console.warn(`_updateUIFromConfig: Input element not found for key "${key}" at depth ${depth}`, parentElement);
}
});
}; // End of updateSection helper
// --- Update Start URL (no change) ---
const startUrlInput = this.uiRoot.querySelector('input[data-role="start-url-input"]');
if (startUrlInput) {
startUrlInput.value = configData.startUrl || '';
} else {
console.error("_updateUIFromConfig: Start URL input not found.");
}
// --- Update Top-Level Fields (no change) ---
const topLevelConfigDiv = this.uiRoot.querySelector('.config-section[data-depth="0"]');
if (topLevelConfigDiv) {
updateSection(topLevelConfigDiv, this.TOP_LEVEL_CONFIG_FIELDS, configData, 0);
} else {
console.error("_updateUIFromConfig: Top-level config section not found.");
}
// --- Update Top-Level Custom Selectors (no change in this part) ---
const topLevelCsFieldset = this.uiRoot.querySelector('fieldset[data-role="custom-selectors-fieldset"]');
if (topLevelCsFieldset) {
// Define updateCustomSelectors helper inside or ensure it's accessible
const updateCustomSelectors = (listContainerParent, selectorsData, depth) => {
let listContainer = listContainerParent.querySelector('.custom-selectors-list');
if (listContainer) {
listContainer.innerHTML = ''; // Clear existing
} else {
console.warn("_updateUIFromConfig: custom-selectors-list container not found, creating.", listContainerParent);
listContainer = document.createElement('div');
listContainer.classList.add('custom-selectors-list');
listContainer.dataset.depth = depth;
const addButton = listContainerParent.querySelector('.add-button');
if (addButton) { listContainerParent.insertBefore(listContainer, addButton); }
else { listContainerParent.appendChild(listContainer); }
}
if (Array.isArray(selectorsData)) {
selectorsData.forEach((csData, index) => {
this._addCustomSelector(listContainer, csData, depth, index);
});
}
};
updateCustomSelectors(topLevelCsFieldset, configData.customSelectors || [], 0);
} else {
console.error("_updateUIFromConfig: Top-level custom selectors fieldset not found.");
}
} // End of _updateUIFromConfig
/**
* Stores the name of the last successfully loaded or saved configuration.
* @param {string} configName - The name of the configuration.
* @private
*/
_setLastUsedConfig(configName) {
try {
if (configName) {
GM_setValue(CONFIG_LAST_USED_KEY, configName);
} else {
// If configName is empty/null, remove the key
GM_deleteValue(CONFIG_LAST_USED_KEY);
}
} catch (e) {
console.error("MediaExtractorUI: Error setting last used config:", e);
}
}
/**
* Loads and parses a configuration object from storage by its name.
* Handles RegExp deserialization.
* @param {string} configName - The name of the configuration to load.
* @returns {object|null} The loaded configuration object, or null if not found or error occurs.
* @private
*/
_loadConfigByName(configName) {
if (!configName) return null;
const configKey = CONFIG_STORAGE_PREFIX + configName;
try {
const configJson = GM_getValue(configKey);
if (!configJson) {
console.warn(`MediaExtractorUI: Configuration "${configName}" not found in storage.`);
return null;
}
const config = JSON.parse(configJson, (key, value) => {
// Special handling for RegExp during deserialization
if (value && typeof value === 'object' && value.__type === 'RegExp') {
return new RegExp(value.source, value.flags);
}
return value;
});
return config;
} catch (e) {
console.error(`MediaExtractorUI: Error loading or parsing configuration "${configName}":`, e);
return null;
}
}
/**
* Handles the 'change' event on the saved configurations dropdown.
* Loads the selected configuration instantly into the UI, or resets the UI
* to defaults if the placeholder option is selected.
* @param {Event} event - The change event object.
* @private
*/
_handleConfigSelectChange(event) {
if (!this.uiRoot) return;
const selectElement = event.target;
const configName = selectElement.value;
const statusSpan = this.uiRoot.querySelector('.config-status[data-role="config-status-message"]');
const saveNameInput = this.uiRoot.querySelector('input[data-role="config-save-name"]');
if (!statusSpan || !saveNameInput) {
console.error("Config Management UI elements not found for handling select change.");
return;
}
// Always update the save name input to match the selection
saveNameInput.value = configName;
statusSpan.textContent = ''; // Clear previous status
if (!configName) {
// "--- Select Config ---" chosen - Reset UI to defaults
console.log("MediaExtractorUI: Resetting UI to defaults.");
// --- Create a default config object ---
const defaultConfig = {
startUrl: '', // Empty Start URL
customSelectors: [] // Empty custom selectors list
};
// Populate with defaults from TOP_LEVEL_CONFIG_FIELDS definition
this.TOP_LEVEL_CONFIG_FIELDS.forEach(fieldDef => {
// Only add if it has a defined defaultValue, excluding structural keys
if (fieldDef.defaultValue !== undefined && fieldDef.key !== 'customSelectors') {
defaultConfig[fieldDef.key] = fieldDef.defaultValue;
}
});
// Also ensure nested defaults like concurrency are present if defined in MediaExtractor defaults
if (typeof MediaExtractor !== 'undefined' && MediaExtractor.DEFAULT_CONFIG) {
Object.assign(defaultConfig, { // Merge, letting our specific defaults take precedence
concurrency: MediaExtractor.DEFAULT_CONFIG.concurrency ?? 5 // Example default
// Add other potential base defaults if needed
});
}
// --- End default config object creation ---
try {
this._updateUIFromConfig(defaultConfig); // Apply defaults to UI
statusSpan.textContent = "UI reset to defaults.";
this._setLastUsedConfig(null); // Clear last used config name as none is selected
} catch (e) {
console.error("MediaExtractorUI: Error resetting UI to defaults:", e);
statusSpan.textContent = "Error resetting UI.";
}
return; // Stop processing, UI is reset
}
// --- Proceed with loading the selected config ---
const config = this._loadConfigByName(configName);
if (config) {
try {
this._updateUIFromConfig(config);
statusSpan.textContent = `Configuration "${configName}" loaded.`;
console.log(`MediaExtractorUI: Config "${configName}" loaded via dropdown.`, config);
this._setLastUsedConfig(configName); // Store as last used
} catch (e) {
console.error(`MediaExtractorUI: Error applying configuration "${configName}" to UI:`, e);
statusSpan.textContent = `Error applying config: ${e.message}`;
// Failed to apply, clear last used?
this._setLastUsedConfig(null);
}
} else {
// Config wasn't found or failed to parse (error logged in _loadConfigByName)
statusSpan.textContent = `Error loading "${configName}". Check console.`;
// Clear last used if loading failed
this._setLastUsedConfig(null);
// Optionally reset UI fully here too? Or leave the previous state?
// Leaving previous state might be less confusing than a sudden reset on load failure.
// this._handleConfigSelectChange({ target: { value: '' } }); // Simulate selecting "--- Select ---"
}
}
/**
* Attempts to load the last used configuration when the UI is initialized.
* Updates the UI, dropdown selection, and save name input if successful.
* @private
*/
_loadLastUsedConfig() {
if (!this.uiRoot) return;
const lastUsedName = GM_getValue(CONFIG_LAST_USED_KEY);
if (!lastUsedName) {
console.log("MediaExtractorUI: No last used configuration found.");
return; // Nothing to load
}
console.log(`MediaExtractorUI: Found last used config name: "${lastUsedName}". Attempting to load.`);
const config = this._loadConfigByName(lastUsedName);
if (config) {
const selectElement = this.uiRoot.querySelector('select[data-role="config-select"]');
const saveNameInput = this.uiRoot.querySelector('input[data-role="config-save-name"]');
const statusSpan = this.uiRoot.querySelector('.config-status[data-role="config-status-message"]');
try {
this._updateUIFromConfig(config);
// Update dropdown and save name input
if (selectElement) selectElement.value = lastUsedName;
if (saveNameInput) saveNameInput.value = lastUsedName;
if (statusSpan) statusSpan.textContent = `Loaded last used config: "${lastUsedName}".`;
console.log(`MediaExtractorUI: Successfully loaded and applied last used config "${lastUsedName}".`);
} catch (e) {
console.error(`MediaExtractorUI: Error applying last used config "${lastUsedName}" to UI:`, e);
if (statusSpan) statusSpan.textContent = `Error applying last config: ${e.message}`;
// Clear last used if applying failed?
// this._setLastUsedConfig(null);
}
} else {
// Config not found or failed parse (error logged in _loadConfigByName)
// Remove the invalid last used key
console.warn(`MediaExtractorUI: Could not load last used config "${lastUsedName}", removing stored key.`);
this._setLastUsedConfig(null);
const statusSpan = this.uiRoot.querySelector('.config-status[data-role="config-status-message"]');
if (statusSpan) statusSpan.textContent = `Could not load last config: "${lastUsedName}".`;
}
}
}// ----------------------------------------------------------------------------------------------
// main.js
// ----------------------------------------------------------------------------------------------
// ----------------------------------------------------------------------------------------------
// DEFAULT CONFIGURATION
// ----------------------------------------------------------------------------------------------
const defaultConfigValues = {
viewer: {
_tabName: "Viewer",
_subgroup: false, // To access values with config.key instead of config.groupName.key
useFullscreen: { default: true, label: "Open in Fullscreen", requiresReload: false },
openInDualPageMode: { default: false, label: "Open in dual page mode", requiresReload: false },
openInRightToLeftMode: { default: false, label: "Open in right to left mode", requiresReload: false },
reverseNavigationInRtlMode: { default: false, label: "Reverse Navigation in Right to Left mode", requiresReload: false },
exitToViewerPage: { default: false, label: "Highlight Current Page After Exiting", requiresReload: false },
useOriginalImages: { default: false, label: "Use original images (slower, limited)", requiresReload: false },
useFallbackImages: {
default: true, label: "Use original images as fallback", requiresReload: false,
condition: ["viewer.useOriginalImages", false], onConditionFail: { label: "Use webp images as fallback" }
},
enableFallbackStyling: { default: true, label: "Enable fallback styling for /s/ links", requiresReload: false },
alwaysRestoreViewer: { default: false, label: "Always restore viewer after reload" },
enableAnimations: { default: false, label: "Enable smooth animations", hidden: true },
panFirst: { default: true, label: "Pan before changing page when zoomed", requiresReload: false },
panStep: { default: 250, label: "Pan step", requiresReload: false, condition: ["viewer.panFirst", true] },
fitMode: { default: "fit-window", label: "Default Fit Mode", choices: ["fit-window", "fit-width", "one-to-one"], requiresReload: false },
dualLayout: { default: "selected-first", label: "Dual page layout", choices: ["selected-first", "odd-first", "even-first"], requiresReload: false },
viewerLabels: {
default: "always", label: "Page Number Visibility", requiresReload: false,
choices: ["proximity", "always", "disabled"]
},
preloadCount: { default: 5, label: "Number of Images to Preload", requiresReload: false },
zoomAnimation: { default: true, label: "Enable Zoom Animation", hidden: true, requiresReload: false },
useFetchFallback: { default: true, label: "Use fetch fallback for videos", hidden: true, requiresReload: false },
},
toolbar: {
_tabName: "Toolbar",
_subgroup: false,
helpAndSettingsButtons: {
default: "always", label: "Toolbar Visibility", requiresReload: false,
choices: ["proximity", "always", "disabled"]
},
buttonSize: { default: 38, label: "Button Size (in pixels)", requiresReload: false },
showChaptersButton: { default: true, label: "Show Chapters Button (☰)", requiresReload: false },
showSettingsButton: { default: true, label: "Show Settings Button (⚙)", requiresReload: false },
showHelpButton: { default: true, label: "Show Help Button (?)", requiresReload: false },
showExitButton: { default: true, label: "Show Exit Button (×)", requiresReload: false },
showNavButtons: { default: false, label: "Show Navigation Buttons (← →)", requiresReload: false },
showRotateButtons: { default: false, label: "Show Rotate Buttons (↶ ↷)", requiresReload: false },
showZoomButtons: { default: false, label: "Show Zoom Buttons (+ -)", requiresReload: false },
showGalleryViewButton: { default: true, label: "Show Gallery View Button", requiresReload: false },
showDualPageButton: { default: true, label: "Show Toggle Dual Page Button", requiresReload: false },
showFullscreenButton: { default: true, label: "Show Fullscreen Button (⛶)", requiresReload: false },
showDownloadButton: { default: false, label: "Show Download Button", requiresReload: false },
showFindGalleriesButton: { default: false, label: "Show 'Find Galleries' Button", requiresReload: false },
showGotoPageButton: { default: true, label: "Show 'Go To Page' Button", requiresReload: false },
},
sidebar: {
_tabName: "Sidebar",
enableSidebar: { default: true, label: "Enable Sidebar" },
pinSidebar: { default: false, label: "Pin Sidebar", requiresReload: false, condition: ["sidebar.enableSidebar", true] },
sidebarGridConfig: {
showPageNumbers: { default: true, label: "Show page numbers", condition: ["sidebar.enableSidebar", true] },
numColumns: { default: 1, label: "Number of columns", hidden: true },
fetchFullImages: { default: true, label: "Fetch full images", requiresReload: false, condition: ["sidebar.enableSidebar", true] },
},
sidebarPosition: { default: "left", label: "Sidebar Position", choices: ["left", "right"], condition: ["sidebar.enableSidebar", true] },
sidebarWidth: { default: 400, label: "Sidebar Width", condition: ["sidebar.enableSidebar", true] },
_subgroup: false,
},
gallery: {
_tabName: "Gallery",
gridViewConfig: {
showPageNumbers: { default: false, label: "Show page numbers" },
fetchFullImages: { default: true, label: "Fetch full images", requiresReload: false },
numColumns: { default: 4, label: "Number of columns" },
},
_subgroup: false,
},
embeddedGallery: {
_tabName: "Gallery (Embedded)",
replaceDefaultGridView: { default: true, label: "Enable (replaces default grid view)" },
embeddedViewFullWidth: {
default: false, label: "Make embedded gallery view span the full width",
condition: ["embeddedGallery.replaceDefaultGridView", true]
},
embeddedGridGotoOpensViewer: {
default: false, label: "Goto box opens viewer instead of scrolling", requiresReload: false,
condition: ["embeddedGallery.replaceDefaultGridView", true]
},
embeddedGridViewConfig: {
showPageNumbers: { default: false, label: "Show page numbers", condition: ["embeddedGallery.replaceDefaultGridView", true] },
fetchFullImages: { default: true, label: "Fetch full images", requiresReload: false, condition: ["embeddedGallery.replaceDefaultGridView", true] },
numColumns: { default: 4, label: "Number of columns", condition: ["embeddedGallery.replaceDefaultGridView", true] },
},
_subgroup: false,
},
videoConfig: {
_tabName: "Video",
_hidden: true,
autoplay: { default: true, label: "Autoplay Videos" },
loop: { default: true, label: "Loop Playback" },
},
};
// ----------------------------------------------------------------------------------------------
// CONSTANTS & STATE
// ----------------------------------------------------------------------------------------------
const PAGINATION = 20;
const THUMB_WIDTH = 200;
const THUMB_HEIGHT = 284;
const baseUrl = `${window.location.protocol}//${window.location.host}`;
// initialized in init()
let galleryId = null;
let totalPages = null;
let totalGalleryImages = null;
let galleryUsesSpritesheets = null;
let config = null; // Config
let thumbs = null; // ThumbCollection
let chapterList = null;
let imageViewer = null; // ImageViewer
let embeddedGridView = null; // GridView
// ----------------------------------------------------------------------------------------------
// INITIALIZATION
// ----------------------------------------------------------------------------------------------
function initializeGlobalShortcuts() {
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
const targetElement = e.target;
if (targetElement && (
targetElement.tagName === 'INPUT' ||
targetElement.tagName === 'TEXTAREA' ||
targetElement.isContentEditable
)) {
return;
}
if (e.key === 'g' || e.key === 'G') {
e.preventDefault(); // Prevent 'g' from being typed
showGotoPageInput();
return;
} else if (e.key === 'p' || e.key === 'P') {
if (config.showingUI()) {
config.closeUI();
} else {
config.showUI();
}
}
});
}
function initializeThumbnailListeners() {
thumbs.forEach((thumbObj, index) => {
if (!thumbObj)
return;
thumbObj.link.addEventListener("click", (e) => {
e.preventDefault();
imageViewer.loadAndShowIndex(index);
});
});
}
function processComments() {
const c6Divs = document.querySelectorAll('.c6');
let bestResult = null;
let maxSuitableLinks = 0;
// First pass: try to find comments with chapter links.
c6Divs.forEach(div => {
const suitableLinks = [];
const links = div.querySelectorAll('a');
links.forEach(link => {
const href = link.getAttribute('href');
// Check if href exists and contains the gallery id with a hyphen.
if (href && href.includes(`${galleryId}-`)) {
const match = href.match(/\d+$/);
if (match) {
const index = parseInt(match[0], 10) - 1;
// Add click event for imageViewer.
link.addEventListener('click', (e) => {
e.preventDefault();
imageViewer.loadAndShowIndex(index);
});
// Extract the text description immediately following the <a> element.
let description = "";
let sibling = link.nextSibling;
while (sibling) {
if (sibling.nodeType === Node.TEXT_NODE) {
if (sibling.textContent.includes('\n')) {
description += sibling.textContent.split('\n')[0];
break;
} else {
description += sibling.textContent;
}
} else if (
sibling.nodeType === Node.ELEMENT_NODE &&
sibling.tagName.toUpperCase() === "BR"
) {
break;
}
sibling = sibling.nextSibling;
}
description = description.trim();
suitableLinks.push({
href: href,
index: index,
linkText: link.innerText.trim(),
description: description
});
}
}
});
// Record the comment if it has at least two suitable links and more than any previous comment.
if (suitableLinks.length >= 2 && suitableLinks.length > maxSuitableLinks) {
bestResult = suitableLinks;
maxSuitableLinks = suitableLinks.length;
}
});
if (bestResult) {
bestResult.sort((a, b) => a.index - b.index);
return bestResult;
}
/************
* BONUS: If no chapter list with links was found,
* fallback to scanning comments for chapter lines.
*
* We pre-process the innerHTML for each comment by replacing two or more <br>
* (and variants like <br/> or <br />) with a single <br> then convert <br> to newline.
* This helps in cases like your example where double <br> can disrupt the line splitting.
************/
let bestTextResult = null;
let maxMatchingLines = 0;
// Regex matching: optional leading zeroes, digits, then one of colon, period, or space followed by optional spaces and text.
const chapterLineRegex = /^0*(\d+)[\:\.\s]\s*(.+)$/;
c6Divs.forEach(div => {
// Use innerHTML to capture <br> tags.
let html = div.innerHTML;
// Replace two or more consecutive <br> variants with a single <br>.
html = html.replace(/(<br\s*\/?>\s*){2,}/gi, '<br>');
// Replace remaining <br> tags with newline.
const text = html.replace(/<br\s*\/?>/gi, '\n');
// Split into lines.
const lines = text.split(/\r?\n/);
let matchingLines = [];
let consecutiveBlock = [];
// Iterate through each line, tracking consecutive matching lines.
lines.forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine === '') {
// Reset block if blank, preserving the longest block found so far.
if (consecutiveBlock.length > matchingLines.length) {
matchingLines = consecutiveBlock.slice();
}
consecutiveBlock = [];
return;
}
const match = trimmedLine.match(chapterLineRegex);
if (match) {
consecutiveBlock.push({
index: parseInt(match[1], 10) - 1,
description: match[2].trim(),
line: trimmedLine
});
} else {
if (consecutiveBlock.length > matchingLines.length) {
matchingLines = consecutiveBlock.slice();
}
consecutiveBlock = [];
}
});
// Final check after the loop.
if (consecutiveBlock.length > matchingLines.length) {
matchingLines = consecutiveBlock.slice();
}
// Save this comment's chapter block if it has at least 2 entries and outperforms previous ones.
if (matchingLines.length >= 2 && matchingLines.length > maxMatchingLines) {
bestTextResult = matchingLines;
maxMatchingLines = matchingLines.length;
}
});
if (bestTextResult) {
bestTextResult.sort((a, b) => a.index - b.index);
return bestTextResult;
}
return null;
}
function initializeEmbeddedGridView() {
const gridParent = document.getElementById('gdt');
gridParent.innerHTML = '';
gridParent.className = 'gt200-nogrid'; // disables default grid css
if (config.embeddedViewFullWidth) {
gridParent.style.maxWidth = "none";
}
const headers = document.getElementsByClassName('gtb');
const comments = document.getElementById('cdiv');
for (let i = 0; i < headers.length; i++) {
const element = headers[i];
element.innerHTML = '';
element.style.height = '10px';
}
// Create combined floating navigation button
const navButton = document.createElement('div');
navButton.style.position = 'fixed';
navButton.style.bottom = '20px';
navButton.style.right = '20px';
navButton.style.zIndex = '1000';
navButton.style.display = 'flex';
navButton.style.flexDirection = 'column';
navButton.style.gap = '4px';
const upButton = document.createElement('button');
upButton.textContent = '▲';
upButton.style.padding = '12px 24px';
upButton.style.fontSize = '18px';
upButton.style.fontWeight = 'bold';
upButton.style.backgroundColor = '#444';
upButton.style.color = '#fff';
upButton.style.border = 'none';
upButton.style.borderRadius = '4px';
upButton.style.cursor = 'pointer';
upButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
upButton.addEventListener('click', () => {
window.scrollTo(0, 0);
});
const downButton = document.createElement('button');
downButton.textContent = '▼';
downButton.style.padding = '12px 24px';
downButton.style.fontSize = '18px';
downButton.style.fontWeight = 'bold';
downButton.style.backgroundColor = '#444';
downButton.style.color = '#fff';
downButton.style.border = 'none';
downButton.style.borderRadius = '4px';
downButton.style.cursor = 'pointer';
downButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
downButton.addEventListener('click', () => {
if (comments) {
comments.scrollIntoView();
}
});
navButton.appendChild(upButton);
navButton.appendChild(downButton);
document.body.appendChild(navButton);
embeddedGridView = new GridView(gridParent, config.embeddedGridViewConfig, true);
embeddedGridView.showGridView();
}
// Fallback logic for /s/ urls
function doFallback() {
const resizeAndCenterImage = () => {
const img = document.getElementById("img");
if (!img) {
console.warn("No image element found.");
return;
}
if (!img.complete) {
console.warn("Image not loaded, ignoring.");
return;
}
const naturalWidth = img.naturalWidth;
const naturalHeight = img.naturalHeight;
if (!naturalWidth || !naturalHeight) {
console.warn("Image dimension values missing, ignoring.");
return;
}
// Calculate dimensions and scaling factor to fit the image inside the window.
const scale = Math.min(window.innerWidth / naturalWidth, window.innerHeight / naturalHeight);
const newWidth = naturalWidth * scale;
const newHeight = naturalHeight * scale;
// Remove any width/height attributes and update inline styles.
img.removeAttribute("width");
img.removeAttribute("height");
img.style.width = newWidth + "px";
img.style.height = newHeight + "px";
// Scroll vertically to center the image.
const imgRectBefore = img.getBoundingClientRect();
const imgCenterY = window.scrollY + imgRectBefore.top + newHeight / 2;
const targetScrollY = imgCenterY - window.innerHeight / 2;
window.scrollTo(window.scrollX, targetScrollY);
// Remove any existing horizontal translate before recalculating.
img.style.transform = '';
const imgRect = img.getBoundingClientRect();
const currentImgLeft = imgRect.left;
const desiredImgLeft = (window.innerWidth - newWidth) / 2;
const translationX = desiredImgLeft - currentImgLeft;
img.style.transform = `translateX(${translationX}px)`;
};
const attachResizeOnImageLoad = () => {
const img = document.getElementById("img");
if (!img) return;
if (img.complete) {
resizeAndCenterImage();
// Second pass after a short delay to override other scripts
setTimeout(resizeAndCenterImage, 100);
} else {
img.removeEventListener('load', attachResizeOnImageLoad);
img.addEventListener('load', attachResizeOnImageLoad);
}
};
const checkAndApplyFallback = () => {
if (window.location.pathname.startsWith('/s/')) {
attachResizeOnImageLoad();
} else {
console.log("Current URL doesn't match '/s/'; fallback operations not needed.");
}
};
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
if (node.id === "img" || (node.querySelector && node.querySelector("#img"))) {
attachResizeOnImageLoad();
}
}
});
});
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
window.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true });
});
}
window.addEventListener('locationchange', () => {
checkAndApplyFallback();
});
checkAndApplyFallback();
}
async function init() {
config = new Config("YAEV Preferences", defaultConfigValues, true, true);
if (config.enableFallbackStyling && window.location.pathname.startsWith('/s/')) {
console.warn("/s/ urls are not supported unless they refer to an image in the same gallery id. Please open the image from its index page.");
console.log("Applying fallback styling and exiting.");
doFallback();
return;
}
galleryId = getGalleryId();
totalPages = getTotalPages();
totalGalleryImages = getTotalImages();
const intialPageIndex = getPageIndexFromUrl(window.location.href);
console.log(`Extracted values:\nGallery ID: ${galleryId}\nTotal Images: ${totalGalleryImages}\nTotal Pages: ${totalPages}\nInitial Page: ${intialPageIndex + 1}`);
const saveIndex = () => {
if (imageViewer?.isActive()) {
const currentIndex = imageViewer.currentIndex;
GM_setValue('_savedViewerIndex', [galleryId, currentIndex]);
console.log(`Saved current index ${currentIndex} for gallery ${galleryId} for reload`);
}
};
config.onReload(saveIndex);
if (config.alwaysRestoreViewer) {
window.addEventListener('beforeunload', saveIndex);
}
thumbs = createThumbCollection(new Array(totalGalleryImages).fill(null));
const initialThumbs = extractThumbnailLinks(document, intialPageIndex);
// console.log(initialThumbs);
if (!initialThumbs?.length) {
console.warn('Could not extract pages');
}
galleryUsesSpritesheets = determineIfSpritesheets(initialThumbs);
console.log(`Gallery uses spritesheets: ${galleryUsesSpritesheets}`);
populateThumbsOnPage(intialPageIndex, initialThumbs);
initializeGlobalShortcuts();
if (!config.replaceDefaultGridView) {
initializeThumbnailListeners();
} else {
initializeEmbeddedGridView();
}
try {
chapterList = processComments();
if (chapterList) {
console.log(`Found possible chapter list:`, chapterList);
}
} catch (e) {
console.error(`Error processing comments: ${e}`);
}
imageViewer = new ImageViewer(config, (exitToPage) => {
if (config.replaceDefaultGridView && embeddedGridView) {
embeddedGridView.enableLoading();
}
if (exitToPage) {
if (!config.replaceDefaultGridView) {
const page = thumbs[imageViewer.currentIndex].page;
const url = new URL(window.location.href);
url.searchParams.set("p", page);
console.log(`Loading page ${page}`);
window.location.href = url.href;
} else if (embeddedGridView) {
embeddedGridView.scrollToIndex(imageViewer.currentIndex);
}
}
});
// Check for saved index from previous reload
const savedData = GM_getValue('_savedViewerIndex', null);
if (savedData !== null) {
const [savedGalleryId, savedIndex] = savedData;
if (savedGalleryId === galleryId) {
console.log(`Restoring saved index ${savedIndex} for gallery ${galleryId}`);
GM_deleteValue('_savedViewerIndex');
imageViewer.loadAndShowIndex(savedIndex);
} else {
console.log(`Saved index ${savedIndex} is for different gallery ${savedGalleryId}, deleting`);
GM_deleteValue('_savedViewerIndex');
}
}
}
init();
})();