Shows all chapter pages at once in a single scrolling reader on 100+ manga, manhwa, comic and adult sites (MangaDex, Asura, Batoto, MangaPark, WebToons, Madara/MangaStream sites and more). Zoom, 7 view modes, hotkeys, bookmarks, auto-scroll, lazy loading, ZIP download, local files.
// ==UserScript==
// @name Manga Viewer MM
// @namespace https://greasyfork.org/en/scripts/599338-manga-viewer-mm
// @description Shows all chapter pages at once in a single scrolling reader on 100+ manga, manhwa, comic and adult sites (MangaDex, Asura, Batoto, MangaPark, WebToons, Madara/MangaStream sites and more). Zoom, 7 view modes, hotkeys, bookmarks, auto-scroll, lazy loading, ZIP download, local files.
// @version 1.0.1
// @license MIT
// @icon https://cdn-icons-png.flaticon.com/32/2281/2281832.png
// @run-at document-end
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_listValues
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_addValueChangeListener
// @noframes on
// @connect *
// @include /https?:\/\/(www.)?(asurascans|asuracomics).(com|net)\/.+/
// @include /https?:\/\/(?:www\.)?(?:fto|jto|hto|dto|mto|wto|bato|battwo|batotwo|comiko|batocomic|readtoto|zbato|xbato|mangatoto)\.(?:to|com|net|org)\/(chapter|title).*/
// @include /https?:\/\/(www\.)?(bilibilicomics).net\/episode\/.+/
// @include /https?:\/\/(www\.)?comick.io\/.+/
// @include /https?:\/\/comix\.to\/(title|comic)\/.+\/.+/
// @include /https?:\/\/(www\.)?demonicscans\.org\/(title\/.+\/chapter\/.+|chaptered\.php\?manga=\d+&chapter=.+)/
// @include /https?:\/\/(www\.)?dynasty-scans.com\/chapters\/.+/
// @include /https?:\/\/(www.)?(flamecomics).(xyz)\/series\/.+/
// @include /https?:\/\/(visorikigai|visualikigai).(ajaco|eltanews|foodib|jobswu).(com|net|site)\/capitulo\/\d+/
// @include /https:\/\/(www\.)?kagane\.org\/series\/.+\/reader\/.+/
// @include /https?:\/\/(www\.)?kumanga.com\/manga\/leer\/.+/
// @include /https?:\/\/(www.)?leercapitulo.co\/leer\/.+/
// @include /https?:\/\/(www\.)?lectorxd\.com\/.+\/leer\/.+/
// @include /https?:\/\/(www\.)?lhtranslation.net\/read.+/
// @include /(file:\/\/\/.+(index)?.html)/
// @include /https?:\/\/(www\.)?m440.in\/manga\/.+\/.+\/\d+/
// @include /https?:\/\/(www\.)?mangabuddy.com\/.+\/chapter.+/
// @include /https?:\/\/(www\.)?mangadex.org/
// @include /https?:\/\/(www\.)?(fanfox.net|mangahere.cc)\/manga\/.+\/.+\//
// @include /https?:\/\/(www\.)?mangago.me\/.*\/.*\/.*/
// @include /https?:\/\/(www\.)?(mangahub).io\/chapter\/.+\/.+/
// @include /https?:\/\/(www\.)?(read|chap)?(nelomanga|mangakakalot|natomanga|manganato|mangabats|mangakakalove).(com|gg|net).*\/(chapter|manga)\/.+\/.+/
// @include /https?:\/\/mangaball\.net\/chapter-detail\/.+/
// @include /https?:\/\/(www\.)?manga-oni.com\/lector\/.+\/\d+\/cascada/
// @include /https?:\/\/(www\.)?(mangapark|mpark|comicpark|readpark|parkmanga).(com|me|org|net|io|to)\/title\/.+\/.+/
// @include /https?:\/\/(www\.)?mangareader.to\/read\/.+\/.+\/.+/
// @include /https?:\/\/.*mangatoon.mobi\/.+\/watch\/.+/
// @include /https?:\/\/www\.mangatown\.com\/manga\/.+\/.+\//
// @include /https?:\/\/(www\.)?manhwaweb.com\/leer\/.+/
// @include /https?:\/\/(www\.)?mgeko.(com|cc)?\/reader\/.*/
// @include /https?:\/\/(www\.)?nineanime.com\/chapter\/.+/
// @include /https?:\/\/(www\.)?olympusxyz.com\/capitulo\/\d+\/.+/
// @include /https?:\/\/(www\.)?qimanga\.com\/series\/[^/]+\/chapter-.+/
// @include /https?:\/\/qimanhwa\.com\/series\/[^/]+\/chapter-.+/
// @include /https?:\/\/(www\.)?readcomicsonline.ru\/comic\/.*\/\d*/
// @include /https?:\/\/(www\.)?reaperscans\.com\/series\/.+\/chapter.+/
// @include /https?:\/\/(www\.)?reimanga\.com\/manga\/[^/]+-\d+\/\d+/
// @include /https?:\/\/(www\.)?zonatmo\.(com|org|app)\/(viewer|news|view_uploads|reader|library)\/.+/
// @include /https?:\/\/(www\.)?webnovel.com\/comic\/.+/
// @include /https?:\/\/(www\.)?webtoons.com\/.+viewer.+/
// @include /https?:\/\/(www\.)?(weebcentral).com\/chapters\/.+/
// @include /https?:\/\/(www\.)?weebdex\.org\/.+/
// @include /https?:\/\/(www.)?(vortexscans).(org)\/.+/
// @include /https?:\/\/(www\.)?zscans.com\/comics\/.+/
// @include /https?:\/\/[^/]*(scans?|comic|realm|rizz|hivetoon|tresdaos|zonamiau|ntrgod|threedaos)[^/]*\/.+/
// @include /^(?!.*jaiminisbox).*\/read\/.+/
// @include /https?:\/\/.+\/(manga|series|manhua|comic|ch|novel|webtoon|tmo)\/.+\/.+/
// @include /https?:\/\/(www\.)?akuma\.moe\/g\/.+\/.+/
// @include /https?:\/\/(www\.)?bestporncomix.com\/gallery\/.+/
// @include /https?:\/\/(www\.)?doujins.com\/.+/
// @include /https?:\/\/(www\.)?dragontranslation.net\/leer\/.+/
// @include /https?:\/\/(comics.)?8muses.(com|io)\/(comics\/)?picture\/.+/
// @include /https?:\/\/(g\.)?(exhentai|e-hentai).org\/s\/.+\/.+/
// @include /https?:\/\/(www\.)?fsicomics.com\/.+/
// @include /https?:\/\/(www\.)?freeadultcomix.com\/.+/
// @include /https?:\/\/(www\.)?gntai.net\/(?!(category|tags|autores))[^/]+\/.+/
// @include /https?:\/\/(www\.)?hdoujin\.org\/.+/
// @include /https?:\/\/(www\.)?hentai2read.com\/[^/]+\/\d+(.\d+)?\//
// @include /https?:\/\/(www\.)?hentaiera.com\/view\/.+\/\d+\/?/
// @include /https?:\/\/(www\.)?hentaiforce.net\/view\/.+\/\d+/
// @include /https?:\/\/(www\.)?hentaifox.com\/g\/.+/
// @include /https?:\/\/(www\.)?(hentaihand|nhentai)\.com\/(?:[a-z]{2}\/)?(?:comic|reader)\/.+\/\d+/
// @include /https?:\/\/(www\.)?hentaihere.com\/.+\/.+\/.+/
// @include /https?:\/\/(www\.)?hentainexus.com\/read\/.+/
// @include /https?:\/\/(www.)?hentalk.pw/
// @include /https?:\/\/hitomi.la\/reader\/.+/
// @include /https?:\/\/(www\.)?imhentai.xxx\/view\/.+\/.+\//
// @include /https?:\/\/(www\.)?(kingcomix|chochox|comics18).(com|org)\/.+/
// @include /https?:\/\/(www\.)?luscious.net\/.+\/read\/.+/
// @include /https?:\/\/(www\.)?multporn.net\/(comics|hentai_manga)\/.+/
// @include /https?:\/\/(www\.)?myhentaigallery.com\/g\/.+\/\d+/
// @include /https?:\/\/(www\.)?nhentai\.net\/g\/.+/
// @include /https?:\/\/(www\.)?nhentai\.to\/g\/\d+\/\d+/
// @include /https?:\/\/(www\.)?nhentai\.xxx\/g\/\d+\/\d+/
// @include /https?:\/\/(www\.)?9hentai.(so)\/g\/.+\/.+/
// @include /https?:\/\/(www\.)?porncomicshd.com\/es.*/
// @include /https?:\/\/(www\.)?pururin.me\/(view|read)\/.+\/.+\/.+/
// @include /https?:\/\/(www\.)?(niyaniya|shupogaki|hoshino).(moe|one)/
// @include /https?:\/\/(www\.)?simply-hentai.com\/.+\/page\/.+/
// @include /https?:\/\/(www\.)?tmohentai\.(com|app)\/(reader|library|view_uploads)\/.+/
// @include /https?:\/\/(www|es|fr|it|pt|ru)?\.?(3hentai|hentaivox).(net|com)\/(d|view)\/.+\/.+/
// @include /https?:\/\/(www\.)?tsumino.com\/Read\/Index\/\d+(\?page=.+)?/
// @include /https?:\/\/(www\.)?(vermangasporno|vercomicsporno).com\/.+/
// @include /https?:\/\/(www\.)?wnacg.com\/photos-view-id-.+/
// @include /https?:\/\/(www\.)?xlecx.one\/.+/
// @include /https?:\/\/(www\.)?xyzcomics.com\/.+/
// @include /https?:\/\/(www\.)?yabai.si\/g\/.+\/read/
// @include /https?:\/\/(.+\/(porncomic|read-scan)|(www\.)?(manytoon|mangadistrict)\.com\/title)\/.+\/.+/
// ==/UserScript==
(function () {
'use strict';
// ==========================================================================================
// HOW THIS FILE IS ORGANIZED
// The script is one function (IIFE) split into numbered sections. Each section only uses
// sections ABOVE it, so you can read the file from top to bottom. Search for "==== NN." to
// jump to a section. Documentation: docs/README.md (reading order) and docs/06-code-map.md.
//
// 01 CONFIG - every tunable constant, including VERBOSE LOGGING
// 02 LOGGING - log / logDebug / logError and safe wrappers
// 03 BASIC HELPERS - pure functions (no DOM): emptiness, merge, diff, debounce...
// 04 WAITING - waitUntil, withTimeout, sleep
// 05 PLATFORM - userscript manager storage, page window, device detection
// 06 PAGE READING/NETWORK - query/read helpers, fetch helpers (used by core and sites)
// 07 LOCALES - UI texts in 6 languages
// 08 SETTINGS STORE - defaults, load, save (diff only), cross-tab sync, bookmarks
// 09 APP STATE - runtime values (manga, image status, page layout, current page)
// 10 COLORS AND THEMES - color math, palettes (hex), theme CSS variables
// 11 STYLES - icons and CSS (base, layout: header, view modes, navbar, pagination,
// side panels and settings controls)
// 12 UI TEMPLATES - functions that return HTML strings (header, pages, navbar, generic
// settings controls, settings panel, keybindings panel shell,
// bookmarks panel, comments panel, toast stack)
// 13 POPUPS - dialogs (start prompt, page range with two-handle slider, confirm,
// warnings) and toasts (small auto-hiding messages)
// 14 SETTING APPLIERS - ONE table: setting name -> function that updates the screen, plus
// the reader features they drive (zoom, view mode, header, navbar,
// pagination, page controls, current page, device, panels, language,
// bookmarks list)
// 15 IMAGE LOADING - <img src> loading with throttle, retry, progress and lazy loading
// (rules 'distance', 'lookAhead', 'both'; load on demand)
// 16 ACTIONS - ONE table: action name -> function (used by clicks and keys),
// incl. keyboard scrolling and the settings panel actions
// 17 KEYBOARD - own hotkeys engine (key names, matching, one keydown listener)
// and the keybindings panel (list, editor, validation, save)
// 18 EVENTS - delegated click/value/wheel listeners, scroll and resize handlers
// 19 ZIP AND DOWNLOAD - self-contained CRC-32, ZIP writer, DEFLATE decoder, ZIP reader;
// then the chapter download (bytes, file names, save, button state)
// 20 FEATURES - setting-change toasts, bookmarks panel and one-time removal,
// auto-scroll, comments capture/panel, local file upload reader
// 21 STARTUP - find the site, validate its result, take over the page
// 22 SITE HELPERS - text finders, scroll collector, language/category labels
// 23 SITE MODULES - one object per supported website
// 24 ENTRY - console helpers and the call to start()
// ==========================================================================================
// ==========================================================================================
// ==== 01. CONFIG ===========================================================================
// ==========================================================================================
/**
* Every tunable value of the script lives here, so nobody has to hunt for magic numbers.
* VERBOSE LOGGING: set `verboseLogging` to true to log every step (site matching, each wait,
* each image). You can also switch it on without editing the file: run `MVMDebug(true)` in the
* browser console and reload the page (the choice is stored by the userscript manager).
*/
const CONFIG = {
scriptName: 'Manga Viewer MM',
logPrefix: 'MangaViewerMM:',
verboseLogging: false,
verboseStorageKey: 'MVMVerboseLogging',
rootElementId: 'MangaViewerMM',
takeoverMarkerAttribute: 'mvm',
siteWaitTimeoutMs: 30000,
waitPollIntervalMs: 200,
fetchTimeoutMs: 30000,
startDelayMs: 50,
waitPopupMs: 3000,
imageRetryDelayMs: 2000,
imageLoadTimeoutMs: 60000,
lazyDistancePx: 2000,
lookAheadPages: 5,
settingsSyncDebounceMs: 300,
maxStoredErrors: 50,
headerHeightPx: 49,
headerEndDistancePx: 100,
headroomThrottleMs: 300,
currentPageThrottleMs: 100,
resizeDebounceMs: 200,
navbarSizePx: 34,
navbarFadeMs: 50,
zoomSliderMax: 200,
maxGlobalZoomExclusive: 500,
// M3: lazy loading (2025 version: checked on scroll/touchmove/resize every 500 ms, OLD:1213-1258)
lazyCheckThrottleMs: 500,
// M3: toasts (2026 version: 3 s default, 2 s for setting changes, 10 s for bookmarks)
toastDurationMs: 3000,
settingToastMs: 2000,
bookmarkToastMs: 10000,
toastExitMs: 300,
// M3: auto-scroll (one "tick" = one 60 Hz frame; resume 500 ms after the last wheel/touch)
autoScrollTickMs: 1000 / 60,
autoScrollResumeMs: 500,
autoScrollStuckFrames: 6,
// M3: comments capture (2025 version waited at most 5 s, OLD:306-308 and OLD:4861)
commentsSelector: '#disqus_thread, #fb-comments',
commentsWaitTimeoutMs: 5000,
scrollCollectTimeoutMs: 5000,
scrollCollectPollMs: 100,
clickCollectDelayMs: 1000,
siteDataSampleItems: 3,
siteDataMaxUrls: 20,
siteDataMaxDepth: 4,
siteDataMaxStringLength: 300,
// M3: download
downloadRequestTimeoutMs: 60000,
objectUrlRevokeMs: 60000,
maxFileNameLength: 150,
loadSpeedPresets: {
Safe: { concurrency: 5, delayMs: 1000 },
Standard: { concurrency: 5, delayMs: 500 },
Faster: { concurrency: 10, delayMs: 500 },
Extreme: { concurrency: 10, delayMs: 250 },
All: { concurrency: 20, delayMs: 50 }
}
};
// ==========================================================================================
// ==== 02. LOGGING ==========================================================================
// Three levels:
// log(...) - always printed: important events (site found, viewer started, failures).
// logDebug(...) - printed only when verbose logging is on: every step, for diagnosing.
// logError(ctx, error, details) - always printed, and kept in a list (MVMErrors() shows it).
// Every line starts with "MangaViewerMM:" so you can filter the browser console.
// ==========================================================================================
/** Current verbose flag. Starts from CONFIG and may be switched on by stored preference. */
let verboseLoggingEnabled = CONFIG.verboseLogging;
/** The last errors, newest last. Shown by the console helper MVMErrors(). */
const ERROR_HISTORY = [];
/**
* Converts arguments into a real array (ES2017-safe replacement for rest parameters in old code).
* @param {IArguments} argumentList - The `arguments` object.
* @returns {Array}
*/
function argumentsToArray(argumentList) {
return Array.prototype.slice.call(argumentList);
}
/** True when verbose logging is on. */
function isVerbose() {
return verboseLoggingEnabled;
}
/** Switches verbose logging on or off for this page (does not store it). */
function setVerbose(enabled) {
verboseLoggingEnabled = enabled === true;
}
/** Always-visible log line. Accepts any number of arguments, like console.log. */
function log() {
console.log.apply(console, [CONFIG.logPrefix].concat(argumentsToArray(arguments)));
}
/** Verbose-only log line. Accepts any number of arguments. */
function logDebug() {
if (!verboseLoggingEnabled) {
return;
}
console.info.apply(console, [CONFIG.logPrefix + ' [debug]'].concat(argumentsToArray(arguments)));
}
/** Always-visible warning line. Accepts any number of arguments. */
function logWarn() {
console.warn.apply(console, [CONFIG.logPrefix + ' [warning]'].concat(argumentsToArray(arguments)));
}
/**
* Turns anything thrown into a readable one-line message.
* @param {*} error - An Error, a string, or anything else that was thrown.
* @returns {string}
*/
function describeError(error) {
if (error === null || error === undefined) {
return 'Unknown error';
}
if (error instanceof Error) {
return error.name + ': ' + error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch (stringifyError) {
return String(error);
}
}
/**
* Logs an error with its context (WHERE it happened) and optional details (WHAT was being
* processed: ids, URLs, selectors...). Also stores it for MVMErrors().
* @param {string} context - Where the error happened, e.g. 'startup.runSite' or 'action:next-chapter'.
* @param {*} error - What was thrown.
* @param {Object} [details] - Extra data that helps to reproduce the problem.
*/
function logError(context, error, details) {
const entry = {
time: new Date().toISOString(),
context: context,
message: describeError(error),
details: details === undefined ? null : details,
stack: error && error.stack ? String(error.stack) : ''
};
ERROR_HISTORY.push(entry);
if (ERROR_HISTORY.length > CONFIG.maxStoredErrors) {
ERROR_HISTORY.shift();
}
console.error(CONFIG.logPrefix + ' [error] ' + context + ': ' + entry.message, details === undefined ? '' : details, error);
}
/**
* Runs a function and catches anything it throws. Use it around any step that must not stop
* the rest of the script.
* @param {string} context - Name of the step, used in the error log.
* @param {function(): *} action - The code to run.
* @param {*} [fallbackValue] - Returned when the action throws.
* @param {Object} [details] - Extra data for the error log.
* @returns {*} The action's result, or fallbackValue.
*/
function safeRun(context, action, fallbackValue, details) {
try {
return action();
} catch (error) {
logError(context, error, details);
return fallbackValue;
}
}
/**
* Async version of safeRun: awaits the action and catches rejections too.
* @param {string} context - Name of the step.
* @param {function(): Promise<*>} action - Async code to run.
* @param {*} [fallbackValue] - Resolved value when the action fails.
* @param {Object} [details] - Extra data for the error log.
* @returns {Promise<*>}
*/
async function safeRunAsync(context, action, fallbackValue, details) {
try {
return await action();
} catch (error) {
logError(context, error, details);
return fallbackValue;
}
}
/**
* Wraps an event handler so an error inside it is logged instead of being lost.
* @param {string} context - Name used in the error log.
* @param {function(Event): void} handler - The handler.
* @returns {function(Event): void}
*/
function guardHandler(context, handler) {
return function (event) {
try {
handler(event);
} catch (error) {
logError(context, error, { eventType: event && event.type ? event.type : '' });
}
};
}
// ==========================================================================================
// ==== 03. BASIC HELPERS ====================================================================
// Pure functions: they only look at their arguments and never touch the page.
// ==========================================================================================
/**
* True when a value carries no data: null, undefined, false, '' (or only spaces), [] or {}.
* Every number (including 0), true and functions are NOT "nothing".
* Note: the 2026 version treated numbers and functions as "nothing", so `waitVar` on a numeric
* page variable (e.g. a chapter id 12345) never finished. Here it does.
* Arrays are "nothing" when every item is "nothing" (e.g. ['', null]).
* @param {*} value - Any value.
* @returns {boolean}
*/
function isNothing(value) {
if (value === null || value === undefined || value === false) {
return true;
}
if (typeof value === 'string') {
return value.trim() === '';
}
if (Array.isArray(value)) {
return value.every(isNothing);
}
if (isPlainObject(value)) {
return Object.keys(value).length === 0;
}
return false;
}
/**
* True for objects created with {} or new Object() (not arrays, not DOM nodes, not null).
* @param {*} value - Any value.
* @returns {boolean}
*/
function isPlainObject(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
/**
* Deep copy of plain data (objects, arrays, strings, numbers, booleans).
* @param {*} value - Data to copy.
* @returns {*}
*/
function deepClone(value) {
if (Array.isArray(value)) {
return value.map(deepClone);
}
if (isPlainObject(value)) {
const copy = {};
Object.keys(value).forEach(function (key) {
copy[key] = deepClone(value[key]);
});
return copy;
}
return value;
}
/**
* Deep equality for plain data (objects, arrays, primitives).
* @param {*} first - First value.
* @param {*} second - Second value.
* @returns {boolean}
*/
function deepEqual(first, second) {
if (first === second) {
return true;
}
if (Array.isArray(first) && Array.isArray(second)) {
if (first.length !== second.length) {
return false;
}
for (let i = 0; i < first.length; i++) {
if (!deepEqual(first[i], second[i])) {
return false;
}
}
return true;
}
if (isPlainObject(first) && isPlainObject(second)) {
const firstKeys = Object.keys(first);
const secondKeys = Object.keys(second);
if (firstKeys.length !== secondKeys.length) {
return false;
}
return firstKeys.every(function (key) {
return Object.prototype.hasOwnProperty.call(second, key) && deepEqual(first[key], second[key]);
});
}
return false;
}
/**
* Returns a NEW object: `value` with every missing key filled from `defaults`, recursively for
* nested plain objects. Arrays are taken whole from `value` when present. Neither input is
* changed (the 2026 version mutated its shared defaults here; this one never does).
* @param {Object} value - Stored data (may be null/undefined).
* @param {Object} defaults - Default data.
* @returns {Object}
*/
function fillDefaults(value, defaults) {
const result = deepClone(defaults);
if (!isPlainObject(value)) {
return result;
}
Object.keys(value).forEach(function (key) {
const storedValue = value[key];
if (storedValue === undefined) {
return;
}
if (isPlainObject(storedValue) && isPlainObject(result[key])) {
result[key] = fillDefaults(storedValue, result[key]);
} else {
result[key] = deepClone(storedValue);
}
});
return result;
}
/**
* Returns only the parts of `changed` that differ from `original` (deep for plain objects,
* whole replacement for arrays). Used to store only what the user changed.
* @param {Object} changed - Current data.
* @param {Object} original - Reference data (usually the defaults).
* @returns {Object}
*/
function diffObjects(changed, original) {
const result = {};
if (!isPlainObject(changed)) {
return result;
}
const base = isPlainObject(original) ? original : {};
Object.keys(changed).forEach(function (key) {
const changedValue = changed[key];
const originalValue = base[key];
if (deepEqual(changedValue, originalValue)) {
return;
}
if (isPlainObject(changedValue) && isPlainObject(originalValue)) {
const nested = diffObjects(changedValue, originalValue);
if (Object.keys(nested).length > 0) {
result[key] = nested;
}
} else {
result[key] = deepClone(changedValue);
}
});
return result;
}
/**
* Returns a function that runs `action` only after it stopped being called for `waitMs`.
* @param {function} action - Function to delay.
* @param {number} waitMs - Quiet time in milliseconds.
* @returns {function}
*/
function debounce(action, waitMs) {
let timerId = null;
return function () {
const callArguments = arguments;
const self = this;
if (timerId !== null) {
clearTimeout(timerId);
}
timerId = setTimeout(function () {
timerId = null;
action.apply(self, callArguments);
}, waitMs);
};
}
/**
* Returns a function that runs `action` at most once every `waitMs` (first call runs at once,
* the last call inside the window runs at its end).
* @param {function} action - Function to limit.
* @param {number} waitMs - Minimum time between runs.
* @returns {function}
*/
function throttle(action, waitMs) {
let lastRun = 0;
let pendingTimer = null;
let pendingArguments = null;
return function () {
const now = Date.now();
const remaining = waitMs - (now - lastRun);
pendingArguments = arguments;
if (remaining <= 0) {
lastRun = now;
action.apply(this, pendingArguments);
return;
}
if (pendingTimer === null) {
const self = this;
pendingTimer = setTimeout(function () {
pendingTimer = null;
lastRun = Date.now();
action.apply(self, pendingArguments);
}, remaining);
}
};
}
/**
* Page numbers from `begin` to `count`, inclusive. sequence(5, 2) -> [2, 3, 4, 5].
* @param {number} count - Last page number.
* @param {number} [begin=1] - First page number.
* @returns {number[]}
*/
function sequence(count, begin) {
const first = begin && begin > 0 ? begin : 1;
const result = [];
for (let page = first; page <= count; page++) {
result.push(page);
}
return result;
}
/**
* Limits a number to a range.
* @param {number} value - The number.
* @param {number} minimum - Lowest allowed value.
* @param {number} maximum - Highest allowed value.
* @returns {number}
*/
function clamp(value, minimum, maximum) {
return Math.min(Math.max(value, minimum), maximum);
}
/**
* Makes text safe to put inside HTML (site titles can contain < > & " ').
* @param {*} text - Any value; null/undefined become ''.
* @returns {string}
*/
function escapeHtml(text) {
if (text === null || text === undefined) {
return '';
}
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
/**
* Trims a URL and turns protocol-relative '//host/x' into 'https://host/x'.
* @param {string} url - URL from a site module.
* @returns {string} '' for empty input.
*/
function normalizeUrl(url) {
if (typeof url !== 'string') {
return '';
}
const trimmed = url.trim();
if (trimmed.indexOf('//') === 0) {
return 'https:' + trimmed;
}
return trimmed;
}
/**
* True when the text is a URL the browser can parse (absolute, or relative to this page).
* Replaces URL.canParse, which older browsers lack.
* @param {string} url - Candidate URL.
* @returns {boolean}
*/
function isValidUrl(url) {
if (typeof url !== 'string' || url.trim() === '') {
return false;
}
try {
new URL(url, window.location.href);
return true;
} catch (error) {
return false;
}
}
/**
* True for URLs that point to data already in memory (no network request needed).
* @param {string} url - URL.
* @returns {boolean}
*/
function isInMemoryUrl(url) {
return typeof url === 'string' && (url.indexOf('data:') === 0 || url.indexOf('blob:') === 0);
}
/**
* Adds or replaces a `forceReload=N` query parameter, so the browser requests the image again
* instead of reusing a broken cached copy.
* @param {string} url - Image URL.
* @param {number} attempt - Retry number (1, 2, ...).
* @returns {string}
*/
function addCacheBuster(url, attempt) {
if (isInMemoryUrl(url)) {
return url;
}
const withoutOld = url.replace(/([?&])forceReload=\d+(&|$)/, function (match, before, after) {
return after === '&' ? before : '';
});
const separator = withoutOld.indexOf('?') === -1 ? '?' : '&';
return withoutOld + separator + 'forceReload=' + attempt;
}
// ==========================================================================================
// ==== 04. WAITING ==========================================================================
// Every wait has a timeout, so nothing can hang silently. A timeout rejects with an Error
// whose message says WHAT was awaited and for HOW LONG.
// ==========================================================================================
/**
* Waits a number of milliseconds.
* @param {number} milliseconds - Time to wait.
* @returns {Promise<void>}
*/
function sleep(milliseconds) {
return new Promise(function (resolve) {
setTimeout(resolve, milliseconds);
});
}
/**
* Rejects if `promise` does not settle within `timeoutMs`.
* @param {Promise} promise - The work.
* @param {number} timeoutMs - Limit in milliseconds.
* @param {string} description - What is awaited (used in the error message).
* @returns {Promise}
*/
function withTimeout(promise, timeoutMs, description) {
return new Promise(function (resolve, reject) {
const timerId = setTimeout(function () {
reject(new Error('Timeout after ' + timeoutMs + ' ms: ' + description));
}, timeoutMs);
promise.then(function (value) {
clearTimeout(timerId);
resolve(value);
}, function (error) {
clearTimeout(timerId);
reject(error);
});
});
}
/**
* Polls `check` until it returns something that is not "nothing", then resolves with it.
* Errors thrown by `check` are logged (verbose) and treated as "not yet".
* @param {function(): *} check - Returns the awaited value, or nothing/false while not ready.
* @param {string} description - What is awaited, e.g. 'element "#reader img"'.
* @param {number} [timeoutMs=CONFIG.siteWaitTimeoutMs] - Limit.
* @returns {Promise<*>} Resolves with the value; rejects on timeout.
*/
function waitUntil(check, description, timeoutMs) {
const limit = timeoutMs || CONFIG.siteWaitTimeoutMs;
const startedAt = Date.now();
return new Promise(function (resolve, reject) {
let lastCheckError = null;
/** One polling round. */
function poll() {
let value = null;
try {
value = check();
lastCheckError = null;
} catch (error) {
lastCheckError = error;
logDebug('waitUntil: check threw (will retry)', description, describeError(error));
}
if (!isNothing(value)) {
logDebug('waitUntil: ready after ' + (Date.now() - startedAt) + ' ms', description);
resolve(value);
return;
}
if (Date.now() - startedAt >= limit) {
const reason = lastCheckError ? ' (last check error: ' + describeError(lastCheckError) + ')' : '';
reject(new Error('Timeout after ' + limit + ' ms waiting for ' + description + reason));
return;
}
setTimeout(poll, CONFIG.waitPollIntervalMs);
}
poll();
});
}
// ==========================================================================================
// ==== 05. PLATFORM =========================================================================
// Everything that depends on the userscript manager or the browser. All GM_* calls are
// guarded with `typeof`, so the script can also run in a plain page (tests, console).
// ==========================================================================================
/** The page's own window (site variables live here). See docs/01 section 4. */
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
/** Script and manager information for logs. */
const SCRIPT_INFO = (function readScriptInfo() {
if (typeof GM_info !== 'undefined' && GM_info && GM_info.script) {
return {
name: GM_info.script.name || CONFIG.scriptName,
version: GM_info.script.version || 'unknown',
manager: GM_info.scriptHandler || 'unknown manager'
};
}
return { name: CONFIG.scriptName, version: 'development', manager: 'none (plain page)' };
})();
/**
* Turns a value returned by the userscript manager into plain data of THIS script.
* Why: Violentmonkey on Firefox (content mode) returns objects created in another JavaScript
* context; they are real objects but fail `Object.getPrototypeOf(x) === Object.prototype`, so
* isPlainObject() rejected stored settings (found 2026-10-08: "stored value is not an object").
* A JSON round trip rebuilds them as ordinary objects. Strings, numbers and booleans pass as is.
* @param {*} value - Value from GM_getValue or a value-change listener.
* @param {string} key - Storage key (for the error log).
* @returns {*}
*/
function normalizeStoredValue(value, key) {
if (value === null || typeof value !== 'object') {
return value;
}
try {
return JSON.parse(JSON.stringify(value));
} catch (error) {
logError('storage.normalize', error, { key: key });
return value;
}
}
/**
* Reads a value stored by the userscript manager. Returns `defaultValue` when missing or when
* no manager is present. Stored JSON strings (very old versions) are parsed.
* @param {string} key - Storage key.
* @param {*} defaultValue - Fallback.
* @returns {*}
*/
function readStoredValue(key, defaultValue) {
if (typeof GM_getValue === 'undefined') {
logDebug('storage: no GM_getValue, using default for', key);
return defaultValue;
}
try {
const value = GM_getValue(key, defaultValue);
if (typeof value === 'string' && value.trim() !== '' && (value.trim()[0] === '{' || value.trim()[0] === '[')) {
try {
return JSON.parse(value);
} catch (parseError) {
logError('storage.read: stored text is not valid JSON', parseError, { key: key });
return defaultValue;
}
}
return normalizeStoredValue(value, key);
} catch (error) {
logError('storage.read', error, { key: key });
return defaultValue;
}
}
/**
* Stores a value with the userscript manager. Does nothing (but logs) without a manager.
* @param {string} key - Storage key.
* @param {*} value - Plain data.
* @returns {boolean} True when stored.
*/
function writeStoredValue(key, value) {
if (typeof GM_setValue === 'undefined') {
logDebug('storage: no GM_setValue, not saved', key, value);
return false;
}
try {
GM_setValue(key, value);
logDebug('storage: saved', key, value);
return true;
} catch (error) {
logError('storage.write', error, { key: key });
return false;
}
}
/**
* Deletes a stored value.
* @param {string} key - Storage key.
*/
function deleteStoredValue(key) {
if (typeof GM_deleteValue === 'undefined') {
return;
}
safeRun('storage.delete', function () {
GM_deleteValue(key);
}, undefined, { key: key });
}
/**
* Calls `onChange(newValue)` when ANOTHER tab changes the stored value of `key`.
* @param {string} key - Storage key.
* @param {function(*): void} onChange - Callback.
*/
function listenToStoredValue(key, onChange) {
if (typeof GM_addValueChangeListener === 'undefined') {
logDebug('storage: no GM_addValueChangeListener, cross-tab sync disabled for', key);
return;
}
safeRun('storage.listen', function () {
GM_addValueChangeListener(key, function (name, oldValue, newValue, remote) {
if (remote) {
safeRun('storage.onRemoteChange', function () {
onChange(normalizeStoredValue(newValue, key));
}, undefined, { key: key });
}
});
}, undefined, { key: key });
}
/**
* Browser name and version from the user agent, e.g. 'Firefox 115'. Best effort, for logs.
* @returns {string}
*/
function getBrowserName() {
const userAgent = window.navigator.userAgent;
const patterns = [
['Edge', /Edg(?:e|A|iOS)?\/([\d.]+)/],
['Opera', /OPR\/([\d.]+)/],
['Firefox', /(?:Firefox|FxiOS)\/([\d.]+)/],
['Chrome', /(?:Chrome|CriOS)\/([\d.]+)/],
['Safari', /Version\/([\d.]+).*Safari/]
];
for (let i = 0; i < patterns.length; i++) {
const match = patterns[i][1].exec(userAgent);
if (match) {
return patterns[i][0] + ' ' + match[1].split('.')[0];
}
}
return 'Unknown browser';
}
/**
* Device type from the user agent and the screen width (same rules as the 2026 version:
* phones or width <= 600px -> 'mobile'; tablets or width <= 992px -> 'tablet').
* @returns {string} 'mobile', 'tablet' or 'desktop'.
*/
function detectDevice() {
const userAgent = window.navigator.userAgent;
const isPhoneAgent = /iPhone|iPod|Android.*Mobile|Windows Phone|Mobile Safari/i.test(userAgent) && !/iPad|Tablet/i.test(userAgent);
const isTabletAgent = /iPad|Tablet|Android(?!.*Mobile)/i.test(userAgent);
/**
* True when the screen is at most `maxWidth` pixels wide.
* @param {number} maxWidth - Width in CSS pixels.
* @returns {boolean}
*/
const matchesWidth = function (maxWidth) {
return typeof window.matchMedia === 'function' && window.matchMedia('screen and (max-width: ' + maxWidth + 'px)').matches;
};
if (isPhoneAgent || matchesWidth(600)) {
return 'mobile';
}
if (isTabletAgent || matchesWidth(992)) {
return 'tablet';
}
return 'desktop';
}
/** True on phones and tablets. */
function isMobileDevice() {
const device = detectDevice();
return device === 'mobile' || device === 'tablet';
}
/** Values of the `deviceMode` setting that force a device type ('auto' = detect). */
const FORCED_DEVICE_MODES = ['desktop', 'tablet', 'mobile'];
/**
* The device type the reader uses: the forced one from the `deviceMode` setting, or the
* detected one when the setting is 'auto' (or unknown). Same rule as the 2026 version
* (NEW:3651-3654). Pure.
* @param {string} deviceMode - Setting value: 'auto', 'desktop', 'tablet' or 'mobile'.
* @param {string} detectedDevice - Result of detectDevice().
* @returns {string} 'mobile', 'tablet' or 'desktop'.
*/
function resolveDeviceMode(deviceMode, detectedDevice) {
if (FORCED_DEVICE_MODES.indexOf(deviceMode) !== -1) {
return deviceMode;
}
return detectedDevice;
}
/** True when running from a local HTML file (standalone local reader). */
function isStandalone() {
return window.location.protocol === 'file:' || /Manga_Viewer_MM_Local\.html$/.test(window.location.pathname);
}
/**
* Exposes a function or value on the page window (and the script window), for console use.
* @param {string} name - Global name, e.g. 'MVMSettings'.
* @param {*} value - Function or value.
*/
function exposeToConsole(name, value) {
safeRun('exposeToConsole', function () {
pageWindow[name] = value;
if (pageWindow !== window) {
window[name] = value;
}
}, undefined, { name: name });
}
// ---- Trusted Types (sites whose security policy blocks plain HTML strings) ----
// Some sites (for example MangaDex) send the CSP rule "require-trusted-types-for 'script'".
// The browser then refuses `element.innerHTML = "<p>..."` with a plain string and only accepts
// a TrustedHTML object created by a named policy. Every HTML assignment in this script goes
// through setElementHtml / setElementOuterHtml / toTrustedHtml below, so it works on those sites
// and behaves exactly as before on all others.
/** Name of this script's Trusted Types policy (must be unique per page). */
const TRUSTED_TYPES_POLICY_NAME = 'manga-viewer-mm';
/**
* Creates the Trusted Types policy once, when the browser supports Trusted Types.
* Returns null when not supported, or when the site's CSP does not allow this policy name
* (then plain strings are used; on such a site HTML assignments may still be refused, which
* is logged).
* @returns {{createHTML: function(string): *}|null}
*/
function createTrustedHtmlPolicy() {
const factory = window.trustedTypes || (pageWindow && pageWindow.trustedTypes);
if (!factory || typeof factory.createPolicy !== 'function') {
return null;
}
try {
const policy = factory.createPolicy(TRUSTED_TYPES_POLICY_NAME, {
createHTML: function (html) {
return html;
}
});
logDebug('Trusted Types policy created:', TRUSTED_TYPES_POLICY_NAME);
return policy;
} catch (error) {
logError('trustedTypes.createPolicy', error, { policyName: TRUSTED_TYPES_POLICY_NAME });
return null;
}
}
/** The policy, or null (see createTrustedHtmlPolicy). */
const TRUSTED_HTML_POLICY = createTrustedHtmlPolicy();
/**
* Wraps an HTML string so it can be assigned on Trusted Types sites. Returns the string itself
* when Trusted Types are not in use.
* @param {string} html - HTML built by this script (site text already escaped).
* @returns {*} TrustedHTML or string.
*/
function toTrustedHtml(html) {
const text = html === null || html === undefined ? '' : String(html);
if (TRUSTED_HTML_POLICY) {
return TRUSTED_HTML_POLICY.createHTML(text);
}
return text;
}
/**
* Replaces an element's content with HTML (Trusted Types safe).
* @param {Element} element - Target element.
* @param {string} html - HTML string.
*/
function setElementHtml(element, html) {
element.innerHTML = toTrustedHtml(html);
}
/**
* Replaces an element itself with HTML (Trusted Types safe).
* @param {Element} element - Target element.
* @param {string} html - HTML string.
*/
function setElementOuterHtml(element, html) {
element.outerHTML = toTrustedHtml(html);
}
// Apply the stored verbose preference (set with MVMDebug(true)) as early as possible.
if (readStoredValue(CONFIG.verboseStorageKey, false) === true) {
setVerbose(true);
}
// ==========================================================================================
// ==== 06. PAGE READING AND NETWORK =========================================================
// Small, safe functions to read the DOM and fetch pages. Used by the core AND by site modules.
// Rules:
// - "read"/"query" helpers never throw for "not found": they return '' / null / [];
// - fetchJson / fetchDocument / fetchTextOrThrow DO throw, with a message naming the URL and
// the HTTP status, because a site cannot continue without that data;
// - fetchHtml / getElementAttribute never throw (they return null / '' and log why).
// ==========================================================================================
/**
* Returns the first element matching a CSS selector, or null.
* @param {string} selector - CSS selector.
* @param {ParentNode} [root=document] - Where to search.
* @returns {Element|null}
*/
function query(selector, root) {
const searchRoot = root || document;
try {
return searchRoot.querySelector(selector);
} catch (error) {
logError('query', error, { selector: selector });
return null;
}
}
/**
* Returns all elements matching a CSS selector as a real Array (never null).
* @param {string} selector - CSS selector.
* @param {ParentNode} [root=document] - Where to search.
* @returns {Element[]}
*/
function queryAll(selector, root) {
const searchRoot = root || document;
try {
return Array.prototype.slice.call(searchRoot.querySelectorAll(selector));
} catch (error) {
logError('queryAll', error, { selector: selector });
return [];
}
}
/**
* Reads an attribute of an element. Returns '' when the element or the attribute is missing.
* @param {Element|null} element - The element (may be null).
* @param {string} attribute - Attribute name, e.g. 'href'.
* @returns {string}
*/
function readAttribute(element, attribute) {
if (!element) {
return '';
}
const value = element.getAttribute(attribute);
if (value === null || value === undefined) {
return '';
}
return value;
}
/**
* Reads the first non-empty attribute from a list, e.g. ['data-src', 'data-lazy-src', 'src'].
* Useful for lazy-loaded images. Returns '' when none is set.
* @param {Element|null} element - The element (may be null).
* @param {string[]} attributes - Attribute names, in priority order.
* @returns {string}
*/
function readFirstAttribute(element, attributes) {
for (let i = 0; i < attributes.length; i++) {
const value = readAttribute(element, attributes[i]).trim();
if (value !== '') {
return value;
}
}
return '';
}
/**
* Reads the trimmed text of an element. Returns '' when the element is missing.
* @param {Element|null} element - The element (may be null).
* @returns {string}
*/
function readText(element) {
if (!element || element.textContent === null) {
return '';
}
return element.textContent.trim();
}
/**
* Shortcut: attribute of the first element matching a selector, or ''.
* @param {string} selector - CSS selector.
* @param {string} attribute - Attribute name.
* @param {ParentNode} [root=document] - Where to search.
* @returns {string}
*/
function queryAttribute(selector, attribute, root) {
return readAttribute(query(selector, root), attribute);
}
/**
* Shortcut: trimmed text of the first element matching a selector, or ''.
* @param {string} selector - CSS selector.
* @param {ParentNode} [root=document] - Where to search.
* @returns {string}
*/
function queryText(selector, root) {
return readText(query(selector, root));
}
/**
* The page's own <title> text, trimmed, or ''.
* @returns {string}
*/
function getPageTitle() {
return queryText('title');
}
/**
* Returns the last item of an array, or undefined for an empty/missing array.
* Replaces `array.at(-1)` (not available in ES2017).
* @param {Array} list - The array.
* @returns {*}
*/
function lastItem(list) {
if (!list || list.length === 0) {
return undefined;
}
return list[list.length - 1];
}
/**
* Returns the keys of an object, or [] when the value is null/undefined/not an object.
* Replaces lodash `_.keys`, which never throws (Object.keys(undefined) throws).
* @param {*} value - Any value.
* @returns {string[]}
*/
function safeKeys(value) {
if (value === null || value === undefined || typeof value !== 'object') {
return [];
}
return Object.keys(value);
}
/**
* Removes duplicates from an array, keeping the first occurrence and the original order.
* @param {Array} list - The array.
* @returns {Array}
*/
function uniqueValues(list) {
const result = [];
const seen = new Set();
for (let i = 0; i < list.length; i++) {
if (!seen.has(list[i])) {
seen.add(list[i]);
result.push(list[i]);
}
}
return result;
}
/**
* Turns a possibly relative URL into an absolute one. Returns '' for empty or invalid input.
* Examples: ('/a/1.jpg') -> 'https://site.com/a/1.jpg'; ('//cdn.x/1.jpg') -> 'https://cdn.x/1.jpg'.
* @param {string} url - The URL found in the page.
* @param {string} [baseUrl=window.location.href] - Base used for relative URLs.
* @returns {string}
*/
function toAbsoluteUrl(url, baseUrl) {
if (typeof url !== 'string' || url.trim() === '') {
return '';
}
const trimmed = url.trim();
if (trimmed.indexOf('data:') === 0 || trimmed.indexOf('blob:') === 0) {
return trimmed;
}
try {
return new URL(trimmed, baseUrl || window.location.href).href;
} catch (error) {
logDebug('toAbsoluteUrl: invalid URL ignored', trimmed);
return '';
}
}
/**
* Parses an HTML (or XML) string into a Document without running its scripts.
* @param {string} text - Markup.
* @param {string} [format='text/html'] - MIME type for DOMParser.
* @returns {Document}
*/
function parseHtml(text, format) {
return new DOMParser().parseFromString(toTrustedHtml(text), format || 'text/html');
}
/**
* fetch() with a timeout. Rejects with a clear message on network error, timeout or HTTP error.
* @param {string} url - URL to fetch.
* @param {RequestInit} [options] - fetch options (headers, credentials...).
* @param {number} [timeoutMs=CONFIG.fetchTimeoutMs] - Timeout.
* @returns {Promise<Response>}
*/
function fetchOrThrow(url, options, timeoutMs) {
const limit = timeoutMs || CONFIG.fetchTimeoutMs;
logDebug('fetch start', url);
const request = fetch(url, options).then(function (response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' ' + response.statusText + ' for ' + url);
}
logDebug('fetch ok', url, response.status);
return response;
});
return withTimeout(request, limit, 'fetch ' + url);
}
/**
* Fetches a URL and parses it as JSON. THROWS on any failure (site cannot continue without it).
* @param {string} url - URL of the JSON API.
* @param {RequestInit} [options] - fetch options.
* @returns {Promise<*>}
*/
async function fetchJson(url, options) {
const response = await fetchOrThrow(url, options);
try {
return await response.json();
} catch (error) {
throw new Error('Invalid JSON from ' + url + ': ' + error.message);
}
}
/**
* Fetches a URL through the userscript manager (GM_xmlhttpRequest) and parses it as JSON.
* Unlike fetchJson (page fetch, CORS-limited), the manager request is not bound by the
* site's CORS headers, so it works for cross-origin APIs (e.g. a CDN GraphQL endpoint).
* Falls back to fetchJson when no manager request function is available. THROWS on failure.
* @param {string} url - URL of the JSON API.
* @param {object} [options] - Request options ({ method, headers, data }).
* @returns {Promise<*>}
*/
function fetchJsonViaManager(url, options) {
const settings = options || {};
if (typeof GM_xmlhttpRequest === 'undefined') {
// Without a manager: same request through the page's fetch (fetch calls the payload 'body').
return fetchJson(url, { method: settings.method || 'GET', headers: settings.headers || {}, body: settings.data || undefined });
}
logDebug('manager fetch start', url);
const request = new Promise(function (resolve, reject) {
try {
GM_xmlhttpRequest({
method: settings.method || 'GET',
url: url,
headers: settings.headers || {},
data: settings.data || '',
onload: function (response) {
if (response.status < 200 || response.status >= 300) {
reject(new Error('HTTP ' + response.status + ' for ' + url));
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (error) {
reject(new Error('Invalid JSON from ' + url + ': ' + error.message));
}
},
onerror: function () {
reject(new Error('Manager request failed for ' + url));
},
ontimeout: function () {
reject(new Error('Manager request timed out for ' + url));
}
});
} catch (error) {
reject(error);
}
});
return withTimeout(request, CONFIG.fetchTimeoutMs, 'manager fetch ' + url);
}
/**
* Fetches a URL and returns the body text. THROWS on any failure.
* @param {string} url - URL.
* @param {RequestInit} [options] - fetch options.
* @returns {Promise<string>}
*/
async function fetchTextOrThrow(url, options) {
const response = await fetchOrThrow(url, options);
return response.text();
}
/**
* Fetches a page and parses it. THROWS on any failure. Use in site modules.
* @param {string} url - Page URL.
* @param {RequestInit} [options] - fetch options.
* @returns {Promise<Document>}
*/
async function fetchDocument(url, options) {
const text = await fetchTextOrThrow(url, options);
return parseHtml(text, 'text/html');
}
/**
* Fetches a page and parses it. NEVER throws: returns null on failure (and logs why).
* Used by the core for the "listPages" strategy, where one bad page must not stop the others.
* @param {string} url - Page URL.
* @returns {Promise<Document|null>}
*/
async function fetchHtml(url) {
try {
return await fetchDocument(url);
} catch (error) {
logError('fetchHtml', error, { url: url });
return null;
}
}
/**
* Fetches a page and returns one attribute of one element in it, as an absolute URL when it
* looks like a URL. Returns '' on any failure (never throws).
* @param {string} url - Page URL.
* @param {string} selector - CSS selector of the element inside that page.
* @param {string} attribute - Attribute to read (e.g. 'src', 'data-src').
* @returns {Promise<string>}
*/
async function getElementAttribute(url, selector, attribute) {
const pageDocument = await fetchHtml(url);
if (!pageDocument) {
return '';
}
const value = queryAttribute(selector, attribute, pageDocument).trim();
if (value === '') {
logError('getElementAttribute', new Error('Element or attribute not found'), { url: url, selector: selector, attribute: attribute });
return '';
}
return toAbsoluteUrl(value, url);
}
// ==========================================================================================
// ==== 07. LOCALES ===========================================================================
// UI texts in 6 languages (en_US first = fallback). To add a text: add the key to EVERY
// language object; a key missing in one language falls back to English.
// ==========================================================================================
/** All UI strings, one object per language. Generated from NEW:1078-1919; en_US is the fallback. */
const LOCALES = [
{
ID: "en_US",
NAME: "English (US)",
STARTING: "Starting Manga Viewer MM",
RESUME: "Resuming reading from Page ",
WAITING: "Please wait, 3 seconds...",
CHOOSE_BEGINNING: "Choose the Page to start from:",
BUTTON_START: "Start Manga Viewer MM",
SETTINGS: "Settings",
LANGUAGE: "Language",
COLOR_SCHEME: "Color Scheme",
THEME: "Theme",
THEME_COLOR: "Color",
THEME_HUE: "Color Hue",
THEME_SHADE: "Color Shade",
DEFAULT_LOAD_MODE: "Default Load Mode",
DEVICE_MODE: "Device Mode",
DEVICE_MODE_AUTO: "Auto",
DEVICE_MODE_DESKTOP: "Desktop",
DEVICE_MODE_TABLET: "Tablet",
DEVICE_MODE_MOBILE: "Mobile",
LOAD_MODE_NORMAL: "Normal(Wait 3 sec)",
LOAD_MODE_ALWAYS: "Always(Immediately)",
LOAD_MODE_NEVER: "Never(Manually)",
LOAD_SPEED: "Load Speed",
DEFAULT_ZOOM: "Default Zoom (between 5 and 200)",
DEFAULT_ZOOM_MODE: "Default Zoom Mode",
MINIMUM_ZOOM: "Minimum Zoom relative to the width of screen (between 30 and 100)",
ZOOM_STEP: "Zoom Change Step (between 5 and 50)",
DEFAULT_VIEW_MODE: "Default View Mode",
VIEW_MODE_VERTICAL: "Vertical",
VIEW_MODE_LEFT: "Horizontal - Left to Right",
VIEW_MODE_RIGHT: "Horizontal - Right to Left",
VIEW_MODE_WEBCOMIC: "WebComic",
VIEW_MODE_BOOK: "Book - Left to Right",
VIEW_MODE_MANGA: "Manga - Right to Left",
VIEW_MODE_GALLERY: "Gallery",
FIT_WIDTH_OVERSIZED: "Fit Width if Oversized",
SHOW_THUMBNAILS: "Show Thumbnails",
HIDE_CONTROLS: "Always Hide Page Controls",
HEADER_TYPE: "Change Header Type",
HEADER_HOVER: "Hover",
HEADER_SCROLL: "Scroll",
HEADER_CLICK: "Click",
HEADER_FIXED: "Fixed",
HEADER_SIMPLE: "Simple",
BUTTON_DOWNLOAD: "Download",
DOWNLOAD_ZIP: "Download Zip file",
DOWNLOAD_IMAGES: "Download Images as Zip Automatically",
DOWNLOAD_PROGRESS: "Downloading: ##num## of ##total##",
GENERATING_ZIP: "Generating Zip file...",
DOWNLOAD_INCOMPLETE: "Download Incomplete",
DOWNLOAD_INCOMPLETE_MESSAGE: "Some pages failed to download and were skipped. A list of failed pages has been added to the ZIP file.",
BUTTON_NEXT: "Next",
NEXT_CHAPTER: "Next Chapter",
BUTTON_PREVIOUS: "Previous",
PREVIOUS_CHAPTER: "Previous Chapter",
BOOKMARKS: "Bookmarks",
BOOKMARK: "Bookmark",
BOOKMARK_REMOVED: "Bookmark Removed",
BOOKMARK_SAVED: "Bookmark Saved",
BOOKMARK_MESSAGE: "Next time you open this chapter it will resume from: Page ##num## (Only ONCE per Bookmark)",
KEYBINDINGS: "Keybindings",
EDIT_KEYBINDS: "Edit KeyBindings",
SAVE_KEYBINDS: "Save KeyBindings",
BUTTON_EDIT: "Edit",
BUTTON_SAVE: "Save",
KEYBIND_RULES: "\n <h3>Supported Keys</h3>\n Allowed modifiers: shift, option, alt, ctrl, control, command. <br/>\n Special keys: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n Examples: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n ",
ATTENTION: "Attention",
WARNING: "Warning",
BUTTON_RESET_SETTINGS: "Reset Settings",
SETTINGS_RESET: "Settings have been reset, reload the page to take effect",
LANGUAGE_CHANGED: "Language has been changed, reload the page to take effect",
AUTO_DOWNLOAD: "Next time a chapter finish loading you will be prompted to save automatically",
LAZY_LOAD: "Pages that have not loaded yet are left out of the ZIP download and listed in failed_pages.txt.<br/> Suggestion: <span style='color:red;font-weight:bold'>Disable Thumbnails</span> to save Bandwidth/Memory.",
LAZY_LOAD_IMAGES_ENABLE: "Enable Lazy Load Images",
LAZY_LOAD_IMAGES: "Lazy Start From Page (between 5 and 100)",
RETURN_CHAPTER_LIST: "Return to Chapter List",
PAGES_LOADED: "Pages Loaded",
GO_TO_PAGE: "Go to Page",
ENLARGE: "Enlarge",
RESTORE: "Restore",
REDUCE: "Reduce",
FIT_WIDTH: "Fit Width",
FIT_HEIGHT: "Fit Height",
PERCENT: "Percent",
TOGGLE_CONTROLS: "Toggle page controls",
ZOOM_IN: "Zoom In",
ZOOM_OUT: "Zoom Out",
ZOOM_RESET: "Zoom Reset",
ZOOM_WIDTH: "Zoom to Width",
ZOOM_HEIGHT: "Zoom to Height",
HIDE: "Hide",
RELOAD: "Reload",
SLOWLY: "Slowly",
NORMAL: "Normal",
FAST: "Fast",
EXTREME: "Extreme",
ALL_PAGES: "All Pages",
SPEED_WARNING: "Loading Speed too High",
SPEED_WARNING_MESSAGE: "This speed is not recommended.<br/> It may hurt some servers or get your IP marked as DDoS attacker.<br/> Please use with caution!",
SCROLL_UP: "Scroll Up",
SCROLL_DOWN: "Scroll Down",
CLOSE: "Close",
CANCEL: "Cancel",
LIST_EMPTY: "List Empty",
SCROLL_START: "Toggle Auto Scroll",
INCREASE_SPEED: "Increase Scroll Speed",
DECREASE_SPEED: "Decrease Scroll Speed",
AUTO_SCROLL_HEIGHT: "Auto Scroll Speed in Pixels",
VERTICAL_SEPARATOR: "Show Vertical Separators",
END: "End",
SCOPE: "Scope",
GLOBAL: "Global",
GENERAL: "General",
LOADING: "Loading",
ZOOM: "Zoom",
OTHERS: "Others",
NAVBAR_TYPE: "Change Navbar Type",
NAVBAR_BOTTOM: "Bottom",
NAVBAR_LEFT: "Left",
NAVBAR_RIGHT: "Right",
NAVBAR_DISABLED: "Disabled",
PAGINATION_TYPE: "Pagination Type",
PAGINATION_DISABLED: "Disabled",
PAGINATION_SLIDER: "Slider",
PAGINATION_ARROWS: "Side Arrows",
PAGINATION_BOTH: "Both",
FILE_MENU: "Main Menu",
VIEW_MENU: "View Menu",
ZOOM_MENU: "Zoom Menu",
DOUBLE_PAGE: "Toggle Double Page",
CHOOSE_FILE: "Choose File",
NO_FILES_SELECTED: "No files selected",
// Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
COLOR_SCHEME_DARK: "Dark",
COLOR_SCHEME_LIGHT: "Light",
LAZY_LOAD_RULE: "Lazy Load Rule",
LAZY_LOAD_RULE_DISTANCE: "Near the screen (2000 px)",
LAZY_LOAD_RULE_LOOKAHEAD: "Next 5 pages",
LAZY_LOAD_RULE_BOTH: "Both",
KEYBIND_INVALID: "Unknown key: ##key##",
KEYBIND_DUPLICATE: "##key## is already used by: ##action##",
// Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
ENABLE_COMMENTS: "Capture Comments (When available)",
DISPLAY_COMMENTS: "Display Comments",
COMMENTS: "Comments Section",
COMMENTS_COLOR_SCHEME: "Comments: light / dark",
BOOKMARK_OPEN: "Open Bookmark",
BOOKMARK_DELETE: "Delete Bookmark",
BOOKMARK_PAGE: "Page",
BOOKMARK_TOGGLE_CURRENT: "Bookmark the current page",
BOOKMARKS_CLEAR: "Remove all bookmarks",
BOOKMARKS_CLEAR_CONFIRM: "Remove all ##count## bookmarks? This cannot be undone.",
SETTING_CHANGED: "##setting## changed",
SETTING_CHANGED_DETAIL: "from ##old## to ##new##",
SCOPE_CHANGED: "Settings scope: ##scope##",
DOWNLOAD_PARTIAL_STARTED: "Downloading ##loaded## of ##total## pages; ##pending## are still loading",
DOWNLOAD_PARTIAL_NOTE: "Pages not loaded yet are left out and listed in failed_pages.txt inside the ZIP.",
DOWNLOAD_NOTHING_LOADED: "No page has finished loading yet. Try again in a moment.",
DOWNLOAD_BUSY: "A download is already running",
DOWNLOAD_CANCELLED: "Download cancelled",
DOWNLOAD_SAVED: "Saved ##file##",
DOWNLOAD_FAILED: "Download failed: ##error##",
DOWNLOAD_SKIPPED_PAGES: "Skipped pages: ##pages##",
LOCAL_FILES_OPEN: "Open local files",
LOCAL_FILES_IMAGES: "Images",
LOCAL_FILES_FOLDER: "Folder",
LOCAL_FILES_ARCHIVE: "ZIP / CBZ archive",
LOCAL_FILES_HINT: "Choose images, a folder of images, or a ZIP/CBZ archive. Nothing is uploaded: your browser reads the files.",
LOCAL_FILES_READING: "Reading ##file##...",
LOCAL_FILES_UNSUPPORTED: "Unsupported file: ##file## (supported: images, folders, ZIP/CBZ; not RAR/CBR or 7z)",
LOCAL_FILES_EMPTY: "No images found in ##file##",
LOCAL_FILES_ERROR: "Could not read ##file##: ##error##",
LOCAL_FILES_TITLE: "Local Images",
LOCAL_FILES_ALREADY_OPEN: "The reader is already open. Reload the page to open other files.",
PAGE_RANGE_NEED_NUMBERS: "Please type two page numbers.",
PAGE_RANGE_INVALID: "Pages must be between 1 and ##total##, first <= last.",
// Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
SCROLL_PREPARING: "Preparing pages...",
SCROLL_PROGRESS: "Scrolling and waiting for pages to load: ##num## / ##total##",
CLICK_PROGRESS: "Opening pages one by one: ##num## / ##total##"
},
{
ID: "es_ES",
NAME: "Español (ES)",
STARTING: "Iniciando Manga Viewer MM",
RESUME: "Continuando lectura desde la Página ",
WAITING: "Por favor espere, 3 segundos...",
CHOOSE_BEGINNING: "Elija la página en la que comenzar:",
BUTTON_START: "Iniciar Manga Viewer MM",
SETTINGS: "Ajustes",
LANGUAGE: "Idioma",
COLOR_SCHEME: "Esquema de color",
THEME: "Tema",
THEME_COLOR: "Color",
THEME_HUE: "Matiz del color",
THEME_SHADE: "Saturación del color",
DEFAULT_LOAD_MODE: "Modo de carga por defecto",
DEVICE_MODE: "Modo de dispositivo",
DEVICE_MODE_AUTO: "Automático",
DEVICE_MODE_DESKTOP: "Escritorio",
DEVICE_MODE_TABLET: "Tableta",
DEVICE_MODE_MOBILE: "Móvil",
LOAD_MODE_NORMAL: "Normal (Espera 3s)",
LOAD_MODE_ALWAYS: "Siempre (Inmediatamente)",
LOAD_MODE_NEVER: "Nunca (Manualmente)",
LOAD_SPEED: "Velocidad carga",
DEFAULT_ZOOM: "Zoom por defecto (entre 5 y 200)",
DEFAULT_ZOOM_MODE: "Modo de zoom por defecto",
MINIMUM_ZOOM: "Zoom mÃnimo relativo al ancho de la pantalla",
ZOOM_STEP: "Paso entre cambios de zoom (entre 5 y 50)",
DEFAULT_VIEW_MODE: "Modo de visualización por defecto",
VIEW_MODE_VERTICAL: "Vertical",
VIEW_MODE_LEFT: "Horizontal - Izquierda a derecha",
VIEW_MODE_RIGHT: "Horizontal - Derecha a izquierda",
VIEW_MODE_WEBCOMIC: "WebComic",
VIEW_MODE_BOOK: "Libro - Izquierda a derecha",
VIEW_MODE_MANGA: "Manga - Derecha a izquierda",
VIEW_MODE_GALLERY: "GalerÃa",
FIT_WIDTH_OVERSIZED: "Ajustar ancho si es demasiado grande",
SHOW_THUMBNAILS: "Mostrar miniaturas",
HIDE_CONTROLS: "Ocultar siempre la barra de controles",
HEADER_TYPE: "Cambiar tipo de cabecera",
HEADER_HOVER: "Pasar por encima",
HEADER_SCROLL: "Desplazamiento",
HEADER_CLICK: "Hacer click",
HEADER_FIXED: "Fijo",
HEADER_SIMPLE: "Sencillo",
BUTTON_DOWNLOAD: "Descargar",
DOWNLOAD_ZIP: "Descargar fichero Zip",
DOWNLOAD_IMAGES: "Autodescargar imágenes como Zip",
DOWNLOAD_PROGRESS: "Descargando: ##num## de ##total##",
GENERATING_ZIP: "Generando archivo Zip...",
DOWNLOAD_INCOMPLETE: "Descarga Incompleta",
DOWNLOAD_INCOMPLETE_MESSAGE: "Algunas páginas no se pudieron descargar y se saltaron. Se ha añadido una lista de páginas fallidas al archivo ZIP.",
BUTTON_NEXT: "Siguiente",
NEXT_CHAPTER: "Siguiente capÃtulo",
BUTTON_PREVIOUS: "Anterior",
PREVIOUS_CHAPTER: "CapÃtulo anterior",
BOOKMARKS: "Marcadores",
BOOKMARK: "Marcador",
BOOKMARK_REMOVED: "Marcador eliminado",
BOOKMARK_SAVED: "Marcador guardado",
BOOKMARK_MESSAGE: "La próxima vez que abra este capÃtulo, continuará desde la página ##num## (Sólo UNA VEZ por Marcador)",
KEYBINDINGS: "Atajos de teclado",
EDIT_KEYBINDS: "Editar atajos",
SAVE_KEYBINDS: "Guardar atajos",
BUTTON_EDIT: "Editar",
BUTTON_SAVE: "Guardar",
KEYBIND_RULES: "\n <h3>Teclas soportadas</h3>\n Modificadores permitidos: shift, option, alt, ctrl, control, command. <br/>\n Teclas especiales: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n Ejemplos: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n ",
ATTENTION: "Atención",
WARNING: "Alerta",
BUTTON_RESET_SETTINGS: "Reiniciar ajustes(Reset Settings)",
SETTINGS_RESET: "Se han restablecido los ajustes, vuelve a cargar la página para que surta efecto",
LANGUAGE_CHANGED: "Se ha cambiado el idioma, vuelve a cargar la página para que surta efecto",
AUTO_DOWNLOAD: "La próxima vez que termine de cargarse un capÃtulo, se le pedirá que guarde automáticamente",
LAZY_LOAD: "Las páginas que aún no se han cargado quedan fuera de la descarga ZIP y se listan en failed_pages.txt.<br/> Sugerencia: <span style='color:red;font-weight:bold'>Desactivar miniaturas</span> para ahorrar Ancho de banda/Memoria.",
LAZY_LOAD_IMAGES_ENABLE: "Habilitar carga de imágenes diferida",
LAZY_LOAD_IMAGES: "Empezar carga diferida a partir de la página (entre 5 y 100)",
RETURN_CHAPTER_LIST: "Regresar a la lista de capÃtulos",
PAGES_LOADED: "Páginas cargadas",
GO_TO_PAGE: "Ir a página",
ENLARGE: "Agrandar",
RESTORE: "Restaurar",
REDUCE: "Reducir",
FIT_WIDTH: "Ajustar al ancho",
FIT_HEIGHT: "Ajustar al alto",
PERCENT: "Porcentual",
TOGGLE_CONTROLS: "Alternar controles de página",
ZOOM_IN: "Acercar",
ZOOM_OUT: "Alejar",
ZOOM_RESET: "Restablecer zoom",
ZOOM_WIDTH: "Zoom al ancho",
ZOOM_HEIGHT: "Zoom al alto",
HIDE: "Ocultar",
RELOAD: "Recargar",
SLOWLY: "Lento",
NORMAL: "Normal",
FAST: "Rápido",
EXTREME: "Extremo",
ALL_PAGES: "Todas las páginas",
SPEED_WARNING: "Velocidad de carga muy alta",
SPEED_WARNING_MESSAGE: "No se recomienda esta velocidad.<br/> Puede dañar algunos servidores o marcar su IP como atacante DDoS.<br/> ¡UtilÃcelo con precaución!",
SCROLL_UP: "Desplazar arriba",
SCROLL_DOWN: "Desplazarse hacia abajo",
CLOSE: "Cerrar",
CANCEL: "Cancelar",
LIST_EMPTY: "Lista vacÃa",
SCROLL_START: "Alternar desplazamiento automático",
INCREASE_SPEED: "Aumentar la velocidad de desplazamiento",
DECREASE_SPEED: "Disminuir la velocidad de desplazamiento",
AUTO_SCROLL_HEIGHT: "Velocidad de desplazamiento automático en pÃxeles",
VERTICAL_SEPARATOR: "Mostrar separadores verticales",
END: "Fin",
SCOPE: "Alcance",
GLOBAL: "Global",
GENERAL: "General",
LOADING: "Carga",
ZOOM: "Zoom",
OTHERS: "Otros",
NAVBAR_TYPE: "Cambiar el tipo de barra de navegación",
NAVBAR_BOTTOM: "Abajo",
NAVBAR_LEFT: "Izquierda",
NAVBAR_RIGHT: "Derecha",
NAVBAR_DISABLED: "Desactivado",
PAGINATION_TYPE: "Tipo de paginación",
PAGINATION_DISABLED: "Desactivado",
PAGINATION_SLIDER: "Control deslizante",
PAGINATION_ARROWS: "Flechas laterales",
PAGINATION_BOTH: "Ambos",
FILE_MENU: "Menú principal",
VIEW_MENU: "Ver menú",
ZOOM_MENU: "Menú Zoom",
DOUBLE_PAGE: "Alternar Página Doble",
CHOOSE_FILE: "Elegir archivo",
NO_FILES_SELECTED: "No se han seleccionado archivos",
// Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
COLOR_SCHEME_DARK: "Oscuro",
COLOR_SCHEME_LIGHT: "Claro",
LAZY_LOAD_RULE: "Regla de carga diferida",
LAZY_LOAD_RULE_DISTANCE: "Cerca de la pantalla (2000 px)",
LAZY_LOAD_RULE_LOOKAHEAD: "Siguientes 5 páginas",
LAZY_LOAD_RULE_BOTH: "Ambas",
KEYBIND_INVALID: "Tecla desconocida: ##key##",
KEYBIND_DUPLICATE: "##key## ya está asignada a: ##action##",
// Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
ENABLE_COMMENTS: "Capturar comentarios (cuando esté disponible)",
DISPLAY_COMMENTS: "Mostrar comentarios",
COMMENTS: "Sección de comentarios",
COMMENTS_COLOR_SCHEME: "Comentarios: claro / oscuro",
BOOKMARK_OPEN: "Abrir marcador",
BOOKMARK_DELETE: "Eliminar marcador",
BOOKMARK_PAGE: "Página",
BOOKMARK_TOGGLE_CURRENT: "Marcar la página actual",
BOOKMARKS_CLEAR: "Eliminar todos los marcadores",
BOOKMARKS_CLEAR_CONFIRM: "¿Eliminar los ##count## marcadores? No se puede deshacer.",
SETTING_CHANGED: "##setting## cambiado",
SETTING_CHANGED_DETAIL: "de ##old## a ##new##",
SCOPE_CHANGED: "Ãmbito de ajustes: ##scope##",
DOWNLOAD_PARTIAL_STARTED: "Descargando ##loaded## de ##total## páginas; ##pending## aún se están cargando",
DOWNLOAD_PARTIAL_NOTE: "Las páginas aún no cargadas se omiten y se listan en failed_pages.txt dentro del ZIP.",
DOWNLOAD_NOTHING_LOADED: "Ninguna página ha terminado de cargar todavÃa. Inténtelo de nuevo en un momento.",
DOWNLOAD_BUSY: "Ya hay una descarga en curso",
DOWNLOAD_CANCELLED: "Descarga cancelada",
DOWNLOAD_SAVED: "Guardado ##file##",
DOWNLOAD_FAILED: "Error en la descarga: ##error##",
DOWNLOAD_SKIPPED_PAGES: "Páginas omitidas: ##pages##",
LOCAL_FILES_OPEN: "Abrir archivos locales",
LOCAL_FILES_IMAGES: "Imágenes",
LOCAL_FILES_FOLDER: "Carpeta",
LOCAL_FILES_ARCHIVE: "Archivo ZIP / CBZ",
LOCAL_FILES_HINT: "Elija imágenes, una carpeta de imágenes o un archivo ZIP/CBZ. No se sube nada: su navegador lee los archivos.",
LOCAL_FILES_READING: "Leyendo ##file##...",
LOCAL_FILES_UNSUPPORTED: "Archivo no compatible: ##file## (compatibles: imágenes, carpetas, ZIP/CBZ; no RAR/CBR ni 7z)",
LOCAL_FILES_EMPTY: "No se encontraron imágenes en ##file##",
LOCAL_FILES_ERROR: "No se pudo leer ##file##: ##error##",
LOCAL_FILES_TITLE: "Imágenes locales",
LOCAL_FILES_ALREADY_OPEN: "El lector ya está abierto. Recargue la página para abrir otros archivos.",
PAGE_RANGE_NEED_NUMBERS: "Escriba dos números de página.",
PAGE_RANGE_INVALID: "Las páginas deben estar entre 1 y ##total##, primera <= última.",
// Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
SCROLL_PREPARING: "Preparando páginas...",
SCROLL_PROGRESS: "Desplazando y esperando a que carguen las páginas: ##num## / ##total##",
CLICK_PROGRESS: "Abriendo las páginas una por una: ##num## / ##total##"
},
{
ID: "pt_BR",
NAME: "Portugues (Brasil)",
STARTING: "Iniciando Manga Viewer MM",
RESUME: "Continuando leitura na Pagina ",
WAITING: "Por Favor espere, 3 segundos...",
CHOOSE_BEGINNING: "Escolha a pagina de onde começar:",
BUTTON_START: "Iniciar Manga Viewer MM",
SETTINGS: "Configurações",
LANGUAGE: "Idioma",
COLOR_SCHEME: "Esquema de Color",
THEME: "Tema",
THEME_COLOR: "Cor",
THEME_HUE: "Tom da Cor",
THEME_SHADE: "Saturação da Cor",
DEFAULT_LOAD_MODE: "Forma de Carregamento Padrão",
DEVICE_MODE: "Modo de Dispositivo",
DEVICE_MODE_AUTO: "Automático",
DEVICE_MODE_DESKTOP: "Desktop",
DEVICE_MODE_TABLET: "Tablet",
DEVICE_MODE_MOBILE: "Celular",
LOAD_MODE_NORMAL: "Normal(Esperando 3 sec)",
LOAD_MODE_ALWAYS: "Sempre(Imediatamente)",
LOAD_MODE_NEVER: "Nunca(Manualmente)",
LOAD_SPEED: "Velocidade de Carregamento",
DEFAULT_ZOOM: "Zoom padrão (entre 5 e 200)",
DEFAULT_ZOOM_MODE: "Modo de Zoom padrão",
MINIMUM_ZOOM: "Zoom minimo, relativo ao tamanho da tela (entre 30 e 100)",
ZOOM_STEP: "Precisão da Mudança do Zoom (entre 5 e 50)",
DEFAULT_VIEW_MODE: "Modo de Visualização Padrão",
VIEW_MODE_VERTICAL: "Vertical",
VIEW_MODE_LEFT: "Horizontal - Esquerda para Direita",
VIEW_MODE_RIGHT: "Horizontal - Direita para Esquerda",
VIEW_MODE_WEBCOMIC: "WebComic",
VIEW_MODE_BOOK: "Livro - Esquerda para Direita",
VIEW_MODE_MANGA: "Mangá - Direita para Esquerda",
VIEW_MODE_GALLERY: "Galeria",
FIT_WIDTH_OVERSIZED: "Encher a tela se grande demais",
SHOW_THUMBNAILS: "Mostra Miniaturas",
HIDE_CONTROLS: "Sempre esconder controles das paginas",
HEADER_TYPE: "Mudar Tipo de Cabeçalho",
HEADER_HOVER: "Passar por perto",
HEADER_SCROLL: "Rolagem do Mouse",
HEADER_CLICK: "Click",
HEADER_FIXED: "Fixo",
HEADER_SIMPLE: "Simples",
BUTTON_DOWNLOAD: "Download",
DOWNLOAD_ZIP: "Baixar arquivo Zip",
DOWNLOAD_IMAGES: "Download das Imagens como Zip Automaticamente",
DOWNLOAD_PROGRESS: "Baixando: ##num## de ##total##",
GENERATING_ZIP: "Gerando arquivo Zip...",
DOWNLOAD_INCOMPLETE: "Download Incompleto",
DOWNLOAD_INCOMPLETE_MESSAGE: "Algumas páginas falharam ao baixar e foram puladas. Uma lista de páginas que falharam foi adicionada ao arquivo ZIP.",
BUTTON_NEXT: "Proximo",
NEXT_CHAPTER: "Proximo Capitulo",
BUTTON_PREVIOUS: "Anterior",
PREVIOUS_CHAPTER: "Capitulo Anterior",
BOOKMARKS: "Marca paginas",
BOOKMARK: "Marcar pagina",
BOOKMARK_REMOVED: "Marca pagina Removido",
BOOKMARK_SAVED: "Marca pagina Salvo",
BOOKMARK_MESSAGE: "Proxima vez que abrir este capitulo continuará a partir da Pagina ##num## (Apenas UMA VEZ por marca pagina)",
KEYBINDINGS: "Atalhos",
EDIT_KEYBINDS: "Editar Atalhos",
SAVE_KEYBINDS: "Salvar Atalhos",
BUTTON_EDIT: "Editar",
BUTTON_SAVE: "Salvar",
KEYBIND_RULES: "\n <h3>Teclas Suportadas</h3>\n Modificadores permitidos: shift, option, alt, ctrl, control, command. <br/>\n Teclas Especiais: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide.<br/>\n Exemplos: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n ",
ATTENTION: "Atenção",
WARNING: "Alerta",
BUTTON_RESET_SETTINGS: "Limpar Configurações(Reset Settings)",
SETTINGS_RESET: "Configurações foram limpas, recarregue o site para efetivar a alteração",
LANGUAGE_CHANGED: "Idioma foi alterado, recarregue o site para efetivar a alteração",
AUTO_DOWNLOAD: "Proxima vez que abrir um capitulo download iniciara automaticamente",
LAZY_LOAD: "Páginas que ainda não carregaram ficam fora do download ZIP e são listadas em failed_pages.txt.<br/> Sugestão: <span style='color:red;font-weight:bold'>Desative Miniaturas</span> para economizar memoria e cota de internet.",
LAZY_LOAD_IMAGES_ENABLE: "Ativar Carregamento de imagens preguiçoso",
LAZY_LOAD_IMAGES: "Carregamento de paginas preguiçoso começa a partir de (entre 5 e 100)",
RETURN_CHAPTER_LIST: "Voltar a lista de Capitulos",
PAGES_LOADED: "Paginas Carregadas",
GO_TO_PAGE: "Pular para",
ENLARGE: "Aumentar",
RESTORE: "Restaurar",
REDUCE: "Diminuir",
FIT_WIDTH: "Preencher Largura",
FIT_HEIGHT: "Preencher Altura ",
PERCENT: "Percentual",
TOGGLE_CONTROLS: "Mostar controles de pagina",
ZOOM_IN: "Mais Zoom",
ZOOM_OUT: "Menos Zoom",
ZOOM_RESET: "Resetar Zoom",
ZOOM_WIDTH: "Zoom para Largura",
ZOOM_HEIGHT: "Zoom para Altura",
HIDE: "Esconder",
RELOAD: "Recarregar",
SLOWLY: "Devagar",
NORMAL: "Normal",
FAST: "Rapido",
EXTREME: "Extremo",
ALL_PAGES: "Todas as Paginas",
SPEED_WARNING: "Velocidade de Carregamento muito alta",
SPEED_WARNING_MESSAGE: "Essa velocidade não é recomendada.<br/> Ela pode derrubar um servidor or marcar voce como um ataque hacker de DDoS.<br/> Use com cuidado!",
SCROLL_UP: "Subir Pagina",
SCROLL_DOWN: "Descer Pagina",
CLOSE: "Fechar",
CANCEL: "Cancelar",
LIST_EMPTY: "Lista Vazia",
SCROLL_START: "Ativar Rolagem Automatica",
INCREASE_SPEED: "Aumentar Valocidade da Rolagem",
DECREASE_SPEED: "Diminuir Valocidade da Rolagem",
AUTO_SCROLL_HEIGHT: "Velocidade da Rolagem Automatica em Pixels",
VERTICAL_SEPARATOR: "Mostrar Separadores Verticais",
END: "Fin",
SCOPE: "Escopo",
GLOBAL: "Global",
GENERAL: "Geral",
LOADING: "Carregamento",
ZOOM: "Zoom",
OTHERS: "Outros",
NAVBAR_TYPE: "Mudar barra de navegação",
NAVBAR_BOTTOM: "Embaixo",
NAVBAR_LEFT: "Esquerda",
NAVBAR_RIGHT: "Direita",
NAVBAR_DISABLED: "Desativado",
PAGINATION_TYPE: "Tipo de Paginação",
PAGINATION_DISABLED: "Desativado",
PAGINATION_SLIDER: "Controle deslizante",
PAGINATION_ARROWS: "Setas Laterais",
PAGINATION_BOTH: "Ambos",
FILE_MENU: "Menu Principal",
VIEW_MENU: "Menu de Visualizações",
ZOOM_MENU: "Menu de Zoom",
DOUBLE_PAGE: "Alternar Página Dupla",
CHOOSE_FILE: "Escolher arquivo",
NO_FILES_SELECTED: "Nenhum arquivo selecionado",
// Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
COLOR_SCHEME_DARK: "Escuro",
COLOR_SCHEME_LIGHT: "Claro",
LAZY_LOAD_RULE: "Regra de carregamento preguiçoso",
LAZY_LOAD_RULE_DISTANCE: "Perto da tela (2000 px)",
LAZY_LOAD_RULE_LOOKAHEAD: "Próximas 5 páginas",
LAZY_LOAD_RULE_BOTH: "Ambas",
KEYBIND_INVALID: "Tecla desconhecida: ##key##",
KEYBIND_DUPLICATE: "##key## já está em uso por: ##action##",
// Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
ENABLE_COMMENTS: "Capturar comentários (quando disponÃvel)",
DISPLAY_COMMENTS: "Mostrar comentários",
COMMENTS: "Seção de comentários",
COMMENTS_COLOR_SCHEME: "Comentários: claro / escuro",
BOOKMARK_OPEN: "Abrir marcador",
BOOKMARK_DELETE: "Excluir marcador",
BOOKMARK_PAGE: "Página",
BOOKMARK_TOGGLE_CURRENT: "Marcar a página atual",
BOOKMARKS_CLEAR: "Remover todos os marcadores",
BOOKMARKS_CLEAR_CONFIRM: "Remover todos os ##count## marcadores? Isso não pode ser desfeito.",
SETTING_CHANGED: "##setting## alterado",
SETTING_CHANGED_DETAIL: "de ##old## para ##new##",
SCOPE_CHANGED: "Escopo das configurações: ##scope##",
DOWNLOAD_PARTIAL_STARTED: "Baixando ##loaded## de ##total## páginas; ##pending## ainda estão carregando",
DOWNLOAD_PARTIAL_NOTE: "Páginas ainda não carregadas ficam de fora e são listadas em failed_pages.txt dentro do ZIP.",
DOWNLOAD_NOTHING_LOADED: "Nenhuma página terminou de carregar ainda. Tente novamente em instantes.",
DOWNLOAD_BUSY: "Já há um download em andamento",
DOWNLOAD_CANCELLED: "Download cancelado",
DOWNLOAD_SAVED: "##file## salvo",
DOWNLOAD_FAILED: "Falha no download: ##error##",
DOWNLOAD_SKIPPED_PAGES: "Páginas puladas: ##pages##",
LOCAL_FILES_OPEN: "Abrir arquivos locais",
LOCAL_FILES_IMAGES: "Imagens",
LOCAL_FILES_FOLDER: "Pasta",
LOCAL_FILES_ARCHIVE: "Arquivo ZIP / CBZ",
LOCAL_FILES_HINT: "Escolha imagens, uma pasta de imagens ou um arquivo ZIP/CBZ. Nada é enviado: seu navegador lê os arquivos.",
LOCAL_FILES_READING: "Lendo ##file##...",
LOCAL_FILES_UNSUPPORTED: "Arquivo não suportado: ##file## (suportados: imagens, pastas, ZIP/CBZ; não RAR/CBR nem 7z)",
LOCAL_FILES_EMPTY: "Nenhuma imagem encontrada em ##file##",
LOCAL_FILES_ERROR: "Não foi possÃvel ler ##file##: ##error##",
LOCAL_FILES_TITLE: "Imagens locais",
LOCAL_FILES_ALREADY_OPEN: "O leitor já está aberto. Recarregue a página para abrir outros arquivos.",
PAGE_RANGE_NEED_NUMBERS: "Digite dois números de página.",
PAGE_RANGE_INVALID: "As páginas devem estar entre 1 e ##total##, primeira <= última.",
// Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
SCROLL_PREPARING: "Preparando páginas...",
SCROLL_PROGRESS: "Rolando e aguardando as páginas carregarem: ##num## / ##total##",
CLICK_PROGRESS: "Abrindo as páginas uma a uma: ##num## / ##total##"
},
{
ID: "zh_CN",
NAME: "䏿–‡ (简体)",
STARTING: "æ£åœ¨å¯åЍ Manga Viewer MM",
RESUME: "从页é¢ç»§ç»é˜…读 ",
WAITING: "请ç‰å¾…3ç§’é’Ÿ...",
CHOOSE_BEGINNING: "选择è¦å¼€å§‹çš„页数:",
BUTTON_START: "å¯åЍManga Viewer MM",
SETTINGS: "设置",
LANGUAGE: "è¯è¨€",
COLOR_SCHEME: "é…色方案",
THEME: "主题",
THEME_COLOR: "颜色",
THEME_HUE: "色相",
THEME_SHADE: "色度",
DEFAULT_LOAD_MODE: "é»˜è®¤åŠ è½½æ¨¡å¼",
DEVICE_MODE: "设备模å¼",
DEVICE_MODE_AUTO: "自动",
DEVICE_MODE_DESKTOP: "桌é¢",
DEVICE_MODE_TABLET: "å¹³æ¿",
DEVICE_MODE_MOBILE: "手机",
LOAD_MODE_NORMAL: "ç‰å¾…模å¼(ç‰å¾…3ç§’è‡ªåŠ¨åŠ è½½ )",
LOAD_MODE_ALWAYS: "自动模å¼(æ— éœ€ç‰å¾…)",
LOAD_MODE_NEVER: "手动模å¼(点击å¯åЍ)",
LOAD_SPEED: "åŠ è½½é€Ÿåº¦",
DEFAULT_ZOOM: "默认缩放 (æœ€å° 5 最大 200)",
DEFAULT_ZOOM_MODE: "默认缩放模å¼",
MINIMUM_ZOOM: "相对于å±å¹•宽度的最å°ç¼©æ”¾ (æœ€å° 30 最大 100)",
ZOOM_STEP: "缩放级别 (æœ€å° 5 最大 50)",
DEFAULT_VIEW_MODE: "默认视图模å¼",
VIEW_MODE_VERTICAL: "垂直有ç¼",
VIEW_MODE_LEFT: "æ¨ªå‘ - 从左到å³",
VIEW_MODE_RIGHT: "æ¨ªå‘ - 从å³åˆ°å·¦",
VIEW_MODE_WEBCOMIC: "åž‚ç›´æ— ç¼",
VIEW_MODE_BOOK: "ä¹¦ç± - 从左到å³",
VIEW_MODE_MANGA: "漫画 - 从å³åˆ°å·¦",
VIEW_MODE_GALLERY: "图库",
FIT_WIDTH_OVERSIZED: "如果尺寸过大ã€åˆ™é€‚åˆå®½åº¦",
SHOW_THUMBNAILS: "显示缩略图",
HIDE_CONTROLS: "始终éšè—页颿ާ件",
HEADER_TYPE: "æ›´æ”¹æ ‡é¢˜æ˜¾ç¤ºæ–¹å¼",
HEADER_HOVER: "悬åœ",
HEADER_SCROLL: "滚动",
HEADER_CLICK: "点击",
HEADER_FIXED: "固定",
HEADER_SIMPLE: "简å•",
BUTTON_DOWNLOAD: "下载",
DOWNLOAD_ZIP: "下载压缩文件",
DOWNLOAD_IMAGES: "自动将图片下载æˆZIP",
DOWNLOAD_PROGRESS: "æ£åœ¨ä¸‹è½½ï¼šç¬¬ ##num## 页,共 ##total## 页",
GENERATING_ZIP: "æ£åœ¨ç”Ÿæˆ Zip 文件...",
DOWNLOAD_INCOMPLETE: "下载ä¸å®Œæ•´",
DOWNLOAD_INCOMPLETE_MESSAGE: "部分页é¢ä¸‹è½½å¤±è´¥å¹¶å·²è·³è¿‡ã€‚失败页é¢åˆ—è¡¨å·²æ·»åŠ åˆ° ZIP 文件ä¸ã€‚",
BUTTON_NEXT: "下一页",
NEXT_CHAPTER: "ä¸‹ä¸€ç« ",
BUTTON_PREVIOUS: "上一页",
PREVIOUS_CHAPTER: "ä¸Šä¸€ç« ",
BOOKMARKS: "书ç¾",
BOOKMARK: "Bookmark",
BOOKMARK_REMOVED: "åˆ é™¤ä¹¦ç¾",
BOOKMARK_SAVED: "ä¿å˜ä¹¦ç¾",
BOOKMARK_MESSAGE: "ä¸‹æ¬¡æ‰“å¼€æœ¬ç« æ—¶ï¼Œå°†ä»Ž: 页ç ##num## (仅一次 æ¯ä¸ªä¹¦ç¾)",
KEYBINDINGS: "å¿«æ·é”®",
EDIT_KEYBINDS: "编辑键绑定",
SAVE_KEYBINDS: "ä¿å˜é”®ç»‘定",
BUTTON_EDIT: "编辑",
BUTTON_SAVE: "æ•‘",
KEYBIND_RULES: "\n <h3>支æŒçš„密钥</h3>\n å…许的修饰符: shift, option, alt, ctrl, control, command. <br/>\n 特殊键: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide.<br/>\n 例å: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n ",
ATTENTION: "注æ„",
WARNING: "è¦å‘Š",
BUTTON_RESET_SETTINGS: "é‡ç½®è®¾ç½®(Reset Settings)",
SETTINGS_RESET: "设置已é‡ç½®ã€é‡æ–°åŠ è½½é¡µé¢æ‰èƒ½ç”Ÿæ•ˆ",
LANGUAGE_CHANGED: "è¯è¨€å·²æ›´æ”¹ã€é‡æ–°åŠ è½½é¡µé¢æ‰èƒ½ç”Ÿæ•ˆ",
AUTO_DOWNLOAD: "ä¸‹æ¬¡ç« èŠ‚åŠ è½½å®Œæˆæ—¶ã€ç³»ç»Ÿå°†æç¤ºæ‚¨è‡ªåЍä¿å˜",
LAZY_LOAD: "å°šæœªåŠ è½½çš„é¡µé¢ä¸ä¼šåŒ…å«åœ¨ZIP下载ä¸ï¼Œå¹¶ä¼šåˆ—在 failed_pages.txt 里.<br/> 建议: <span style='color:red;font-weight:bold'>ç¦ç”¨ç¼©ç•¥å›¾</span> ä»¥èŠ‚çœæµé‡å’Œå†…å˜.",
LAZY_LOAD_IMAGES_ENABLE: "å¯ç”¨å»¶è¿ŸåŠ è½½å›¾åƒ",
LAZY_LOAD_IMAGES: "æƒ°æ€§åŠ è½½ä»Žé¡µé¢ (æœ€å° 5 最大 100)",
RETURN_CHAPTER_LIST: "è¿”å›žç« èŠ‚åˆ—è¡¨",
PAGES_LOADED: "å·²åŠ è½½çš„é¡µæ•°",
GO_TO_PAGE: "转到页数",
ENLARGE: "放大",
RESTORE: "还原",
REDUCE: "缩å°",
FIT_WIDTH: "适åˆå®½åº¦",
FIT_HEIGHT: "适åˆé«˜åº¦",
PERCENT: "百分之",
TOGGLE_CONTROLS: "显示éšè—页颿ާ件",
ZOOM_IN: "放大",
ZOOM_OUT: "缩å°",
ZOOM_RESET: "还原",
ZOOM_WIDTH: "适åˆå®½åº¦",
ZOOM_HEIGHT: "适åˆé«˜åº¦",
HIDE: "显示éšè—页颿ާ件",
RELOAD: "釿–°åŠ è½½",
SLOWLY: "慢速",
NORMAL: "æ£å¸¸",
FAST: "快速",
EXTREME: "æžç«¯",
ALL_PAGES: "所有页é¢",
SPEED_WARNING: "åŠ è½½é€Ÿåº¦è¿‡é«˜",
SPEED_WARNING_MESSAGE: "ä¸å»ºè®®ä½¿ç”¨æ¤é€Ÿåº¦.<br/>它å¯èƒ½ä¼šä¼¤å®³æŸäº›æœåŠ¡å™¨æˆ–å°†æ‚¨çš„ IP æ ‡è®°ä¸º DDoS 攻击者.<br/>请谨慎使用!",
SCROLL_UP: "å‘上滚动",
SCROLL_DOWN: "å‘下滚动",
CLOSE: "å…³é—",
CANCEL: "å–æ¶ˆ",
LIST_EMPTY: "没有收è—书ç¾",
SCROLL_START: "切æ¢è‡ªåŠ¨æ»šåŠ¨",
INCREASE_SPEED: "å¢žåŠ æ»šåŠ¨é€Ÿåº¦",
DECREASE_SPEED: "é™ä½Žæ»šåŠ¨é€Ÿåº¦",
AUTO_SCROLL_HEIGHT: "自动滚动速度(以åƒç´ 为å•ä½ï¼‰",
VERTICAL_SEPARATOR: "显示垂直分隔符",
END: "结尾",
SCOPE: "范围",
GLOBAL: "å…¨çƒ",
GENERAL: "常规",
LOADING: "装载",
ZOOM: "缩放",
OTHERS: "别人",
NAVBAR_TYPE: "更改导航æ 类型",
NAVBAR_BOTTOM: "底部",
NAVBAR_LEFT: "左边",
NAVBAR_RIGHT: "æ£ç¡®çš„",
NAVBAR_DISABLED: "å·²ç¦ç”¨",
PAGINATION_TYPE: "分页类型",
PAGINATION_DISABLED: "å·²ç¦ç”¨",
PAGINATION_SLIDER: "滑å—",
PAGINATION_ARROWS: "ä¾§è¾¹ç®å¤´",
PAGINATION_BOTH: "两者",
FILE_MENU: "主èœå•",
VIEW_MENU: "查看èœå•",
ZOOM_MENU: "缩放èœå•",
DOUBLE_PAGE: "切æ¢åŒé¡µ",
CHOOSE_FILE: "选择文件",
NO_FILES_SELECTED: "未选择任何文件",
// Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
COLOR_SCHEME_DARK: "深色",
COLOR_SCHEME_LIGHT: "浅色",
LAZY_LOAD_RULE: "å»¶è¿ŸåŠ è½½è§„åˆ™",
LAZY_LOAD_RULE_DISTANCE: "接近å±å¹•æ—¶ (2000 px)",
LAZY_LOAD_RULE_LOOKAHEAD: "åŽé¢ 5 页",
LAZY_LOAD_RULE_BOTH: "两者",
KEYBIND_INVALID: "未知按键:##key##",
KEYBIND_DUPLICATE: "##key## 已被以下æ“作使用:##action##",
// Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
ENABLE_COMMENTS: "æ•获评论(如果å¯ç”¨ï¼‰",
DISPLAY_COMMENTS: "显示注释",
COMMENTS: "评论部分",
COMMENTS_COLOR_SCHEME: "评论:浅色 / 深色",
BOOKMARK_OPEN: "打开书ç¾",
BOOKMARK_DELETE: "åˆ é™¤ä¹¦ç¾",
BOOKMARK_PAGE: "页",
BOOKMARK_TOGGLE_CURRENT: "为当å‰é¡µæ·»åР书ç¾",
BOOKMARKS_CLEAR: "åˆ é™¤æ‰€æœ‰ä¹¦ç¾",
BOOKMARKS_CLEAR_CONFIRM: "åˆ é™¤å…¨éƒ¨ ##count## 个书ç¾ï¼Ÿæ¤æ“ä½œæ— æ³•æ’¤é”€ã€‚",
SETTING_CHANGED: "##setting## 已更改",
SETTING_CHANGED_DETAIL: "从 ##old## 改为 ##new##",
SCOPE_CHANGED: "设置范围:##scope##",
DOWNLOAD_PARTIAL_STARTED: "æ£åœ¨ä¸‹è½½ ##total## 页ä¸çš„ ##loaded## 页;还有 ##pending## 页ä»åœ¨åŠ è½½",
DOWNLOAD_PARTIAL_NOTE: "å°šæœªåŠ è½½çš„é¡µé¢ä¸ä¼šä¸‹è½½ï¼Œå¹¶åˆ—在 ZIP 内的 failed_pages.txt ä¸ã€‚",
DOWNLOAD_NOTHING_LOADED: "还没有页é¢åŠ è½½å®Œæˆï¼Œè¯·ç¨åŽå†è¯•。",
DOWNLOAD_BUSY: "已有下载æ£åœ¨è¿›è¡Œ",
DOWNLOAD_CANCELLED: "ä¸‹è½½å·²å–æ¶ˆ",
DOWNLOAD_SAVED: "å·²ä¿å˜ ##file##",
DOWNLOAD_FAILED: "下载失败:##error##",
DOWNLOAD_SKIPPED_PAGES: "已跳过的页é¢ï¼š##pages##",
LOCAL_FILES_OPEN: "打开本地文件",
LOCAL_FILES_IMAGES: "图片",
LOCAL_FILES_FOLDER: "文件夹",
LOCAL_FILES_ARCHIVE: "ZIP / CBZ 压缩包",
LOCAL_FILES_HINT: "选择图片ã€å›¾ç‰‡æ–‡ä»¶å¤¹æˆ– ZIP/CBZ 压缩包。ä¸ä¼šä¸Šä¼ 任何内容:文件由æµè§ˆå™¨è¯»å–。",
LOCAL_FILES_READING: "æ£åœ¨è¯»å– ##file##...",
LOCAL_FILES_UNSUPPORTED: "䏿”¯æŒçš„æ–‡ä»¶ï¼š##file##(支æŒï¼šå›¾ç‰‡ã€æ–‡ä»¶å¤¹ã€ZIP/CBZï¼›ä¸æ”¯æŒ RAR/CBR 或 7z)",
LOCAL_FILES_EMPTY: "在 ##file## 䏿œªæ‰¾åˆ°å›¾ç‰‡",
LOCAL_FILES_ERROR: "æ— æ³•è¯»å– ##file##:##error##",
LOCAL_FILES_TITLE: "本地图片",
LOCAL_FILES_ALREADY_OPEN: "阅读器已打开。请刷新页é¢ä»¥æ‰“开其他文件。",
PAGE_RANGE_NEED_NUMBERS: "请输入两个页ç 。",
PAGE_RANGE_INVALID: "页ç 必须在 1 到 ##total## 之间,且起始页 <= 结æŸé¡µã€‚",
// Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
SCROLL_PREPARING: "æ£åœ¨å‡†å¤‡é¡µé¢...",
SCROLL_PROGRESS: "æ£åœ¨æ»šåЍ并ç‰å¾…页é¢åŠ è½½ï¼š##num## / ##total##",
CLICK_PROGRESS: "æ£åœ¨é€é¡µæ‰“开:##num## / ##total##"
},
{
ID: "de_DE",
NAME: "Deutsch",
STARTING: "Starte Manga Viewer MM",
RESUME: "Fortsetzen ab Seite ",
WAITING: "Bitte warten, 3 Sekunden...",
CHOOSE_BEGINNING: "Wähle die Startseite:",
BUTTON_START: "Manga Viewer MM starten",
SETTINGS: "Einstellungen",
LANGUAGE: "Sprache",
COLOR_SCHEME: "Farbschema",
THEME: "Design",
THEME_COLOR: "Farbe",
THEME_HUE: "Farbton",
THEME_SHADE: "Schattierung",
DEFAULT_LOAD_MODE: "Standard-Lademodus",
DEVICE_MODE: "Gerätemodus",
DEVICE_MODE_AUTO: "Automatisch",
DEVICE_MODE_DESKTOP: "Desktop",
DEVICE_MODE_TABLET: "Tablet",
DEVICE_MODE_MOBILE: "Mobil",
LOAD_MODE_NORMAL: "Normal (3 Sek. warten)",
LOAD_MODE_ALWAYS: "Immer (sofort)",
LOAD_MODE_NEVER: "Nie (manuell)",
LOAD_SPEED: "Ladegeschwindigkeit",
DEFAULT_ZOOM: "Standard-Zoom (zwischen 5 und 200)",
DEFAULT_ZOOM_MODE: "Standard-Zoommodus",
MINIMUM_ZOOM: "Minimaler Zoom relativ zur Bildschirmbreite (zwischen 30 und 100)",
ZOOM_STEP: "Zoom-Schrittgröße (zwischen 5 und 50)",
DEFAULT_VIEW_MODE: "Standard-Ansichtsmodus",
VIEW_MODE_VERTICAL: "Vertikal",
VIEW_MODE_LEFT: "Horizontal - Links nach Rechts",
VIEW_MODE_RIGHT: "Horizontal - Rechts nach Links",
VIEW_MODE_WEBCOMIC: "WebComic",
VIEW_MODE_BOOK: "Buch - Links nach Rechts",
VIEW_MODE_MANGA: "Manga - Rechts nach Links",
VIEW_MODE_GALLERY: "Galerie",
FIT_WIDTH_OVERSIZED: "Breite anpassen bei Übergröße",
SHOW_THUMBNAILS: "Miniaturansichten anzeigen",
HIDE_CONTROLS: "Seitensteuerung immer ausblenden",
HEADER_TYPE: "Kopfbereichstyp ändern",
HEADER_HOVER: "Hover",
HEADER_SCROLL: "Scrollen",
HEADER_CLICK: "Klicken",
HEADER_FIXED: "Fixiert",
HEADER_SIMPLE: "Einfach",
BUTTON_DOWNLOAD: "Herunterladen",
DOWNLOAD_ZIP: "Zip-Datei herunterladen",
DOWNLOAD_IMAGES: "Bilder automatisch als Zip herunterladen",
DOWNLOAD_PROGRESS: "Herunterladen: ##num## von ##total##",
GENERATING_ZIP: "Zip-Datei wird erstellt...",
DOWNLOAD_INCOMPLETE: "Download unvollständig",
DOWNLOAD_INCOMPLETE_MESSAGE: "Einige Seiten konnten nicht heruntergeladen werden und wurden übersprungen. Eine Liste der fehlgeschlagenen Seiten wurde der ZIP-Datei hinzugefügt.",
BUTTON_NEXT: "Weiter",
NEXT_CHAPTER: "Nächstes Kapitel",
BUTTON_PREVIOUS: "Zurück",
PREVIOUS_CHAPTER: "Vorheriges Kapitel",
BOOKMARKS: "Lesezeichen",
BOOKMARK: "Lesezeichen",
BOOKMARK_REMOVED: "Lesezeichen entfernt",
BOOKMARK_SAVED: "Lesezeichen gespeichert",
BOOKMARK_MESSAGE: "Beim nächsten Öffnen dieses Kapitels wird ab fortgesetzt: Seite ##num## (Nur EINMAL pro Lesezeichen)",
KEYBINDINGS: "Tastenkürzel",
EDIT_KEYBINDS: "Tastenkürzel bearbeiten",
SAVE_KEYBINDS: "Tastenkürzel speichern",
BUTTON_EDIT: "Bearbeiten",
BUTTON_SAVE: "Speichern",
KEYBIND_RULES: "\n <h3>Unterstützte Tasten</h3>\n Erlaubte Modifikatoren: shift, option, alt, ctrl, control, command. <br/>\n Spezielle Tasten: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n Beispiele: <kbd>a</kbd>, <kbd>ctrl+a</kbd>, <kbd>shift+a</kbd>, <kbd>num_2</kbd>, <kbd>2</kbd>\n ",
ATTENTION: "Achtung",
WARNING: "Warnung",
BUTTON_RESET_SETTINGS: "Einstellungen zurücksetzen(Reset Settings)",
SETTINGS_RESET: "Die Einstellungen wurden zurückgesetzt, bitte Seite neu laden",
LANGUAGE_CHANGED: "Die Sprache wurde geändert, bitte Seite neu laden",
AUTO_DOWNLOAD: "Beim nächsten Laden eines Kapitels wirst du automatisch gefragt, ob du speichern möchtest",
LAZY_LOAD: "Seiten, die noch nicht geladen sind, fehlen im Zip-Download und werden in failed_pages.txt aufgeführt.<br/> Empfehlung: <span style='color:red;font-weight:bold'>Miniaturansichten deaktivieren</span> um Bandbreite/Speicher zu sparen.",
LAZY_LOAD_IMAGES_ENABLE: "Lazy Load Bilder aktivieren",
LAZY_LOAD_IMAGES: "Lazy Start ab Seite (zwischen 5 und 100)",
RETURN_CHAPTER_LIST: "Zur Kapitelübersicht zurückkehren",
PAGES_LOADED: "Seiten geladen",
GO_TO_PAGE: "Gehe zu Seite",
ENLARGE: "Vergrößern",
RESTORE: "Wiederherstellen",
REDUCE: "Wiederherstellen",
FIT_WIDTH: "Breite anpassen",
FIT_HEIGHT: "Höhe anpassen",
PERCENT: "Prozent",
TOGGLE_CONTROLS: "Seitensteuerung umschalten",
ZOOM_IN: "Hineinzoomen",
ZOOM_OUT: "Herauszoomen",
ZOOM_RESET: "Zoom zurücksetzen",
ZOOM_WIDTH: "Auf Breite zoomen",
ZOOM_HEIGHT: "Auf Höhe zoomen",
HIDE: "Ausblenden",
RELOAD: "Neu laden",
SLOWLY: "Langsam",
NORMAL: "Normal",
FAST: "Schnell",
EXTREME: "Extrem",
ALL_PAGES: "Alle Seiten",
SPEED_WARNING: "Ladegeschwindigkeit zu hoch",
SPEED_WARNING_MESSAGE: "Diese Geschwindigkeit wird nicht empfohlen.<br/> Sie kann einige Server überlasten oder deine IP als DDoS-Angreifer markieren.<br/> Bitte mit Vorsicht verwenden!",
SCROLL_UP: "Nach oben scrollen",
SCROLL_DOWN: "Nach unten scrollen",
CLOSE: "Schließen",
CANCEL: "Abbrechen",
LIST_EMPTY: "Liste leer",
SCROLL_START: "Auto-Scroll umschalten",
INCREASE_SPEED: "Scrollgeschwindigkeit erhöhen",
DECREASE_SPEED: "Scrollgeschwindigkeit verringern",
AUTO_SCROLL_HEIGHT: "Auto-Scroll-Geschwindigkeit in Pixel",
VERTICAL_SEPARATOR: "Vertikale Trenner anzeigen",
END: "Ende",
SCOPE: "Bereich",
GLOBAL: "Global",
GENERAL: "Allgemein",
LOADING: "Lädt",
ZOOM: "Zoom",
OTHERS: "Sonstiges",
NAVBAR_TYPE: "Navigationsleistentyp ändern",
NAVBAR_BOTTOM: "Unten",
NAVBAR_LEFT: "Links",
NAVBAR_RIGHT: "Rechts",
NAVBAR_DISABLED: "Deaktiviert",
PAGINATION_TYPE: "Paginierungstyp",
PAGINATION_DISABLED: "Deaktiviert",
PAGINATION_SLIDER: "Schieberegler",
PAGINATION_ARROWS: "Seitenpfeile",
PAGINATION_BOTH: "Beides",
FILE_MENU: "Hauptmenü",
VIEW_MENU: "Menü „Ansicht“",
ZOOM_MENU: "Zoom-Menü",
DOUBLE_PAGE: "Doppelseite umschalten",
CHOOSE_FILE: "Datei auswählen",
NO_FILES_SELECTED: "Keine Dateien ausgewählt",
// Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
COLOR_SCHEME_DARK: "Dunkel",
COLOR_SCHEME_LIGHT: "Hell",
LAZY_LOAD_RULE: "Lazy-Load-Regel",
LAZY_LOAD_RULE_DISTANCE: "Nahe am Bildschirm (2000 px)",
LAZY_LOAD_RULE_LOOKAHEAD: "Nächste 5 Seiten",
LAZY_LOAD_RULE_BOTH: "Beide",
KEYBIND_INVALID: "Unbekannte Taste: ##key##",
KEYBIND_DUPLICATE: "##key## wird bereits verwendet von: ##action##",
// Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
ENABLE_COMMENTS: "Kommentare erfassen (wenn verfügbar)",
DISPLAY_COMMENTS: "Kommentare anzeigen",
COMMENTS: "Kommentarbereich",
COMMENTS_COLOR_SCHEME: "Kommentare: hell / dunkel",
BOOKMARK_OPEN: "Lesezeichen öffnen",
BOOKMARK_DELETE: "Lesezeichen löschen",
BOOKMARK_PAGE: "Seite",
BOOKMARK_TOGGLE_CURRENT: "Aktuelle Seite als Lesezeichen",
BOOKMARKS_CLEAR: "Alle Lesezeichen entfernen",
BOOKMARKS_CLEAR_CONFIRM: "Alle ##count## Lesezeichen entfernen? Dies kann nicht rückgängig gemacht werden.",
SETTING_CHANGED: "##setting## geändert",
SETTING_CHANGED_DETAIL: "von ##old## auf ##new##",
SCOPE_CHANGED: "Gültigkeit der Einstellungen: ##scope##",
DOWNLOAD_PARTIAL_STARTED: "Lade ##loaded## von ##total## Seiten herunter; ##pending## werden noch geladen",
DOWNLOAD_PARTIAL_NOTE: "Noch nicht geladene Seiten fehlen und sind in failed_pages.txt im ZIP aufgeführt.",
DOWNLOAD_NOTHING_LOADED: "Noch keine Seite ist fertig geladen. Versuche es gleich noch einmal.",
DOWNLOAD_BUSY: "Ein Download läuft bereits",
DOWNLOAD_CANCELLED: "Download abgebrochen",
DOWNLOAD_SAVED: "##file## gespeichert",
DOWNLOAD_FAILED: "Download fehlgeschlagen: ##error##",
DOWNLOAD_SKIPPED_PAGES: "Übersprungene Seiten: ##pages##",
LOCAL_FILES_OPEN: "Lokale Dateien öffnen",
LOCAL_FILES_IMAGES: "Bilder",
LOCAL_FILES_FOLDER: "Ordner",
LOCAL_FILES_ARCHIVE: "ZIP- / CBZ-Archiv",
LOCAL_FILES_HINT: "Wähle Bilder, einen Ordner mit Bildern oder ein ZIP/CBZ-Archiv. Nichts wird hochgeladen: dein Browser liest die Dateien.",
LOCAL_FILES_READING: "Lese ##file##...",
LOCAL_FILES_UNSUPPORTED: "Nicht unterstützte Datei: ##file## (unterstützt: Bilder, Ordner, ZIP/CBZ; kein RAR/CBR oder 7z)",
LOCAL_FILES_EMPTY: "Keine Bilder in ##file## gefunden",
LOCAL_FILES_ERROR: "##file## konnte nicht gelesen werden: ##error##",
LOCAL_FILES_TITLE: "Lokale Bilder",
LOCAL_FILES_ALREADY_OPEN: "Der Reader ist bereits geöffnet. Lade die Seite neu, um andere Dateien zu öffnen.",
PAGE_RANGE_NEED_NUMBERS: "Bitte gib zwei Seitenzahlen ein.",
PAGE_RANGE_INVALID: "Seiten müssen zwischen 1 und ##total## liegen, erste <= letzte.",
// Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
SCROLL_PREPARING: "Seiten werden vorbereitet...",
SCROLL_PROGRESS: "Scrollen und auf das Laden der Seiten warten: ##num## / ##total##",
CLICK_PROGRESS: "Seiten werden einzeln geöffnet: ##num## / ##total##"
},
{
ID: "fr_FR",
NAME: "Français (FR)",
STARTING: "Démarrage Manga Viewer MM",
RESUME: "Reprise de la lecture à partir de la Page ",
WAITING: "Veuillez patienter, 3 secondes...",
CHOOSE_BEGINNING: "Choisissez la page par laquelle commencer :",
BUTTON_START: "Démarrer Manga Viewer MM",
SETTINGS: "Paramètres",
LANGUAGE: "Langue",
COLOR_SCHEME: "Palette de couleurs",
THEME: "Thème",
THEME_COLOR: "Couleur",
THEME_HUE: "Teinte de couleur",
THEME_SHADE: "Nuance de couleur",
DEFAULT_LOAD_MODE: "Mode de chargement par défaut",
DEVICE_MODE: "Mode d'appareil",
DEVICE_MODE_AUTO: "Automatique",
DEVICE_MODE_DESKTOP: "Bureau",
DEVICE_MODE_TABLET: "Tablette",
DEVICE_MODE_MOBILE: "Mobile",
LOAD_MODE_NORMAL: "Normal (attendre 3 s)",
LOAD_MODE_ALWAYS: "Toujours (immédiatement)",
LOAD_MODE_NEVER: "Jamais (manuellement)",
LOAD_SPEED: "Vitesse de chargement",
DEFAULT_ZOOM: "Zoom par défaut (entre 5 et 200)",
DEFAULT_ZOOM_MODE: "Mode de zoom par défaut",
MINIMUM_ZOOM: "Zoom minimum par rapport à la largeur de l'écran (entre 30 et 100)",
ZOOM_STEP: "Pas de changement de zoom (entre 5 et 50)",
DEFAULT_VIEW_MODE: "Mode d'affichage par défaut",
VIEW_MODE_VERTICAL: "Vertical",
VIEW_MODE_LEFT: "Horizontal - De gauche à droite",
VIEW_MODE_RIGHT: "Horizontal - De droite à gauche",
VIEW_MODE_WEBCOMIC: "WebComic",
VIEW_MODE_BOOK: "Livre - De gauche à droite",
VIEW_MODE_MANGA: "Manga - De droite à gauche",
VIEW_MODE_GALLERY: "Galerie",
FIT_WIDTH_OVERSIZED: "Ajuster à la largeur si surdimensionné",
SHOW_THUMBNAILS: "Afficher les vignettes",
HIDE_CONTROLS: "Toujours masquer les contrôles de page",
HEADER_TYPE: "Changer le type d'en-tête",
HEADER_HOVER: "Survol",
HEADER_SCROLL: "Défilement",
HEADER_CLICK: "Clic",
HEADER_FIXED: "Fixe",
HEADER_SIMPLE: "Simple",
BUTTON_DOWNLOAD: "Télécharger",
DOWNLOAD_ZIP: "Télécharger le fichier Zip",
DOWNLOAD_IMAGES: "Télécharger les images en Zip automatiquement",
DOWNLOAD_PROGRESS: "Téléchargement : ##num## sur ##total##",
GENERATING_ZIP: "Génération du fichier Zip...",
DOWNLOAD_INCOMPLETE: "Téléchargement incomplet",
DOWNLOAD_INCOMPLETE_MESSAGE: "Certaines pages n'ont pas pu être téléchargées et ont été ignorées. Une liste des pages concernées a été ajoutée au fichier ZIP.",
BUTTON_NEXT: "Suivant",
NEXT_CHAPTER: "Chapitre suivant",
BUTTON_PREVIOUS: "Précédent",
PREVIOUS_CHAPTER: "Chapitre précédent",
BOOKMARKS: "Favoris",
BOOKMARK: "Favori",
BOOKMARK_REMOVED: "Favori supprimé",
BOOKMARK_SAVED: "Favori enregistré",
BOOKMARK_MESSAGE: "La prochaine fois que vous ouvrirez ce chapitre, il reprendra à partir de: Page ##num## (Seulement UNE FOIS par favori)",
KEYBINDINGS: "Raccourcis clavier",
EDIT_KEYBINDS: "Modifier les raccourcis clavier",
SAVE_KEYBINDS: "Enregistrer les raccourcis clavier",
BUTTON_EDIT: "Modifier",
BUTTON_SAVE: "Enregistrer",
KEYBIND_RULES: "\n <h3>Touches prises en charge</h3>\n Modificateurs autorisés : shift, option, alt, ctrl, control, command. <br/>\n Touches spéciales : backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n Exemples : <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n ",
ATTENTION: "Attention",
WARNING: "Avertissement",
BUTTON_RESET_SETTINGS: "Réinitialiser les paramètres",
SETTINGS_RESET: "Les paramètres ont été réinitialisés, rechargez la page pour prendre effet",
LANGUAGE_CHANGED: "La langue a été modifiée, rechargez la page pour prendre effet",
AUTO_DOWNLOAD: "La prochaine fois qu'un chapitre finira de se charger, il vous sera proposé de l'enregistrer automatiquement",
LAZY_LOAD: "Les pages pas encore chargées sont exclues du téléchargement zip et listées dans failed_pages.txt.<br/> Suggestion : <span style='color:red;font-weight:bold'>Désactivez les vignettes</span> pour économiser de la bande passante/mémoire.",
LAZY_LOAD_IMAGES_ENABLE: "Activer le chargement paresseux des images",
LAZY_LOAD_IMAGES: "Début du chargement paresseux à partir de la page (entre 5 et 100)",
RETURN_CHAPTER_LIST: "Retour à la liste des chapitres",
PAGES_LOADED: "Pages chargées",
GO_TO_PAGE: "Aller à la page",
ENLARGE: "Agrandir",
RESTORE: "Restaurer",
REDUCE: "Réduire",
FIT_WIDTH: "Ajuster à la largeur",
FIT_HEIGHT: "Ajuster à la hauteur",
PERCENT: "Pourcentage",
TOGGLE_CONTROLS: "Basculer les contrôles de page",
ZOOM_IN: "Zoom avant",
ZOOM_OUT: "Zoom arrière",
ZOOM_RESET: "Réinitialiser le zoom",
ZOOM_WIDTH: "Zoomer à la largeur",
ZOOM_HEIGHT: "Zoomer à la hauteur",
HIDE: "Masquer",
RELOAD: "Recharger",
SLOWLY: "Lentement",
NORMAL: "Normal",
FAST: "Rapide",
EXTREME: "Extrême",
ALL_PAGES: "Toutes les pages",
SPEED_WARNING: "Vitesse de chargement trop élevée",
SPEED_WARNING_MESSAGE: "Cette vitesse n'est pas recommandée.<br/> Elle peut nuire à certains serveurs ou marquer votre IP comme un attaquant DDoS.<br/> Veuillez l'utiliser avec prudence !",
SCROLL_UP: "Faire défiler vers le haut",
SCROLL_DOWN: "Faire défiler vers le bas",
CLOSE: "Fermer",
CANCEL: "Annuler",
LIST_EMPTY: "Liste vide",
SCROLL_START: "Basculer le défilement automatique",
INCREASE_SPEED: "Augmenter la vitesse de défilement",
DECREASE_SPEED: "Diminuer la vitesse de défilement",
AUTO_SCROLL_HEIGHT: "Vitesse de défilement automatique en pixels",
VERTICAL_SEPARATOR: "Afficher les séparateurs verticaux",
END: "Fin",
SCOPE: "Portée",
GLOBAL: "Global",
GENERAL: "Général",
LOADING: "Chargement",
ZOOM: "Zoom",
OTHERS: "Autres",
NAVBAR_TYPE: "Changer le type de barre de navigation",
NAVBAR_BOTTOM: "Bas",
NAVBAR_LEFT: "Gauche",
NAVBAR_RIGHT: "Droite",
NAVBAR_DISABLED: "Désactivé",
PAGINATION_TYPE: "Type de pagination",
PAGINATION_DISABLED: "Désactivé",
PAGINATION_SLIDER: "Curseur",
PAGINATION_ARROWS: "Flèches latérales",
PAGINATION_BOTH: "Les deux",
FILE_MENU: "Menu principal",
VIEW_MENU: "Menu Affichage",
ZOOM_MENU: "Menu Zoom",
DOUBLE_PAGE: "Basculer Double Page",
CHOOSE_FILE: "Choisir un fichier",
NO_FILES_SELECTED: "Aucun fichier sélectionné",
// Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
COLOR_SCHEME_DARK: "Sombre",
COLOR_SCHEME_LIGHT: "Clair",
LAZY_LOAD_RULE: "Règle de chargement paresseux",
LAZY_LOAD_RULE_DISTANCE: "Près de l'écran (2000 px)",
LAZY_LOAD_RULE_LOOKAHEAD: "5 pages suivantes",
LAZY_LOAD_RULE_BOTH: "Les deux",
KEYBIND_INVALID: "Touche inconnue : ##key##",
KEYBIND_DUPLICATE: "##key## est déjà utilisée par : ##action##",
// Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
ENABLE_COMMENTS: "Capturer les commentaires (si disponibles)",
DISPLAY_COMMENTS: "Afficher les commentaires",
COMMENTS: "Section des commentaires",
COMMENTS_COLOR_SCHEME: "Commentaires : clair / sombre",
BOOKMARK_OPEN: "Ouvrir le signet",
BOOKMARK_DELETE: "Supprimer le signet",
BOOKMARK_PAGE: "Page",
BOOKMARK_TOGGLE_CURRENT: "Ajouter un signet à la page actuelle",
BOOKMARKS_CLEAR: "Supprimer tous les signets",
BOOKMARKS_CLEAR_CONFIRM: "Supprimer les ##count## signets ? Cette action est irréversible.",
SETTING_CHANGED: "##setting## modifié",
SETTING_CHANGED_DETAIL: "de ##old## Ã ##new##",
SCOPE_CHANGED: "Portée des paramètres : ##scope##",
DOWNLOAD_PARTIAL_STARTED: "Téléchargement de ##loaded## pages sur ##total## ; ##pending## sont encore en cours de chargement",
DOWNLOAD_PARTIAL_NOTE: "Les pages pas encore chargées sont exclues et listées dans failed_pages.txt dans le ZIP.",
DOWNLOAD_NOTHING_LOADED: "Aucune page n'a encore fini de charger. Réessayez dans un instant.",
DOWNLOAD_BUSY: "Un téléchargement est déjà en cours",
DOWNLOAD_CANCELLED: "Téléchargement annulé",
DOWNLOAD_SAVED: "##file## enregistré",
DOWNLOAD_FAILED: "Échec du téléchargement : ##error##",
DOWNLOAD_SKIPPED_PAGES: "Pages ignorées : ##pages##",
LOCAL_FILES_OPEN: "Ouvrir des fichiers locaux",
LOCAL_FILES_IMAGES: "Images",
LOCAL_FILES_FOLDER: "Dossier",
LOCAL_FILES_ARCHIVE: "Archive ZIP / CBZ",
LOCAL_FILES_HINT: "Choisissez des images, un dossier d'images ou une archive ZIP/CBZ. Rien n'est envoyé : votre navigateur lit les fichiers.",
LOCAL_FILES_READING: "Lecture de ##file##...",
LOCAL_FILES_UNSUPPORTED: "Fichier non pris en charge : ##file## (pris en charge : images, dossiers, ZIP/CBZ ; pas RAR/CBR ni 7z)",
LOCAL_FILES_EMPTY: "Aucune image trouvée dans ##file##",
LOCAL_FILES_ERROR: "Impossible de lire ##file## : ##error##",
LOCAL_FILES_TITLE: "Images locales",
LOCAL_FILES_ALREADY_OPEN: "Le lecteur est déjà ouvert. Rechargez la page pour ouvrir d'autres fichiers.",
PAGE_RANGE_NEED_NUMBERS: "Veuillez saisir deux numéros de page.",
PAGE_RANGE_INVALID: "Les pages doivent être comprises entre 1 et ##total##, première <= dernière.",
// Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
SCROLL_PREPARING: "Préparation des pages...",
SCROLL_PROGRESS: "Défilement et attente du chargement des pages : ##num## / ##total##",
CLICK_PROGRESS: "Ouverture des pages une par une : ##num## / ##total##"
}
];
// ==========================================================================================
// ==== 08. SETTINGS STORE ===================================================================
// WHERE settings live (identical to the 2025 and 2026 versions, so users keep their settings):
// - userscript manager key "settings" -> GLOBAL settings (all sites)
// - userscript manager key "<site hostname>" -> LOCAL settings (one site), active only when
// the local object has `enabled: true`
// Only values that DIFFER from the defaults are stored ("diff-only" persistence).
// Keys in NEVER_LOCAL_KEYS are always global (language, bookmarks, keyboard shortcuts).
//
// HOW to use it from other sections:
// getSetting(key) - current effective value
// setSessionSetting(key, value) - change for this page only (not stored)
// saveSetting(key, value) - change AND store (always writes; fixes the 2026 bug where a
// previous session change made "save" skip storing)
// onSettingChange(key, fn) - fn(newValue, oldValue, key) after a change; key '*' = all
// Full description: docs/04-settings-and-storage.md.
// ==========================================================================================
/** Defaults for every setting. Values from the 2026 version, plus product decisions D1-D7 (docs/04). */
const DEFAULT_SETTINGS = {
bookmarks: [],
colorScheme: 'dark',
deviceMode: 'auto',
downloadZip: false,
enableComments: true,
enabled: false,
fitWidthIfOversize: true,
header: 'scroll',
hidePageControls: false,
lazyLoadImages: false,
lazyLoadRule: 'distance',
lazyStart: 50,
loadMode: 'wait',
loadSpeed: 'Safe',
locale: 'en_US',
maxReload: 5,
minZoom: 30,
navbar: 'bottom',
pagination: 'disabled',
scrollHeight: 25,
theme: '#29487D',
viewMode: 'WebComic',
zoomMode: 'percent',
zoomStep: 30,
zoomValue: 100,
keybinds: {
SCROLL_UP: ['up', 'W', 'num_8'],
SCROLL_DOWN: ['down', 'S', 'num_2'],
NEXT_CHAPTER: ['right', '/', 'D', 'num_6'],
PREVIOUS_CHAPTER: ['left', ';', 'A', 'num_4'],
RETURN_CHAPTER_LIST: ['backspace', 'del', 'num_decimal'],
ENLARGE: ['=', 'num_add', 'E'],
REDUCE: ['-', 'num_subtract', 'Q'],
RESTORE: ['9', 'R'],
FIT_WIDTH: ['0', 'num_multiply', 'F'],
FIT_HEIGHT: ['H', 'num_0'],
SETTINGS: ['num_divide', 'num_5', 'X'],
VIEW_MODE_WEBCOMIC: ['C'],
VIEW_MODE_VERTICAL: ['V'],
VIEW_MODE_LEFT: ['N'],
VIEW_MODE_RIGHT: ['B'],
VIEW_MODE_GALLERY: ['G'],
SCROLL_START: ['space'],
INCREASE_SPEED: ['.'],
DECREASE_SPEED: [','],
TOGGLE_CONTROLS: ['L']
}
};
/** Overrides applied on phones and tablets. */
const MOBILE_SETTING_OVERRIDES = {
lazyLoadImages: true,
fitWidthIfOversize: true,
navbar: 'disabled',
viewMode: 'WebComic',
header: 'scroll',
hidePageControls: true,
pagination: 'disabled'
};
/** Overrides applied in the standalone local-file reader. */
const STANDALONE_SETTING_OVERRIDES = {
loadSpeed: 'All',
lazyLoadImages: false,
downloadZip: false
};
/** Settings that are always global, even when local (per-site) settings are on. */
const NEVER_LOCAL_KEYS = ['locale', 'bookmarks', 'keybinds'];
/** Storage key of the global settings. */
const GLOBAL_SETTINGS_KEY = 'settings';
/** Storage key of this site's local settings. */
const LOCAL_SETTINGS_KEY = window.location.hostname;
/**
* Whether the mobile defaults apply, decided ONCE at startup. Device detection also looks at
* the window width, so deciding at every save would make the stored diff depend on the window
* size at that moment (docs/04 section 8.2 item 10).
*/
const DEFAULTS_FOR_MOBILE = isMobileDevice();
/**
* Builds a FRESH defaults object (never shared, so nobody can change the defaults by accident).
* @param {boolean} forGlobal - True for global defaults, false for local (per-site) defaults.
* @returns {Object}
*/
function buildDefaultSettings(forGlobal) {
let defaults = deepClone(DEFAULT_SETTINGS);
defaults.theme = forGlobal ? '#29487D' : '#004526';
if (DEFAULTS_FOR_MOBILE) {
defaults = Object.assign(defaults, deepClone(MOBILE_SETTING_OVERRIDES));
}
if (isStandalone()) {
defaults = Object.assign(defaults, deepClone(STANDALONE_SETTING_OVERRIDES));
}
return defaults;
}
/**
* Keys of the 2025 version whose meaning maps 1:1 onto a key of this version (only the name
* changed). Migrated ONCE when a stored settings object is read (migrateOldSettingKeys).
* Not listed on purpose (no 1:1 meaning, see docs/analysis/m4-followup-notes.md):
* showThumbnails (-> navbar), throttlePageLoad (-> loadSpeed), customTheme / themeShade
* (-> theme), verticalSeparator (no equivalent).
*/
const OLD_SETTING_RENAMES = {
defaultZoom: 'zoomValue'
};
/**
* True when a value of an old key can be copied as the value of its new key: same type as
* the new key's default, and for numbers a finite number above 0.
* @param {string} newKey - Key of this version.
* @param {*} value - Stored value of the old key.
* @returns {boolean}
*/
function isUsableOldSettingValue(newKey, value) {
const defaultValue = DEFAULT_SETTINGS[newKey];
if (typeof value !== typeof defaultValue) {
return false;
}
if (typeof value === 'number') {
return isFinite(value) && value > 0;
}
return true;
}
/**
* One-time migration of renamed keys of the 2025 version (OLD_SETTING_RENAMES). For each old
* key in `stored`: if the new key is absent and the value is usable, the value is copied to
* the new key; the old key is removed in every case (this version never reads it, and the
* 2026 version re-saved it forever, docs/04 D8). Pure: does not read or write storage.
* @param {Object} stored - Stored settings object (not changed).
* @param {string} storageKey - Storage key, only for the log.
* @returns {{settings: Object, changes: string[]}} Migrated copy and one text per change.
*/
function migrateOldSettingKeys(stored, storageKey) {
const settings = deepClone(stored);
const changes = [];
Object.keys(OLD_SETTING_RENAMES).forEach(function (oldKey) {
if (!Object.prototype.hasOwnProperty.call(settings, oldKey)) {
return;
}
const newKey = OLD_SETTING_RENAMES[oldKey];
const oldValue = settings[oldKey];
if (Object.prototype.hasOwnProperty.call(settings, newKey)) {
changes.push(oldKey + ' removed (' + newKey + ' already stored: ' + JSON.stringify(settings[newKey]) + ', old value ' + JSON.stringify(oldValue) + ' ignored)');
} else if (isUsableOldSettingValue(newKey, oldValue)) {
settings[newKey] = oldValue;
changes.push(oldKey + ' ' + JSON.stringify(oldValue) + ' -> ' + newKey);
} else {
changes.push(oldKey + ' removed (value ' + JSON.stringify(oldValue) + ' is not a valid ' + newKey + ')');
}
delete settings[oldKey];
});
if (changes.length > 0) {
logDebug('settings migration (' + storageKey + '): ' + changes.join('; '));
}
return { settings: settings, changes: changes };
}
/**
* Applies migrateOldSettingKeys to a stored object and, when something changed, writes the
* migrated object back (so the migration runs once) and logs it once. Never throws: on any
* error the stored object is used unchanged.
* @param {Object} stored - Stored settings object.
* @param {string} storageKey - GLOBAL_SETTINGS_KEY or LOCAL_SETTINGS_KEY.
* @returns {Object} The object to use (migrated, or `stored` on error / no change).
*/
function migrateStoredSettings(stored, storageKey) {
return safeRun('settings.migrate', function () {
const result = migrateOldSettingKeys(stored, storageKey);
if (result.changes.length === 0) {
return stored;
}
const saved = writeStoredValue(storageKey, result.settings);
log('Settings of the 2025 version migrated in "' + storageKey + '"' + (saved ? '' : ' (not saved: no storage available)') + ': ' + result.changes.join('; '));
return result.settings;
}, stored, { storageKey: storageKey, oldKeys: Object.keys(OLD_SETTING_RENAMES).join(', ') });
}
/**
* Reads one stored settings object, migrates renamed keys of the 2025 version once, and
* completes it with defaults.
* @param {string} storageKey - GLOBAL_SETTINGS_KEY or LOCAL_SETTINGS_KEY.
* @param {boolean} forGlobal - Which defaults to use.
* @returns {Object}
*/
function loadSettingsObject(storageKey, forGlobal) {
const stored = readStoredValue(storageKey, {});
if (stored !== null && stored !== undefined && !isPlainObject(stored)) {
logWarn('settings: stored value is not an object, ignoring it', storageKey, stored);
return buildDefaultSettings(forGlobal);
}
const migrated = isPlainObject(stored) ? migrateStoredSettings(stored, storageKey) : stored;
return fillDefaults(migrated, buildDefaultSettings(forGlobal));
}
/** The internal state of the settings store. Only this section touches it directly. */
const SETTINGS_STATE = {
global: loadSettingsObject(GLOBAL_SETTINGS_KEY, true),
local: loadSettingsObject(LOCAL_SETTINGS_KEY, false),
effective: null,
listeners: {}
};
/** True when this site uses its own (local) settings. */
function isLocalScopeEnabled() {
return SETTINGS_STATE.local.enabled === true;
}
/**
* True when `key` is read from / written to the local settings right now.
* @param {string} key - Setting name.
* @returns {boolean}
*/
function usesLocalValue(key) {
return isLocalScopeEnabled() && NEVER_LOCAL_KEYS.indexOf(key) === -1;
}
/**
* Recomputes the effective settings (global, or local + always-global keys).
* @returns {Object}
*/
function computeEffectiveSettings() {
if (!isLocalScopeEnabled()) {
return deepClone(SETTINGS_STATE.global);
}
const effective = deepClone(SETTINGS_STATE.local);
NEVER_LOCAL_KEYS.forEach(function (key) {
effective[key] = deepClone(SETTINGS_STATE.global[key]);
});
return effective;
}
SETTINGS_STATE.effective = computeEffectiveSettings();
/**
* Current value of a setting. Returns a copy for objects/arrays, so callers cannot change the
* store by accident.
* @param {string} key - Setting name.
* @returns {*}
*/
function getSetting(key) {
if (!Object.prototype.hasOwnProperty.call(SETTINGS_STATE.effective, key)) {
logWarn('settings: unknown setting requested', key);
return undefined;
}
return deepClone(SETTINGS_STATE.effective[key]);
}
/**
* Registers a listener called after a setting changes.
* @param {string} key - Setting name, or '*' for every change.
* @param {function(*, *, string): void} listener - Receives (newValue, oldValue, key).
*/
function onSettingChange(key, listener) {
if (!SETTINGS_STATE.listeners[key]) {
SETTINGS_STATE.listeners[key] = [];
}
SETTINGS_STATE.listeners[key].push(listener);
}
/**
* Calls the listeners of one key and the '*' listeners. A failing listener is logged and does
* not stop the others.
* @param {string} key - Setting name.
* @param {*} newValue - New value.
* @param {*} oldValue - Previous value.
*/
function notifySettingListeners(key, newValue, oldValue) {
const specific = SETTINGS_STATE.listeners[key] || [];
const general = SETTINGS_STATE.listeners['*'] || [];
specific.concat(general).forEach(function (listener) {
safeRun('settings.listener', function () {
listener(deepClone(newValue), deepClone(oldValue), key);
}, undefined, { key: key });
});
}
/**
* Changes a setting for THIS PAGE ONLY (not stored). Example: header zoom buttons.
* @param {string} key - Setting name.
* @param {*} value - New value.
* @returns {boolean} True when the value actually changed.
*/
function setSessionSetting(key, value) {
const oldValue = SETTINGS_STATE.effective[key];
if (deepEqual(oldValue, value)) {
return false;
}
SETTINGS_STATE.effective[key] = deepClone(value);
logDebug('settings: session change', key, value);
notifySettingListeners(key, value, oldValue);
return true;
}
/**
* Stores the global or local settings object (only the values that differ from defaults).
* @param {boolean} toGlobal - True for global, false for local.
*/
function persistSettingsObject(toGlobal) {
if (toGlobal) {
writeStoredValue(GLOBAL_SETTINGS_KEY, diffObjects(SETTINGS_STATE.global, buildDefaultSettings(true)));
} else {
const localDiff = diffObjects(SETTINGS_STATE.local, buildDefaultSettings(false));
localDiff.enabled = SETTINGS_STATE.local.enabled === true;
writeStoredValue(LOCAL_SETTINGS_KEY, localDiff);
}
}
/**
* Changes a setting AND stores it (in local settings when this site uses them and the key may
* be local, otherwise in global settings). Always writes, even if the screen already shows it.
* @param {string} key - Setting name.
* @param {*} value - New value.
*/
function saveSetting(key, value) {
const toLocal = usesLocalValue(key);
const previousStored = deepClone(toLocal ? SETTINGS_STATE.local[key] : SETTINGS_STATE.global[key]);
if (toLocal) {
SETTINGS_STATE.local[key] = deepClone(value);
} else {
SETTINGS_STATE.global[key] = deepClone(value);
}
persistSettingsObject(!toLocal);
log('Setting saved (' + (toLocal ? 'this site' : 'global') + '):', key, value);
setSessionSetting(key, value);
notifySettingSavedListeners(key, value, previousStored);
}
/** Listeners called after a setting was SAVED by the user (not on session-only changes). */
const SETTING_SAVED_LISTENERS = [];
/**
* Registers a listener called after saveSetting() stored a value. Used for the "setting
* changed" toasts, which the 2026 version showed for every change, saved or not (docs/04
* section 8.2 item 12 asks for saved changes only).
* @param {function(string, *, *): void} listener - Receives (key, newValue, previousStoredValue).
*/
function onSettingSaved(listener) {
SETTING_SAVED_LISTENERS.push(listener);
}
/**
* Calls the "saved" listeners. A failing listener is logged and does not stop the others.
* @param {string} key - Setting name.
* @param {*} newValue - Saved value.
* @param {*} previousStored - Value stored before.
*/
function notifySettingSavedListeners(key, newValue, previousStored) {
SETTING_SAVED_LISTENERS.forEach(function (listener) {
safeRun('settings.savedListener', function () {
listener(key, deepClone(newValue), previousStored);
}, undefined, { key: key });
});
}
/**
* The STORED value of a setting (what a reload would show), ignoring changes made for this page
* only with setSessionSetting. Reads the local settings when this site uses them for that key.
* @param {string} key - Setting name.
* @returns {*} A copy of the stored value.
*/
function getStoredSetting(key) {
const source = usesLocalValue(key) ? SETTINGS_STATE.local : SETTINGS_STATE.global;
return deepClone(source[key]);
}
/**
* Undoes a session-only change: the setting gets its stored value back (listeners run when the
* value changes). Example: leaving a horizontal view mode restores the stored zoom and header.
* @param {string} key - Setting name.
* @returns {boolean} True when the value changed.
*/
function restoreStoredSetting(key) {
return setSessionSetting(key, getStoredSetting(key));
}
/**
* Switches this site between its own settings and the global settings, then refreshes every
* setting so the screen follows (fixes the 2026 bug where nothing refreshed).
* @param {boolean} useLocal - True to use per-site settings.
*/
function setLocalScope(useLocal) {
SETTINGS_STATE.local.enabled = useLocal === true;
persistSettingsObject(false);
reloadEffectiveSettings('scope changed to ' + (useLocal ? 'local' : 'global'));
}
/**
* Resets the settings of the current scope to defaults (bookmarks and keybinds are kept when
* resetting local settings, because they are global).
*/
function resetSettings() {
if (isLocalScopeEnabled()) {
SETTINGS_STATE.local = buildDefaultSettings(false);
SETTINGS_STATE.local.enabled = true;
persistSettingsObject(false);
} else {
SETTINGS_STATE.global = buildDefaultSettings(true);
deleteStoredValue(GLOBAL_SETTINGS_KEY);
}
reloadEffectiveSettings('reset');
}
/**
* Recomputes the effective settings and notifies every key that changed.
* @param {string} reason - For the log.
*/
function reloadEffectiveSettings(reason) {
const previous = SETTINGS_STATE.effective;
SETTINGS_STATE.effective = computeEffectiveSettings();
log('Settings reloaded:', reason);
Object.keys(SETTINGS_STATE.effective).forEach(function (key) {
if (!deepEqual(previous[key], SETTINGS_STATE.effective[key])) {
notifySettingListeners(key, SETTINGS_STATE.effective[key], previous[key]);
}
});
}
// Cross-tab sync: another tab saved settings -> load them here too.
listenToStoredValue(GLOBAL_SETTINGS_KEY, debounce(function (newValue) {
SETTINGS_STATE.global = fillDefaults(newValue, buildDefaultSettings(true));
reloadEffectiveSettings('global settings changed in another tab');
}, CONFIG.settingsSyncDebounceMs));
listenToStoredValue(LOCAL_SETTINGS_KEY, debounce(function (newValue) {
SETTINGS_STATE.local = fillDefaults(newValue, buildDefaultSettings(false));
reloadEffectiveSettings('site settings changed in another tab');
}, CONFIG.settingsSyncDebounceMs));
// ---- Bookmarks (the script's own "resume reading" marks, always global) ----
/**
* Page number bookmarked for a chapter URL, or 0 when there is none.
* @param {string} [url=window.location.href] - Chapter URL.
* @returns {number}
*/
function getBookmarkedPage(url) {
const chapterUrl = url || window.location.href;
const bookmarks = getSetting('bookmarks') || [];
for (let i = 0; i < bookmarks.length; i++) {
if (bookmarks[i] && bookmarks[i].url === chapterUrl) {
return parseInt(bookmarks[i].page, 10) || 0;
}
}
return 0;
}
/**
* Saves (or replaces) the bookmark of a chapter. Stored immediately (fixes both old versions,
* which only kept bookmarks in memory). Format of both old versions (docs/04 section 4.1):
* { name, url, page, date: 'YYYY-MM-DD' }.
* @param {number} page - Page number to resume from.
* @param {string} [url=window.location.href] - Chapter URL.
* @param {string} [name] - Chapter title shown in the bookmarks list (default: the hostname).
*/
function saveBookmark(page, url, name) {
const chapterUrl = url || window.location.href;
const others = (getSetting('bookmarks') || []).filter(function (bookmark) {
return bookmark && bookmark.url !== chapterUrl;
});
const title = typeof name === 'string' && name.trim() !== '' ? name.trim() : window.location.hostname;
others.push({ name: title, url: chapterUrl, page: page, date: new Date().toISOString().slice(0, 10) });
saveSetting('bookmarks', others);
}
/**
* Removes the bookmark of a chapter, if any.
* @param {string} [url=window.location.href] - Chapter URL.
* @returns {boolean} True when a bookmark was removed.
*/
function removeBookmark(url) {
const chapterUrl = url || window.location.href;
const bookmarks = getSetting('bookmarks') || [];
const remaining = bookmarks.filter(function (bookmark) {
return bookmark && bookmark.url !== chapterUrl;
});
if (remaining.length === bookmarks.length) {
return false;
}
saveSetting('bookmarks', remaining);
return true;
}
// ---- Locale lookup (needs getSetting, so it lives after the settings store) ----
/**
* Returns the UI text for `key` in the user's language, falling back to English, then to a
* visible placeholder (so a missing translation is easy to spot on screen).
* @param {string} key - Text key, e.g. 'BUTTON_START'.
* @returns {string}
*/
function getLocaleString(key) {
const wantedId = getSetting('locale');
let language = null;
for (let i = 0; i < LOCALES.length; i++) {
if (LOCALES[i].ID === wantedId) {
language = LOCALES[i];
}
}
if (language && typeof language[key] === 'string') {
return language[key];
}
const english = LOCALES[0];
if (typeof english[key] === 'string') {
if (language) {
logDebug('locale: missing "' + key + '" in ' + wantedId + ', using English');
}
return english[key];
}
logWarn('locale: unknown text key', key);
return '##MISSING_STRING_' + key + '##';
}
// ==========================================================================================
// ==== 09. APP STATE ========================================================================
// Runtime values that are NOT settings and are never stored: which site won, the manga object,
// the status of every page image, the current page. One object, with small accessors.
// ==========================================================================================
/** Possible states of one page image. */
const IMAGE_STATUS = {
WAITING: 'waiting',
LOADING: 'loading',
LOADED: 'loaded',
FAILED: 'failed'
};
/** The runtime state. Only change it through the functions below. */
const APP_STATE = {
site: null,
manga: null,
device: detectDevice(),
currentPage: 0,
images: {},
pageLayouts: {},
panel: 'none',
keybindsEditing: false,
// M3: the bookmark page used to resume this chapter (0 = none), removed once the last page
// is reached (section 20); comments element captured from the site (section 20); true while
// openViewer() prepares the takeover (protects against opening twice).
resumedBookmarkPage: 0,
comments: null,
opening: false,
listeners: []
};
/**
* Registers a listener for app state changes.
* @param {function(string, *): void} listener - Receives (what, value), e.g. ('image', {page, status}).
*/
function onAppStateChange(listener) {
APP_STATE.listeners.push(listener);
}
/**
* Notifies app state listeners. A failing listener is logged and does not stop the others.
* @param {string} what - Name of the change.
* @param {*} value - Data of the change.
*/
function notifyAppState(what, value) {
APP_STATE.listeners.forEach(function (listener) {
safeRun('appState.listener', function () {
listener(what, value);
}, undefined, { what: what });
});
}
/**
* Returns the record of one page image, creating it when missing.
* @param {number} page - Page number (1-based).
* @returns {{page: number, status: string, src: string, attempts: number}}
*/
function getImageRecord(page) {
if (!APP_STATE.images[page]) {
APP_STATE.images[page] = { page: page, status: IMAGE_STATUS.WAITING, src: '', attempts: 0 };
}
return APP_STATE.images[page];
}
/**
* Updates the record of one page image and notifies listeners.
* @param {number} page - Page number.
* @param {Object} changes - Fields to change (status, src, attempts).
*/
function updateImageRecord(page, changes) {
const record = Object.assign(getImageRecord(page), changes);
notifyAppState('image', record);
}
/**
* Returns the layout record of one page (size, zoom, hidden, double page), creating it when
* missing. Kept apart from the image record so layout changes never trigger 'image' listeners.
* width/height null = automatic size.
* @param {number} page - Page number (1-based).
* @returns {{naturalWidth: number, naturalHeight: number, width: (number|null), height: (number|null), hidden: boolean, doublePage: boolean, processedSource: string}}
*/
function getPageLayout(page) {
if (!APP_STATE.pageLayouts[page]) {
APP_STATE.pageLayouts[page] = { naturalWidth: 0, naturalHeight: 0, width: null, height: null, hidden: false, doublePage: false, processedSource: '' };
}
return APP_STATE.pageLayouts[page];
}
/**
* Sets the page that is in view and notifies listeners ('currentPage', n) when it changed.
* @param {number} page - Page number.
* @returns {boolean} True when it changed.
*/
function setCurrentPage(page) {
if (typeof page !== 'number' || isNaN(page) || page === APP_STATE.currentPage) {
return false;
}
APP_STATE.currentPage = page;
logDebug('current page', page);
notifyAppState('currentPage', page);
return true;
}
/**
* Counts page images by status.
* @returns {{total: number, loaded: number, failed: number, loading: number}}
*/
function countImages() {
const counts = { total: 0, loaded: 0, failed: 0, loading: 0 };
Object.keys(APP_STATE.images).forEach(function (page) {
const status = APP_STATE.images[page].status;
counts.total += 1;
if (status === IMAGE_STATUS.LOADED) {
counts.loaded += 1;
} else if (status === IMAGE_STATUS.FAILED) {
counts.failed += 1;
} else if (status === IMAGE_STATUS.LOADING) {
counts.loading += 1;
}
});
return counts;
}
// ==========================================================================================
// ==== 10. COLORS AND THEMES ================================================================
// Pure color math (no DOM access) and the CSS variables of the active theme.
// - A color is a hex string '#RRGGBB', or an {r, g, b} object with channels 0-255.
// - The 2026 version wrote its palettes in oklch(). Older browsers cannot read oklch() in CSS,
// so the palettes below were converted ONCE to hex with okLchToHex() (same math as here,
// including the CSS Color 4 gamut mapping). okLchToHex() is also used at run time to build
// the 11 shades of the user's theme color, exactly like the 2026 version did.
// - resolveThemeColor() accepts every value the `theme` setting may hold: '#RGB', '#RRGGBB',
// 'rgb()', 'hsl()', 'oklch()' (saved by the 2026 version) and the palette names saved by the
// 2025 version ('darkblue', 'darkgreen', ...). Anything else falls back to navy (as 2026).
// - buildThemeCss() returns the CSS-variable block for the theme color + color scheme. The
// rest of the CSS (section 11) only uses these variables, never a fixed theme color.
// ==========================================================================================
/** Recommended dark theme colors (2026 version "sample" colors, NEW:4423-4435). */
const RECOMMENDED_THEME_COLORS = {
navy: '#001F3F',
darkblue: '#1E4F7A',
blue: '#1A2F4B',
darkgreen: '#062925',
green: '#1A3636',
grass: '#1B3C53',
teal: '#044A42',
darkpurple: '#1B0044',
purple: '#363062',
grape: '#31326F',
maroon: '#44000D'
};
/**
* Palette names stored by the 2025 version in the `theme` setting, mapped to the color the 2025
* version showed with its default shade 600 (OLD:2310-2583, default `themeShade` 600 OLD:2091).
*/
const LEGACY_THEME_COLORS = {
dark: '#25262B',
gray: '#868E96',
red: '#FA5252',
wine: '#B71D15',
pink: '#E64980',
grape: '#BE4BDB',
violet: '#7950F2',
purple: '#4D21AB',
indigo: '#4C6EF5',
blue: '#228BE6',
darkblue: '#314E89',
cyan: '#15AABF',
teal: '#12B886',
green: '#40C057',
darkgreen: '#263E3A',
moss: '#62AF7A',
greener: '#408E3E',
lime: '#82C91E',
yellow: '#FAB005',
golden: '#BF990D',
orange: '#FD7E14'
};
/**
* The 2026 version's color palettes (NEW:4441-4738) converted from oklch() to hex.
* Family -> shade (50 lightest ... 950 darkest) -> '#RRGGBB'.
*/
const COLOR_PALETTES = {
dark: { 50: '#C1C2C5', 100: '#A6A7AB', 200: '#909296', 300: '#5C5F66', 400: '#373A40', 500: '#2C2E33', 600: '#25262B', 700: '#1A1B1E', 800: '#141517', 900: '#101113', 950: '#000000' },
slate: { 50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CAD5E2', 400: '#90A1B9', 500: '#62748E', 600: '#45556C', 700: '#314158', 800: '#1D293D', 900: '#0F172B', 950: '#020618' },
gray: { 50: '#F9FAFB', 100: '#F3F4F6', 200: '#E5E7EB', 300: '#D1D5DC', 400: '#99A1AF', 500: '#6A7282', 600: '#4A5565', 700: '#364153', 800: '#1E2939', 900: '#101828', 950: '#030712' },
zinc: { 50: '#FAFAFA', 100: '#F4F4F5', 200: '#E4E4E7', 300: '#D4D4D8', 400: '#9F9FA9', 500: '#71717B', 600: '#52525C', 700: '#3F3F46', 800: '#27272A', 900: '#18181B', 950: '#09090B' },
neutral: { 50: '#FAFAFA', 100: '#F5F5F5', 200: '#E5E5E5', 300: '#D4D4D4', 400: '#A1A1A1', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0A0A0A' },
stone: { 50: '#FAFAF9', 100: '#F5F5F4', 200: '#E7E5E4', 300: '#D6D3D1', 400: '#A6A09B', 500: '#79716B', 600: '#57534D', 700: '#44403B', 800: '#292524', 900: '#1C1917', 950: '#0C0A09' },
red: { 50: '#FEF2F2', 100: '#FFE2E2', 200: '#FFC9C9', 300: '#FFA2A2', 400: '#FF6467', 500: '#FB2C36', 600: '#E7000B', 700: '#C10007', 800: '#9F0712', 900: '#82181A', 950: '#460809' },
orange: { 50: '#FFF7ED', 100: '#FFEDD4', 200: '#FFD6A7', 300: '#FFB86A', 400: '#FF8904', 500: '#FF6900', 600: '#F54900', 700: '#CA3500', 800: '#9F2D00', 900: '#7E2A0C', 950: '#441306' },
amber: { 50: '#FFFBEB', 100: '#FEF3C6', 200: '#FEE685', 300: '#FFD230', 400: '#FFBA00', 500: '#FD9A00', 600: '#E17100', 700: '#BB4D00', 800: '#973C00', 900: '#7B3306', 950: '#461901' },
yellow: { 50: '#FEFCE8', 100: '#FEF9C2', 200: '#FFF085', 300: '#FFDF20', 400: '#FCC800', 500: '#EFB100', 600: '#D08700', 700: '#A65F00', 800: '#894B00', 900: '#733E0A', 950: '#432004' },
lime: { 50: '#F7FEE7', 100: '#ECFCCA', 200: '#D8F999', 300: '#BBF451', 400: '#9AE600', 500: '#7CCF00', 600: '#5EA500', 700: '#497D00', 800: '#3C6300', 900: '#35530E', 950: '#192E03' },
green: { 50: '#F0FDF4', 100: '#DCFCE7', 200: '#B9F8CF', 300: '#7BF1A8', 400: '#05DF72', 500: '#00C950', 600: '#00A63E', 700: '#008236', 800: '#016630', 900: '#0D542B', 950: '#032E15' },
emerald: { 50: '#ECFDF5', 100: '#D0FAE5', 200: '#A4F4CF', 300: '#5EE9B5', 400: '#00D492', 500: '#00BC7D', 600: '#009966', 700: '#007A55', 800: '#006045', 900: '#004F3B', 950: '#002C22' },
teal: { 50: '#F0FDFA', 100: '#CBFBF1', 200: '#96F7E4', 300: '#46ECD5', 400: '#00D5BE', 500: '#00BBA7', 600: '#009689', 700: '#00786F', 800: '#005F5A', 900: '#0B4F4A', 950: '#022F2E' },
cyan: { 50: '#ECFEFF', 100: '#CEFAFE', 200: '#A2F4FD', 300: '#53EAFD', 400: '#00D3F2', 500: '#00B8DB', 600: '#0092B8', 700: '#007595', 800: '#005F78', 900: '#104E64', 950: '#053345' },
sky: { 50: '#F0F9FF', 100: '#DFF2FE', 200: '#B8E6FE', 300: '#74D4FF', 400: '#00BCFF', 500: '#00A6F4', 600: '#0084D1', 700: '#0069A8', 800: '#00598A', 900: '#024A70', 950: '#052F4A' },
blue: { 50: '#EFF6FF', 100: '#DBEAFE', 200: '#BEDBFF', 300: '#8EC5FF', 400: '#51A2FF', 500: '#2B7FFF', 600: '#155DFC', 700: '#1447E6', 800: '#193CB8', 900: '#1C398E', 950: '#162456' },
indigo: { 50: '#EEF2FF', 100: '#E0E7FF', 200: '#C6D2FF', 300: '#A3B3FF', 400: '#7C86FF', 500: '#615FFF', 600: '#4F39F6', 700: '#432DD7', 800: '#372AAC', 900: '#312C85', 950: '#1E1A4D' },
violet: { 50: '#F5F3FF', 100: '#EDE9FE', 200: '#DDD6FF', 300: '#C4B4FF', 400: '#A684FF', 500: '#8E51FF', 600: '#7F22FE', 700: '#7008E7', 800: '#5D0EC0', 900: '#4D179A', 950: '#2F0D68' },
purple: { 50: '#FAF5FF', 100: '#F3E8FF', 200: '#E9D4FF', 300: '#DAB2FF', 400: '#C27AFF', 500: '#AD46FF', 600: '#9810FA', 700: '#8200DB', 800: '#6E11B0', 900: '#59168B', 950: '#3C0366' },
fuchsia: { 50: '#FDF4FF', 100: '#FAE8FF', 200: '#F6CFFF', 300: '#F4A8FF', 400: '#ED6AFF', 500: '#E12AFB', 600: '#C800DE', 700: '#A800B7', 800: '#8A0194', 900: '#721378', 950: '#4B004F' },
pink: { 50: '#FDF2F8', 100: '#FCE7F3', 200: '#FCCEE8', 300: '#FDA5D5', 400: '#FB64B6', 500: '#F6339A', 600: '#E60076', 700: '#C6005C', 800: '#A3004C', 900: '#861043', 950: '#510424' },
rose: { 50: '#FFF1F2', 100: '#FFE4E6', 200: '#FFCCD3', 300: '#FFA1AD', 400: '#FF637E', 500: '#FF2056', 600: '#EC003F', 700: '#C70036', 800: '#A50036', 900: '#8B0836', 950: '#4D0218' }
};
/** Shade keys of every palette, lightest first. */
const PALETTE_SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
/** Used when a stored theme color cannot be read (same fallback as the 2026 version). */
const FALLBACK_THEME_COLOR = RECOMMENDED_THEME_COLORS.navy;
/** Text colors chosen by getReadableTextColor(). */
const TEXT_ON_DARK = '#FFFFFF';
const TEXT_ON_LIGHT = '#000000';
/**
* Rounds and limits a color channel to 0-255.
* @param {number} value - Channel value.
* @returns {number}
*/
function clampChannel(value) {
return clamp(Math.round(value), 0, 255);
}
/**
* Turns an {r, g, b} color (0-255) into '#RRGGBB' (upper case).
* @param {{r: number, g: number, b: number}} rgb - The color.
* @returns {string}
*/
function rgbToHex(rgb) {
return '#' + [rgb.r, rgb.g, rgb.b].map(function (channel) {
const text = clampChannel(channel).toString(16).toUpperCase();
return text.length === 1 ? '0' + text : text;
}).join('');
}
/**
* Reads '#RGB' or '#RRGGBB' (with or without '#'). Returns null for anything else.
* @param {string} text - Hex color.
* @returns {{r: number, g: number, b: number}|null}
*/
function parseHexColor(text) {
if (typeof text !== 'string') {
return null;
}
let hex = text.trim().replace(/^#/, '');
if (/^[0-9a-f]{3}$/i.test(hex)) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (!/^[0-9a-f]{6}$/i.test(hex)) {
return null;
}
return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16) };
}
/**
* Converts {r, g, b} (0-255) to {h, s, l} (hue 0-360, saturation and lightness 0-100).
* @param {{r: number, g: number, b: number}} rgb - The color.
* @returns {{h: number, s: number, l: number}}
*/
function rgbToHsl(rgb) {
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l: lightness * 100 };
}
const delta = max - min;
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
let hue = 0;
if (max === r) {
hue = (g - b) / delta + (g < b ? 6 : 0);
} else if (max === g) {
hue = (b - r) / delta + 2;
} else {
hue = (r - g) / delta + 4;
}
return { h: hue * 60, s: saturation * 100, l: lightness * 100 };
}
/**
* Converts {h, s, l} (hue 0-360, saturation and lightness 0-100) to {r, g, b} (0-255).
* @param {{h: number, s: number, l: number}} hsl - The color.
* @returns {{r: number, g: number, b: number}}
*/
function hslToRgb(hsl) {
const hue = (((hsl.h % 360) + 360) % 360) / 360;
const saturation = clamp(hsl.s, 0, 100) / 100;
const lightness = clamp(hsl.l, 0, 100) / 100;
if (saturation === 0) {
return { r: lightness * 255, g: lightness * 255, b: lightness * 255 };
}
const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
/** One channel of the standard HSL formula. */
function hueToChannel(t) {
let position = t;
if (position < 0) {
position += 1;
}
if (position > 1) {
position -= 1;
}
if (position < 1 / 6) {
return p + (q - p) * 6 * position;
}
if (position < 1 / 2) {
return q;
}
if (position < 2 / 3) {
return p + (q - p) * (2 / 3 - position) * 6;
}
return p;
}
return { r: hueToChannel(hue + 1 / 3) * 255, g: hueToChannel(hue) * 255, b: hueToChannel(hue - 1 / 3) * 255 };
}
/**
* Changes the HSL lightness of a hex color by `delta` points (-100..100).
* @param {string} hex - '#RRGGBB'.
* @param {number} delta - Points to add (negative darkens).
* @returns {string} New hex color; the input unchanged when it is not a hex color.
*/
function adjustLightness(hex, delta) {
const rgb = parseHexColor(hex);
if (!rgb) {
return hex;
}
const hsl = rgbToHsl(rgb);
hsl.l = clamp(hsl.l + delta, 0, 100);
return rgbToHex(hslToRgb(hsl));
}
/**
* Lighter version of a color (HSL lightness + amount, like tinycolor's lighten).
* @param {string} hex - '#RRGGBB'.
* @param {number} amount - Points 0-100.
* @returns {string}
*/
function lightenColor(hex, amount) {
return adjustLightness(hex, Math.abs(amount));
}
/**
* Darker version of a color (HSL lightness - amount, like tinycolor's darken).
* @param {string} hex - '#RRGGBB'.
* @param {number} amount - Points 0-100.
* @returns {string}
*/
function darkenColor(hex, amount) {
return adjustLightness(hex, -Math.abs(amount));
}
/**
* sRGB channel (0-1, gamma encoded) to linear light.
* @param {number} value - Channel 0-1.
* @returns {number}
*/
function srgbToLinear(value) {
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
}
/**
* Linear light to sRGB channel (0-1, gamma encoded).
* @param {number} value - Linear channel.
* @returns {number}
*/
function linearToSrgb(value) {
if (value <= 0.0031308) {
return 12.92 * value;
}
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
}
/**
* {r, g, b} (0-255) to OKLab [L, a, b] (Bjorn Ottosson's published matrices).
* @param {{r: number, g: number, b: number}} rgb - The color.
* @returns {number[]}
*/
function rgbToOkLab(rgb) {
const r = srgbToLinear(rgb.r / 255);
const g = srgbToLinear(rgb.g / 255);
const b = srgbToLinear(rgb.b / 255);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
];
}
/**
* {r, g, b} (0-255) to OKLCH {l (0-1), c, h (degrees)}.
* @param {{r: number, g: number, b: number}} rgb - The color.
* @returns {{l: number, c: number, h: number}}
*/
function rgbToOkLch(rgb) {
const lab = rgbToOkLab(rgb);
const chroma = Math.sqrt(lab[1] * lab[1] + lab[2] * lab[2]);
let hue = Math.atan2(lab[2], lab[1]) * 180 / Math.PI;
if (hue < 0) {
hue += 360;
}
return { l: lab[0], c: chroma, h: chroma < 0.0000001 ? 0 : hue };
}
/**
* OKLCH to gamma-encoded sRGB channels 0-1 (may be outside 0-1 when out of gamut).
* @param {number} lightness - L, 0-1.
* @param {number} chroma - C, 0-0.4.
* @param {number} hue - h in degrees.
* @returns {number[]} [r, g, b]
*/
function okLchToUnitRgb(lightness, chroma, hue) {
const radians = hue * Math.PI / 180;
const a = chroma * Math.cos(radians);
const b = chroma * Math.sin(radians);
const l = Math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3);
const m = Math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3);
const s = Math.pow(lightness - 0.0894841775 * a - 1.2914855480 * b, 3);
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
].map(linearToSrgb);
}
/**
* True when every channel is inside 0-1 (with a tiny tolerance for rounding).
* @param {number[]} unitRgb - [r, g, b] 0-1.
* @returns {boolean}
*/
function isUnitRgbInGamut(unitRgb) {
return unitRgb.every(function (channel) {
return channel >= -0.000075 && channel <= 1.000075;
});
}
/**
* Limits every channel to 0-1.
* @param {number[]} unitRgb - [r, g, b].
* @returns {number[]}
*/
function clipUnitRgb(unitRgb) {
return unitRgb.map(function (channel) {
return clamp(channel, 0, 1);
});
}
/**
* Distance in OKLab between an sRGB color and an OKLCH color (deltaE OK).
* @param {number[]} unitRgb - [r, g, b] 0-1.
* @param {number} lightness - L.
* @param {number} chroma - C.
* @param {number} hue - h in degrees.
* @returns {number}
*/
function oklabDistance(unitRgb, lightness, chroma, hue) {
const lab = rgbToOkLab({ r: unitRgb[0] * 255, g: unitRgb[1] * 255, b: unitRgb[2] * 255 });
const radians = hue * Math.PI / 180;
const dl = lab[0] - lightness;
const da = lab[1] - chroma * Math.cos(radians);
const db = lab[2] - chroma * Math.sin(radians);
return Math.sqrt(dl * dl + da * da + db * db);
}
/**
* OKLCH to '#RRGGBB'. Colors outside sRGB are brought inside with the CSS Color 4 gamut-mapping
* algorithm (lower the chroma until clipping changes the color less than 0.02 deltaE OK), which
* is what browsers and Color.js do, so the result matches what the 2026 version showed.
* @param {number} lightness - L, 0-1.
* @param {number} chroma - C.
* @param {number} hue - h in degrees.
* @returns {string}
*/
function okLchToHex(lightness, chroma, hue) {
/**
* Hex color of an RGB triple with channels from 0 to 1.
* @param {number[]} unitRgb - [r, g, b] in 0..1.
* @returns {string}
*/
const toHex = function (unitRgb) {
return rgbToHex({ r: unitRgb[0] * 255, g: unitRgb[1] * 255, b: unitRgb[2] * 255 });
};
if (lightness >= 1) {
return '#FFFFFF';
}
if (lightness <= 0) {
return '#000000';
}
let unitRgb = okLchToUnitRgb(lightness, chroma, hue);
if (isUnitRgbInGamut(unitRgb)) {
return toHex(clipUnitRgb(unitRgb));
}
const justNoticeable = 0.02;
const epsilon = 0.0001;
let low = 0;
let high = chroma;
let lowInGamut = true;
let clipped = clipUnitRgb(unitRgb);
if (oklabDistance(clipped, lightness, chroma, hue) < justNoticeable) {
return toHex(clipped);
}
while (high - low > epsilon) {
const middle = (low + high) / 2;
unitRgb = okLchToUnitRgb(lightness, middle, hue);
if (lowInGamut && isUnitRgbInGamut(unitRgb)) {
low = middle;
continue;
}
clipped = clipUnitRgb(unitRgb);
const distance = oklabDistance(clipped, lightness, middle, hue);
if (distance < justNoticeable) {
if (justNoticeable - distance < epsilon) {
return toHex(clipped);
}
lowInGamut = false;
low = middle;
} else {
high = middle;
}
}
return toHex(clipped);
}
/**
* Reads a number that may end with '%'. With `percentScale`, '50%' becomes 0.5 * percentScale.
* @param {string} text - e.g. '44.6%' or '0.446'.
* @param {number} percentScale - Value of 100%.
* @returns {number} NaN when not a number.
*/
function parseCssNumber(text, percentScale) {
const trimmed = String(text).trim();
if (/%$/.test(trimmed)) {
return parseFloat(trimmed) / 100 * percentScale;
}
return parseFloat(trimmed);
}
/**
* Splits the inside of 'name(a b c)' or 'name(a, b, c / alpha)' into its first three parts.
* @param {string} text - CSS color function.
* @param {string} name - Function name, e.g. 'rgb'.
* @returns {string[]|null}
*/
function readCssFunctionArguments(text, name) {
const match = new RegExp('^' + name + 'a?\\(([^)]*)\\)$', 'i').exec(text.trim());
if (!match) {
return null;
}
const parts = match[1].split('/')[0].trim().split(/[\s,]+/);
return parts.length >= 3 ? parts.slice(0, 3) : null;
}
/**
* Reads a CSS color written as hex, rgb()/rgba(), hsl()/hsla() or oklch(). Returns null for
* anything else (CSS color names are not supported; 2025 palette names are handled by
* resolveThemeColor).
* @param {string} text - CSS color.
* @returns {{r: number, g: number, b: number}|null}
*/
function parseCssColor(text) {
if (typeof text !== 'string') {
return null;
}
const hex = parseHexColor(text);
if (hex) {
return hex;
}
const rgbParts = readCssFunctionArguments(text, 'rgb');
if (rgbParts) {
const rgb = { r: parseCssNumber(rgbParts[0], 255), g: parseCssNumber(rgbParts[1], 255), b: parseCssNumber(rgbParts[2], 255) };
return isNaN(rgb.r) || isNaN(rgb.g) || isNaN(rgb.b) ? null : { r: clampChannel(rgb.r), g: clampChannel(rgb.g), b: clampChannel(rgb.b) };
}
const hslParts = readCssFunctionArguments(text, 'hsl');
if (hslParts) {
const hsl = { h: parseFloat(hslParts[0]), s: parseCssNumber(hslParts[1], 100), l: parseCssNumber(hslParts[2], 100) };
return isNaN(hsl.h) || isNaN(hsl.s) || isNaN(hsl.l) ? null : hslToRgb(hsl);
}
const okLchParts = readCssFunctionArguments(text, 'OKLCH');
if (okLchParts) {
const lightness = parseCssNumber(okLchParts[0], 1);
const chroma = parseCssNumber(okLchParts[1], 0.4);
const hue = parseFloat(okLchParts[2]);
return isNaN(lightness) || isNaN(chroma) || isNaN(hue) ? null : parseHexColor(okLchToHex(lightness, chroma, hue));
}
return null;
}
/**
* Turns a stored `theme` value into '#RRGGBB'. Accepts CSS colors (see parseCssColor) and the
* 2025 version's palette names. Falls back to navy and logs a warning for anything else.
* @param {*} theme - Value of the `theme` setting.
* @returns {string}
*/
function resolveThemeColor(theme) {
if (typeof theme === 'string') {
const legacyName = theme.trim().toLowerCase();
if (Object.prototype.hasOwnProperty.call(LEGACY_THEME_COLORS, legacyName)) {
return LEGACY_THEME_COLORS[legacyName];
}
const rgb = parseCssColor(theme);
if (rgb) {
return rgbToHex(rgb);
}
}
logWarn('theme: unreadable theme color, using navy instead', theme);
return FALLBACK_THEME_COLOR;
}
/**
* CIE lightness L* (0 black - 100 white) of a hex color.
* @param {string} hex - '#RRGGBB'.
* @returns {number}
*/
function getLightnessStar(hex) {
const rgb = parseHexColor(hex) || { r: 0, g: 0, b: 0 };
const luminance = 0.2126 * srgbToLinear(rgb.r / 255) + 0.7152 * srgbToLinear(rgb.g / 255) + 0.0722 * srgbToLinear(rgb.b / 255);
return luminance > 216 / 24389 ? 116 * Math.cbrt(luminance) - 16 : luminance * 24389 / 27;
}
/**
* True when white text contrasts better than black text on this color. Same rule as the 2026
* version (Color.js "Lstar" contrast = difference of L*), i.e. L* below 50.
* @param {string} hex - '#RRGGBB'.
* @returns {boolean}
*/
function isDarkColor(hex) {
const lightness = getLightnessStar(hex);
return 100 - lightness > lightness;
}
/**
* Black or white, whichever is more readable on the given background.
* @param {string} hex - Background '#RRGGBB'.
* @returns {string} '#FFFFFF' or '#000000'.
*/
function getReadableTextColor(hex) {
return isDarkColor(hex) ? TEXT_ON_DARK : TEXT_ON_LIGHT;
}
/**
* 11 shades (lightest first) of a color, built like the 2026 version's default "steps" palette
* (NEW:4915-4946): the same chroma and hue at OKLCH lightness 95%, 90%, 80% ... 10%, 5%; the
* step closest to the base color's own lightness is replaced by the base color itself.
* @param {string} hex - Base '#RRGGBB'.
* @returns {string[]} 11 hex colors.
*/
function generateColorGradient(hex) {
const base = parseHexColor(hex) ? hex.toUpperCase() : FALLBACK_THEME_COLOR;
const okLch = rgbToOkLch(parseHexColor(base));
const lightnessSteps = [0.95, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.05];
let closestIndex = 0;
lightnessSteps.forEach(function (step, index) {
if (Math.abs(step - okLch.l) < Math.abs(lightnessSteps[closestIndex] - okLch.l)) {
closestIndex = index;
}
});
return lightnessSteps.map(function (step, index) {
return index === closestIndex ? base : okLchToHex(step, okLch.c, okLch.h);
});
}
/**
* The CSS variables of a theme, as a plain object (name -> value). Values follow the 2026
* version's themesCSS (NEW:11542-11602) with its light-scheme defect fixed (the light block
* had an invalid selector there, NEW:11578-11579).
* @param {string} theme - Value of the `theme` setting (any format resolveThemeColor reads).
* @param {string} colorScheme - 'dark' or 'light'.
* @returns {Object<string, string>}
*/
function buildThemeVariables(theme, colorScheme) {
const primary = resolveThemeColor(theme);
const gradient = generateColorGradient(primary);
const isLight = colorScheme === 'light';
const secondary = isLight ? gradient[2] : gradient[8];
const base = isLight ? COLOR_PALETTES.gray : COLOR_PALETTES.dark;
return {
'--mvm-primary': primary,
'--mvm-primary-text': getReadableTextColor(primary),
'--mvm-secondary': secondary,
'--mvm-secondary-text': getReadableTextColor(secondary),
'--mvm-background': isLight ? base['50'] : base['600'],
'--mvm-surface': isLight ? base['100'] : base['700'],
'--mvm-highlight': base['500'],
'--mvm-border': isLight ? base['100'] : base['400'],
'--mvm-text': isLight ? base['900'] : base['50'],
'--mvm-fill-quiet': isLight ? gradient[0] : gradient[9],
'--mvm-border-quiet': isLight ? gradient[1] : gradient[8],
'--mvm-border-normal': isLight ? gradient[2] : gradient[7],
'--mvm-border-loud': isLight ? gradient[4] : gradient[6],
'--mvm-on-quiet': isLight ? gradient[6] : gradient[4]
};
}
/**
* CSS text of the theme: the variables on the reader root (also when it has the light/dark
* class, so these rules win over the defaults in section 11) and the page background.
* @param {string} theme - Value of the `theme` setting.
* @param {string} colorScheme - 'dark' or 'light'.
* @returns {string}
*/
function buildThemeCss(theme, colorScheme) {
const variables = buildThemeVariables(theme, colorScheme);
const declarations = Object.keys(variables).map(function (name) {
return ' ' + name + ': ' + variables[name] + ';';
});
return [
'#MangaViewerMM, #MangaViewerMM.light, #MangaViewerMM.dark {',
declarations.join('\n'),
' color-scheme: ' + (colorScheme === 'light' ? 'light' : 'dark') + ';',
'}',
'html[mvm], html[mvm] body { background-color: ' + variables['--mvm-background'] + '; }'
].join('\n');
}
// ==========================================================================================
// ==== 11. STYLES ===========================================================================
// CSS as arrays of lines. Rules for this file (browser support, see docs/01 section 6):
// no :has(), no nested CSS rules, no oklch(), no color-mix(). Every reader rule starts with
// #MangaViewerMM so it never leaks into other pages; popup rules use the .mvm-popup prefix.
// ==========================================================================================
// ---- Icons (Tabler icons, MIT license, as used by the 2025 and 2026 versions) ----
// Only the drawing (the SVG children) is stored; buildIconSvg() adds the <svg> wrapper. The
// first child of every Tabler icon is an invisible 24x24 box; it is added back by the wrapper so
// the icon color rules (ICON_COLOR_CSS, copied from NEW:2481) still count children correctly.
/** Icon name -> { strokeWidth, paths }. Extracted from NEW:2481-2780. */
const ICONS = {
bookmark: { strokeWidth: '2', paths: '<path d="M18 7v14l-6 -4l-6 4v-14a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4z"/>' },
bookmarkOff: { strokeWidth: '2', paths: '<path d="M7.708 3.721a3.982 3.982 0 0 1 2.292 -.721h4a4 4 0 0 1 4 4v7m0 4v3l-6 -4l-6 4v-14c0 -.308 .035 -.609 .1 -.897"/><path d="M3 3l18 18"/>' },
zoomIn: { strokeWidth: '2', paths: '<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M7 10l6 0"/><path d="M10 7l0 6"/><path d="M21 21l-6 -6"/>' },
zoomCancel: { strokeWidth: '2', paths: '<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M8 8l4 4"/><path d="M12 8l-4 4"/><path d="M21 21l-6 -6"/>' },
zoomOut: { strokeWidth: '2', paths: '<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M7 10l6 0"/><path d="M21 21l-6 -6"/>' },
arrowAutofitHeight: { strokeWidth: '2', paths: '<path d="M12 20h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h6"/><path d="M18 14v7"/><path d="M18 3v7"/><path d="M15 18l3 3l3 -3"/><path d="M15 6l3 -3l3 3"/>' },
arrowAutofitWidth: { strokeWidth: '2', paths: '<path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6"/><path d="M10 18h-7"/><path d="M21 18h-7"/><path d="M6 15l-3 3l3 3"/><path d="M18 15l3 3l-3 3"/>' },
spacingHorizontal: { strokeWidth: '2', paths: '<path d="M20 20h-2a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h2"/><path d="M4 20h2a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"/><path d="M12 8v8"/>' },
eye: { strokeWidth: '2', paths: '<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/><path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"/>' },
eyeOff: { strokeWidth: '2', paths: '<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828"/><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"/><path d="M3 3l18 18"/>' },
refresh: { strokeWidth: '2', paths: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>' },
listNumbers: { strokeWidth: '2', paths: '<path d="M11 6h9"/><path d="M11 12h9"/><path d="M12 18h8"/><path d="M4 16a2 2 0 1 1 4 0c0 .591 -.5 1 -1 1.5l-3 2.5h4"/><path d="M6 10v-6l-2 2"/>' },
spacingVertical: { strokeWidth: '2', paths: '<path d="M4 20v-2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v2"/><path d="M4 4v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M16 12h-8"/>' },
arrowAutofitDown: { strokeWidth: '2', paths: '<path d="M12 20h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h8"/><path d="M18 4v17"/><path d="M15 18l3 3l3 -3"/>' },
arrowAutofitRight: { strokeWidth: '2', paths: '<path d="M20 12v-6a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v8"/><path d="M4 18h17"/><path d="M18 15l3 3l-3 3"/>' },
arrowAutofitLeft: { strokeWidth: '2', paths: '<path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v8"/><path d="M20 18h-17"/><path d="M6 15l-3 3l3 3"/>' },
bookArrowRight: { strokeWidth: '2', paths: '<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 1.006 -.5"/><path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"/><path d="M3 6v13"/><path d="M12 6v13"/><path d="M21 6v6"/><path d="M16 19h6"/><path d="M19 16l3 3l-3 3"/>' },
bookArrowLeft: { strokeWidth: '2', paths: '<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 1.006 -.5"/><path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"/><path d="M3 6v13"/><path d="M12 6v13"/><path d="M21 6v6"/><path d="M16 19h6"/><path d="M19 16l-3 3l3 3"/>' },
layoutDashboard: { strokeWidth: '1.5', paths: '<path d="M5 4h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1"/><path d="M5 16h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1"/><path d="M15 12h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1"/><path d="M15 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1"/>' },
zoomInArea: { strokeWidth: '2', paths: '<path d="M15 13v4"/><path d="M13 15h4"/><path d="M15 15m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0"/><path d="M22 22l-3 -3"/><path d="M6 18h-1a2 2 0 0 1 -2 -2v-1"/><path d="M3 11v-1"/><path d="M3 6v-1a2 2 0 0 1 2 -2h1"/><path d="M10 3h1"/><path d="M15 3h1a2 2 0 0 1 2 2v1"/>' },
zoomPan: { strokeWidth: '2', paths: '<path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M17 17l-2.5 -2.5"/><path d="M10 5l2 -2l2 2"/><path d="M19 10l2 2l-2 2"/><path d="M5 10l-2 2l2 2"/><path d="M10 19l2 2l2 -2"/>' },
zoomOutArea: { strokeWidth: '2', paths: '<path d="M13 15h4"/><path d="M15 15m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0"/><path d="M22 22l-3 -3"/><path d="M6 18h-1a2 2 0 0 1 -2 -2v-1"/><path d="M3 11v-1"/><path d="M3 6v-1a2 2 0 0 1 2 -2h1"/><path d="M10 3h1"/><path d="M15 3h1a2 2 0 0 1 2 2v1"/>' },
booksReturn: { strokeWidth: '2', paths: '<defs><mask id="arrow-mask"><rect width="24" height="24" fill="white"/><rect x="15" y="15" width="8" height="8" fill="black"/></mask></defs><path d="M5 5a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1l0 -14"/><path d="M9 5a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1l0 -14"/><path d="M5 8h4"/><path d="M9 16h4"/><g mask="url(#arrow-mask)"><path d="M13.803 4.56l2.184 -.53c.562 -.135 1.133 .19 1.282 .732l3.695 13.418a1.02 1.02 0 0 1 -.634 1.219l-.133 .041l-2.184 .53c-.562 .135 -1.133 -.19 -1.282 -.732l-3.695 -13.418a1.02 1.02 0 0 1 .634 -1.219l.133 -.041"/><path d="M14 9l4 -1"/><path d="M16 16l3.923 -.98"/></g><path d="M16 19h6"/><path d="M19 16l-3 3l3 3"/>' },
arrowBigLeft: { strokeWidth: '2', paths: '<path d="M20 15h-8v3.586a1 1 0 0 1 -1.707 .707l-6.586 -6.586a1 1 0 0 1 0 -1.414l6.586 -6.586a1 1 0 0 1 1.707 .707v3.586h8a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1z"/>' },
arrowBigRight: { strokeWidth: '2', paths: '<path d="M4 9h8v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1z"/>' },
chevronLeft: { strokeWidth: '2', paths: '<path d="M15 6l-6 6l6 6"/>' },
chevronRight: { strokeWidth: '2', paths: '<path d="M9 6l6 6l-6 6"/>' },
menu2: { strokeWidth: '2', paths: '<path d="M4 6l16 0"/><path d="M4 12l16 0"/><path d="M4 18l16 0"/>' },
category: { strokeWidth: '2', paths: '<path d="M4 4h6v6h-6z"/><path d="M14 4h6v6h-6z"/><path d="M4 14h6v6h-6z"/><path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/>' },
photo: { strokeWidth: '2', paths: '<path d="M15 8h.01"/><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>' },
photoOff: { strokeWidth: '2', paths: '<path d="M15 8h.01"/><path d="M7 3h11a3 3 0 0 1 3 3v11m-.856 3.099a2.991 2.991 0 0 1 -2.144 .901h-12a3 3 0 0 1 -3 -3v-12c0 -.845 .349 -1.608 .91 -2.153"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M16.33 12.338c.574 -.054 1.155 .166 1.67 .662l3 3"/><path d="M3 3l18 18" color="orange"/>' },
// Panels (M2 part B), from NEW:2598-2748.
settings: { strokeWidth: '2', paths: '<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>' },
settingsOff: { strokeWidth: '2', paths: '<path d="M9.451 5.437c.418 -.218 .75 -.609 .874 -1.12c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35c-.486 .118 -.894 .44 -1.123 .878m-.188 3.803c-.517 .523 -1.349 .734 -2.125 .262a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.472 -.774 -.262 -1.604 .259 -2.121"/><path d="M9.889 9.869a3 3 0 1 0 4.226 4.26m.592 -3.424a3.012 3.012 0 0 0 -1.419 -1.415"/><path d="M3 3l18 18"/>' },
keyboard: { strokeWidth: '2', paths: '<path d="M2 6m0 2a2 2 0 0 1 2 -2h16a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-16a2 2 0 0 1 -2 -2z"/><path d="M6 10l0 .01"/><path d="M10 10l0 .01"/><path d="M14 10l0 .01"/><path d="M18 10l0 .01"/><path d="M6 14l0 .01"/><path d="M18 14l0 .01"/><path d="M10 14l4 .01"/>' },
x: { strokeWidth: '2', paths: '<path d="M18 6l-12 12"/><path d="M6 6l12 12"/>' },
pencil: { strokeWidth: '2', paths: '<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4"/><path d="M13.5 6.5l4 4"/>' },
deviceFloppy: { strokeWidth: '2', paths: '<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M14 4l0 4l-6 0l0 -4"/>' },
moon: { strokeWidth: '2', paths: '<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"/>' },
sun: { strokeWidth: '2', paths: '<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7"/>' },
// Features (M3), from NEW:2487-2742 (alert-circle 2487, book-upload 2541, bookmarks 2550,
// circle-check 2589, circle-x 2592, external-link 2622, file-download 2631, folder-open
// 2637, info-circle 2646, message 2688, player-pause 2718, player-play 2721, trash 2742).
playerPlay: { strokeWidth: '2', paths: '<path d="M7 4v16l13 -8z"/>' },
playerPause: { strokeWidth: '2', paths: '<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z"/><path d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z"/>' },
bookmarks: { strokeWidth: '2', paths: '<path d="M15 10v11l-5 -3l-5 3v-11a3 3 0 0 1 3 -3h4a3 3 0 0 1 3 3z"/><path d="M11 3h5a3 3 0 0 1 3 3v11"/>' },
fileDownload: { strokeWidth: '2', paths: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/><path d="M12 17v-6"/><path d="M9.5 14.5l2.5 2.5l2.5 -2.5"/>' },
message: { strokeWidth: '2', paths: '<path d="M8 9h8"/><path d="M8 13h6"/><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z"/>' },
trash: { strokeWidth: '2', paths: '<path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>' },
externalLink: { strokeWidth: '2', paths: '<path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6"/><path d="M11 13l9 -9"/><path d="M15 4h5v5"/>' },
folderOpen: { strokeWidth: '1.5', paths: '<path d="M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2"/>' },
circleCheck: { strokeWidth: '2', paths: '<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M9 12l2 2l4 -4"/>' },
alertCircle: { strokeWidth: '2', paths: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 8v4"/><path d="M12 16h.01"/>' },
infoCircle: { strokeWidth: '2', paths: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 9h.01"/><path d="M11 12h1v4h1"/>' },
circleX: { strokeWidth: '2', paths: '<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 10l4 4m0 -4l-4 4"/>' },
bookUpload: { strokeWidth: '2', paths: '<path d="M14 20h-8a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12v5"/><path d="M11 16h-5a2 2 0 0 0 -2 2"/><path d="M15 16l3 -3l3 3"/><path d="M18 13v9"/>' }
};
/** Kebab-case class name of an icon, e.g. 'zoomInArea' -> 'zoom-in-area'. */
function getIconClassName(name) {
return name.replace(/([a-z])([A-Z0-9])/g, '$1-$2').replace(/([0-9])([A-Za-z])/g, '$1-$2').toLowerCase();
}
/**
* SVG markup of one icon (class "icon-tabler icon-tabler-<name>"). Unknown names give '' and a
* warning, so a typo never breaks the reader.
* @param {string} name - Key of ICONS, e.g. 'zoomIn'.
* @returns {string}
*/
function buildIconSvg(name) {
const icon = ICONS[name];
if (!icon) {
logWarn('icon: unknown icon name', name);
return '';
}
return [
'<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-', getIconClassName(name), '" width="24" height="24" viewBox="0 0 24 24"',
' stroke-width="', icon.strokeWidth, '" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
'<path stroke="none" d="M0 0h24v24H0z" fill="none"/>', icon.paths, '</svg>'
].join('');
}
/**
* An icon as a CSS url() value (used for image placeholders).
* @param {string} name - Key of ICONS.
* @param {string} color - Stroke color, e.g. '#C0C0C0'.
* @returns {string}
*/
function buildIconCssUrl(name, color) {
const svg = buildIconSvg(name).replace('stroke="currentColor"', 'stroke="' + color + '"');
return 'url("data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + '")';
}
/** Two-tone icon colors (NEW:2481), scoped to the reader. */
const ICON_COLOR_CSS = [
'#MangaViewerMM .icon-tabler { width: 1rem; height: 1rem; vertical-align: sub; flex-shrink: 0; }',
'#MangaViewerMM .icon-tabler-arrow-autofit-width > :nth-child(n + 3), #MangaViewerMM .icon-tabler-arrow-autofit-height > :nth-child(n + 3) { color: yellow; }',
'#MangaViewerMM .icon-tabler-zoom-in-area > :nth-child(2), #MangaViewerMM .icon-tabler-zoom-in-area > :nth-child(3) { color: lime; }',
'#MangaViewerMM .icon-tabler-zoom-out-area > :nth-child(2) { color: red; }',
'#MangaViewerMM .icon-tabler-zoom-pan > :nth-child(n + 4) { color: #9966ff; }',
'#MangaViewerMM .icon-tabler-arrow-autofit-down > :nth-child(n + 3), #MangaViewerMM .icon-tabler-arrow-autofit-left > :nth-child(n + 3), #MangaViewerMM .icon-tabler-arrow-autofit-right > :nth-child(n + 3) { color: #28ffbf; }',
'#MangaViewerMM .icon-tabler-spacing-vertical > :nth-child(4), #MangaViewerMM .icon-tabler-spacing-horizontal > :nth-child(4) { color: fuchsia; }',
'#MangaViewerMM .icon-tabler-list-numbers > :nth-child(n + 5) { color: #e48900; }',
'#MangaViewerMM .icon-tabler-bookmark > :nth-child(2), #MangaViewerMM .icon-tabler-bookmark-off > :nth-child(2) { color: orange; }',
'#MangaViewerMM .icon-tabler-bookmark-off > :nth-child(3), #MangaViewerMM .icon-tabler-eye-off > :nth-child(4) { color: red; }',
'#MangaViewerMM .icon-tabler-zoom-cancel > :nth-child(3), #MangaViewerMM .icon-tabler-zoom-cancel > :nth-child(4) { color: #9966ff; }',
'#MangaViewerMM .icon-tabler-zoom-in > :nth-child(3), #MangaViewerMM .icon-tabler-zoom-in > :nth-child(4) { color: lime; }',
'#MangaViewerMM .icon-tabler-zoom-out > :nth-child(3) { color: red; }',
'#MangaViewerMM .icon-tabler-refresh > :nth-child(n + 2) { color: cyan; }',
'#MangaViewerMM .icon-tabler-book-arrow-left > :nth-child(7), #MangaViewerMM .icon-tabler-book-arrow-left > :nth-child(8), #MangaViewerMM .icon-tabler-book-arrow-right > :nth-child(7), #MangaViewerMM .icon-tabler-book-arrow-right > :nth-child(8) { color: greenyellow; }',
'#MangaViewerMM .icon-tabler-books-return > :nth-child(8), #MangaViewerMM .icon-tabler-books-return > :nth-child(9) { color: greenyellow; }'
].join('\n');
/** Colors per color scheme, as CSS variables on the reader root. */
const READER_BASE_CSS = [
'#MangaViewerMM {',
' --mvm-background: #25262b;',
' --mvm-surface: #1a1b1e;',
' --mvm-highlight: #2c2e33;',
' --mvm-border: #373a40;',
' --mvm-text: #c1c2c5;',
' --mvm-primary: #29487D;',
' --mvm-primary-text: #ffffff;',
' min-height: 100vh;',
' color: var(--mvm-text);',
' background-color: var(--mvm-background);',
' font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;',
' font-size: 14px;',
' line-height: 20px;',
'}',
'#MangaViewerMM.light {',
' --mvm-background: #f1f3f5;',
' --mvm-surface: #ffffff;',
' --mvm-highlight: #e9ecef;',
' --mvm-border: #ced4da;',
' --mvm-text: #212529;',
'}',
'html[mvm], html[mvm] body { margin: 0; padding: 0; background-color: #25262b; }',
'#MangaViewerMM a, #MangaViewerMM a:visited { color: var(--mvm-text); text-decoration: none; }',
'#MangaViewerMM .mvm-button {',
' display: inline-flex; align-items: center; justify-content: center; gap: 4px;',
' min-height: 32px; padding: 3px 10px; margin: 2px; cursor: pointer;',
' border: 1px solid var(--mvm-border); border-radius: 5px;',
' color: var(--mvm-primary-text); background-color: var(--mvm-primary); font-size: 14px;',
'}',
'#MangaViewerMM a.mvm-button, #MangaViewerMM a.mvm-button:visited { color: var(--mvm-primary-text); text-decoration: none; }',
'#MangaViewerMM .mvm-button:hover { opacity: 0.85; }',
'#MangaViewerMM .mvm-button.disabled, #MangaViewerMM .mvm-button[disabled] { opacity: 0.4; pointer-events: none; }',
'#MangaViewerMM #ProgressBar { position: fixed; top: 0; left: 0; width: 100%; height: 4px; z-index: 2000; pointer-events: none; }',
'#MangaViewerMM #ProgressBar .mvm-progress-fill { width: 0; height: 100%; background-color: #29d; transition: width 0.2s linear; }',
'#MangaViewerMM #ProgressBar.complete { display: none; }',
'#MangaViewerMM #Header {',
' display: flex; flex-flow: row wrap; justify-content: space-between; align-items: center;',
' padding: 5px 10px; background-color: var(--mvm-surface); border-bottom: 1px solid var(--mvm-border);',
'}',
'#MangaViewerMM .ViewerTitle { flex: 1 1 300px; text-align: center; padding: 5px; }',
'#MangaViewerMM #MangaTitle { margin: 0; font-size: 1.2rem; font-weight: 400; word-wrap: break-word; }',
'#MangaViewerMM #ChapterNavigation { display: flex; flex-flow: column nowrap; align-items: flex-end; padding: 5px; }',
'#MangaViewerMM #Counters { padding: 0 5px; font-family: monospace; }',
'#MangaViewerMM #Chapter { display: block; text-align: center; min-width: 225px; }',
'#MangaViewerMM .MangaPage { position: relative; display: block; width: 100%; text-align: center; line-height: 0; min-height: 22px; }',
'#MangaViewerMM #Chapter.Vertical .MangaPage { margin: 8px 0; }',
'#MangaViewerMM .PageContent { display: inline-block; max-width: 100%; overflow-x: auto; }',
'#MangaViewerMM .PageImg { height: auto; vertical-align: middle; border: 0; }',
'#MangaViewerMM #Chapter.fitWidthIfOversize .PageImg { max-width: 100%; }',
'#MangaViewerMM .PageImg:not([src]), #MangaViewerMM .PageImg.imgBroken {',
' display: inline-block; width: 40vw; height: 80vh; background-color: var(--mvm-highlight);',
'}',
'#MangaViewerMM .PageFunctions {',
' position: absolute; top: 0; right: 0; z-index: 1; display: flex; gap: 3px; align-items: center;',
' line-height: 1rem; font-family: monospace; opacity: 0.5;',
'}',
'#MangaViewerMM .PageFunctions:hover { opacity: 1; }',
'#MangaViewerMM.hideControls .PageFunctions { visibility: hidden; }',
'#MangaViewerMM .PageIndex { padding: 3px 6px; border-radius: 5px; color: var(--mvm-primary-text); background-color: var(--mvm-primary); }',
'#MangaViewerMM .PageFunctions .mvm-button { min-height: 22px; padding: 2px 6px; margin: 0; font-size: 12px; }',
'#MangaViewerMM .MangaPage.failed .PageIndex { background-color: #c92a2a; }'
].join('\n');
/**
* Reader layout: header modes, view modes, page controls, thumbnails bar, pagination and the
* phone/tablet rules. Same behavior as the 2026 version's CSS (NEW:9204, 9207, 9973, 7890-8019,
* 11512-11531), rewritten without nesting, :has(), :where(), selector lists inside :not(),
* oklch() and color-mix(). Colors come only from the theme variables (section 10).
*/
const READER_LAYOUT_CSS = [
// -- buttons and button groups --
'#MangaViewerMM .mvm-button .icon-tabler { width: 20px; height: 20px; }',
'#MangaViewerMM .mvm-button.icon-only { padding: 3px 6px; }',
'#MangaViewerMM .mvm-button.selected { color: var(--mvm-secondary-text); background-color: var(--mvm-secondary); }',
'#MangaViewerMM .mvm-button-group { display: inline-flex; flex-flow: row nowrap; margin: 2px; }',
'#MangaViewerMM .mvm-button-group .mvm-button { margin: 0; border-radius: 0; }',
'#MangaViewerMM .mvm-button-group .mvm-button:first-child { border-radius: 5px 0 0 5px; }',
'#MangaViewerMM .mvm-button-group .mvm-button:last-child { border-radius: 0 5px 5px 0; }',
// -- header and its modes (visibility is decided in JS, see section 14 "Header") --
'#MangaViewerMM #Header {',
' position: -webkit-sticky; position: sticky; top: 0; z-index: 900;',
' box-shadow: 0 0 25px rgba(0, 0, 0, 0.5); transition: transform 0.3s ease-in;',
'}',
'#MangaViewerMM #Header.simple { position: static; }',
'#MangaViewerMM #Header.header-hidden { transform: translateY(-100%); }',
'#MangaViewerMM #Header.click { padding-left: 60px; }',
'#MangaViewerMM #menu { display: none; position: fixed; top: 25px; left: 5px; z-index: 901; opacity: 0.8; width: 40px; height: 40px; }',
'#MangaViewerMM #menu.click { display: inline-flex; }',
'#MangaViewerMM #menu.click.hide { display: none; }',
'#MangaViewerMM #Toolbar { display: flex; flex-flow: row wrap; align-items: center; justify-content: center; }',
'#MangaViewerMM #ZoomControl { display: flex; flex-direction: column; align-items: center; padding: 5px; min-width: 100px; line-height: 1rem; }',
'#MangaViewerMM #ZoomSlider { width: 140px; margin: 4px 0 0; }',
'#MangaViewerMM #GoToPageControl { padding: 0 5px; }',
'#MangaViewerMM #GoToPage { min-width: 50px; margin: 2px; color: inherit; background-color: var(--mvm-background); border: 1px solid var(--mvm-border); }',
// -- chapter area, pages, placeholders --
'#MangaViewerMM #Chapter { box-sizing: border-box; }',
'#MangaViewerMM .PageContent { overflow-y: hidden; transition: height 0.3s ease-in-out; }',
'#MangaViewerMM .MangaPage.hide .PageContent { height: 0; }',
'#MangaViewerMM .PageImg:not([src]), #MangaViewerMM .PageImg.imgBroken {',
' background-position: center; background-repeat: no-repeat; background-size: 20%;',
'}',
'#MangaViewerMM .PageImg:not([src]) { background-image: __PHOTO_URL__; }',
'#MangaViewerMM .PageImg.imgBroken { background-image: __PHOTO_OFF_URL__; }',
'#MangaViewerMM .PageFunctions .mvm-button { padding: 3px; }',
'#MangaViewerMM .PageFunctions .mvm-button .icon-tabler { width: 1rem; height: 1rem; }',
'#MangaViewerMM .PageFunctions .ToggleDoublePage { display: none; }',
'#MangaViewerMM #Chapter.Book .PageFunctions .ToggleDoublePage, #MangaViewerMM #Chapter.Manga .PageFunctions .ToggleDoublePage { display: inline-flex; }',
'#MangaViewerMM #Chapter.Book .PageFunctions .ZoomWidth, #MangaViewerMM #Chapter.Manga .PageFunctions .ZoomWidth { display: none; }',
'#MangaViewerMM #Chapter.FluidLTR .PageFunctions .ZoomWidth, #MangaViewerMM #Chapter.FluidRTL .PageFunctions .ZoomWidth { display: none; }',
// two-icon buttons: bookmark / bookmark-off, eye-off (hide) / eye (show)
'#MangaViewerMM .Bookmark .icon-tabler-bookmark-off { display: none; }',
'#MangaViewerMM.bookmarked .Bookmark .icon-tabler-bookmark { display: none; }',
'#MangaViewerMM.bookmarked .Bookmark .icon-tabler-bookmark-off { display: inline-block; }',
'#MangaViewerMM .Hide .icon-tabler-eye { display: none; }',
'#MangaViewerMM .MangaPage.hide .Hide .icon-tabler-eye { display: inline-block; }',
'#MangaViewerMM .MangaPage.hide .Hide .icon-tabler-eye-off { display: none; }',
// -- view mode: Vertical (separators) --
'#MangaViewerMM .separator { display: none; }',
'#MangaViewerMM #Chapter.Vertical .separator { display: flex; align-items: center; text-align: center; font-style: italic; line-height: 20px; }',
'#MangaViewerMM #Chapter.Vertical .separator::before, #MangaViewerMM #Chapter.Vertical .separator::after { content: ""; flex: 1; border-bottom: 1px solid var(--mvm-text); }',
'#MangaViewerMM #Chapter.Vertical .separator::before { margin-right: 0.25em; }',
'#MangaViewerMM #Chapter.Vertical .separator::after { margin-left: 0.25em; }',
// -- view mode: Fluid (horizontal) --
'#MangaViewerMM #Chapter.FluidLTR, #MangaViewerMM #Chapter.FluidRTL { display: flex; flex-wrap: nowrap; overflow-x: auto; min-width: 0; }',
'#MangaViewerMM #Chapter.FluidLTR { flex-direction: row; }',
'#MangaViewerMM #Chapter.FluidRTL { flex-direction: row-reverse; }',
'#MangaViewerMM #Chapter.FluidLTR .MangaPage, #MangaViewerMM #Chapter.FluidRTL .MangaPage { width: auto; flex: 0 0 auto; min-width: -moz-fit-content; min-width: fit-content; }',
'#MangaViewerMM #Chapter.FluidLTR .PageImg, #MangaViewerMM #Chapter.FluidRTL .PageImg { min-width: 0; }',
'#MangaViewerMM #Chapter.FluidLTR.fitWidthIfOversize .PageImg, #MangaViewerMM #Chapter.FluidRTL.fitWidthIfOversize .PageImg { max-width: none; }',
'#MangaViewerMM #Chapter.FluidLTR .PageContent, #MangaViewerMM #Chapter.FluidRTL .PageContent { max-width: none; }',
'#MangaViewerMM #Chapter.FluidLTR .MangaPage .PageFunctions { right: auto; left: 0; direction: rtl; }',
// -- view mode: Book and Manga (two columns) --
'#MangaViewerMM #Chapter.Book, #MangaViewerMM #Chapter.Manga { display: grid; grid-template-columns: 1fr 1fr; grid-auto-flow: row; width: 100%; min-width: 0; }',
'#MangaViewerMM #Chapter.Book .MangaPage, #MangaViewerMM #Chapter.Manga .MangaPage { width: 100%; display: block; overflow: hidden; }',
'#MangaViewerMM #Chapter.Book .MangaPage.DoublePage, #MangaViewerMM #Chapter.Manga .MangaPage.DoublePage { grid-column: span 2; }',
'#MangaViewerMM #Chapter.Book .MangaPage.LeftPage .PageFunctions, #MangaViewerMM #Chapter.Manga .MangaPage.LeftPage .PageFunctions { right: auto; left: 0; flex-direction: row-reverse; }',
'#MangaViewerMM #Chapter.Book .PageContent, #MangaViewerMM #Chapter.Manga .PageContent { display: flex; justify-content: center; align-items: center; overflow: hidden; }',
'#MangaViewerMM #Chapter.Book .LeftPage .PageContent, #MangaViewerMM #Chapter.Manga .LeftPage .PageContent { justify-content: flex-end; }',
'#MangaViewerMM #Chapter.Book .RightPage .PageContent, #MangaViewerMM #Chapter.Manga .RightPage .PageContent { justify-content: flex-start; }',
'#MangaViewerMM #Chapter.Manga { direction: rtl; }',
'#MangaViewerMM #Chapter.Manga .MangaPage { direction: ltr; }',
// -- view mode: Gallery (margins instead of flex gap, which old Safari lacks) --
'#MangaViewerMM #Chapter.Gallery { display: flex; flex-wrap: wrap; justify-content: center; padding: 5px; }',
'#MangaViewerMM #Chapter.Gallery .MangaPage { width: auto; min-width: 0; flex: 0 1 auto; margin: 5px; }',
'#MangaViewerMM #Chapter.Gallery .PageImg { min-width: 0; }',
'#MangaViewerMM #Chapter.Gallery .PageFunctions { display: none; }',
// -- thumbnails bar (navbar) --
'#MangaViewerMM.navbar-bottom { padding-bottom: 34px; }',
'#MangaViewerMM.navbar-left { padding-left: 34px; }',
'#MangaViewerMM.navbar-right { padding-right: 34px; }',
'#MangaViewerMM #Navigation {',
' position: fixed; z-index: 1000; display: flex; box-sizing: border-box; overflow: hidden; white-space: nowrap;',
' text-align: center; line-height: 0; color: var(--mvm-text); background-color: var(--mvm-highlight);',
' transition: height 0.3s ease, width 0.3s ease, opacity 0.2s ease-in-out;',
'}',
'#MangaViewerMM #Navigation.disabled { display: none; }',
'#MangaViewerMM #Navigation.hiding { opacity: 0; transition: none; }',
'#MangaViewerMM #Navigation.horizontal { flex-direction: column; height: 34px; width: 100%; left: 0; right: 0; bottom: 0; }',
'#MangaViewerMM #Navigation.horizontal:hover { height: 200px; }',
'#MangaViewerMM #Navigation.vertical { flex-direction: row; width: 34px; top: 0; bottom: 0; }',
'#MangaViewerMM #Navigation.vertical:hover { width: 200px; }',
'#MangaViewerMM #Navigation.left { left: 0; flex-direction: row-reverse; }',
'#MangaViewerMM #Navigation.right { right: 0; }',
'#MangaViewerMM #NavigationCounters { flex-shrink: 0; padding: 5px; line-height: 1rem; display: flex; align-items: center; justify-content: center; }',
'#MangaViewerMM #NavigationCounters > * { margin: 0 3px; }',
'#MangaViewerMM #Navigation.vertical #NavigationCounters { -webkit-writing-mode: vertical-rl; writing-mode: vertical-rl; transform: rotate(180deg); }',
'#MangaViewerMM #Navigation.right #NavigationCounters { transform: rotate(0deg); }',
'#MangaViewerMM #Thumbnails { flex-grow: 1; display: none; }',
'#MangaViewerMM #Navigation:hover #Thumbnails { display: flex; }',
'#MangaViewerMM #Navigation.horizontal #Thumbnails { flex-direction: row; overflow-x: auto; overflow-y: hidden; }',
'#MangaViewerMM #Navigation.vertical #Thumbnails { flex-direction: column; overflow-x: hidden; overflow-y: auto; }',
'#MangaViewerMM #Navigation.left #Thumbnails { direction: rtl; }',
'#MangaViewerMM .Thumbnail { position: relative; display: inline-flex; flex: 0 0 auto; justify-content: center; align-items: center; width: 150px; height: 150px; margin: 0 5px; cursor: pointer; }',
'#MangaViewerMM #Navigation.vertical .Thumbnail { margin: 5px 0; }',
'#MangaViewerMM .ThumbnailIndex {',
' position: absolute; left: 0; bottom: 30%; z-index: 1; display: block; width: 100%; opacity: 0.9;',
' line-height: 1.2rem; text-align: center; font-weight: 600; color: var(--mvm-primary-text); background-color: var(--mvm-primary);',
'}',
'#MangaViewerMM .ThumbnailImg { display: inline-block; max-height: 150px; min-height: 150px; min-width: 80px; max-width: 150px; background-repeat: no-repeat; background-position: center; background-size: 48px 48px; }',
'#MangaViewerMM .ThumbnailImg:not([src]) { background-image: __PHOTO_URL__; }',
'#MangaViewerMM .ThumbnailImg.imgBroken { background-image: __PHOTO_OFF_URL__; }',
// -- pagination: slider bar and side arrows (shown by root classes pagination-*) --
'#MangaViewerMM .slider-pagination { display: none; position: fixed; left: 0; right: 0; bottom: 30px; z-index: 100; justify-content: center; align-items: center; }',
'#MangaViewerMM.navbar-bottom .slider-pagination { bottom: 40px; }',
'#MangaViewerMM.pagination-slider .slider-pagination, #MangaViewerMM.pagination-both .slider-pagination { display: flex; }',
'#MangaViewerMM .slider-pagination .mvm-button { min-width: 36px; height: 36px; }',
'#MangaViewerMM #PaginationSlider { flex: 1 1 auto; max-width: 1000px; margin: 0 5px; }',
'#MangaViewerMM .side-arrow {',
' display: none; position: fixed; top: 50px; bottom: 0; z-index: 99; width: 10vw; padding: 0; border: 0;',
' align-items: center; justify-content: center; cursor: pointer; opacity: 0; color: var(--mvm-primary-text);',
' background-color: transparent; transition: opacity 0.2s ease-in-out; -webkit-tap-highlight-color: transparent;',
'}',
'#MangaViewerMM.pagination-side-arrows .side-arrow, #MangaViewerMM.pagination-both .side-arrow { display: flex; }',
'#MangaViewerMM .side-arrow:hover { opacity: 1; background-color: rgba(0, 0, 0, 0.15); }',
'#MangaViewerMM .side-arrow.left { left: 0; }',
'#MangaViewerMM .side-arrow.right { right: 0; }',
'#MangaViewerMM .side-arrow .icon-tabler { width: 48px; height: 48px; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); }',
'#MangaViewerMM.pagination-side-arrows .side-arrow.unavailable, #MangaViewerMM.pagination-both .side-arrow.unavailable { display: none; }',
// -- phones and tablets (device class on the root, NEW:9207) --
'#MangaViewerMM.mobile #Header, #MangaViewerMM.tablet #Header { justify-content: center; }',
'#MangaViewerMM.mobile .ViewerTitle, #MangaViewerMM.tablet .ViewerTitle { order: 4; flex-basis: 100%; }',
'#MangaViewerMM.mobile .PageFunctions .mvm-button { display: none; }',
'#MangaViewerMM.mobile .PageFunctions .Bookmark { display: inline-flex; opacity: 1; }',
'#MangaViewerMM.mobile #ZoomControl, #MangaViewerMM.mobile #ZoomGroup, #MangaViewerMM.mobile #ViewModeGroup { display: none; }',
'#MangaViewerMM.tablet #ZoomSlider { display: none; }',
'#MangaViewerMM.tablet #ViewModeGroup .view-Book, #MangaViewerMM.tablet #ViewModeGroup .view-Manga { display: none; }'
].join('\n').replace(/__PHOTO_URL__/g, buildIconCssUrl('photo', '#C0C0C0')).replace(/__PHOTO_OFF_URL__/g, buildIconCssUrl('photoOff', '#C0C0C0'));
/**
* Side panels (settings on the left, keybindings on the right), their overlay and the generic
* settings controls (switch, segmented control, slider with value, select, color swatches).
* Look of the 2025 version's panels (OLD:4752, OLD:4764: fieldsets, value badges, red/green
* switch, key caps), colors from the theme variables. Hidden panels use visibility:hidden so
* keyboard focus never enters them.
*/
const PANELS_CSS = [
// -- overlay and drawers --
'#MangaViewerMM #Overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1900; background-color: rgba(0, 0, 0, 0.5); }',
'#MangaViewerMM #Overlay.visible { display: block; }',
'#MangaViewerMM .mvm-panel {',
' position: fixed; top: 0; bottom: 0; z-index: 1950; box-sizing: border-box; width: 360px; max-width: 100vw;',
' padding: 10px; overflow-y: auto; line-height: 1.5em; color: var(--mvm-text); background-color: var(--mvm-surface);',
' box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); visibility: hidden; transition: transform 0.3s ease-in-out, visibility 0s linear 0.3s;',
'}',
'#MangaViewerMM .mvm-panel.left { left: 0; transform: translateX(-100%); }',
'#MangaViewerMM .mvm-panel.right { right: 0; transform: translateX(100%); }',
'#MangaViewerMM .mvm-panel.visible { transform: translateX(0); visibility: visible; transition: transform 0.3s ease-in-out, visibility 0s; }',
'#MangaViewerMM .mvm-panel:focus { outline: none; }',
'#MangaViewerMM .mvm-panel-header { display: flex; flex-flow: row nowrap; align-items: center; justify-content: space-between; margin-bottom: 5px; }',
'#MangaViewerMM .mvm-panel-header h2 { flex: 1 1 auto; margin: 0; font-size: 1.3rem; font-weight: 600; }',
'#MangaViewerMM .mvm-panel-actions { display: flex; flex-flow: row nowrap; }',
'#MangaViewerMM #ResetSettings { width: 100%; margin: 0 0 5px; }',
// -- settings groups and rows --
'#MangaViewerMM #SettingsPanel fieldset { margin: 5px 0; padding: 3px 6px; border: 1px solid var(--mvm-border); border-radius: 10px; }',
'#MangaViewerMM #SettingsPanel legend { padding: 0 4px; font-weight: 600; }',
'#MangaViewerMM #SettingsPanel .ControlLabel { display: flex; flex-flow: row wrap; justify-content: space-between; align-items: center; padding: 3px 2px; }',
'#MangaViewerMM #SettingsPanel .ControlLabel.mvm-row-hidden { display: none; }',
'#MangaViewerMM #SettingsPanel .ControlLabelText { margin-right: 6px; }',
'#MangaViewerMM #SettingsPanel .mvm-slider-row .ControlLabelText { flex: 1 1 100%; }',
'#MangaViewerMM.mobile #SettingsPanel .hide-on-mobile { display: none; }',
'#MangaViewerMM #SettingsPanel input[type="range"] { width: 100%; margin: 4px 0; }',
'#MangaViewerMM #SettingsPanel .RangeValue {',
' display: inline-block; margin-left: 8px; padding: 2px 5px; border-radius: 3px; line-height: 20px; text-align: center;',
' color: var(--mvm-primary-text); background-color: var(--mvm-primary);',
'}',
'#MangaViewerMM .mvm-select { max-width: 200px; padding: 2px 4px; border: 1px solid var(--mvm-border); border-radius: 4px; color: var(--mvm-text); background-color: var(--mvm-background); }',
// -- segmented control --
'#MangaViewerMM .mvm-segmented { display: inline-flex; flex-flow: row wrap; padding: 2px; border-radius: 0.5rem; background-color: var(--mvm-highlight); }',
'#MangaViewerMM .mvm-segment {',
' display: inline-flex; flex: 1 1 auto; align-items: center; justify-content: center; margin: 1px; padding: 4px 8px;',
' border: 0; border-radius: 0.4rem; cursor: pointer; font-size: 13px; color: var(--mvm-text); background-color: transparent;',
'}',
'#MangaViewerMM .mvm-segment .icon-tabler { width: 18px; height: 18px; }',
'#MangaViewerMM .mvm-segment .icon-tabler + span { margin-left: 4px; }',
'#MangaViewerMM .mvm-segment.selected { font-weight: 600; color: var(--mvm-primary-text); background-color: var(--mvm-primary); }',
// -- switch (2025 version "toggler" colors) --
'#MangaViewerMM .mvm-switch { position: relative; display: inline-block; flex-shrink: 0; width: 38px; height: 20px; }',
'#MangaViewerMM .mvm-switch input { position: absolute; top: 0; left: 0; z-index: 1; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }',
'#MangaViewerMM .mvm-switch-track { position: absolute; top: 0; left: 0; right: 0; bottom: 0; border: 1px solid #d6d6d6; border-radius: 20px; background-color: #e4e8e8; }',
'#MangaViewerMM .mvm-switch-track::after {',
' content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%;',
' background-color: #d7062a; transition: left 0.15s ease-in-out, background-color 0.2s ease-out;',
'}',
'#MangaViewerMM .mvm-switch input:checked + .mvm-switch-track::after { left: 20px; background-color: #50ac5d; }',
'#MangaViewerMM .mvm-switch input:focus + .mvm-switch-track { box-shadow: 0 0 0 2px var(--mvm-primary); }',
// -- theme color picker --
'#MangaViewerMM .mvm-theme-picker { flex: 1 1 100%; }',
'#MangaViewerMM #ThemeColorInput { width: 44px; height: 28px; padding: 0; border: 1px solid var(--mvm-border); background-color: transparent; cursor: pointer; vertical-align: middle; }',
'#MangaViewerMM .mvm-swatches { display: flex; flex-flow: row wrap; align-items: center; margin: 3px 0; }',
'#MangaViewerMM .mvm-swatch { width: 22px; height: 22px; margin: 2px; padding: 0; border: 1px solid var(--mvm-border); border-radius: 50%; cursor: pointer; }',
'#MangaViewerMM .mvm-palette-row .mvm-swatch { border-radius: 3px; }',
'#MangaViewerMM .mvm-swatch.selected { outline: 2px solid var(--mvm-text); outline-offset: 1px; }',
'#MangaViewerMM .mvm-palettes summary { cursor: pointer; }',
'#MangaViewerMM .mvm-palette-row { display: flex; flex-flow: row nowrap; }',
// -- keybindings panel --
'#MangaViewerMM #KeybindingsList, #MangaViewerMM #KeybindingsEditor { display: grid; grid-template-columns: 1fr 2fr; grid-gap: 5px; gap: 5px; align-items: center; }',
'#MangaViewerMM .KeybindInput { box-sizing: border-box; width: 100%; padding: 2px 4px; border: 1px solid var(--mvm-border); border-radius: 4px; color: var(--mvm-text); background-color: var(--mvm-background); }',
'#MangaViewerMM .KeybindInput.invalid { border-color: #d33; }',
'#MangaViewerMM .mvm-keybind-error { font-size: 12px; line-height: 1.2em; color: #ff6b6b; }',
'#MangaViewerMM .mvm-keybind-error:empty { display: none; }',
'#MangaViewerMM #HotKeysRules { margin-top: 10px; font-size: 13px; }',
'#MangaViewerMM #HotKeysRules h3 { margin: 5px 0; font-size: 1rem; }',
'#MangaViewerMM kbd {',
' display: inline-block; min-width: 1em; margin: 1px 0; padding: 0.3em 0.4em 0.2em 0.3em; border-radius: 0.3em; white-space: nowrap;',
' font-family: "Lucida Grande", Lucida, Arial, sans-serif; font-size: 0.85em; font-style: normal; line-height: 1; text-align: center;',
' color: #fafafa; background-color: #505050; text-shadow: -1px -1px 0 #464646;',
' box-shadow: inset 0 0 1px #969696, inset 0 -0.05em 0.4em #505050, 0 0.1em 0 #1e1e1e, 0 0.1em 0.1em rgba(0, 0, 0, 0.3);',
'}'
].join('\n');
/**
* Milestone M3 features inside the reader: header feature buttons (auto-scroll play/pause,
* download progress), the bookmarks panel and the comments panel (2025 version look,
* OLD:4740: a large panel with its own light/dark area). Same CSS rules as the rest of the file.
*/
const FEATURES_CSS = [
// -- header feature buttons --
'#MangaViewerMM #AutoScroll .icon-tabler-player-pause { display: none; }',
'#MangaViewerMM #AutoScroll.running .icon-tabler-player-pause { display: inline-block; }',
'#MangaViewerMM #AutoScroll.running .icon-tabler-player-play { display: none; }',
'#MangaViewerMM #download.working { cursor: progress; }',
'#MangaViewerMM #download.partial { opacity: 0.6; }',
'#MangaViewerMM #download .mvm-download-progress { margin-left: 4px; font-family: monospace; font-size: 12px; }',
'#MangaViewerMM #download .mvm-download-progress:empty { display: none; }',
// -- bookmarks panel --
'#MangaViewerMM #BookmarksPanel { width: 480px; }',
'#MangaViewerMM #BookmarksClear { width: 100%; margin: 5px 0; }',
'#MangaViewerMM .mvm-bookmark-list { display: flex; flex-direction: column; }',
'#MangaViewerMM .mvm-bookmark-item { display: flex; flex-flow: row nowrap; align-items: center; padding: 6px 4px; border-bottom: 1px solid var(--mvm-border); }',
'#MangaViewerMM .mvm-bookmark-item.current { background-color: var(--mvm-highlight); }',
'#MangaViewerMM .mvm-bookmark-info { flex: 1 1 auto; min-width: 0; }',
'#MangaViewerMM .mvm-bookmark-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#MangaViewerMM .mvm-bookmark-url { display: block; font-size: 12px; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#MangaViewerMM .mvm-bookmark-details { flex-shrink: 0; width: 90px; margin: 0 6px; font-size: 12px; text-align: right; opacity: 0.75; }',
'#MangaViewerMM .mvm-bookmark-actions { display: flex; flex-flow: row nowrap; flex-shrink: 0; }',
'#MangaViewerMM .mvm-list-empty { padding: 10px; text-align: center; opacity: 0.75; }',
// -- comments panel --
'#MangaViewerMM #CommentsPanel { width: 720px; display: flex; flex-direction: column; }',
'#MangaViewerMM #CommentsArea { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 5px; border-radius: 5px; }',
'#MangaViewerMM #CommentsArea.light { color: #222222; background-color: #ffffff; }',
'#MangaViewerMM #CommentsArea.dark { color: #dddddd; background-color: #1a1b1e; }',
'#MangaViewerMM #CommentsArea iframe { max-width: 100%; }'
].join('\n');
/** Popup (dialog) and start button styles. Injected on the ORIGINAL site page too. */
const POPUP_CSS = [
'.mvm-popup-backdrop {',
' position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 2147483000;',
' display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.4);',
' font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;',
'}',
'.mvm-popup {',
' box-sizing: border-box; width: 32em; max-width: 92vw; padding: 1.25em; border-radius: 5px;',
' background: #ffffff; color: #545454; text-align: center; font-size: 16px; line-height: 1.4;',
' box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);',
'}',
'.mvm-popup-icon {',
' box-sizing: content-box; width: 4em; height: 4em; margin: 0.5em auto 0.6em; border: 0.25em solid #87adbd;',
' border-radius: 50%; color: #87adbd; font-size: 16px; line-height: 4em; font-weight: bold;',
'}',
'.mvm-popup-icon::before { content: "?"; font-size: 2.6em; }',
'.mvm-popup-icon.info { border-color: #3fc3ee; color: #3fc3ee; }',
'.mvm-popup-icon.info::before { content: "i"; }',
'.mvm-popup-icon.warning { border-color: #facea8; color: #f8bb86; }',
'.mvm-popup-icon.warning::before { content: "!"; }',
'.mvm-popup-title { margin: 0 0 0.4em; font-size: 1.8em; font-weight: 600; color: #595959; }',
'.mvm-popup-content { margin: 0 0 1em; font-size: 1.1em; }',
'.mvm-popup-actions { display: flex; flex-flow: row-reverse wrap; justify-content: center; gap: 0.5em; }',
'.mvm-popup-button {',
' padding: 0.6em 1.4em; border: 0; border-radius: 4px; font-size: 1em; color: #ffffff; cursor: pointer;',
' background: #7066e0;',
'}',
'.mvm-popup-button.cancel { background: #d33; }',
'.mvm-popup-timer { height: 4px; margin-top: 1em; background: rgba(0, 0, 0, 0.2); transition-property: width; transition-timing-function: linear; }',
'.mvm-popup-range { display: flex; justify-content: center; align-items: center; gap: 0.5em; margin-top: 0.8em; }',
'.mvm-popup-range input { width: 5em; padding: 0.3em; font-size: 1em; text-align: center; border: 1px solid #d9d9d9; border-radius: 4px; }',
'.mvm-popup-error { min-height: 1.2em; margin-top: 0.4em; color: #d33; font-size: 0.9em; }',
// -- page-range popup: two-handle slider (two overlapping range inputs; only the thumbs take
// the pointer, so each handle can be dragged; the number inputs stay in sync) --
'.mvm-range-display { margin-top: 0.6em; font-family: monospace; font-size: 1.1em; }',
'.mvm-dual-range { position: relative; height: 28px; margin: 0.6em 0.6em 0; }',
'.mvm-dual-range-track { position: absolute; left: 9px; right: 9px; top: 12px; height: 4px; border-radius: 2px; background: #d9d9d9; }',
'.mvm-dual-range-fill { position: absolute; top: 0; bottom: 0; left: 0; right: 0; border-radius: 2px; background: #7066e0; }',
'.mvm-dual-range input[type="range"] { position: absolute; left: 0; top: 0; z-index: 2; width: 100%; height: 28px; margin: 0; padding: 0; background: transparent; pointer-events: none; -webkit-appearance: none; -moz-appearance: none; appearance: none; }',
'.mvm-dual-range input[type="range"].on-top { z-index: 3; }',
'.mvm-dual-range input[type="range"]:focus { outline: none; }',
'.mvm-dual-range input[type="range"]::-webkit-slider-runnable-track { height: 28px; border: 0; background: transparent; }',
'.mvm-dual-range input[type="range"]::-moz-range-track { height: 28px; border: 0; background: transparent; }',
'.mvm-dual-range input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; pointer-events: auto; width: 18px; height: 18px; margin-top: 5px; border: 2px solid #ffffff; border-radius: 50%; background: #7066e0; box-shadow: 0 0 3px rgba(0, 0, 0, 0.4); cursor: pointer; }',
'.mvm-dual-range input[type="range"]::-moz-range-thumb { pointer-events: auto; width: 14px; height: 14px; border: 2px solid #ffffff; border-radius: 50%; background: #7066e0; box-shadow: 0 0 3px rgba(0, 0, 0, 0.4); cursor: pointer; }',
'.mvm-dual-range input[type="range"]:focus::-webkit-slider-thumb { box-shadow: 0 0 0 3px rgba(112, 102, 224, 0.5); }',
'.mvm-dual-range input[type="range"]:focus::-moz-range-thumb { box-shadow: 0 0 0 3px rgba(112, 102, 224, 0.5); }',
// -- toasts (also used before the takeover, so they use the .mvm-toast prefix like popups) --
'.mvm-toast-stack {',
' position: fixed; right: 12px; bottom: 50px; z-index: 2147482000; display: flex; flex-direction: column; align-items: flex-end;',
' max-width: 92vw; pointer-events: none; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;',
'}',
'.mvm-toast {',
' box-sizing: border-box; display: flex; flex-flow: row nowrap; align-items: flex-start; width: 340px; max-width: 100%; margin-top: 8px;',
' padding: 10px 12px; border-left: 5px solid #29487D; border-radius: 5px; color: #212529; background: #ffffff; text-align: left;',
' box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); font-size: 14px; line-height: 1.35; pointer-events: auto;',
' opacity: 0; transform: translateX(120%); transition: transform 0.3s ease, opacity 0.3s ease;',
'}',
'.mvm-toast.visible { opacity: 1; transform: translateX(0); }',
'.mvm-toast.info { border-left-color: #3fc3ee; }',
'.mvm-toast.success { border-left-color: #2f9e44; }',
'.mvm-toast.warning { border-left-color: #f59f00; }',
'.mvm-toast.error { border-left-color: #e03131; }',
'.mvm-toast-icon { flex-shrink: 0; line-height: 0; }',
'.mvm-toast-icon .icon-tabler { width: 20px; height: 20px; }',
'.mvm-toast.info .mvm-toast-icon { color: #3fc3ee; }',
'.mvm-toast.success .mvm-toast-icon { color: #2f9e44; }',
'.mvm-toast.warning .mvm-toast-icon { color: #f59f00; }',
'.mvm-toast.error .mvm-toast-icon { color: #e03131; }',
'.mvm-toast-body { flex: 1 1 auto; min-width: 0; margin: 0 8px; word-wrap: break-word; }',
'.mvm-toast-title { font-weight: 600; }',
'.mvm-toast-message:empty { display: none; }',
'.mvm-toast-progress { height: 4px; margin-top: 6px; background: rgba(0, 0, 0, 0.15); }',
'.mvm-toast-progress-fill { width: 0; height: 100%; background: #29487D; transition: width 0.2s linear; }',
'.mvm-toast-actions { margin-top: 6px; }',
'.mvm-toast-button { padding: 3px 10px; border: 0; border-radius: 4px; color: #ffffff; background: #d33; cursor: pointer; font-size: 13px; }',
'.mvm-toast-close { flex-shrink: 0; padding: 0 2px; border: 0; color: inherit; background: transparent; cursor: pointer; font-size: 18px; line-height: 1; }',
// -- local files panel (standalone page, before the reader exists) --
'.mvm-upload {',
' position: fixed; left: 50%; top: 50%; z-index: 2147482500; box-sizing: border-box; width: 30em; max-width: 92vw; padding: 1.25em;',
' -webkit-transform: translate(-50%, -50%); transform: translate(-50%, -50%); border-radius: 5px; color: #545454; background: #ffffff;',
' box-shadow: 0 0 20px rgba(0, 0, 0, 0.3); text-align: center; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.4;',
'}',
'.mvm-upload h2 { margin: 0 0 0.5em; font-size: 1.5em; color: #595959; }',
'.mvm-upload-hint { margin: 0 0 1em; font-size: 0.9em; }',
'.mvm-upload-option { display: flex; flex-flow: row wrap; align-items: center; justify-content: space-between; margin: 0.4em 0; padding: 0.5em; border: 1px solid #d9d9d9; border-radius: 4px; text-align: left; }',
'.mvm-upload-option input { max-width: 60%; font-size: 0.85em; }',
'.mvm-upload-status { min-height: 1.2em; margin-top: 0.6em; font-size: 0.9em; }',
'.mvm-upload-status.error { color: #d33; }',
'#StartMOV {',
' position: fixed; right: 10px; bottom: 40px; z-index: 2147483000; padding: 8px 16px;',
' border: 0; border-radius: 5px; font-size: 16px; color: #ffffff; background: #29487D; cursor: pointer;',
' box-shadow: 0 0 6px rgba(0, 0, 0, 0.4);',
'}'
].join('\n');
/**
* Adds (or replaces) a <style> element with a given id in <head>.
* @param {string} styleId - Unique id for the style element.
* @param {string} cssText - CSS.
*/
function installStyle(styleId, cssText) {
safeRun('installStyle', function () {
let style = document.getElementById(styleId);
if (!style) {
style = document.createElement('style');
style.id = styleId;
(document.head || document.documentElement).appendChild(style);
}
style.textContent = cssText;
}, undefined, { styleId: styleId });
}
// ==========================================================================================
// ==== 12. UI TEMPLATES =====================================================================
// Functions that return HTML strings. They never attach events: every clickable element has
// data-action="<name>" (and data-page when it concerns one page, data-value for a choice), and
// section 18 handles all clicks, slider moves and select changes with ONE listener each.
// Everything that comes from a site is passed through escapeHtml() first. Buttons, button
// groups and per-page buttons are built from small tables (VIEW_MODE_BUTTONS, ZOOM_BUTTONS,
// PAGE_BUTTONS) by the generic buildButtonHtml / buildButtonGroupHtml. The settings panel is
// built from the SETTINGS_PANEL_LAYOUT table by small generic control builders (switch,
// segmented control, slider with value, select, color swatches).
// ==========================================================================================
/**
* HTML of one button. Every option is optional.
* @param {string} action - Name in the ACTIONS table (section 16).
* @param {string} label - Text (escaped). With `iconOnly` it is used as tooltip and aria-label.
* @param {Object} [options]
* @param {string} [options.id] - Element id.
* @param {string} [options.title] - Tooltip (default: the label when iconOnly).
* @param {number} [options.page] - Page number (data-page).
* @param {string} [options.value] - Extra value for the action (data-value).
* @param {boolean} [options.disabled] - Disabled look and behavior.
* @param {boolean} [options.selected] - Highlighted (current choice in a group).
* @param {string} [options.className] - Extra classes.
* @param {string|string[]} [options.icon] - Icon name(s) from ICONS (several = CSS picks one).
* @param {boolean} [options.iconOnly] - Show only the icon(s).
* @param {string} [options.href] - Makes it a real link (<a href>) so the browser can open it in
* a new tab (middle click, Ctrl/Cmd click, context menu). Must already be safe and absolute
* (resolveChapterHref). Ignored when empty or when `disabled` is set (a <button> is drawn).
* @returns {string}
*/
function buildButtonHtml(action, label, options) {
const settings = options || {};
const icons = settings.icon ? [].concat(settings.icon) : [];
const title = settings.title || (settings.iconOnly ? label : '');
const classes = ['mvm-button'];
if (settings.className) {
classes.push(settings.className);
}
if (settings.iconOnly) {
classes.push('icon-only');
}
if (settings.selected) {
classes.push('selected');
}
if (settings.disabled) {
classes.push('disabled');
}
const isLink = typeof settings.href === 'string' && settings.href !== '' && !settings.disabled;
return [
isLink ? '<a href="' + escapeHtml(settings.href) + '"' : '<button type="button"',
' class="', escapeHtml(classes.join(' ')), '" data-action="', escapeHtml(action), '"',
settings.id ? ' id="' + escapeHtml(settings.id) + '"' : '',
title ? ' title="' + escapeHtml(title) + '"' : '',
settings.iconOnly ? ' aria-label="' + escapeHtml(label) + '"' : '',
settings.page ? ' data-page="' + settings.page + '"' : '',
settings.value !== undefined ? ' data-value="' + escapeHtml(settings.value) + '"' : '',
settings.disabled ? ' disabled' : '',
'>',
icons.map(buildIconSvg).join(''),
settings.iconOnly ? '' : '<span class="mvm-button-label">' + escapeHtml(label) + '</span>',
isLink ? '</a>' : '</button>'
].join('');
}
/**
* HTML of a group of buttons drawn as one segmented control. Generic: used for the view
* modes and the zoom buttons; the applier marks the current choice with markSelectedButton().
* @param {string} groupId - Element id of the group.
* @param {Object[]} buttons - Each: { action, labelKey (locale key), icon, value, className }.
* @param {string} [selectedValue] - data-value of the button to show as selected.
* @returns {string}
*/
function buildButtonGroupHtml(groupId, buttons, selectedValue) {
return [
'<div class="mvm-button-group" role="group" id="', escapeHtml(groupId), '">',
buttons.map(function (button) {
return buildButtonHtml(button.action, getLocaleString(button.labelKey), {
icon: button.icon,
iconOnly: true,
value: button.value,
className: button.className,
selected: button.value !== undefined && button.value === selectedValue
});
}).join(''),
'</div>'
].join('');
}
/**
* The view modes, in menu order (2026 header View menu, NEW:9332-9427). `action` is the ACTIONS
* name (section 16); `keybind` the keyboard action of DEFAULT_SETTINGS.keybinds ('' = none).
* VIEW_MODE_LEFT = Left to Right (FluidLTR) and VIEW_MODE_RIGHT = Right to Left (FluidRTL):
* the 2025 and 2026 versions had these two swapped (docs/05 section 3.1).
*/
const VIEW_MODE_BUTTONS = [
{ value: 'WebComic', action: 'view-webcomic', labelKey: 'VIEW_MODE_WEBCOMIC', icon: 'spacingVertical', keybind: 'VIEW_MODE_WEBCOMIC', className: 'view-WebComic' },
{ value: 'Vertical', action: 'view-vertical', labelKey: 'VIEW_MODE_VERTICAL', icon: 'arrowAutofitDown', keybind: 'VIEW_MODE_VERTICAL', className: 'view-Vertical' },
{ value: 'FluidLTR', action: 'view-fluid-ltr', labelKey: 'VIEW_MODE_LEFT', icon: 'arrowAutofitRight', keybind: 'VIEW_MODE_LEFT', className: 'view-FluidLTR' },
{ value: 'FluidRTL', action: 'view-fluid-rtl', labelKey: 'VIEW_MODE_RIGHT', icon: 'arrowAutofitLeft', keybind: 'VIEW_MODE_RIGHT', className: 'view-FluidRTL' },
{ value: 'Book', action: 'view-book', labelKey: 'VIEW_MODE_BOOK', icon: 'bookArrowRight', keybind: '', className: 'view-Book' },
{ value: 'Manga', action: 'view-manga', labelKey: 'VIEW_MODE_MANGA', icon: 'bookArrowLeft', keybind: '', className: 'view-Manga' },
{ value: 'Gallery', action: 'view-gallery', labelKey: 'VIEW_MODE_GALLERY', icon: 'layoutDashboard', keybind: 'VIEW_MODE_GALLERY', className: 'view-Gallery' }
];
/** Global zoom buttons (2026 header Zoom menu, NEW:9428-9494). value = zoom mode it selects. */
const ZOOM_BUTTONS = [
{ action: 'enlarge', labelKey: 'ENLARGE', icon: 'zoomInArea' },
{ action: 'restore', labelKey: 'RESTORE', icon: 'zoomPan' },
{ action: 'reduce', labelKey: 'REDUCE', icon: 'zoomOutArea' },
{ action: 'fit-width', labelKey: 'FIT_WIDTH', icon: 'arrowAutofitWidth', value: 'width' },
{ action: 'fit-height', labelKey: 'FIT_HEIGHT', icon: 'arrowAutofitHeight', value: 'height' }
];
/**
* Per-page buttons, in display order (NEW:11378-11455). Each carries the page number, so an
* action always works on the page whose button was clicked. Two icons = CSS shows one of them.
*/
const PAGE_BUTTONS = [
{ action: 'page-bookmark-toggle', className: 'Bookmark', labelKey: 'BOOKMARK', icon: ['bookmark', 'bookmarkOff'] },
{ action: 'page-zoom-in', className: 'ZoomIn', labelKey: 'ZOOM_IN', icon: 'zoomIn' },
{ action: 'page-zoom-restore', className: 'ZoomRestore', labelKey: 'ZOOM_RESET', icon: 'zoomCancel' },
{ action: 'page-zoom-out', className: 'ZoomOut', labelKey: 'ZOOM_OUT', icon: 'zoomOut' },
{ action: 'page-zoom-height', className: 'ZoomHeight', labelKey: 'ZOOM_HEIGHT', icon: 'arrowAutofitHeight' },
{ action: 'page-zoom-width', className: 'ZoomWidth', labelKey: 'ZOOM_WIDTH', icon: 'arrowAutofitWidth' },
{ action: 'page-double-toggle', className: 'ToggleDoublePage', labelKey: 'DOUBLE_PAGE', icon: 'spacingHorizontal' },
{ action: 'page-hide-toggle', className: 'Hide', labelKey: 'HIDE', icon: ['eyeOff', 'eye'] },
{ action: 'reload-page', className: 'Reload', labelKey: 'RELOAD', icon: 'refresh' }
];
/**
* True when a chapter link is usable (not empty, not '#').
* @param {string} url - Link from the site module.
* @returns {boolean}
*/
function isUsableLink(url) {
return typeof url === 'string' && url.trim() !== '' && url.trim() !== '#';
}
/**
* Absolute address of a chapter link (prev, next, series), resolved against the current page,
* or '' when the link is missing, invalid or uses a protocol other than http(s) or file (a
* `javascript:` link from a site is never followed). Shared by the header link buttons (so the
* browser can open them in a new tab) and navigateTo() (keyboard and plain clicks).
* @param {string} url - Link from the site module (absolute or relative).
* @returns {string}
*/
function resolveChapterHref(url) {
if (!isUsableLink(url)) {
return '';
}
let target = null;
try {
target = new URL(url.trim(), window.location.href);
} catch (error) {
logDebug('resolveChapterHref: invalid URL ignored', url);
return '';
}
if (target.protocol !== 'http:' && target.protocol !== 'https:' && target.protocol !== 'file:') {
logWarn('resolveChapterHref: blocked link with protocol ' + target.protocol, url);
return '';
}
return target.href;
}
/**
* Text of the header zoom label: 'Zoom: 100%', or the name of the fit mode.
* @param {string} zoomMode - 'percent', 'width' or 'height'.
* @param {number} zoomValue - Percent.
* @returns {string}
*/
function getZoomLabel(zoomMode, zoomValue) {
if (zoomMode === 'width') {
return getLocaleString('FIT_WIDTH');
}
if (zoomMode === 'height') {
return getLocaleString('FIT_HEIGHT');
}
return getLocaleString('ZOOM') + ': ' + Math.round(zoomValue) + '%';
}
/**
* <option> list of a go-to-page select, from `begin` to the last page.
* @param {Object} manga - The manga object.
* @returns {string}
*/
function buildPageOptionsHtml(manga) {
return sequence(manga.pages, manga.begin).map(function (page) {
return '<option value="' + page + '">' + page + '</option>';
}).join('');
}
/**
* HTML of the "menu" (burger) button that shows/hides the header in header mode 'click'.
* @returns {string}
*/
function buildMenuButtonHtml() {
return buildButtonHtml('toggle-header', getLocaleString('FILE_MENU'), { id: 'menu', icon: 'menu2', iconOnly: true, className: getSetting('header') });
}
/**
* HTML of the header buttons of the M3 features: bookmarks panel, comments panel, auto-scroll
* (play/pause icon, CSS shows one), download (with a progress text). Download and comments
* start disabled; sections 19 and 20 enable them when they are usable.
* @returns {string}
*/
function buildFeatureButtonsHtml() {
const downloadLabel = getLocaleString('DOWNLOAD_ZIP');
return [
buildButtonHtml('open-bookmarks', getLocaleString('BOOKMARKS'), { id: 'bookmarks', icon: 'bookmarks', iconOnly: true }),
buildButtonHtml('open-comments', getLocaleString('DISPLAY_COMMENTS'), { id: 'CommentsButton', icon: 'message', iconOnly: true, disabled: true }),
buildButtonHtml('toggle-auto-scroll', getLocaleString('SCROLL_START'), { id: 'AutoScroll', icon: ['playerPlay', 'playerPause'], iconOnly: true }),
'<button type="button" class="mvm-button icon-only disabled" id="download" data-action="download" disabled',
' title="', escapeHtml(downloadLabel), '" aria-label="', escapeHtml(downloadLabel), '">',
buildIconSvg('fileDownload'), '<span class="mvm-download-progress"></span>',
'</button>'
].join('');
}
/**
* HTML of the header: toolbar (view modes, zoom, page controls), title and series link,
* zoom label and slider, counters, go-to-page select, previous/next chapter buttons.
* @param {Object} manga - The manga object from the site module.
* @returns {string}
*/
function buildHeaderHtml(manga) {
const zoomMode = getSetting('zoomMode');
const zoomValue = getSetting('zoomValue');
return [
'<header id="Header" class="', escapeHtml(getSetting('header')), '">',
'<div id="Toolbar">',
buildButtonGroupHtml('ViewModeGroup', VIEW_MODE_BUTTONS, getSetting('viewMode')),
buildButtonGroupHtml('ZoomGroup', ZOOM_BUTTONS, zoomMode),
buildButtonHtml('toggle-page-controls', getLocaleString('TOGGLE_CONTROLS'), { id: 'pageControls', icon: 'listNumbers', iconOnly: true, selected: getSetting('hidePageControls') === true }),
buildButtonHtml('open-settings', getLocaleString('SETTINGS'), { id: 'settings', icon: 'settings', iconOnly: true }),
buildButtonHtml('open-keybindings', getLocaleString('KEYBINDINGS'), { id: 'keybindings', icon: 'keyboard', iconOnly: true }),
buildFeatureButtonsHtml(),
'</div>',
'<div class="ViewerTitle">',
'<h1 id="MangaTitle" title="', escapeHtml(manga.title), '">', escapeHtml(manga.title), '</h1>',
buildButtonHtml('go-series', getLocaleString('RETURN_CHAPTER_LIST'), { id: 'series', icon: 'booksReturn', href: resolveChapterHref(manga.series), disabled: resolveChapterHref(manga.series) === '' }),
'</div>',
'<div id="ZoomControl">',
'<output id="ZoomValue" for="ZoomSlider">', escapeHtml(getZoomLabel(zoomMode, zoomValue)), '</output>',
'<input type="range" id="ZoomSlider" data-action="zoom-slider" min="', parseInt(getSetting('minZoom'), 10) || 1, '" max="', CONFIG.zoomSliderMax, '" value="', Math.round(zoomValue), '" aria-label="', escapeHtml(getLocaleString('ZOOM')), '" />',
'</div>',
'<div id="ChapterNavigation">',
'<div id="Counters">', escapeHtml(getLocaleString('PAGES_LOADED')), ': <span class="mvm-loaded-count">0</span> / <span class="mvm-total-count">0</span></div>',
'<div id="GoToPageControl"><label for="GoToPage">', escapeHtml(getLocaleString('GO_TO_PAGE')), ': </label>',
'<select id="GoToPage" data-action="go-to-page">', buildPageOptionsHtml(manga), '</select></div>',
'<div id="ChapterControl">',
buildButtonHtml('go-previous-chapter', getLocaleString('BUTTON_PREVIOUS'), { id: 'prev', icon: 'arrowBigLeft', title: getLocaleString('PREVIOUS_CHAPTER'), href: resolveChapterHref(manga.prev), disabled: resolveChapterHref(manga.prev) === '' }),
buildButtonHtml('go-next-chapter', getLocaleString('BUTTON_NEXT'), { id: 'next', icon: 'arrowBigRight', title: getLocaleString('NEXT_CHAPTER'), href: resolveChapterHref(manga.next), disabled: resolveChapterHref(manga.next) === '' }),
'</div>',
'</div>',
'</header>'
].join('');
}
/**
* HTML of one page: control buttons, page number, an <img> without src (filled by section 15),
* then the separator line shown only in Vertical mode ("[ n / last ]" or "[ End ]").
* @param {number} page - Page number (1-based).
* @param {number} lastPage - Last page number of the chapter.
* @returns {string}
*/
function buildPageHtml(page, lastPage) {
return [
'<div class="MangaPage" id="Page', page, '" data-page="', page, '">',
'<div class="PageFunctions">',
PAGE_BUTTONS.map(function (button) {
return buildButtonHtml(button.action, getLocaleString(button.labelKey), { page: page, icon: button.icon, iconOnly: true, className: button.className });
}).join(''),
'<span class="PageIndex">', page, '</span>',
'</div>',
'<div class="PageContent">',
'<img class="PageImg" id="PageImg', page, '" data-page="', page, '" alt="Page ', page, '" />',
'</div>',
'</div>',
'<div class="separator">[ ', page === lastPage ? escapeHtml(getLocaleString('END')) : page + ' / ' + lastPage, ' ]</div>'
].join('');
}
/**
* HTML of the reader area with every page from `begin` to `manga.pages`.
* @param {Object} manga - The manga object.
* @returns {string}
*/
function buildChapterHtml(manga) {
return [
'<main id="Chapter">',
sequence(manga.pages, manga.begin).map(function (page) {
return buildPageHtml(page, manga.pages);
}).join(''),
'</main>'
].join('');
}
/**
* HTML of the thumbnails bar (navbar). Always present; the `navbar` setting only changes its
* classes. Thumbnail images get their src when the page has loaded (section 14 "Navbar").
* @param {Object} manga - The manga object.
* @returns {string}
*/
function buildNavbarHtml(manga) {
return [
'<nav id="Navigation" class="disabled">',
'<div id="NavigationCounters">', buildIconSvg('category'),
'<span><span class="mvm-loaded-count">0</span> / <span class="mvm-total-count">0</span></span>',
'<span>', escapeHtml(getLocaleString('PAGES_LOADED')), ': <span class="mvm-current-page">', manga.begin, '</span></span>',
'</div>',
'<div id="Thumbnails">',
sequence(manga.pages, manga.begin).map(function (page) {
return [
'<figure class="Thumbnail" id="Thumbnail', page, '" data-action="go-to-page" data-page="', page, '" role="button" tabindex="0" title="', escapeHtml(getLocaleString('GO_TO_PAGE') + ' ' + page), '">',
'<img class="ThumbnailImg" id="ThumbnailImg', page, '" alt="" />',
'<figcaption class="ThumbnailIndex">', page, '</figcaption>',
'</figure>'
].join('');
}).join(''),
'</div>',
'</nav>'
].join('');
}
/**
* HTML of the pagination widgets: the bottom slider bar and the two side arrows. Always present;
* the `pagination` setting decides what is visible (root classes pagination-*).
* @param {Object} manga - The manga object.
* @returns {string}
*/
function buildPaginationHtml(manga) {
return [
'<div id="Pagination">',
'<div class="slider-pagination">',
buildButtonHtml('go-previous-chapter', getLocaleString('PREVIOUS_CHAPTER'), { icon: 'arrowBigLeft', iconOnly: true, href: resolveChapterHref(manga.prev), disabled: resolveChapterHref(manga.prev) === '' }),
buildButtonHtml('previous-page', getLocaleString('BUTTON_PREVIOUS'), { id: 'PaginationPrevious', icon: 'chevronLeft', iconOnly: true }),
'<input type="range" id="PaginationSlider" data-action="go-to-page" min="', manga.begin, '" max="', manga.pages, '" value="', manga.begin, '" aria-label="', escapeHtml(getLocaleString('GO_TO_PAGE')), '" />',
buildButtonHtml('next-page', getLocaleString('BUTTON_NEXT'), { id: 'PaginationNext', icon: 'chevronRight', iconOnly: true }),
buildButtonHtml('go-next-chapter', getLocaleString('NEXT_CHAPTER'), { icon: 'arrowBigRight', iconOnly: true, href: resolveChapterHref(manga.next), disabled: resolveChapterHref(manga.next) === '' }),
'</div>',
'<button type="button" class="side-arrow left" id="SideArrowLeft" data-action="side-arrow-previous" aria-label="', escapeHtml(getLocaleString('BUTTON_PREVIOUS')), '">', buildIconSvg('chevronLeft'), '</button>',
'<button type="button" class="side-arrow right" id="SideArrowRight" data-action="side-arrow-next" aria-label="', escapeHtml(getLocaleString('BUTTON_NEXT')), '">', buildIconSvg('chevronRight'), '</button>',
'</div>'
].join('');
}
// ---- Settings controls (generic builders) ----
// Every control carries data-action="setting-change" and data-setting="<setting key>", so ONE
// delegated listener (section 18) handles them all and the action (section 16) saves the value
// with saveSetting(). The screen then follows through SETTING_APPLIERS (section 14), and
// syncSettingsControls (section 14) keeps the controls in step with the STORED values.
/**
* The choices of every setting that has a fixed list of values. Used by the segmented controls
* and selects of the settings panel AND to validate a value before it is saved (section 16).
* A choice is { value, labelKey } or { value, label } (literal text), optionally an icon.
* `showValue` appends the stored value to the label, e.g. "Slowly (Safe)".
*/
const SETTING_CHOICES = {
colorScheme: [
{ value: 'dark', labelKey: 'COLOR_SCHEME_DARK', icon: 'moon' },
{ value: 'light', labelKey: 'COLOR_SCHEME_LIGHT', icon: 'sun' }
],
deviceMode: [
{ value: 'auto', labelKey: 'DEVICE_MODE_AUTO' },
{ value: 'desktop', labelKey: 'DEVICE_MODE_DESKTOP' },
{ value: 'tablet', labelKey: 'DEVICE_MODE_TABLET' },
{ value: 'mobile', labelKey: 'DEVICE_MODE_MOBILE' }
],
loadMode: [
{ value: 'wait', labelKey: 'LOAD_MODE_NORMAL' },
{ value: 'always', labelKey: 'LOAD_MODE_ALWAYS' },
{ value: 'never', labelKey: 'LOAD_MODE_NEVER' }
],
loadSpeed: [
{ value: 'Safe', labelKey: 'SLOWLY', showValue: true },
{ value: 'Standard', labelKey: 'NORMAL', showValue: true },
{ value: 'Faster', labelKey: 'FAST', showValue: true },
{ value: 'Extreme', labelKey: 'EXTREME', showValue: true },
{ value: 'All', labelKey: 'ALL_PAGES', showValue: true }
],
lazyLoadRule: [
{ value: 'distance', labelKey: 'LAZY_LOAD_RULE_DISTANCE' },
{ value: 'lookAhead', labelKey: 'LAZY_LOAD_RULE_LOOKAHEAD' },
{ value: 'both', labelKey: 'LAZY_LOAD_RULE_BOTH' }
],
zoomMode: [
{ value: 'percent', labelKey: 'PERCENT', icon: 'zoomPan' },
{ value: 'width', labelKey: 'FIT_WIDTH', icon: 'arrowAutofitWidth' },
{ value: 'height', labelKey: 'FIT_HEIGHT', icon: 'arrowAutofitHeight' }
],
header: [
{ value: 'hover', labelKey: 'HEADER_HOVER' },
{ value: 'scroll', labelKey: 'HEADER_SCROLL' },
{ value: 'click', labelKey: 'HEADER_CLICK' },
{ value: 'fixed', labelKey: 'HEADER_FIXED' },
{ value: 'simple', labelKey: 'HEADER_SIMPLE' }
],
navbar: [
{ value: 'bottom', labelKey: 'NAVBAR_BOTTOM' },
{ value: 'left', labelKey: 'NAVBAR_LEFT' },
{ value: 'right', labelKey: 'NAVBAR_RIGHT' },
{ value: 'disabled', labelKey: 'NAVBAR_DISABLED' }
],
pagination: [
{ value: 'disabled', labelKey: 'PAGINATION_DISABLED' },
{ value: 'slider', labelKey: 'PAGINATION_SLIDER' },
{ value: 'side-arrows', labelKey: 'PAGINATION_ARROWS' },
{ value: 'both', labelKey: 'PAGINATION_BOTH' }
],
viewMode: VIEW_MODE_BUTTONS.map(function (button) {
return { value: button.value, labelKey: button.labelKey, icon: button.icon };
}),
locale: LOCALES.map(function (language) {
return { value: language.ID, label: language.NAME };
})
};
/**
* Range of every slider setting. `max` may be a function (computed when the panel is built).
* Ranges from the 2026 settings panel (docs/04 section 2); the auto-scroll maximum depends on
* the window height (NEW:10429-10431). minZoom and zoomStep use the ranges their labels state
* in every language ("between 30 and 100", "between 5 and 50"); the 2026 sliders were 25-100
* and 10-50, so text and control disagreed (docs/05 section 4.1 #17, fixed in M4).
*/
const SETTING_SLIDERS = {
lazyStart: { min: 5, max: 100, step: 5, unit: '' },
zoomValue: { min: 5, max: 200, step: 5, unit: '%' },
minZoom: { min: 30, max: 100, step: 5, unit: '%' },
zoomStep: { min: 5, max: 50, step: 5, unit: '%' },
scrollHeight: {
min: 1,
max: function () {
return getAutoScrollMaximum(window.innerHeight);
},
step: 1,
unit: 'px'
}
};
/**
* Highest auto-scroll speed for a window height (pure; NEW:7564, NEW:10431).
* @param {number} viewportHeight - Window height in pixels.
* @returns {number}
*/
function getAutoScrollMaximum(viewportHeight) {
return Math.ceil(viewportHeight / 200) * 100;
}
/**
* Text of a choice: its literal label, or its localized label (plus the value when asked).
* @param {{value: string, label: (string|undefined), labelKey: (string|undefined), showValue: (boolean|undefined)}} choice
* @returns {string}
*/
function getChoiceLabel(choice) {
if (choice.label !== undefined) {
return String(choice.label);
}
const text = getLocaleString(choice.labelKey);
return choice.showValue ? text + ' (' + choice.value + ')' : text;
}
/**
* The ' data-setting="key"' attribute, or '' when the control is not bound to a setting.
* @param {string} settingKey - Setting name or ''.
* @returns {string}
*/
function buildSettingAttribute(settingKey) {
return settingKey ? ' data-setting="' + escapeHtml(settingKey) + '"' : '';
}
/**
* HTML of an on/off switch (a real checkbox, styled).
* @param {string} settingKey - Boolean setting.
* @param {boolean} checked - Current value.
* @param {string} label - Accessible name.
* @returns {string}
*/
function buildSwitchHtml(settingKey, checked, label) {
return [
'<label class="mvm-switch">',
'<input type="checkbox" data-action="setting-change"', buildSettingAttribute(settingKey), checked === true ? ' checked' : '', ' aria-label="', escapeHtml(label), '" />',
'<span class="mvm-switch-track"></span>',
'</label>'
].join('');
}
/**
* HTML of a segmented control: one button per choice, the current one selected.
* @param {string} settingKey - Setting name ('' for a control that is not a setting).
* @param {Object[]} choices - See SETTING_CHOICES.
* @param {string} selectedValue - Current value.
* @param {Object} [options]
* @param {string} [options.action='setting-change'] - ACTIONS name.
* @param {boolean} [options.iconOnly] - Show only icons (label as tooltip).
* @param {string} [options.label] - Accessible name of the group.
* @returns {string}
*/
function buildSegmentedHtml(settingKey, choices, selectedValue, options) {
const settings = options || {};
return [
'<div class="mvm-segmented" role="radiogroup"', settings.label ? ' aria-label="' + escapeHtml(settings.label) + '"' : '', '>',
choices.map(function (choice) {
const text = getChoiceLabel(choice);
const selected = String(choice.value) === String(selectedValue);
return [
'<button type="button" class="mvm-segment', selected ? ' selected' : '', '" role="radio" aria-checked="', selected ? 'true' : 'false', '"',
' data-action="', escapeHtml(settings.action || 'setting-change'), '"', buildSettingAttribute(settingKey),
' data-value="', escapeHtml(choice.value), '" title="', escapeHtml(text), '"', settings.iconOnly ? ' aria-label="' + escapeHtml(text) + '"' : '', '>',
choice.icon ? buildIconSvg(choice.icon) : '',
settings.iconOnly && choice.icon ? '' : '<span>' + escapeHtml(text) + '</span>',
'</button>'
].join('');
}).join(''),
'</div>'
].join('');
}
/**
* HTML of a <select> with one option per choice.
* @param {string} settingKey - Setting name.
* @param {Object[]} choices - See SETTING_CHOICES.
* @param {string} selectedValue - Current value.
* @param {string} label - Accessible name.
* @returns {string}
*/
function buildSelectHtml(settingKey, choices, selectedValue, label) {
return [
'<select class="mvm-select" data-action="setting-change"', buildSettingAttribute(settingKey), ' aria-label="', escapeHtml(label), '">',
choices.map(function (choice) {
const selected = String(choice.value) === String(selectedValue);
return '<option value="' + escapeHtml(choice.value) + '"' + (selected ? ' selected' : '') + '>' + escapeHtml(getChoiceLabel(choice)) + '</option>';
}).join(''),
'</select>'
].join('');
}
/**
* Id of the value badge (<output>) of a slider setting.
* @param {string} settingKey - Setting name.
* @returns {string}
*/
function getSliderOutputId(settingKey) {
return 'SettingValue_' + settingKey;
}
/**
* HTML of a slider with its value badge. The value is saved when the user releases the slider
* (data-commit="change"); while dragging only the badge changes (section 18).
* @param {string} settingKey - Number setting.
* @param {{min: number, max: (number|function(): number), step: number, unit: string}} spec - Range.
* @param {number} value - Current value.
* @param {string} label - Accessible name.
* @returns {{badge: string, input: string}} The two parts (the row puts the badge next to the label).
*/
function buildSliderParts(settingKey, spec, value, label) {
const maximum = typeof spec.max === 'function' ? spec.max() : spec.max;
const outputId = getSliderOutputId(settingKey);
return {
badge: '<output class="RangeValue" id="' + escapeHtml(outputId) + '">' + escapeHtml(String(value) + spec.unit) + '</output>',
input: [
'<input type="range" data-action="setting-change" data-commit="change"', buildSettingAttribute(settingKey),
' data-output="', escapeHtml(outputId), '" data-unit="', escapeHtml(spec.unit), '"',
' min="', spec.min, '" max="', maximum, '" step="', spec.step, '" value="', escapeHtml(value), '" aria-label="', escapeHtml(label), '" />'
].join('')
};
}
/**
* HTML of one color swatch button that picks a theme color.
* @param {string} color - '#RRGGBB'.
* @param {string} title - Tooltip.
* @param {string} currentColor - Current theme color ('#RRGGBB'), for the selected mark.
* @returns {string}
*/
function buildSwatchHtml(color, title, currentColor) {
const selected = String(color).toUpperCase() === String(currentColor).toUpperCase();
return [
'<button type="button" class="mvm-swatch', selected ? ' selected' : '', '" data-action="setting-change" data-setting="theme"',
' data-value="', escapeHtml(color), '" title="', escapeHtml(title), '" aria-label="', escapeHtml(title), '" style="background-color: ', escapeHtml(color), ';"></button>'
].join('');
}
/**
* The 11 shades generated from the current theme color (click one to use it).
* @param {string} theme - Value of the `theme` setting.
* @returns {string}
*/
function buildThemeShadesHtml(theme) {
const current = resolveThemeColor(theme);
return generateColorGradient(current).map(function (color, index) {
return buildSwatchHtml(color, PALETTE_SHADES[index] + ' ' + color, current);
}).join('');
}
/** Palette families shown in the "Color Hue & Color Shade" grid (NEW:10496-10502: no grays). */
const THEME_GRID_EXCLUDED_FAMILIES = ['dark', 'gray', 'zinc', 'neutral', 'stone'];
/** Shades shown in that grid (NEW: 200 to 950). */
const THEME_GRID_SHADES = [200, 300, 400, 500, 600, 700, 800, 900, 950];
/**
* Theme color picker: native color input, shades of the current color, recommended colors and
* a collapsible grid of palette colors. Every swatch is a setting-change button for `theme`.
* @param {string} theme - Value of the `theme` setting.
* @returns {string}
*/
function buildThemePickerHtml(theme) {
const current = resolveThemeColor(theme);
const recommended = Object.keys(RECOMMENDED_THEME_COLORS).map(function (name) {
return buildSwatchHtml(RECOMMENDED_THEME_COLORS[name], name, current);
}).join('');
const grid = Object.keys(COLOR_PALETTES).filter(function (family) {
return THEME_GRID_EXCLUDED_FAMILIES.indexOf(family) === -1;
}).map(function (family) {
return '<div class="mvm-palette-row">' + THEME_GRID_SHADES.map(function (shade) {
return buildSwatchHtml(COLOR_PALETTES[family][shade], family + ' ' + shade, current);
}).join('') + '</div>';
}).join('');
return [
'<div class="mvm-theme-picker">',
'<input type="color" id="ThemeColorInput" data-action="setting-change" data-setting="theme" value="', escapeHtml(current.toLowerCase()), '" aria-label="', escapeHtml(getLocaleString('THEME_COLOR')), '" />',
'<div id="ThemeShades" class="mvm-swatches">', buildThemeShadesHtml(theme), '</div>',
'<div class="mvm-swatches mvm-recommended">', recommended, '</div>',
'<details class="mvm-palettes"><summary>', escapeHtml(getLocaleString('THEME_HUE') + ' / ' + getLocaleString('THEME_SHADE')), '</summary>', grid, '</details>',
'</div>'
].join('');
}
/**
* The settings panel layout: groups of rows (General, Loading, Zoom, Others). A row is
* { type, setting, labelKey } plus optional `hideOnMobile` and `visibleWhen` ("key" = shown
* while that boolean setting is true, "key=value" = shown while the setting has that value).
* Types: 'scope', 'select', 'segmented', 'switch', 'slider', 'theme'.
*/
const SETTINGS_PANEL_LAYOUT = [
{
id: 'SettingsPanelGeneral',
labelKey: 'GENERAL',
rows: [
{ type: 'scope', setting: '', labelKey: 'SCOPE' },
{ type: 'select', setting: 'locale', labelKey: 'LANGUAGE' },
{ type: 'segmented', setting: 'colorScheme', labelKey: 'COLOR_SCHEME' },
{ type: 'theme', setting: 'theme', labelKey: 'THEME_COLOR' },
{ type: 'segmented', setting: 'deviceMode', labelKey: 'DEVICE_MODE' },
{ type: 'select', setting: 'loadMode', labelKey: 'DEFAULT_LOAD_MODE' }
]
},
{
id: 'SettingsPanelLoading',
labelKey: 'LOADING',
rows: [
{ type: 'select', setting: 'loadSpeed', labelKey: 'LOAD_SPEED' },
{ type: 'switch', setting: 'lazyLoadImages', labelKey: 'LAZY_LOAD_IMAGES_ENABLE', hideOnMobile: true },
{ type: 'slider', setting: 'lazyStart', labelKey: 'LAZY_LOAD_IMAGES', visibleWhen: 'lazyLoadImages', hideOnMobile: true },
{ type: 'segmented', setting: 'lazyLoadRule', labelKey: 'LAZY_LOAD_RULE', visibleWhen: 'lazyLoadImages', hideOnMobile: true },
{ type: 'switch', setting: 'downloadZip', labelKey: 'DOWNLOAD_IMAGES', hideOnMobile: true }
]
},
{
id: 'SettingsPanelZoom',
labelKey: 'ZOOM',
hideOnMobile: true,
rows: [
{ type: 'segmented', setting: 'zoomMode', labelKey: 'DEFAULT_ZOOM_MODE' },
{ type: 'slider', setting: 'zoomValue', labelKey: 'DEFAULT_ZOOM', visibleWhen: 'zoomMode=percent' },
{ type: 'slider', setting: 'minZoom', labelKey: 'MINIMUM_ZOOM' },
{ type: 'slider', setting: 'zoomStep', labelKey: 'ZOOM_STEP' },
{ type: 'switch', setting: 'fitWidthIfOversize', labelKey: 'FIT_WIDTH_OVERSIZED' }
]
},
{
id: 'SettingsPanelOthers',
labelKey: 'OTHERS',
rows: [
{ type: 'segmented', setting: 'header', labelKey: 'HEADER_TYPE', hideOnMobile: true },
{ type: 'segmented', setting: 'navbar', labelKey: 'NAVBAR_TYPE', hideOnMobile: true },
{ type: 'segmented', setting: 'pagination', labelKey: 'PAGINATION_TYPE' },
{ type: 'switch', setting: 'hidePageControls', labelKey: 'HIDE_CONTROLS' },
{ type: 'slider', setting: 'scrollHeight', labelKey: 'AUTO_SCROLL_HEIGHT', hideOnMobile: true },
{ type: 'switch', setting: 'enableComments', labelKey: 'ENABLE_COMMENTS' },
{ type: 'segmented', setting: 'viewMode', labelKey: 'DEFAULT_VIEW_MODE', iconOnly: true }
]
}
];
/**
* True when a row with a `visibleWhen` condition must be shown (pure).
* @param {string} condition - '' (always), 'key' (boolean true) or 'key=value'.
* @param {function(string): *} readSetting - Returns a setting value.
* @returns {boolean}
*/
function isRowConditionMet(condition, readSetting) {
if (!condition) {
return true;
}
const parts = condition.split('=');
const value = readSetting(parts[0]);
if (parts.length === 1) {
return value === true;
}
return String(value) === parts[1];
}
/**
* The control HTML of a settings row, by type. The value shown is the STORED value (what a
* reload shows), not a change made for this page only from the header.
* @param {Object} row - A row of SETTINGS_PANEL_LAYOUT.
* @param {string} label - Localized row label.
* @returns {{control: string, badge: string}}
*/
function buildSettingsControlHtml(row, label) {
const value = row.setting ? getStoredSetting(row.setting) : '';
if (row.type === 'scope') {
const scopeChoices = [{ value: 'global', labelKey: 'GLOBAL' }, { value: 'local', label: window.location.hostname }];
return { control: buildSegmentedHtml('', scopeChoices, isLocalScopeEnabled() ? 'local' : 'global', { action: 'settings-scope', label: label }), badge: '' };
}
if (row.type === 'select') {
return { control: buildSelectHtml(row.setting, SETTING_CHOICES[row.setting], value, label), badge: '' };
}
if (row.type === 'segmented') {
return { control: buildSegmentedHtml(row.setting, SETTING_CHOICES[row.setting], value, { label: label, iconOnly: row.iconOnly === true }), badge: '' };
}
if (row.type === 'switch') {
return { control: buildSwitchHtml(row.setting, value === true, label), badge: '' };
}
if (row.type === 'slider') {
const parts = buildSliderParts(row.setting, SETTING_SLIDERS[row.setting], value, label);
return { control: parts.input, badge: parts.badge };
}
if (row.type === 'theme') {
return { control: buildThemePickerHtml(value), badge: '' };
}
logWarn('settings panel: unknown row type', row.type);
return { control: '', badge: '' };
}
/**
* HTML of one settings row: label (with the slider value badge) and its control.
* @param {Object} row - A row of SETTINGS_PANEL_LAYOUT.
* @returns {string}
*/
function buildSettingsRowHtml(row) {
const label = getLocaleString(row.labelKey);
const parts = buildSettingsControlHtml(row, label);
const classes = ['ControlLabel', 'row-' + (row.setting || row.type)];
if (row.type === 'slider') {
classes.push('mvm-slider-row');
}
if (row.hideOnMobile) {
classes.push('hide-on-mobile');
}
if (row.visibleWhen && !isRowConditionMet(row.visibleWhen, getStoredSetting)) {
classes.push('mvm-row-hidden');
}
return [
'<div class="', classes.join(' '), '"', row.visibleWhen ? ' data-visible-when="' + escapeHtml(row.visibleWhen) + '"' : '', '>',
'<span class="ControlLabelText">', escapeHtml(label), parts.badge, '</span>',
parts.control,
'</div>'
].join('');
}
/**
* HTML of the settings panel body: one fieldset per group.
* @returns {string}
*/
function buildSettingsPanelBodyHtml() {
return SETTINGS_PANEL_LAYOUT.map(function (group) {
return [
'<fieldset id="', group.id, '"', group.hideOnMobile ? ' class="hide-on-mobile"' : '', '>',
'<legend>', escapeHtml(getLocaleString(group.labelKey)), '</legend>',
group.rows.map(buildSettingsRowHtml).join(''),
'</fieldset>'
].join('');
}).join('');
}
/**
* HTML of the settings panel (left drawer): title, close button, reset button, groups.
* @returns {string}
*/
function buildSettingsPanelHtml() {
return [
'<aside id="SettingsPanel" class="mvm-panel left" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('SETTINGS')), '">',
'<div class="mvm-panel-header">',
'<h2>', escapeHtml(getLocaleString('SETTINGS')), '</h2>',
buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
'</div>',
buildButtonHtml('reset-settings', getLocaleString('BUTTON_RESET_SETTINGS'), { id: 'ResetSettings', icon: 'settingsOff' }),
'<div id="SettingsPanelBody">', buildSettingsPanelBodyHtml(), '</div>',
'</aside>'
].join('');
}
/**
* HTML of the keybindings panel (right drawer) WITHOUT its list: section 17 fills
* #KeybindingsActions and #KeybindingsBody. The rules text is the script's own locale HTML.
* @returns {string}
*/
function buildKeybindingsPanelHtml() {
return [
'<aside id="KeybindingsPanel" class="mvm-panel right" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('KEYBINDINGS')), '">',
'<div class="mvm-panel-header">',
buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
'<h2>', escapeHtml(getLocaleString('KEYBINDINGS')), '</h2>',
'<div id="KeybindingsActions" class="mvm-panel-actions"></div>',
'</div>',
'<div id="KeybindingsBody"></div>',
'<div id="HotKeysRules">', getLocaleString('KEYBIND_RULES'), '</div>',
'</aside>'
].join('');
}
/**
* HTML of the dark overlay behind open panels (a click on it closes them).
* @returns {string}
*/
function buildOverlayHtml() {
return '<div id="Overlay" data-action="close-panels"></div>';
}
/**
* A link target that is safe to put in href: http(s) and file URLs only, else '#'.
* @param {string} url - Stored URL (bookmarks).
* @returns {string}
*/
function getSafeLinkHref(url) {
return typeof url === 'string' && /^(https?|file):/i.test(url.trim()) ? url.trim() : '#';
}
/**
* HTML of the bookmarks list (2026 bookmarks dialog rows, NEW:9585-9658): name, URL (opens in
* a new tab), date, "Page: N", an open button (new tab) and a delete button. The row of the
* chapter open in this tab is marked "current". Empty list: "List Empty".
* @param {Object[]} bookmarks - Stored bookmarks [{name, url, page, date}].
* @param {string} currentUrl - URL of this chapter.
* @returns {string}
*/
function buildBookmarkListHtml(bookmarks, currentUrl) {
const list = Array.isArray(bookmarks) ? bookmarks.filter(function (bookmark) {
return bookmark && typeof bookmark.url === 'string';
}) : [];
if (list.length === 0) {
return '<div class="mvm-list-empty">' + escapeHtml(getLocaleString('LIST_EMPTY')) + '</div>';
}
return [
'<div class="mvm-bookmark-list">',
list.map(function (bookmark) {
const href = getSafeLinkHref(bookmark.url);
return [
'<div class="mvm-bookmark-item', bookmark.url === currentUrl ? ' current' : '', '">',
'<div class="mvm-bookmark-info">',
'<div class="mvm-bookmark-name" title="', escapeHtml(bookmark.name), '">', escapeHtml(bookmark.name || bookmark.url), '</div>',
'<a class="mvm-bookmark-url" href="', escapeHtml(href), '" target="_blank" rel="noopener noreferrer" title="', escapeHtml(bookmark.url), '">', escapeHtml(bookmark.url), '</a>',
'</div>',
'<div class="mvm-bookmark-details">',
'<div>', escapeHtml(bookmark.date || ''), '</div>',
'<div>', escapeHtml(getLocaleString('BOOKMARK_PAGE')), ': ', escapeHtml(bookmark.page), '</div>',
'</div>',
'<div class="mvm-bookmark-actions">',
'<a class="mvm-button icon-only" href="', escapeHtml(href), '" target="_blank" rel="noopener noreferrer" title="', escapeHtml(getLocaleString('BOOKMARK_OPEN')), '" aria-label="', escapeHtml(getLocaleString('BOOKMARK_OPEN')), '">', buildIconSvg('externalLink'), '</a>',
buildButtonHtml('bookmark-remove', getLocaleString('BOOKMARK_DELETE'), { icon: 'trash', iconOnly: true, value: bookmark.url }),
'</div>',
'</div>'
].join('');
}).join(''),
'</div>'
].join('');
}
/**
* HTML of the bookmarks panel (right drawer): title, bookmark toggle for the current page,
* close button, "remove all" button and the list (section 14 redraws #BookmarksList).
* @returns {string}
*/
function buildBookmarksPanelHtml() {
return [
'<aside id="BookmarksPanel" class="mvm-panel right" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('BOOKMARKS')), '">',
'<div class="mvm-panel-header">',
buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
'<h2>', escapeHtml(getLocaleString('BOOKMARKS')), '</h2>',
buildButtonHtml('bookmark-toggle-current', getLocaleString('BOOKMARK_TOGGLE_CURRENT'), { icon: ['bookmark', 'bookmarkOff'], iconOnly: true, className: 'Bookmark' }),
'</div>',
buildButtonHtml('bookmarks-clear', getLocaleString('BOOKMARKS_CLEAR'), { id: 'BookmarksClear', icon: 'trash' }),
'<div id="BookmarksList">', buildBookmarkListHtml(getSetting('bookmarks'), window.location.href), '</div>',
'</aside>'
].join('');
}
/**
* HTML of the comments panel (2025 version, OLD:3759-3780): title, light/dark switch of the
* comments area, close button, and the empty #CommentsArea (section 20 moves the captured
* comments element into it).
* @returns {string}
*/
function buildCommentsPanelHtml() {
return [
'<aside id="CommentsPanel" class="mvm-panel right" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('COMMENTS')), '">',
'<div class="mvm-panel-header">',
buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
'<h2>', escapeHtml(getLocaleString('COMMENTS')), '</h2>',
buildButtonHtml('comments-color-scheme', getLocaleString('COMMENTS_COLOR_SCHEME'), { icon: ['sun', 'moon'], iconOnly: true }),
'</div>',
'<div id="CommentsArea" class="', getSetting('colorScheme') === 'light' ? 'light' : 'dark', '"></div>',
'</aside>'
].join('');
}
/**
* HTML of the toast stack. It lives INSIDE the reader root: the 2026 version appended it to
* <body>, where its own takeover CSS hid it (docs/05 section 4.2 A).
* @returns {string}
*/
function buildToastStackHtml() {
return '<div id="Toasts" class="mvm-toast-stack" aria-live="polite"></div>';
}
/**
* HTML of the whole reader (goes inside <body>).
* @param {Object} manga - The manga object.
* @returns {string}
*/
function buildAppHtml(manga) {
return [
'<div id="', CONFIG.rootElementId, '">',
'<div id="ProgressBar"><div class="mvm-progress-fill"></div></div>',
buildMenuButtonHtml(),
buildHeaderHtml(manga),
buildChapterHtml(manga),
buildNavbarHtml(manga),
buildPaginationHtml(manga),
buildOverlayHtml(),
buildSettingsPanelHtml(),
buildKeybindingsPanelHtml(),
buildBookmarksPanelHtml(),
buildCommentsPanelHtml(),
buildToastStackHtml(),
'</div>'
].join('');
}
/**
* HTML of the new <head>: charset, viewport, title and the reader styles (the theme style is
* added by the theme applier, section 14).
* @param {Object} manga - The manga object.
* @returns {string}
*/
function buildHeadHtml(manga) {
return [
'<meta charset="UTF-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
'<title>', escapeHtml(manga.title), '</title>',
'<style id="MVMReaderStyle">', READER_BASE_CSS, '</style>',
'<style id="MVMLayoutStyle">', READER_LAYOUT_CSS, '\n', ICON_COLOR_CSS, '</style>',
'<style id="MVMPanelsStyle">', PANELS_CSS, '</style>',
'<style id="MVMFeaturesStyle">', FEATURES_CSS, '</style>',
'<style id="MVMPopupStyle">', POPUP_CSS, '</style>'
].join('');
}
// ==========================================================================================
// ==== 13. POPUPS ===========================================================================
// One generic dialog (look of the 2025 version's SweetAlert popups), plus the start prompt and
// the page-range prompt (two-handle slider + number inputs) built on it. showPopup() returns a
// Promise with what the user did. Also the toasts (showToast / updateToast / closeToast): they
// live here, not in section 20, because the download (section 19) needs them too.
// ==========================================================================================
/** Ways a popup can close. */
const POPUP_RESULT = {
CONFIRM: 'confirm',
CANCEL: 'cancel',
TIMER: 'timer',
ESCAPE: 'escape',
BACKDROP: 'backdrop'
};
/**
* HTML of a popup (icon, title, content, confirm/cancel buttons, optional timer bar).
* @param {Object} options - Same options as showPopup.
* @returns {string}
*/
function buildPopupHtml(options) {
return [
'<div class="mvm-popup" role="dialog" aria-modal="true">',
'<div class="mvm-popup-icon ', ['info', 'warning'].indexOf(options.icon) !== -1 ? options.icon : 'question', '"></div>',
'<h2 class="mvm-popup-title">', escapeHtml(options.title), '</h2>',
'<div class="mvm-popup-content">', options.contentHtml || '', '</div>',
'<div class="mvm-popup-actions">',
'<button type="button" class="mvm-popup-button confirm">', escapeHtml(options.confirmText || getLocaleString('CLOSE')), '</button>',
options.cancelText ? '<button type="button" class="mvm-popup-button cancel">' + escapeHtml(options.cancelText) + '</button>' : '',
'</div>',
options.timerMs ? '<div class="mvm-popup-timer" style="width:100%"></div>' : '',
'</div>'
].join('');
}
/**
* Shows a dialog and resolves when it closes.
* @param {Object} options
* @param {string} options.title - Title text (escaped).
* @param {string} [options.contentHtml] - Body HTML (caller must escape site data).
* @param {string} [options.icon='question'] - 'question', 'info' or 'warning'.
* @param {string} [options.confirmText] - Confirm button text.
* @param {string} [options.cancelText] - Cancel button text; omit for no cancel button.
* @param {number} [options.timerMs] - Auto-close after this time (result TIMER).
* @param {function(HTMLElement): void} [options.onOpen] - Called with the popup element.
* @param {function(HTMLElement): boolean} [options.beforeConfirm] - Return false to keep it open.
* @returns {Promise<{result: string, popup: HTMLElement}>}
*/
function showPopup(options) {
installStyle('MVMPopupStyle', POPUP_CSS);
return new Promise(function (resolve) {
const backdrop = document.createElement('div');
backdrop.className = 'mvm-popup-backdrop';
setElementHtml(backdrop, buildPopupHtml(options));
const popup = backdrop.querySelector('.mvm-popup');
let closed = false;
let timerId = null;
/** Closes the popup once and resolves with the reason. */
function close(result) {
if (closed) {
return;
}
closed = true;
if (timerId !== null) {
clearTimeout(timerId);
}
document.removeEventListener('keydown', onKeyDown, true);
backdrop.remove();
logDebug('popup closed', options.title, result);
resolve({ result: result, popup: popup });
}
/** Escape closes, Enter confirms. */
function onKeyDown(event) {
if (event.key === 'Escape') {
close(POPUP_RESULT.ESCAPE);
} else if (event.key === 'Enter') {
confirmPopup();
}
}
/** Runs the optional validation, then closes with CONFIRM. */
function confirmPopup() {
const allowed = options.beforeConfirm ? safeRun('popup.beforeConfirm', function () {
return options.beforeConfirm(popup);
}, false) : true;
if (allowed) {
close(POPUP_RESULT.CONFIRM);
}
}
backdrop.addEventListener('click', guardHandler('popup.click', function (event) {
if (event.target === backdrop) {
close(POPUP_RESULT.BACKDROP);
} else if (event.target.classList.contains('confirm')) {
confirmPopup();
} else if (event.target.classList.contains('cancel')) {
close(POPUP_RESULT.CANCEL);
}
}));
document.addEventListener('keydown', onKeyDown, true);
(document.body || document.documentElement).appendChild(backdrop);
if (options.timerMs) {
const bar = popup.querySelector('.mvm-popup-timer');
setTimeout(function () {
bar.style.transitionDuration = options.timerMs + 'ms';
bar.style.width = '0%';
}, 20);
timerId = setTimeout(function () {
close(POPUP_RESULT.TIMER);
}, options.timerMs);
}
if (options.onOpen) {
safeRun('popup.onOpen', function () {
options.onOpen(popup);
});
}
const confirmButton = popup.querySelector('.confirm');
if (confirmButton) {
confirmButton.focus();
}
});
}
/**
* "Starting... waiting" prompt shown in load mode 'wait'. Resolves true to start, false when
* the user cancels (then the caller shows the Start button).
* @param {Object} manga - The manga object (uses begin).
* @returns {Promise<boolean>}
*/
async function showWaitPrompt(manga) {
const resumeText = manga.begin > 1 ? escapeHtml(getLocaleString('RESUME')) + manga.begin + '.<br/>' : '';
const answer = await showPopup({
title: getLocaleString('STARTING'),
contentHtml: resumeText + escapeHtml(getLocaleString('WAITING')),
confirmText: getLocaleString('BUTTON_START'),
cancelText: getLocaleString('CANCEL'),
timerMs: CONFIG.waitPopupMs
});
return answer.result === POPUP_RESULT.CONFIRM || answer.result === POPUP_RESULT.TIMER;
}
/**
* Zero-pads a page number for the page-range display, e.g. (7, 3) -> '007' (NEW:11700-11703).
* @param {number} page - Page number.
* @param {number} digits - Minimum length.
* @returns {string}
*/
function padPageNumber(page, digits) {
let text = String(page);
while (text.length < digits) {
text = '0' + text;
}
return text;
}
/**
* Keeps a page range valid after one handle or number input changed (pure). Both ends become
* whole numbers between 1 and totalPages (the 2026 slider allowed 0, docs/05 section 4 #6), and
* the end that moved cannot pass the other end: it stops there.
* @param {*} begin - First page (any value; invalid -> 1).
* @param {*} end - Last page (any value; invalid -> totalPages).
* @param {number} totalPages - Number of pages.
* @param {string} changed - 'begin' or 'end': which end the user moved.
* @returns {{begin: number, end: number}}
*/
function normalizePageRange(begin, end, totalPages, changed) {
const last = Math.max(1, Math.floor(Number(totalPages)) || 1);
let first = clamp(Math.round(Number(begin)) || 1, 1, last);
let final = clamp(Math.round(Number(end)) || last, 1, last);
if (first > final) {
if (changed === 'end') {
final = first;
} else {
first = final;
}
}
return { begin: first, end: final };
}
/**
* Position of the colored part of the two-handle slider, in percent of the track (pure).
* @param {number} begin - First page.
* @param {number} end - Last page.
* @param {number} totalPages - Number of pages.
* @returns {{left: number, right: number}} Distances from the left and right ends of the track.
*/
function computeRangeFillPercent(begin, end, totalPages) {
const span = totalPages - 1;
if (span <= 0) {
return { left: 0, right: 0 };
}
return { left: ((begin - 1) / span) * 100, right: 100 - ((end - 1) / span) * 100 };
}
/**
* HTML of the page-range prompt body: the "[001 , 045]" display, the two-handle slider (two
* range inputs over one track) and the two number inputs of the 2025 version (OLD:5011-5091).
* @param {number} begin - First page shown at start.
* @param {number} totalPages - Number of pages.
* @returns {string}
*/
function buildPageRangeHtml(begin, totalPages) {
const pageLabel = escapeHtml(getLocaleString('BOOKMARK_PAGE'));
return [
escapeHtml(getLocaleString('CHOOSE_BEGINNING')),
'<div class="mvm-range-display">[ <span class="mvm-range-display-begin">', padPageNumber(begin, 3), '</span> , <span class="mvm-range-display-end">', padPageNumber(totalPages, 3), '</span> ]</div>',
'<div class="mvm-dual-range">',
'<div class="mvm-dual-range-track"><div class="mvm-dual-range-fill"></div></div>',
'<input type="range" class="mvm-range-slider-begin" min="1" max="', totalPages, '" step="1" value="', begin, '" aria-label="', pageLabel, ' (1)" />',
'<input type="range" class="mvm-range-slider-end" min="1" max="', totalPages, '" step="1" value="', totalPages, '" aria-label="', pageLabel, ' (2)" />',
'</div>',
'<div class="mvm-popup-range">',
'<input type="number" class="mvm-range-begin" min="1" max="', totalPages, '" value="', begin, '" inputmode="numeric" />',
'<span>-</span>',
'<input type="number" class="mvm-range-end" min="1" max="', totalPages, '" value="', totalPages, '" inputmode="numeric" />',
'</div>',
'<div class="mvm-popup-error"></div>'
].join('');
}
/**
* Connects the page-range controls: each slider handle and each number input updates the
* others, the "[001 , 045]" display and the colored part of the track. A number being typed
* moves the slider when it is valid; leaving the input writes the corrected value back.
* @param {HTMLElement} popup - The popup element.
* @param {number} totalPages - Number of pages.
*/
function wirePageRangeControls(popup, totalPages) {
const sliders = { begin: popup.querySelector('.mvm-range-slider-begin'), end: popup.querySelector('.mvm-range-slider-end') };
const numbers = { begin: popup.querySelector('.mvm-range-begin'), end: popup.querySelector('.mvm-range-end') };
const fill = popup.querySelector('.mvm-dual-range-fill');
const display = { begin: popup.querySelector('.mvm-range-display-begin'), end: popup.querySelector('.mvm-range-display-end') };
if (!sliders.begin || !sliders.end || !numbers.begin || !numbers.end) {
logWarn('page range: controls not found');
return;
}
/** Writes a range to the sliders, the display, the track and (optionally) the inputs. */
function render(range, updateNumbers) {
sliders.begin.value = String(range.begin);
sliders.end.value = String(range.end);
sliders.begin.classList.toggle('on-top', range.begin === totalPages);
if (updateNumbers) {
numbers.begin.value = String(range.begin);
numbers.end.value = String(range.end);
}
if (display.begin && display.end) {
display.begin.textContent = padPageNumber(range.begin, 3);
display.end.textContent = padPageNumber(range.end, 3);
}
if (fill) {
const percent = computeRangeFillPercent(range.begin, range.end, totalPages);
fill.style.left = percent.left + '%';
fill.style.right = percent.right + '%';
}
}
/** Handler factory: a slider handle moved. */
function onSliderInput(changed) {
return guardHandler('pageRange.slider', function () {
render(normalizePageRange(sliders.begin.value, sliders.end.value, totalPages, changed), true);
});
}
/** Handler factory: a number is being typed; follow it only while it is a valid page. */
function onNumberInput(changed) {
return guardHandler('pageRange.numberInput', function () {
const typed = parseInt(numbers[changed].value, 10);
if (isNaN(typed) || typed < 1 || typed > totalPages) {
return;
}
const range = changed === 'begin' ? normalizePageRange(typed, sliders.end.value, totalPages, changed) : normalizePageRange(sliders.begin.value, typed, totalPages, changed);
render(range, false);
});
}
/** Handler factory: the number input was left (or Enter): write the corrected range back. */
function onNumberChange(changed) {
return guardHandler('pageRange.numberChange', function () {
render(normalizePageRange(numbers.begin.value, numbers.end.value, totalPages, changed), true);
});
}
['begin', 'end'].forEach(function (side) {
sliders[side].addEventListener('input', onSliderInput(side));
numbers[side].addEventListener('input', onNumberInput(side));
numbers[side].addEventListener('change', onNumberChange(side));
});
render(normalizePageRange(sliders.begin.value, sliders.end.value, totalPages, 'begin'), true);
}
/**
* Prompt to choose the first and last page to load: a two-handle slider plus two number
* inputs, minimum page 1. Resolves {begin, end} or null when cancelled.
* @param {number} totalPages - Number of pages in the chapter.
* @param {number} suggestedBegin - Initial first page.
* @returns {Promise<{begin: number, end: number}|null>}
*/
async function showPageRangePrompt(totalPages, suggestedBegin) {
const begin = clamp(suggestedBegin || 1, 1, totalPages);
const answer = await showPopup({
title: getLocaleString('STARTING'),
contentHtml: buildPageRangeHtml(begin, totalPages),
confirmText: getLocaleString('BUTTON_START'),
cancelText: getLocaleString('CANCEL'),
onOpen: function (popup) {
wirePageRangeControls(popup, totalPages);
},
beforeConfirm: function (popup) {
const range = readPageRange(popup, totalPages);
popup.querySelector('.mvm-popup-error').textContent = range.error;
return range.error === '';
}
});
if (answer.result !== POPUP_RESULT.CONFIRM) {
return null;
}
const chosen = readPageRange(answer.popup, totalPages);
logDebug('page range chosen', chosen.begin, chosen.end);
return { begin: chosen.begin, end: chosen.end };
}
/**
* Reads and validates the two page inputs of the page-range prompt.
* @param {HTMLElement} popup - The popup element.
* @param {number} totalPages - Upper limit.
* @returns {{begin: number, end: number, error: string}}
*/
function readPageRange(popup, totalPages) {
const begin = parseInt(popup.querySelector('.mvm-range-begin').value, 10);
const end = parseInt(popup.querySelector('.mvm-range-end').value, 10);
if (isNaN(begin) || isNaN(end)) {
return { begin: 1, end: totalPages, error: getLocaleString('PAGE_RANGE_NEED_NUMBERS') };
}
if (begin < 1 || end > totalPages || begin > end) {
return { begin: begin, end: end, error: getLocaleString('PAGE_RANGE_INVALID').replace('##total##', String(totalPages)) };
}
return { begin: begin, end: end, error: '' };
}
// ---- Toasts ----
// Small messages stacked at the bottom-right that hide by themselves (2026 version toasts,
// NEW:3345-3388). The stack lives INSIDE the reader root (#Toasts); before the reader exists
// (local files page, start prompt) a fallback stack is added to <body>. A toast with
// `durationMs: 0` stays until closeToast() or its close button. Close and action buttons
// carry data-action (section 16 / 19); in the fallback stack (outside the reader, no click
// delegation) a toast has no buttons and closes by its timer only.
/** Toast types -> icon name. */
const TOAST_ICONS = {
info: 'infoCircle',
success: 'circleCheck',
warning: 'alertCircle',
error: 'circleX'
};
/** Counter for toast ids. */
let toastCounter = 0;
/**
* The element that holds the toasts: #Toasts inside the reader, or a fallback stack in <body>.
* @returns {HTMLElement}
*/
function getToastStack() {
const inReader = document.getElementById('Toasts');
if (inReader) {
return inReader;
}
let fallback = document.getElementById('MVMToastsFallback');
if (!fallback) {
installStyle('MVMPopupStyle', POPUP_CSS);
fallback = document.createElement('div');
fallback.id = 'MVMToastsFallback';
fallback.className = 'mvm-toast-stack';
fallback.setAttribute('aria-live', 'polite');
(document.body || document.documentElement).appendChild(fallback);
}
return fallback;
}
/**
* Shows a toast and returns its id (use it with updateToast / closeToast).
* @param {Object} options
* @param {string} [options.type='info'] - 'info', 'success', 'warning' or 'error'.
* @param {string} options.title - Title (escaped).
* @param {string} [options.message] - Text below the title (escaped).
* @param {number} [options.durationMs=CONFIG.toastDurationMs] - 0 = stays until closed.
* @param {boolean} [options.progress] - Show a progress bar (see updateToast).
* @param {{action: string, label: string}} [options.button] - One action button (data-action).
* @returns {string} The toast element id.
*/
function showToast(options) {
const settings = options || {};
const type = TOAST_ICONS[settings.type] ? settings.type : 'info';
toastCounter += 1;
const toastId = 'MVMToast' + toastCounter;
return safeRun('toast.show', function () {
const stack = getToastStack();
const insideReader = stack.id === 'Toasts';
const toast = document.createElement('div');
toast.id = toastId;
toast.className = 'mvm-toast ' + type;
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
setElementHtml(toast, [
'<span class="mvm-toast-icon">', buildIconSvg(TOAST_ICONS[type]), '</span>',
'<div class="mvm-toast-body">',
'<div class="mvm-toast-title">', escapeHtml(settings.title || ''), '</div>',
'<div class="mvm-toast-message">', escapeHtml(settings.message || ''), '</div>',
settings.progress ? '<div class="mvm-toast-progress"><div class="mvm-toast-progress-fill"></div></div>' : '',
settings.button && insideReader ? '<div class="mvm-toast-actions"><button type="button" class="mvm-toast-button" data-action="' + escapeHtml(settings.button.action) + '">' + escapeHtml(settings.button.label) + '</button></div>' : '',
'</div>',
insideReader ? '<button type="button" class="mvm-toast-close" data-action="toast-close" data-value="' + toastId + '" aria-label="' + escapeHtml(getLocaleString('CLOSE')) + '">×</button>' : ''
].join(''));
stack.appendChild(toast);
setTimeout(function () {
toast.classList.add('visible');
}, 20);
const duration = typeof settings.durationMs === 'number' ? settings.durationMs : CONFIG.toastDurationMs;
if (duration > 0) {
setTimeout(function () {
closeToast(toastId);
}, duration);
}
logDebug('toast', type, settings.title, settings.message || '');
return toastId;
}, toastId, { title: settings.title });
}
/**
* Gives a toast another type (color and icon).
* @param {HTMLElement} toast - Toast element.
* @param {string} type - Key of TOAST_ICONS.
*/
function setToastType(toast, type) {
Object.keys(TOAST_ICONS).forEach(function (name) {
toast.classList.toggle(name, name === type);
});
const icon = toast.querySelector('.mvm-toast-icon');
if (icon) {
setElementHtml(icon, buildIconSvg(TOAST_ICONS[type]));
}
}
/**
* Changes the texts, the type and/or the progress bar of a visible toast. Does nothing when
* the toast is already gone.
* @param {string} toastId - Id returned by showToast.
* @param {{title: (string|undefined), message: (string|undefined), type: (string|undefined), progressPercent: (number|undefined)}} changes
*/
function updateToast(toastId, changes) {
const toast = document.getElementById(toastId);
if (!toast) {
return;
}
if (typeof changes.title === 'string') {
toast.querySelector('.mvm-toast-title').textContent = changes.title;
}
if (typeof changes.message === 'string') {
toast.querySelector('.mvm-toast-message').textContent = changes.message;
}
if (changes.type && TOAST_ICONS[changes.type]) {
setToastType(toast, changes.type);
}
const fill = toast.querySelector('.mvm-toast-progress-fill');
if (fill && typeof changes.progressPercent === 'number') {
fill.style.width = clamp(changes.progressPercent, 0, 100) + '%';
}
}
/**
* Hides a toast (slides out), then removes it. Safe to call twice or for a missing toast.
* @param {string} toastId - Id returned by showToast.
*/
function closeToast(toastId) {
const toast = document.getElementById(toastId);
if (!toast || toast.classList.contains('closing')) {
return;
}
toast.classList.add('closing');
toast.classList.remove('visible');
setTimeout(function () {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, CONFIG.toastExitMs);
}
// ==========================================================================================
// ==== 14. SETTING APPLIERS =================================================================
// ONE table that says how the screen follows each setting. When a setting changes, only its
// applier runs (no rebuilding of the whole reader). To make a new setting visible on screen:
// write an apply function here and add it to SETTING_APPLIERS. Appliers must be safe to run
// at any time (also before the reader exists: then they simply find no element and return).
// The section is grouped by feature, each group starting with "// ---- <feature> ----":
// DOM helpers, theme, page size and zoom, per-page controls, scrolling to a page, current
// page, view mode, header, thumbnails bar, pagination, device, side panels, language,
// reactions to image changes, THE TABLE.
// The functions used by ACTIONS (section 16) live here too, next to the state they change.
// ==========================================================================================
// ---- Small DOM helpers ----
/** The reader root element, or null before the takeover. */
function getReaderRoot() {
return document.getElementById(CONFIG.rootElementId);
}
/** The chapter (pages) container, or null. */
function getChapterElement() {
return document.getElementById('Chapter');
}
/** The <img> element of a page, or null. */
function getPageImage(page) {
return document.getElementById('PageImg' + page);
}
/** The header element, or null. */
function getHeaderElement() {
return document.getElementById('Header');
}
/**
* Sets exactly one class out of a group on an element (e.g. one view mode).
* @param {Element|null} element - Target.
* @param {string[]} group - All possible classes of the group.
* @param {string} active - The class to keep.
*/
function setExclusiveClass(element, group, active) {
if (!element) {
return;
}
group.forEach(function (className) {
element.classList.remove(className);
});
if (active) {
element.classList.add(active);
}
}
/**
* Marks the button whose data-value equals `value` as selected inside a container (a button
* group), and unmarks the others. Generic: used for view modes and zoom modes.
* @param {string} containerId - Element id of the group.
* @param {string} value - data-value to select ('' selects none).
*/
function markSelectedButton(containerId, value) {
const container = document.getElementById(containerId);
if (!container) {
return;
}
queryAll('[data-value]', container).forEach(function (button) {
button.classList.toggle('selected', button.getAttribute('data-value') === value);
});
}
/**
* First and last page of the open chapter, or null before the reader opens.
* @returns {{begin: number, last: number}|null}
*/
function getPageRange() {
const manga = APP_STATE.manga;
if (!manga) {
return null;
}
return { begin: manga.begin || 1, last: manga.pages };
}
/**
* Calls `action(page)` for every page of the open chapter.
* @param {function(number): void} action - Work per page.
*/
function forEachPage(action) {
const range = getPageRange();
if (!range) {
return;
}
sequence(range.last, range.begin).forEach(action);
}
// ---- Theme and color scheme (section 10 builds the CSS) ----
/**
* Writes the theme style (theme color + color scheme) and the light/dark classes. Runs for both
* the `theme` and the `colorScheme` setting. Does nothing before the reader exists.
*/
function applyThemeSettings() {
const root = getReaderRoot();
if (!root) {
return;
}
const colorScheme = getSetting('colorScheme') === 'light' ? 'light' : 'dark';
setExclusiveClass(root, ['light', 'dark'], colorScheme);
setExclusiveClass(document.documentElement, ['light', 'dark'], colorScheme);
installStyle('MVMThemeStyle', buildThemeCss(getSetting('theme'), colorScheme));
logDebug('theme applied', getSetting('theme'), colorScheme);
}
// ---- Page size and zoom ----
// Global zoom (setting zoomMode + zoomValue) gives every page a size; per-page buttons change
// one page until the next global zoom change (same as the 2026 version, NEW:7611-7622).
/** Width available for pages: window width minus a side thumbnails bar (NEW:7580-7584). */
function getAvailableWidth() {
const navbar = getSetting('navbar');
return window.innerWidth - (navbar === 'left' || navbar === 'right' ? CONFIG.navbarSizePx : 0);
}
/** Height available for pages: window height minus a bottom thumbnails bar (NEW:7585-7588). */
function getAvailableHeight() {
return window.innerHeight - (getSetting('navbar') === 'bottom' ? CONFIG.navbarSizePx : 0);
}
/**
* Size of a page for a global zoom (pure). null = automatic.
* @param {number} naturalWidth - Image width in pixels (0 when not loaded yet).
* @param {string} zoomMode - 'percent', 'width' or 'height'.
* @param {number} zoomValue - Percent (percent mode).
* @param {number} availableWidth - Pixels.
* @param {number} availableHeight - Pixels.
* @returns {{width: (number|null), height: (number|null)}}
*/
function computeGlobalPageSize(naturalWidth, zoomMode, zoomValue, availableWidth, availableHeight) {
if (zoomMode === 'width') {
return { width: availableWidth, height: null };
}
if (zoomMode === 'height') {
return { width: null, height: availableHeight };
}
return { width: naturalWidth > 0 ? naturalWidth * (zoomValue / 100) : null, height: null };
}
/**
* New width of a page after one per-page zoom step (pure), or null when the image has not
* loaded yet (the 2026 version also ignores the click then, NEW:11237-11268).
* @param {{naturalWidth: number, width: (number|null)}} layout - Page layout.
* @param {number} zoomStep - Percent per step.
* @param {number} direction - 1 = bigger, -1 = smaller.
* @returns {number|null}
*/
function computeSteppedPageWidth(layout, zoomStep, direction) {
if (!layout.naturalWidth) {
return null;
}
return (layout.width || layout.naturalWidth) * (1 + direction * zoomStep / 100);
}
/** True for the two horizontal view modes. */
function isFluidViewMode(viewMode) {
return viewMode === 'FluidLTR' || viewMode === 'FluidRTL';
}
/** True for the two-column view modes. */
function isBookViewMode(viewMode) {
return viewMode === 'Book' || viewMode === 'Manga';
}
/**
* Writes a page's layout to its <img> (width, height, horizontal-mode height limit).
* @param {number} page - Page number.
*/
function renderPageSize(page) {
const image = getPageImage(page);
if (!image) {
return;
}
const layout = getPageLayout(page);
image.style.width = layout.width ? layout.width + 'px' : '';
image.style.height = layout.height ? layout.height + 'px' : '';
image.style.maxHeight = isFluidViewMode(getSetting('viewMode')) ? getAvailableHeight() + 'px' : '';
}
/**
* Gives one page the size of the current global zoom.
* @param {number} page - Page number.
*/
function applyGlobalZoomToPage(page) {
const layout = getPageLayout(page);
const size = computeGlobalPageSize(layout.naturalWidth, getSetting('zoomMode'), Number(getSetting('zoomValue')) || 100, getAvailableWidth(), getAvailableHeight());
layout.width = size.width;
layout.height = size.height;
renderPageSize(page);
}
/** Gives every page the size of the current global zoom (overwrites per-page zoom). */
function applyGlobalZoomToAllPages() {
forEachPage(applyGlobalZoomToPage);
}
/** Updates the header zoom label, slider and the selected fit button. */
function renderZoomControls() {
const zoomMode = getSetting('zoomMode');
const zoomValue = Number(getSetting('zoomValue')) || 100;
const label = document.getElementById('ZoomValue');
if (label) {
label.textContent = getZoomLabel(zoomMode, zoomValue);
}
const slider = document.getElementById('ZoomSlider');
if (slider) {
slider.value = String(Math.round(zoomValue));
}
markSelectedButton('ZoomGroup', zoomMode);
}
/** Applier of `zoomMode` and `zoomValue`: header controls + every page. */
function applyZoomSettings() {
renderZoomControls();
applyGlobalZoomToAllPages();
}
/**
* Changes the global zoom for this page only (session), like the 2026 applyZoom (NEW:7611-7622):
* all pages are recomputed; 'height' scrolls back to the current page, any other mode gives the
* header its stored mode back.
* @param {string} zoomMode - 'percent', 'width' or 'height'.
* @param {number} [zoomValue] - Percent (default: current value).
*/
function setGlobalZoom(zoomMode, zoomValue) {
const value = typeof zoomValue === 'number' && !isNaN(zoomValue) ? zoomValue : Number(getSetting('zoomValue'));
const modeChanged = setSessionSetting('zoomMode', zoomMode);
const valueChanged = setSessionSetting('zoomValue', value);
if (!modeChanged && !valueChanged) {
applyZoomSettings();
}
if (zoomMode === 'height') {
scrollToPage(APP_STATE.currentPage);
} else {
restoreStoredSetting('header');
}
}
/**
* Enlarge (+1) or reduce (-1) the global zoom by `zoomStep`, switching to percent mode. Ignored
* unless the result stays between 0 and 500 (exclusive), as in the 2026 version (NEW:7639-7644).
* @param {number} direction - 1 or -1.
* @returns {boolean} True when the zoom changed.
*/
function changeGlobalZoomByStep(direction) {
const target = (Number(getSetting('zoomValue')) || 100) + direction * (Number(getSetting('zoomStep')) || 0);
if (target <= 0 || target >= CONFIG.maxGlobalZoomExclusive) {
logDebug('zoom step ignored, out of range', target);
return false;
}
setGlobalZoom('percent', target);
return true;
}
/**
* Applier of `minZoom`: minimum page width in percent of the window width. Only for WebComic and
* Vertical: the 2026 version skips Book, Manga and Gallery (NEW:11343) and its CSS meant to skip
* the horizontal modes too (NEW:11526). Also the lower end of the header zoom slider.
* @param {number} minZoom - Percent of the window width.
*/
function applyMinZoom(minZoom) {
const value = parseInt(minZoom, 10);
const css = isNaN(value) ? '' : [
'#MangaViewerMM #Chapter.WebComic .PageImg, #MangaViewerMM #Chapter.Vertical .PageImg { min-width: ' + value + 'vw; }'
].join('\n');
installStyle('MVMMinZoomStyle', css);
const slider = document.getElementById('ZoomSlider');
if (slider && !isNaN(value)) {
slider.min = String(value);
}
}
/** Applier of `fitWidthIfOversize`: images wider than the reader shrink (not in horizontal modes). */
function applyFitWidthIfOversize(enabled) {
const chapter = getChapterElement();
if (chapter) {
chapter.classList.toggle('fitWidthIfOversize', enabled === true);
}
}
// ---- Per-page controls ----
/**
* Zooms one page in (+1) or out (-1) by `zoomStep` percent.
* @param {number} page - Page number (the clicked page).
* @param {number} direction - 1 or -1.
*/
function zoomPageByStep(page, direction) {
const layout = getPageLayout(page);
const width = computeSteppedPageWidth(layout, Number(getSetting('zoomStep')) || 0, direction);
if (width === null) {
logDebug('page zoom ignored: image not loaded yet', page);
return;
}
layout.width = width;
layout.height = null;
renderPageSize(page);
}
/** Natural size for one page (NEW:11273-11286). */
function restorePageZoom(page) {
const layout = getPageLayout(page);
layout.width = null;
layout.height = null;
renderPageSize(page);
}
/** One page as wide as the available width (NEW:11291-11304). */
function fitPageToWidth(page) {
const layout = getPageLayout(page);
layout.width = getAvailableWidth();
layout.height = null;
renderPageSize(page);
}
/** One page as tall as the available height (NEW:11309-11322). */
function fitPageToHeight(page) {
const layout = getPageLayout(page);
layout.width = null;
layout.height = getAvailableHeight();
renderPageSize(page);
}
/** Collapses or shows one page image (the button row stays, NEW:11182-11185). */
function togglePageHidden(page) {
const layout = getPageLayout(page);
layout.hidden = !layout.hidden;
const container = document.getElementById('Page' + page);
if (container) {
container.classList.toggle('hide', layout.hidden);
}
}
/** Flips the "double page" mark of one page (Book/Manga, NEW:11431-11433). */
function togglePageDoublePage(page) {
const layout = getPageLayout(page);
layout.doublePage = !layout.doublePage;
applyBookPairing();
}
/** Applier of `hidePageControls`: hides all per-page button rows; header button shows the state. */
function applyHidePageControls(hidden) {
const root = getReaderRoot();
if (root) {
root.classList.toggle('hideControls', hidden === true);
}
const button = document.getElementById('pageControls');
if (button) {
button.classList.toggle('selected', hidden === true);
}
}
/**
* Applier of `bookmarks`: root class 'bookmarked' switches the bookmark icons (NEW:11869), and
* the bookmarks panel list is drawn again (also after a change in another tab).
*/
function applyBookmarksState() {
const root = getReaderRoot();
if (root) {
root.classList.toggle('bookmarked', getBookmarkedPage() > 0);
}
const list = document.getElementById('BookmarksList');
if (list) {
setElementHtml(list, buildBookmarkListHtml(getSetting('bookmarks'), window.location.href));
}
}
// ---- Scrolling to a page ----
/**
* Scrolls so that a page is at the top-left of the view, instantly (NEW:6936-6968). Page 0 or
* less = top of the window. In horizontal modes the chapter container scrolls.
* @param {number} page - Page number.
*/
function scrollToPage(page) {
if (!page || page <= 0) {
window.scrollTo(0, 0);
return;
}
const target = document.getElementById('Page' + page);
if (!target) {
logDebug('scrollToPage: page not in the reader', page);
return;
}
const rect = target.getBoundingClientRect();
const chapter = getChapterElement();
if (isFluidViewMode(getSetting('viewMode')) && chapter) {
const chapterRect = chapter.getBoundingClientRect();
chapter.scrollBy(rect.left - chapterRect.left, rect.top - chapterRect.top);
} else {
window.scrollTo(rect.left + window.pageXOffset, rect.top + window.pageYOffset);
}
logDebug('scrolled to page', page);
}
// ---- Current page ----
/**
* Picks the current page (pure): among pages whose leading edge passed the window center, the
* one whose edge is closest to it (NEW:11019-11046).
* @param {{page: number, edge: number}[]} candidates - Leading edge position of each page.
* @param {number} center - Window center on the same axis.
* @param {number} fallback - Result when no page qualifies (the first page).
* @returns {number}
*/
function pickCurrentPage(candidates, center, fallback) {
let best = null;
candidates.forEach(function (candidate) {
if (candidate.edge <= center && (best === null || candidate.edge > best.edge)) {
best = candidate;
}
});
return best === null ? fallback : best.page;
}
/** Computes and stores the page in view (leading edge: top, left in FluidLTR, right in FluidRTL). */
function updateCurrentPage() {
const range = getPageRange();
if (!range) {
return;
}
const viewMode = getSetting('viewMode');
const horizontal = isFluidViewMode(viewMode);
const candidates = [];
sequence(range.last, range.begin).forEach(function (page) {
const image = getPageImage(page);
if (!image) {
return;
}
const rect = image.getBoundingClientRect();
let edge = rect.top;
if (horizontal) {
edge = viewMode === 'FluidRTL' ? rect.right : rect.left;
}
candidates.push({ page: page, edge: edge });
});
const center = horizontal ? window.innerWidth / 2 : window.innerHeight / 2;
setCurrentPage(pickCurrentPage(candidates, center, range.begin));
}
// ---- View mode ----
/** View mode names, from the header buttons table (section 12). */
const VIEW_MODES = VIEW_MODE_BUTTONS.map(function (button) {
return button.value;
});
/** View modes that switch to Fit Height and header 'click' for this page (NEW:7683-7691). */
const FORCED_HEIGHT_VIEW_MODES = ['FluidLTR', 'FluidRTL', 'Book', 'Manga'];
/**
* Left/right/double position of every page in Book and Manga modes (pure). Counting back from a
* page to the previous double page: an even number of single pages means "left" in Book and
* "right" in Manga (NEW:11358-11366).
* @param {number} begin - First page.
* @param {number} last - Last page.
* @param {Object<number, boolean>} doublePages - page -> true when it is a double page.
* @param {string} viewMode - 'Book' or 'Manga'.
* @returns {Object<number, string>} page -> 'left', 'right' or 'double'.
*/
function computeBookPageSides(begin, last, doublePages, viewMode) {
const sides = {};
let singlesSinceDouble = 0;
for (let page = begin; page <= last; page++) {
if (doublePages[page] === true) {
sides[page] = 'double';
singlesSinceDouble = 0;
continue;
}
const even = singlesSinceDouble % 2 === 0;
sides[page] = (viewMode === 'Book') === even ? 'left' : 'right';
singlesSinceDouble += 1;
}
return sides;
}
/** Writes LeftPage / RightPage / DoublePage classes on every page (used in Book and Manga). */
function applyBookPairing() {
const range = getPageRange();
if (!range) {
return;
}
const doublePages = {};
sequence(range.last, range.begin).forEach(function (page) {
doublePages[page] = getPageLayout(page).doublePage;
});
const sides = computeBookPageSides(range.begin, range.last, doublePages, getSetting('viewMode') === 'Manga' ? 'Manga' : 'Book');
Object.keys(sides).forEach(function (page) {
const container = document.getElementById('Page' + page);
if (container) {
container.classList.toggle('LeftPage', sides[page] === 'left');
container.classList.toggle('RightPage', sides[page] === 'right');
container.classList.toggle('DoublePage', sides[page] === 'double');
}
});
}
/** Applier of `viewMode`: layout class, header buttons, book pairing, page sizes. */
function applyViewMode(viewMode) {
const mode = VIEW_MODES.indexOf(viewMode) === -1 ? 'WebComic' : viewMode;
setExclusiveClass(getChapterElement(), VIEW_MODES, mode);
markSelectedButton('ViewModeGroup', mode);
applyBookPairing();
forEachPage(renderPageSize);
updateCurrentPage();
}
/**
* Changes the view mode for this page only, with the 2026 side effects (NEW:7680-7699):
* horizontal/book modes force Fit Height and header 'click'; the other modes give zoom and header
* their stored values back. Then the global zoom is applied again.
* @param {string} viewMode - One of VIEW_MODES.
*/
function switchViewMode(viewMode) {
if (VIEW_MODES.indexOf(viewMode) === -1) {
logWarn('Unknown view mode', viewMode);
return;
}
setSessionSetting('viewMode', viewMode);
if (FORCED_HEIGHT_VIEW_MODES.indexOf(viewMode) !== -1) {
setSessionSetting('zoomMode', 'height');
setSessionSetting('header', 'click');
} else {
restoreStoredSetting('zoomMode');
restoreStoredSetting('zoomValue');
restoreStoredSetting('header');
}
setGlobalZoom(getSetting('zoomMode'), Number(getSetting('zoomValue')));
log('View mode: ' + viewMode);
}
// ---- Header (setting `header`: hover, scroll, click, fixed, simple) ----
// The 2026 version decided visibility with CSS selectors on headroom classes (NEW:9204). Here the
// same rules are computed in JS (isHeaderShown) and the CSS only knows "header-hidden".
/** Header modes, in the settings order (NEW:10318-10338). */
const HEADER_MODES = ['hover', 'scroll', 'click', 'fixed', 'simple'];
/** Runtime state of the header ("headroom"). */
const HEADROOM = {
previousScrollY: 0,
position: 'top',
visible: true
};
/**
* Where the page is, for the header (pure; NEW:8980-8987):
* 'end' near the bottom (not in Fit Height), 'hide' scrolling down, 'show' scrolling up (scroll
* mode), 'top' near the top (not in click mode), else 'none'.
* @param {Object} input
* @param {number} input.scrollY - Current vertical scroll.
* @param {number} input.previousScrollY - Previous vertical scroll.
* @param {number} input.viewportHeight - Window height.
* @param {number} input.documentHeight - Total page height.
* @param {string} input.headerMode - Setting `header`.
* @param {string} input.zoomMode - Setting `zoomMode`.
* @returns {string}
*/
function computeHeadroomPosition(input) {
if (input.zoomMode !== 'height' && input.scrollY + input.viewportHeight + CONFIG.headerEndDistancePx > input.documentHeight) {
return 'end';
}
if (input.scrollY > input.previousScrollY && input.scrollY > CONFIG.headerHeightPx) {
return 'hide';
}
if (input.headerMode === 'scroll' && input.scrollY < input.previousScrollY && input.scrollY > CONFIG.headerHeightPx) {
return 'show';
}
if (input.headerMode !== 'click' && input.scrollY <= CONFIG.headerHeightPx) {
return 'top';
}
return 'none';
}
/**
* True when the header is on screen (pure; the 2026 CSS rules of NEW:9204 written as logic).
* @param {string} headerMode - Setting `header`.
* @param {string} position - Result of computeHeadroomPosition.
* @param {boolean} visible - Mouse/burger visibility flag.
* @returns {boolean}
*/
function isHeaderShown(headerMode, position, visible) {
if (headerMode === 'fixed' || headerMode === 'simple' || visible) {
return true;
}
if (position === 'top' || position === 'end') {
return true;
}
return headerMode === 'scroll' && position === 'show';
}
/** Writes the header state classes, and shows the burger only where the 2026 version did. */
function renderHeaderState() {
const header = getHeaderElement();
const headerMode = getSetting('header');
if (header) {
setExclusiveClass(header, ['headroom-top', 'headroom-end', 'headroom-hide', 'headroom-show', 'headroom-none'], 'headroom-' + HEADROOM.position);
header.classList.toggle('header-hidden', !isHeaderShown(headerMode, HEADROOM.position, HEADROOM.visible));
}
const menu = document.getElementById('menu');
if (menu) {
menu.classList.toggle('hide', HEADROOM.position === 'top' || HEADROOM.position === 'end');
}
}
/** Recomputes the header position from the current scroll (scroll listener, throttled). */
function updateHeadroomOnScroll() {
const headerMode = getSetting('header');
const scrollY = window.pageYOffset;
const position = computeHeadroomPosition({
scrollY: scrollY,
previousScrollY: HEADROOM.previousScrollY,
viewportHeight: window.innerHeight,
documentHeight: document.body ? document.body.scrollHeight : 0,
headerMode: headerMode,
zoomMode: getSetting('zoomMode')
});
HEADROOM.position = position;
if (headerMode === 'scroll') {
HEADROOM.visible = position !== 'hide';
}
HEADROOM.previousScrollY = scrollY;
renderHeaderState();
}
/**
* In hover and scroll modes the header shows while the mouse is in the top band (1.5 x header
* height, NEW:9010-9016).
* @param {MouseEvent} event - mousemove.
*/
function updateHeadroomOnMouseMove(event) {
const headerMode = getSetting('header');
if (headerMode !== 'hover' && headerMode !== 'scroll') {
return;
}
const inside = event.clientX >= 0 && event.clientX <= window.innerWidth && event.clientY >= 0 && event.clientY <= CONFIG.headerHeightPx * 1.5;
if (inside !== HEADROOM.visible) {
HEADROOM.visible = inside;
renderHeaderState();
}
}
/** Burger button: shows/hides the header in click mode (NEW:9018-9023). */
function toggleHeaderVisibility() {
if (getSetting('header') !== 'click') {
return;
}
HEADROOM.visible = !HEADROOM.visible;
renderHeaderState();
}
/** Applier of `header`: mode class on header and burger, start state (NEW:9027). */
function applyHeaderMode(headerMode) {
const mode = HEADER_MODES.indexOf(headerMode) === -1 ? 'scroll' : headerMode;
setExclusiveClass(getHeaderElement(), HEADER_MODES, mode);
setExclusiveClass(document.getElementById('menu'), HEADER_MODES, mode);
HEADROOM.visible = !(getSetting('zoomMode') === 'height' && (mode === 'click' || mode === 'hover'));
HEADROOM.previousScrollY = window.pageYOffset;
HEADROOM.position = window.pageYOffset <= CONFIG.headerHeightPx && mode !== 'click' ? 'top' : 'none';
renderHeaderState();
}
// ---- Thumbnails bar (setting `navbar`: bottom, left, right, disabled) ----
/** Navbar positions (NEW:10389-10404). */
const NAVBAR_MODES = ['bottom', 'left', 'right', 'disabled'];
/**
* Shows a page's image in its thumbnail once the page has loaded, or the broken icon when it
* failed (the 2026 version never set the broken class on thumbnails, docs/05 section 4 #16).
* Thumbnails stay empty while the bar is disabled, so no extra image is requested.
* @param {number} page - Page number.
*/
function renderThumbnail(page) {
const thumbnail = document.getElementById('ThumbnailImg' + page);
if (!thumbnail) {
return;
}
const record = getImageRecord(page);
thumbnail.classList.toggle('imgBroken', record.status === IMAGE_STATUS.FAILED);
if (record.status === IMAGE_STATUS.LOADED && getSetting('navbar') !== 'disabled' && thumbnail.getAttribute('src') !== record.src) {
thumbnail.setAttribute('src', record.src);
}
}
/**
* Writes the position classes of the thumbnails bar and fills its thumbnails. Does not touch
* page sizes (also used after the bar is re-rendered for a language change).
* @param {string} navbarMode - Setting `navbar`.
* @returns {string} The mode actually used.
*/
function renderNavbarElement(navbarMode) {
const mode = NAVBAR_MODES.indexOf(navbarMode) === -1 ? 'bottom' : navbarMode;
const navigation = document.getElementById('Navigation');
if (navigation) {
navigation.classList.add('hiding');
setExclusiveClass(navigation, NAVBAR_MODES, mode);
navigation.classList.toggle('horizontal', mode === 'bottom');
navigation.classList.toggle('vertical', mode === 'left' || mode === 'right');
setTimeout(function () {
navigation.classList.remove('hiding');
}, CONFIG.navbarFadeMs);
}
forEachPage(renderThumbnail);
return mode;
}
/** Applier of `navbar`: bar position classes, page padding, thumbnails, page sizes. */
function applyNavbar(navbarMode) {
const mode = renderNavbarElement(navbarMode);
setExclusiveClass(getReaderRoot(), ['navbar-bottom', 'navbar-left', 'navbar-right', 'navbar-disabled'], 'navbar-' + mode);
applyGlobalZoomToAllPages();
}
// ---- Pagination (setting `pagination`: disabled, slider, side-arrows, both) ----
/** Pagination modes (NEW:8097-8104). */
const PAGINATION_MODES = ['disabled', 'slider', 'side-arrows', 'both'];
/**
* First/last page flags for pagination (pure). The last page is the chapter's last page number.
* The 2026 version compared with `totalPages + begin - 1`, which is wrong when the chapter starts
* after page 1 (NEW:8024-8026, docs/05 section 4 #7).
* @param {number} currentPage - Page in view.
* @param {number} begin - First page shown.
* @param {number} last - Last page shown (manga.pages).
* @returns {{isFirst: boolean, isLast: boolean}}
*/
function getPaginationState(currentPage, begin, last) {
return { isFirst: currentPage <= begin, isLast: currentPage >= last };
}
/** Updates everything that shows the current page: counters, select, slider, buttons, arrows. */
function renderCurrentPageControls() {
const range = getPageRange();
if (!range) {
return;
}
const current = APP_STATE.currentPage || range.begin;
const state = getPaginationState(current, range.begin, range.last);
const manga = APP_STATE.manga;
queryAll('.mvm-current-page').forEach(function (element) {
element.textContent = String(current);
});
const select = document.getElementById('GoToPage');
if (select && select !== document.activeElement) {
select.value = String(current);
}
const slider = document.getElementById('PaginationSlider');
if (slider && slider !== document.activeElement) {
slider.value = String(current);
}
[['PaginationPrevious', state.isFirst], ['PaginationNext', state.isLast]].forEach(function (pair) {
const button = document.getElementById(pair[0]);
if (button) {
button.disabled = pair[1];
button.classList.toggle('disabled', pair[1]);
}
});
const left = document.getElementById('SideArrowLeft');
if (left) {
left.classList.toggle('unavailable', state.isFirst && !isUsableLink(manga.prev));
}
const right = document.getElementById('SideArrowRight');
if (right) {
right.classList.toggle('unavailable', state.isLast && !isUsableLink(manga.next));
}
}
/** Applier of `pagination`: root classes decide what is visible. */
function applyPagination(paginationMode) {
const mode = PAGINATION_MODES.indexOf(paginationMode) === -1 ? 'disabled' : paginationMode;
setExclusiveClass(getReaderRoot(), PAGINATION_MODES.map(function (name) {
return 'pagination-' + name;
}), 'pagination-' + mode);
renderCurrentPageControls();
}
// ---- Device (setting `deviceMode`: auto, desktop, tablet, mobile) ----
/**
* Applier of `deviceMode`: the device class on the reader root ('mobile', 'tablet', 'desktop'),
* which drives the phone/tablet CSS. 'auto' uses the detected device (NEW:7573-7577).
* @param {string} deviceMode - Setting value.
*/
function applyDeviceMode(deviceMode) {
const device = resolveDeviceMode(deviceMode, detectDevice());
APP_STATE.device = device;
setExclusiveClass(getReaderRoot(), ['mobile', 'tablet', 'desktop'], device);
logDebug('device class', device, '(deviceMode ' + deviceMode + ')');
}
// ---- Side panels (settings, keybindings, bookmarks, comments) ----
// All panels are always in the page; APP_STATE.panel says which one is open ('none',
// 'settings', 'keybindings', 'bookmarks', 'comments'). Opening one closes the others. The overlay covers the reader while
// a panel is open; a click on it, the close buttons and the Escape key close the panels.
/** Panel name -> element id. */
const PANEL_ELEMENT_IDS = {
settings: 'SettingsPanel',
keybindings: 'KeybindingsPanel',
bookmarks: 'BookmarksPanel',
comments: 'CommentsPanel'
};
/** Writes the open/closed state of the panels and the overlay. */
function renderPanelState() {
Object.keys(PANEL_ELEMENT_IDS).forEach(function (name) {
const panel = document.getElementById(PANEL_ELEMENT_IDS[name]);
if (panel) {
panel.classList.toggle('visible', APP_STATE.panel === name);
panel.setAttribute('aria-hidden', APP_STATE.panel === name ? 'false' : 'true');
}
});
const overlay = document.getElementById('Overlay');
if (overlay) {
overlay.classList.toggle('visible', APP_STATE.panel !== 'none');
}
}
/**
* Opens one panel ('settings', 'keybindings') or closes all ('none'). Leaving the keybindings
* panel ends its edit mode (unsaved edits are dropped). Listeners get ('panel', name).
* @param {string} name - Panel name or 'none'.
*/
function setOpenPanel(name) {
const panelName = PANEL_ELEMENT_IDS[name] ? name : 'none';
if (panelName !== 'keybindings' && APP_STATE.keybindsEditing) {
APP_STATE.keybindsEditing = false;
}
APP_STATE.panel = panelName;
renderPanelState();
const panel = document.getElementById(PANEL_ELEMENT_IDS[panelName] || '');
if (panel && typeof panel.focus === 'function') {
panel.focus();
}
logDebug('panel', panelName);
notifyAppState('panel', panelName);
}
/**
* Shows the value of a slider in its badge (<output>), with its unit.
* @param {HTMLInputElement} slider - A settings slider with data-output.
*/
function updateRangeOutput(slider) {
const output = document.getElementById(slider.getAttribute('data-output') || '');
if (output) {
output.textContent = slider.value + (slider.getAttribute('data-unit') || '');
}
}
/**
* Shows or hides the settings rows that depend on another setting (data-visible-when), using
* the stored values.
*/
function renderSettingsRowVisibility() {
queryAll('#SettingsPanel [data-visible-when]').forEach(function (row) {
row.classList.toggle('mvm-row-hidden', !isRowConditionMet(row.getAttribute('data-visible-when'), getStoredSetting));
});
}
/**
* Makes the settings panel controls of one setting show its STORED value (after a save, a
* reset, a scope switch or a change in another tab). Generic: works for every control type
* built in section 12 (checkbox, slider, select, color input, choice buttons).
* @param {string} key - Setting name.
*/
function syncSettingsControls(key) {
const panel = document.getElementById('SettingsPanel');
if (!panel || !Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) {
return;
}
const value = getStoredSetting(key);
queryAll('[data-setting="' + key + '"]', panel).forEach(function (control) {
if (control.tagName === 'INPUT' && control.type === 'checkbox') {
control.checked = value === true;
} else if (control.tagName === 'INPUT' && control.type === 'color') {
control.value = resolveThemeColor(value).toLowerCase();
} else if (control.tagName === 'INPUT' || control.tagName === 'SELECT') {
if (control !== document.activeElement || control.type === 'range') {
control.value = String(value);
}
if (control.hasAttribute('data-output')) {
updateRangeOutput(control);
}
} else if (control.hasAttribute('data-value')) {
const selected = String(control.getAttribute('data-value')).toLowerCase() === String(key === 'theme' ? resolveThemeColor(value) : value).toLowerCase();
control.classList.toggle('selected', selected);
if (control.getAttribute('role') === 'radio') {
control.setAttribute('aria-checked', selected ? 'true' : 'false');
}
}
});
if (key === 'theme') {
const shades = document.getElementById('ThemeShades');
if (shades) {
setElementHtml(shades, buildThemeShadesHtml(value));
}
}
renderSettingsRowVisibility();
}
/** Draws the settings panel again (after a scope switch or a reset); keeps it open. */
function refreshSettingsPanel() {
const panel = document.getElementById('SettingsPanel');
if (!panel) {
return;
}
setElementOuterHtml(panel, buildSettingsPanelHtml());
renderPanelState();
}
// ---- Language (setting `locale`) ----
/**
* Replaces an element (found by id) with new HTML. Returns false when it is not in the page.
* @param {string} elementId - Id of the element to replace.
* @param {string} html - New HTML (one element).
* @returns {boolean}
*/
function replaceElementHtml(elementId, html) {
const element = document.getElementById(elementId);
if (!element) {
return false;
}
setElementOuterHtml(element, html);
return true;
}
/** Gives the per-page buttons and the "[ End ]" separator their texts in the current language. */
function relabelPages() {
PAGE_BUTTONS.forEach(function (button) {
const text = getLocaleString(button.labelKey);
queryAll('#Chapter .PageFunctions .' + button.className).forEach(function (element) {
element.setAttribute('title', text);
element.setAttribute('aria-label', text);
});
});
const lastSeparator = lastItem(queryAll('#Chapter .separator'));
if (lastSeparator) {
lastSeparator.textContent = '[ ' + getLocaleString('END') + ' ]';
}
}
/**
* Applier of `locale` (runs on change only, not at startup): draws the header, burger, thumbnails
* bar, pagination and both panels again with the new texts, then restores their state. Pages and
* their images are NOT re-rendered (no image is requested again, per-page zoom stays); only their
* button tooltips and the end separator get new texts. Listeners get ('textsRefreshed').
*/
function refreshLocalizedTexts() {
const manga = APP_STATE.manga;
if (!manga || !getReaderRoot()) {
return;
}
log('Language changed to ' + getSetting('locale') + ': refreshing the reader texts');
replaceElementHtml('menu', buildMenuButtonHtml());
replaceElementHtml('Header', buildHeaderHtml(manga));
replaceElementHtml('Navigation', buildNavbarHtml(manga));
replaceElementHtml('Pagination', buildPaginationHtml(manga));
replaceElementHtml('SettingsPanel', buildSettingsPanelHtml());
replaceElementHtml('KeybindingsPanel', buildKeybindingsPanelHtml());
replaceElementHtml('BookmarksPanel', buildBookmarksPanelHtml());
replaceElementHtml('CommentsPanel', buildCommentsPanelHtml());
applyBookmarksState();
const headerMode = HEADER_MODES.indexOf(getSetting('header')) === -1 ? 'scroll' : getSetting('header');
setExclusiveClass(getHeaderElement(), HEADER_MODES, headerMode);
setExclusiveClass(document.getElementById('menu'), HEADER_MODES, headerMode);
renderHeaderState();
markSelectedButton('ViewModeGroup', getSetting('viewMode'));
renderZoomControls();
applyMinZoom(getSetting('minZoom'));
applyHidePageControls(getSetting('hidePageControls'));
renderNavbarElement(getSetting('navbar'));
applyPagination(getSetting('pagination'));
relabelPages();
renderPanelState();
notifyAppState('textsRefreshed', getSetting('locale'));
}
// ---- Reactions to image and current-page changes ----
/**
* After a page image loaded: remember its natural size, mark wide images as double pages
* (NEW:11200), apply the global zoom to it, refresh book pairing and its thumbnail.
* @param {number} page - Page number.
*/
function onPageImageLoaded(page) {
const image = getPageImage(page);
const record = getImageRecord(page);
const layout = getPageLayout(page);
if (!image || layout.processedSource === record.src) {
return;
}
layout.processedSource = record.src;
layout.naturalWidth = image.naturalWidth || 0;
layout.naturalHeight = image.naturalHeight || 0;
layout.doublePage = layout.naturalWidth > layout.naturalHeight;
applyGlobalZoomToPage(page);
if (isBookViewMode(getSetting('viewMode'))) {
applyBookPairing();
}
}
onAppStateChange(function (what, value) {
if (what === 'image' && value && value.page) {
if (value.status === IMAGE_STATUS.LOADED) {
onPageImageLoaded(value.page);
}
renderThumbnail(value.page);
} else if (what === 'currentPage') {
renderCurrentPageControls();
}
});
// ---- THE TABLE ----
/**
* THE TABLE. Setting name -> function(newValue). Settings not listed here do not change the
* screen directly (for example `loadSpeed` is read when loading starts, `zoomStep` when a zoom
* button is used). Order matters only for applyAllSettings(): the bar position comes before the
* zoom because it changes the space available to pages.
*/
const SETTING_APPLIERS = {
colorScheme: applyThemeSettings,
theme: applyThemeSettings,
header: applyHeaderMode,
navbar: applyNavbar,
pagination: applyPagination,
viewMode: applyViewMode,
fitWidthIfOversize: applyFitWidthIfOversize,
minZoom: applyMinZoom,
zoomMode: applyZoomSettings,
zoomValue: applyZoomSettings,
hidePageControls: applyHidePageControls,
bookmarks: applyBookmarksState,
deviceMode: applyDeviceMode,
locale: refreshLocalizedTexts
// keybinds: added by section 17 (redraws the keybindings panel)
};
/**
* Appliers that run only when their setting CHANGES, not in applyAllSettings() at startup
* (the reader is drawn in the right language from the start).
*/
const APPLY_ON_CHANGE_ONLY = ['locale'];
/** Runs every applier with the current values (used once after the takeover). */
function applyAllSettings() {
Object.keys(SETTING_APPLIERS).forEach(function (key) {
if (APPLY_ON_CHANGE_ONLY.indexOf(key) !== -1) {
return;
}
safeRun('applier:' + key, function () {
SETTING_APPLIERS[key](getSetting(key));
}, undefined, { key: key });
});
}
// Any setting change calls its applier (if it has one), then the settings panel controls of
// that setting show the stored value again.
onSettingChange('*', function (newValue, oldValue, key) {
const applier = SETTING_APPLIERS[key];
if (applier) {
logDebug('applier run', key, newValue);
applier(newValue);
}
syncSettingsControls(key);
});
// ==========================================================================================
// ==== 15. IMAGE LOADING ====================================================================
// Pages are shown with plain <img src="..."> (no fetch of image bytes), so it works without
// CORS and asks the site for each image only once. A queue limits how many images load at the
// same time and spaces their start (setting loadSpeed, or the site's `timer`).
// Strategies (what the site module returned):
// 'images' - listImages: direct image URLs
// 'pages' - listPages + img (+ lazyAttr): fetch each page's HTML, read the image URL
// 'bruteForce' - the site module drives loading itself through a small API
// Lazy loading (decision D3; setting `lazyLoadImages`, or the site's `manga.lazy` which wins):
// the first `lazyStart` + 1 pages are queued at once (NEW:10944-10958), the others wait in
// LOADER.deferred until a rule asks for them. Setting `lazyLoadRule`:
// 'distance' - 2025 rule: a page within CONFIG.lazyDistancePx of the window (any direction,
// also sideways in the horizontal modes) is queued; checked on scroll,
// touchmove, resize, chapter scroll, layout changes (throttled)
// 'lookAhead' - 2026 rule: when the current page moves forward, the next
// CONFIG.lookAheadPages pages (from the current one) are queued
// 'both' - both rules
// Any page can also be loaded on demand (go-to-page select, thumbnails, pagination):
// requestPageLoad(page) puts it at the FRONT of the queue. bruteForce sites are never lazy
// (the 2026 version did not make them lazy either).
// ==========================================================================================
/** Loader state. Reset for every chapter by startImageLoading(). */
const LOADER = {
manga: null,
strategy: '',
queue: [],
activeCount: 0,
lastStartTime: 0,
concurrency: 10,
delayMs: 250,
pumpTimer: null,
completionLogged: false,
lazyActive: false,
lazyRule: 'distance',
deferred: {},
lazyListenersAttached: false,
lastLookAheadPage: 0
};
/** The values of the `lazyLoadRule` setting. */
const LAZY_LOAD_RULES = ['distance', 'lookAhead', 'both'];
/**
* Which loading strategy a manga object uses: 'images', 'pages', 'bruteForce' or ''.
* @param {Object} manga - The manga object.
* @returns {string}
*/
function getLoadingStrategy(manga) {
if (Array.isArray(manga.listImages) && manga.listImages.length > 0) {
return 'images';
}
if (Array.isArray(manga.listPages) && manga.listPages.length > 0) {
return 'pages';
}
if (typeof manga.bruteForce === 'function') {
return 'bruteForce';
}
return '';
}
/**
* Adds a page to the queue. The `resolveSource` function returns the image URL (or a Promise of
* it) when the page's turn comes.
* @param {number} page - Page number.
* @param {function(): (string|Promise<string>)} resolveSource - Produces the image URL.
* @param {boolean} [urgent] - True to put the page at the FRONT of the queue (load on demand).
*/
function enqueuePage(page, resolveSource, urgent) {
updateImageRecord(page, { status: IMAGE_STATUS.WAITING });
const job = { page: page, resolveSource: resolveSource };
if (urgent === true) {
LOADER.queue.unshift(job);
} else {
LOADER.queue.push(job);
}
pumpQueue();
}
/**
* Starts queued pages while there is a free slot and the delay since the last start passed.
* Schedules itself again when it has to wait.
*/
function pumpQueue() {
if (LOADER.pumpTimer !== null) {
return;
}
while (LOADER.queue.length > 0 && LOADER.activeCount < LOADER.concurrency) {
const waitMs = LOADER.delayMs - (Date.now() - LOADER.lastStartTime);
if (waitMs > 0) {
LOADER.pumpTimer = setTimeout(function () {
LOADER.pumpTimer = null;
pumpQueue();
}, waitMs);
return;
}
const job = LOADER.queue.shift();
LOADER.activeCount += 1;
LOADER.lastStartTime = Date.now();
startPageJob(job);
}
}
/**
* Gives a slot back and lets the next page start. Safe to call once per job only (guarded by
* the job's `released` flag).
* @param {Object} job - The queue job.
*/
function releaseSlot(job) {
if (job.released) {
return;
}
job.released = true;
LOADER.activeCount = Math.max(0, LOADER.activeCount - 1);
pumpQueue();
}
/**
* Resolves the page's image URL, then sets it on the <img>.
* @param {Object} job - { page, resolveSource }.
*/
async function startPageJob(job) {
updateImageRecord(job.page, { status: IMAGE_STATUS.LOADING });
let source = '';
try {
source = normalizeUrl(await job.resolveSource());
} catch (error) {
logError('imageLoading.resolveSource', error, { page: job.page });
}
if (source === '') {
logWarn('Page ' + job.page + ': no image URL, marking it as failed');
markPageFailed(job.page);
releaseSlot(job);
return;
}
setPageImageSource(job, source);
}
/**
* Sets the image URL and waits for load/error. A slot is released on load, on error, or after
* CONFIG.imageLoadTimeoutMs (a very slow image keeps loading but no longer blocks the queue).
* @param {Object} job - The queue job.
* @param {string} source - Image URL.
*/
function setPageImageSource(job, source) {
const image = getPageImage(job.page);
if (!image) {
logWarn('Page ' + job.page + ': <img> element not found');
releaseSlot(job);
return;
}
const slowTimer = setTimeout(function () {
logDebug('Page ' + job.page + ': still loading after ' + CONFIG.imageLoadTimeoutMs + ' ms, freeing its slot');
releaseSlot(job);
}, CONFIG.imageLoadTimeoutMs);
image.onload = guardHandler('image.onload', function () {
clearTimeout(slowTimer);
image.classList.remove('imgBroken');
updateImageRecord(job.page, { status: IMAGE_STATUS.LOADED, src: source });
logDebug('Page ' + job.page + ' loaded', source);
releaseSlot(job);
});
image.onerror = guardHandler('image.onerror', function () {
clearTimeout(slowTimer);
releaseSlot(job);
handleImageError(job.page, source);
});
updateImageRecord(job.page, { src: source });
image.setAttribute('src', source);
}
/**
* Retries a broken image after a delay with a cache-busting parameter, up to the setting
* maxReload. After that the page is marked failed (it can still be reloaded by hand).
* @param {number} page - Page number.
* @param {string} source - The URL that failed.
*/
function handleImageError(page, source) {
const record = getImageRecord(page);
const attempts = record.attempts + 1;
const maxAttempts = parseInt(getSetting('maxReload'), 10) || 0;
updateImageRecord(page, { attempts: attempts });
if (attempts > maxAttempts || isInMemoryUrl(source)) {
logWarn('Page ' + page + ' failed after ' + record.attempts + ' retries', source);
markPageFailed(page);
return;
}
log('Page ' + page + ' failed to load, retry ' + attempts + '/' + maxAttempts + ' in ' + CONFIG.imageRetryDelayMs + ' ms');
setTimeout(function () {
const original = source.replace(/([?&])forceReload=\d+$/, '');
enqueuePage(page, function () {
return addCacheBuster(original, attempts);
});
}, CONFIG.imageRetryDelayMs);
}
/** Marks a page as failed and shows it. */
function markPageFailed(page) {
updateImageRecord(page, { status: IMAGE_STATUS.FAILED });
const image = getPageImage(page);
if (image) {
image.classList.add('imgBroken');
}
const container = document.getElementById('Page' + page);
if (container) {
container.classList.add('failed');
}
}
/**
* Manual reload of one page (button). Resets its retry counter and loads it again.
* @param {number} page - Page number.
*/
function reloadPageImage(page) {
const record = getImageRecord(page);
const container = document.getElementById('Page' + page);
if (container) {
container.classList.remove('failed');
}
updateImageRecord(page, { attempts: 0 });
delete LOADER.deferred[page];
if (record.src && !isInMemoryUrl(record.src)) {
const original = record.src.replace(/([?&])forceReload=\d+$/, '');
const bust = Date.now() % 100000;
log('Reloading page ' + page);
enqueuePage(page, function () {
return addCacheBuster(original, bust);
});
return;
}
const resolver = createSourceResolver(page);
if (resolver) {
enqueuePage(page, resolver);
} else {
logWarn('Reload: no way to find the image of page ' + page);
}
}
/**
* Builds the function that finds the image URL of a page, for the current strategy.
* @param {number} page - Page number.
* @returns {function(): (string|Promise<string>)|null}
*/
function createSourceResolver(page) {
const manga = LOADER.manga;
if (!manga) {
return null;
}
if (LOADER.strategy === 'images') {
return function () {
return manga.listImages[page - 1] || '';
};
}
if (LOADER.strategy === 'pages') {
return function () {
return getElementAttribute(manga.listPages[page - 1] || '', manga.img, manga.lazyAttr || 'src').then(function (source) {
if (source !== '' || typeof manga.reload !== 'function') {
return source;
}
// Some sites: the page HTML alone has no usable image (anti-hotlink token);
// the site module re-fetches the page with a fresh token. Errors reject and the page fails.
log('Page ' + page + ': direct read failed, trying the site reload()');
return Promise.resolve(manga.reload(page));
});
};
}
return null;
}
/**
* Starts loading a chapter. Called once after the takeover.
* @param {Object} manga - Validated manga object (begin and pages already set).
*/
function startImageLoading(manga) {
const preset = CONFIG.loadSpeedPresets[getSetting('loadSpeed')] || CONFIG.loadSpeedPresets.Safe;
LOADER.manga = manga;
LOADER.strategy = getLoadingStrategy(manga);
LOADER.queue = [];
LOADER.activeCount = 0;
LOADER.concurrency = preset.concurrency;
LOADER.delayMs = typeof manga.timer === 'number' && manga.timer > 0 ? manga.timer : preset.delayMs;
LOADER.completionLogged = false;
LOADER.deferred = {};
LOADER.lastLookAheadPage = manga.begin;
LOADER.lazyActive = LOADER.strategy !== 'bruteForce' && isLazyLoadingWanted(manga);
LOADER.lazyRule = LAZY_LOAD_RULES.indexOf(getSetting('lazyLoadRule')) === -1 ? 'distance' : getSetting('lazyLoadRule');
APP_STATE.images = {};
log('Loading images: strategy "' + LOADER.strategy + '", pages ' + manga.begin + '-' + manga.pages + ', ' + LOADER.concurrency + ' at a time, ' + LOADER.delayMs + ' ms apart' + (LOADER.lazyActive ? ', lazy (rule "' + LOADER.lazyRule + '", first ' + (Number(getSetting('lazyStart')) + 1) + ' pages at once)' : ''));
if (LOADER.strategy === 'bruteForce') {
startBruteForceLoading(manga);
return;
}
const eagerPages = LOADER.lazyActive ? computeEagerPages(manga.begin, manga.pages, Number(getSetting('lazyStart'))) : sequence(manga.pages, manga.begin);
sequence(manga.pages, manga.begin).forEach(function (page) {
if (eagerPages.indexOf(page) === -1) {
LOADER.deferred[page] = true;
return;
}
const resolver = createSourceResolver(page);
if (resolver) {
enqueuePage(page, resolver);
}
});
if (LOADER.lazyActive) {
logDebug('lazy: deferred pages', Object.keys(LOADER.deferred).join(','));
attachLazyLoadListeners();
scheduleLazyDistanceCheck();
}
}
// ---- Lazy loading ----
/**
* True when this chapter loads lazily: the site's `manga.lazy` (true/false) wins over the
* `lazyLoadImages` setting (NEW:10945, OLD:1940).
* @param {Object} manga - The manga object.
* @returns {boolean}
*/
function isLazyLoadingWanted(manga) {
if (manga && typeof manga.lazy === 'boolean') {
return manga.lazy;
}
return getSetting('lazyLoadImages') === true;
}
/**
* Pages queued at once when lazy loading is on (pure): positions 0..lazyStart counted from
* `begin`, i.e. lazyStart + 1 pages (NEW:10944-10958: `position <= lazyStart`).
* @param {number} begin - First page.
* @param {number} last - Last page.
* @param {number} lazyStart - Setting `lazyStart` (invalid -> 0).
* @returns {number[]}
*/
function computeEagerPages(begin, last, lazyStart) {
const count = Math.max(0, Math.floor(Number(lazyStart)) || 0) + 1;
return sequence(Math.min(last, begin + count - 1), begin);
}
/**
* True when a rectangle is within `distance` pixels of the window on both axes (pure).
* @param {{top: number, bottom: number, left: number, right: number}} rect - Element box (viewport coordinates).
* @param {number} viewportWidth - Window width.
* @param {number} viewportHeight - Window height.
* @param {number} distance - Extra margin around the window, in pixels.
* @returns {boolean}
*/
function isRectNearViewport(rect, viewportWidth, viewportHeight, distance) {
return rect.bottom >= -distance && rect.top <= viewportHeight + distance && rect.right >= -distance && rect.left <= viewportWidth + distance;
}
/**
* The pages whose box is near the window (pure; the 2025 rule, OLD:1221-1227, extended to the
* horizontal axis for the Fluid modes). Works the same when the reader jumped backward.
* @param {{page: number, rect: Object}[]} entries - Page boxes.
* @param {number} viewportWidth - Window width.
* @param {number} viewportHeight - Window height.
* @param {number} distance - CONFIG.lazyDistancePx.
* @returns {number[]}
*/
function selectPagesNearViewport(entries, viewportWidth, viewportHeight, distance) {
return entries.filter(function (entry) {
return isRectNearViewport(entry.rect, viewportWidth, viewportHeight, distance);
}).map(function (entry) {
return entry.page;
});
}
/**
* Pages the 2026 look-ahead rule wants (pure): from the current page, `count` pages, only
* when the current page moved FORWARD (NEW:11002-11008). [] when it did not move forward.
* @param {number} currentPage - Page in view now.
* @param {number} previousPage - Highest page seen before.
* @param {number} last - Last page.
* @param {number} count - CONFIG.lookAheadPages.
* @returns {number[]}
*/
function selectLookAheadPages(currentPage, previousPage, last, count) {
if (!(currentPage > previousPage)) {
return [];
}
return sequence(Math.min(last, currentPage + count - 1), currentPage);
}
/** True when the active lazy rule includes `rule` ('distance' or 'lookAhead'). */
function isLazyRuleActive(rule) {
return LOADER.lazyActive && (LOADER.lazyRule === rule || LOADER.lazyRule === 'both');
}
/**
* Queues a deferred page (lazy mode). Does nothing for a page that is not deferred.
* @param {number} page - Page number.
* @param {boolean} urgent - True to put it at the front of the queue.
* @param {string} reason - For the debug log ('distance', 'lookAhead', 'on demand').
* @returns {boolean} True when the page was queued.
*/
function queueDeferredPage(page, urgent, reason) {
if (!LOADER.deferred[page]) {
return false;
}
delete LOADER.deferred[page];
const resolver = createSourceResolver(page);
if (!resolver) {
logWarn('lazy: no way to find the image of page ' + page);
return false;
}
logDebug('lazy: queue page ' + page + ' (' + reason + ')');
enqueuePage(page, resolver, urgent);
return true;
}
/**
* Load on demand: a page the user wants to see now (go-to-page, thumbnail, pagination) is
* queued at the front when it was still waiting for a lazy rule. Safe to call for any page.
* @param {number} page - Page number.
* @returns {boolean} True when the page was queued now.
*/
function requestPageLoad(page) {
return queueDeferredPage(page, true, 'on demand');
}
/** The distance rule: queues every deferred page that is near the window now. */
function checkLazyPagesByDistance() {
if (!isLazyRuleActive('distance')) {
return;
}
const pending = Object.keys(LOADER.deferred);
if (pending.length === 0) {
return;
}
const entries = [];
pending.forEach(function (key) {
const container = document.getElementById('Page' + key);
if (container) {
entries.push({ page: parseInt(key, 10), rect: container.getBoundingClientRect() });
}
});
selectPagesNearViewport(entries, window.innerWidth, window.innerHeight, CONFIG.lazyDistancePx).sort(function (first, second) {
return first - second;
}).forEach(function (page) {
queueDeferredPage(page, false, 'distance');
});
}
/** Throttled distance check (scroll, touchmove, resize, layout changes). */
const scheduleLazyDistanceCheck = throttle(function () {
safeRun('lazy.distanceCheck', checkLazyPagesByDistance);
}, CONFIG.lazyCheckThrottleMs);
/**
* The look-ahead rule: when the current page moved forward, queue the next pages.
* @param {number} currentPage - Page in view.
*/
function onLazyCurrentPage(currentPage) {
if (!isLazyRuleActive('lookAhead') || !LOADER.manga) {
return;
}
const pages = selectLookAheadPages(currentPage, LOADER.lastLookAheadPage, LOADER.manga.pages, CONFIG.lookAheadPages);
if (currentPage > LOADER.lastLookAheadPage) {
LOADER.lastLookAheadPage = currentPage;
}
pages.forEach(function (page) {
queueDeferredPage(page, false, 'lookAhead');
});
}
/**
* Attaches the distance-rule listeners once (window scroll, touchmove, resize; chapter scroll
* for the horizontal modes). They do nothing when no page is deferred.
*/
function attachLazyLoadListeners() {
if (LOADER.lazyListenersAttached) {
return;
}
const handler = guardHandler('lazy.listener', function () {
scheduleLazyDistanceCheck();
});
window.addEventListener('scroll', handler, { passive: true });
window.addEventListener('touchmove', handler, { passive: true });
window.addEventListener('resize', handler);
const chapter = document.getElementById('Chapter');
if (chapter) {
chapter.addEventListener('scroll', handler, { passive: true });
}
LOADER.lazyListenersAttached = true;
logDebug('lazy: listeners attached');
}
// Layout changes move pages: check the distance rule again. The current page drives the
// look-ahead rule.
['viewMode', 'zoomMode', 'zoomValue', 'navbar'].forEach(function (key) {
onSettingChange(key, function () {
if (LOADER.lazyActive) {
scheduleLazyDistanceCheck();
}
});
});
onAppStateChange(function (what, value) {
if (!LOADER.lazyActive) {
return;
}
if (what === 'image') {
scheduleLazyDistanceCheck();
} else if (what === 'currentPage') {
onLazyCurrentPage(value);
}
});
/**
* Gives a site module's bruteForce function a small API to feed images itself.
* Same context fields as the earlier versions: begin, addImg, loadImages, loadPages, wait.
* @param {Object} manga - The manga object with a bruteForce function.
*/
function startBruteForceLoading(manga) {
const context = {
begin: manga.begin,
wait: LOADER.delayMs,
addImg: function (ignoredManga, page, source) {
enqueuePage(page, function () {
return source;
});
},
loadImages: function (list) {
LOADER.manga = Object.assign({}, manga, { listImages: list });
LOADER.strategy = 'images';
sequence(manga.pages, manga.begin).forEach(function (page) {
enqueuePage(page, createSourceResolver(page));
});
},
loadPages: function (list, imageSelector, lazyAttribute) {
LOADER.manga = Object.assign({}, manga, { listPages: list, img: imageSelector, lazyAttr: lazyAttribute });
LOADER.strategy = 'pages';
sequence(manga.pages, manga.begin).forEach(function (page) {
enqueuePage(page, createSourceResolver(page));
});
}
};
safeRunAsync('site.bruteForce', function () {
return Promise.resolve(manga.bruteForce(context));
});
}
/** Updates counters, tab title and progress bar after any image change. */
function updateProgress() {
const manga = APP_STATE.manga;
if (!manga) {
return;
}
const counts = countImages();
const total = manga.pages - manga.begin + 1;
const done = counts.loaded + counts.failed;
const percentage = total > 0 ? Math.floor((counts.loaded / total) * 100) : 0;
const root = getReaderRoot();
if (!root) {
return;
}
queryAll('.mvm-loaded-count', root).forEach(function (element) {
element.textContent = String(counts.loaded);
});
queryAll('.mvm-total-count', root).forEach(function (element) {
element.textContent = String(total);
});
document.title = '(' + percentage + '%) ' + manga.title;
const bar = document.getElementById('ProgressBar');
if (bar) {
bar.querySelector('.mvm-progress-fill').style.width = percentage + '%';
bar.classList.toggle('complete', done >= total);
}
if (done >= total && !LOADER.completionLogged) {
LOADER.completionLogged = true;
log('Image loading finished: ' + counts.loaded + ' loaded, ' + counts.failed + ' failed, of ' + total);
}
}
// Every image change updates the progress display; so does a redraw of the header and the
// thumbnails bar for a language change (their counters are drawn empty).
onAppStateChange(function (what) {
if (what === 'image' || what === 'textsRefreshed') {
updateProgress();
}
});
// ==========================================================================================
// ==== 16. ACTIONS ==========================================================================
// ONE table of everything the user can trigger. A click on an element with
// data-action="<name>" runs ACTIONS[name] (section 18); the keyboard (section 17)
// uses the same table through KEYBIND_ACTIONS (keybind id -> action name).
// Every action receives a context: { element, page, value, setting, event } (any may be
// missing: a key press has no element and no page). Names are kebab-case.
// ==========================================================================================
/**
* Opens a chapter link safely: only http(s) or relative links are followed.
* @param {string} url - Link from the site module.
* @param {string} what - For the log, e.g. 'next chapter'.
* @returns {boolean} True when navigation started.
*/
function navigateTo(url, what) {
const href = resolveChapterHref(url);
if (href === '') {
log('No usable link for ' + what + (isUsableLink(url) ? ' (invalid or blocked: ' + url + ')' : ''));
return false;
}
log('Opening ' + what + ':', href);
window.location.href = href;
return true;
}
/** Go to the next chapter. */
function actionNextChapter() {
navigateTo(APP_STATE.manga ? APP_STATE.manga.next : '', 'next chapter');
}
/** Go to the previous chapter. */
function actionPreviousChapter() {
navigateTo(APP_STATE.manga ? APP_STATE.manga.prev : '', 'previous chapter');
}
/** Go to the series page; without a link, go back in history (same as the 2026 version). */
function actionSeries() {
const series = APP_STATE.manga ? APP_STATE.manga.series : '';
if (!navigateTo(series, 'chapter list')) {
window.history.back();
}
}
/**
* Page number an action works on: the clicked element's data-page, else the page in view (key
* presses have no element).
* @param {Object} context - Action context.
* @returns {number} 0 when unknown.
*/
function getActionPage(context) {
if (context && context.page) {
return context.page;
}
return APP_STATE.currentPage || 0;
}
/**
* Wraps a per-page function into an action that logs a warning when no page is known.
* Generic: used for every per-page button.
* @param {string} name - Action name (for the log).
* @param {function(number): void} pageFunction - Work on one page.
* @returns {function(Object): void}
*/
function createPageAction(name, pageFunction) {
return function (context) {
const page = getActionPage(context);
if (!page) {
logWarn(name + ': no page number');
return;
}
pageFunction(page);
};
}
/**
* Builds the action of one view-mode button (session-only change, docs/04 product decision
* table item 14: header view mode is not saved).
* @param {string} viewMode - One of VIEW_MODES.
* @returns {function(): void}
*/
function createViewModeAction(viewMode) {
return function () {
switchViewMode(viewMode);
};
}
/** Reload one page image. */
function actionReloadPage(context) {
if (!context.page) {
logWarn('reload-page: no page number on the clicked element');
return;
}
reloadPageImage(context.page);
}
/** Header slider: percent zoom, live (session only). */
function actionZoomSlider(context) {
const value = parseInt(context.value, 10);
if (isNaN(value)) {
logWarn('zoom-slider: not a number', context.value);
return;
}
setGlobalZoom('percent', value);
}
/** Go-to-page select, pagination slider and thumbnails: scroll to that page. */
function actionGoToPage(context) {
const page = context.value !== undefined ? parseInt(context.value, 10) : context.page;
if (!page || isNaN(page)) {
logWarn('go-to-page: no page number', context.value);
return;
}
requestPageLoad(page);
scrollToPage(page);
}
/**
* Scrolls one page back (-1) or forward (+1) from the page in view, within the chapter.
* @param {number} direction - -1 or 1.
*/
function goToNeighborPage(direction) {
const range = getPageRange();
if (!range) {
return;
}
const target = clamp((APP_STATE.currentPage || range.begin) + direction, range.begin, range.last);
requestPageLoad(target);
scrollToPage(target);
}
/** Left side arrow: previous page, or previous chapter on the first page (NEW:8105-8108). */
function actionSideArrowPrevious() {
const range = getPageRange();
if (range && getPaginationState(APP_STATE.currentPage || range.begin, range.begin, range.last).isFirst) {
actionPreviousChapter();
return;
}
goToNeighborPage(-1);
}
/** Right side arrow: next page, or next chapter on the LAST page (fixed last-page check). */
function actionSideArrowNext() {
const range = getPageRange();
if (range && getPaginationState(APP_STATE.currentPage || range.begin, range.begin, range.last).isLast) {
actionNextChapter();
return;
}
goToNeighborPage(1);
}
/** Header button and key L: hide/show all page controls, SAVED (decision D5). */
function actionTogglePageControls() {
saveSetting('hidePageControls', getSetting('hidePageControls') !== true);
}
/**
* Bookmark toggle of a page button: if this chapter is bookmarked, remove the bookmark;
* otherwise bookmark THE CLICKED PAGE (the 2026 version used the page in view instead,
* docs/05 section 4 #3). Saved at once.
* @param {number} page - Clicked page.
*/
function toggleBookmarkForPage(page) {
// A bookmark changed by hand is no longer the one used to resume (no automatic removal).
APP_STATE.resumedBookmarkPage = 0;
if (getBookmarkedPage() > 0) {
removeBookmark();
log(getLocaleString('BOOKMARK_REMOVED'), window.location.href);
showToast({ type: 'error', title: getLocaleString('BOOKMARK_REMOVED'), durationMs: CONFIG.bookmarkToastMs });
return;
}
saveBookmark(page, window.location.href, APP_STATE.manga ? APP_STATE.manga.title : '');
const message = getLocaleString('BOOKMARK_MESSAGE').replace('##num##', String(page));
log(getLocaleString('BOOKMARK_SAVED') + ': ' + message, window.location.href);
showToast({ type: 'success', title: getLocaleString('BOOKMARK_SAVED'), message: message, durationMs: CONFIG.bookmarkToastMs });
}
// ---- Keyboard scrolling (SCROLL_UP / SCROLL_DOWN, docs/05 section 2.15, NEW:7718-7776) ----
/**
* Target of a one-page step in Fit Height mode (pure, NEW:7725-7729): 0 = top of the window,
* -1 = do nothing (past the last page), else the page number.
* @param {number} currentPage - Page in view.
* @param {number} begin - First page.
* @param {number} last - Last page.
* @param {number} direction - -1 (up) or 1 (down).
* @returns {number}
*/
function computePageStepTarget(currentPage, begin, last, direction) {
const target = (currentPage || begin) + direction;
if (target > last) {
return -1;
}
return target < begin ? 0 : target;
}
/**
* True when a page starts a row in Book/Manga mode (pure, NEW:7741-7749): a double page, or a
* page preceded by an even number of single pages since the previous double page.
* @param {number} page - Page number.
* @param {number} begin - First page.
* @param {number} last - Last page.
* @param {Object<number, boolean>} doublePages - page -> true for double pages.
* @returns {boolean}
*/
function isBookRowStart(page, begin, last, doublePages) {
if (page < begin || page > last) {
return false;
}
if (doublePages[page] === true) {
return true;
}
let singles = 0;
for (let previous = page - 1; previous >= begin; previous--) {
if (doublePages[previous] === true) {
break;
}
singles += 1;
}
return singles % 2 === 0;
}
/**
* Page to jump to for one row step in Book/Manga + Fit Height mode (pure, NEW:7736-7767).
* 0 = top of the window.
* @param {number} currentPage - Page in view.
* @param {number} begin - First page.
* @param {number} last - Last page.
* @param {Object<number, boolean>} doublePages - page -> true for double pages.
* @param {number} direction - -1 (up) or 1 (down).
* @returns {number}
*/
function computeBookRowTarget(currentPage, begin, last, doublePages, direction) {
const current = currentPage || begin;
let target = current;
if (direction > 0) {
target = current + 1;
while (target <= last && !isBookRowStart(target, begin, last, doublePages)) {
target += 1;
}
} else {
target = isBookRowStart(current, begin, last, doublePages) ? current - 1 : current;
while (target > begin && !isBookRowStart(target, begin, last, doublePages)) {
target -= 1;
}
}
if (target < begin) {
return 0;
}
return target > last ? last : target;
}
/**
* Smooth scroll of the window or an element by an amount, with a plain fallback for browsers
* that do not accept the options object.
* @param {Window|Element} target - What scrolls.
* @param {number} left - Horizontal pixels.
* @param {number} top - Vertical pixels.
*/
function smoothScrollBy(target, left, top) {
try {
target.scrollBy({ left: left, top: top, behavior: 'smooth' });
} catch (error) {
logDebug('smooth scrollBy not supported, scrolling instantly', describeError(error));
if (target === window) {
window.scrollBy(left, top);
} else {
target.scrollLeft += left;
target.scrollTop += top;
}
}
}
/**
* Scrolls the reader one step back (-1) or forward (+1), in the 2026 order (NEW:7768-7776):
* Book/Manga with Fit Height -> next/previous row; horizontal modes -> 80% of the window width
* sideways (reversed right to left); Fit Height -> next/previous page; else 80% of the window
* height, smoothly.
* @param {number} direction - -1 or 1.
*/
function scrollReader(direction) {
const range = getPageRange();
const viewMode = getSetting('viewMode');
const zoomMode = getSetting('zoomMode');
logDebug('keyboard scroll', direction, viewMode, zoomMode);
if (range && isBookViewMode(viewMode) && zoomMode === 'height') {
const doublePages = {};
sequence(range.last, range.begin).forEach(function (page) {
doublePages[page] = getPageLayout(page).doublePage;
});
scrollToPage(computeBookRowTarget(APP_STATE.currentPage, range.begin, range.last, doublePages, direction));
return;
}
const chapter = getChapterElement();
if (isFluidViewMode(viewMode) && chapter) {
smoothScrollBy(chapter, 0.8 * window.innerWidth * direction * (viewMode === 'FluidRTL' ? -1 : 1), 0);
return;
}
if (range && zoomMode === 'height') {
const target = computePageStepTarget(APP_STATE.currentPage, range.begin, range.last, direction);
if (target >= 0) {
scrollToPage(target);
}
return;
}
smoothScrollBy(window, 0, 0.8 * window.innerHeight * direction);
}
// ---- Auto-scroll speed (keys "." and ","; SAVED, decision D5) ----
/**
* New auto-scroll speed after one key press (pure, NEW:7560-7568): +/-25 px, limited to
* 0 .. getAutoScrollMaximum(window height).
* @param {number} currentSpeed - Current `scrollHeight`.
* @param {number} direction - 1 faster, -1 slower.
* @param {number} viewportHeight - Window height.
* @returns {number}
*/
function computeAutoScrollSpeed(currentSpeed, direction, viewportHeight) {
return clamp((Number(currentSpeed) || 0) + direction * 25, 0, getAutoScrollMaximum(viewportHeight));
}
/**
* Changes the auto-scroll speed by one step and SAVES it (decision D5; the 2026 version kept
* it for this page only).
* @param {number} direction - 1 or -1.
*/
function changeAutoScrollSpeed(direction) {
const speed = computeAutoScrollSpeed(getSetting('scrollHeight'), direction, window.innerHeight);
saveSetting('scrollHeight', speed);
log('Auto-scroll speed: ' + speed + ' px');
}
// ---- Settings panel actions ----
/**
* Converts a value read from a control (always text) to the type of the setting's default
* (pure). Booleans accept true/'true'; numbers must parse.
* @param {string} key - Setting name.
* @param {*} rawValue - Value from the control.
* @returns {{ok: boolean, value: *}}
*/
function convertSettingValue(key, rawValue) {
if (!Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) {
return { ok: false, value: undefined };
}
const defaultType = typeof DEFAULT_SETTINGS[key];
if (defaultType === 'boolean') {
return { ok: true, value: rawValue === true || rawValue === 'true' };
}
if (defaultType === 'number') {
const number = Number(rawValue);
return { ok: rawValue !== '' && rawValue !== null && !isNaN(number), value: number };
}
if (defaultType === 'string') {
return { ok: rawValue !== undefined && rawValue !== null && String(rawValue) !== '', value: String(rawValue) };
}
return { ok: false, value: undefined };
}
/**
* True when a value is allowed for a setting with a fixed list of choices (SETTING_CHOICES);
* settings without a list accept any value of the right type.
* @param {string} key - Setting name.
* @param {*} value - Converted value.
* @returns {boolean}
*/
function isAllowedSettingValue(key, value) {
const choices = SETTING_CHOICES[key];
if (!choices) {
return true;
}
return choices.some(function (choice) {
return String(choice.value) === String(value);
});
}
/**
* Messages shown when the USER picks some values in the settings panel (not on changes from
* another tab). Same texts and triggers as the 2026 version (NEW:7474-7517).
* setting -> function(value) returning popup options, or null for no message.
*/
const SETTING_CHOICE_NOTICES = {
loadSpeed: function (value) {
return value === 'Extreme' || value === 'All' ? { title: getLocaleString('SPEED_WARNING'), contentHtml: getLocaleString('SPEED_WARNING_MESSAGE'), icon: 'warning' } : null;
},
lazyLoadImages: function (value) {
return value === true ? { title: getLocaleString('WARNING'), contentHtml: getLocaleString('LAZY_LOAD'), icon: 'warning' } : null;
},
downloadZip: function (value) {
return value === true ? { title: getLocaleString('ATTENTION'), contentHtml: getLocaleString('AUTO_DOWNLOAD'), icon: 'info', timerMs: 10000 } : null;
}
};
/**
* Generic action of every settings control (data-setting + value): converts, validates,
* SAVES (the appliers update the screen), then shows the notice for that choice, if any.
* The default view mode is also applied to this page, as in the 2026 version (NEW:7705-7709).
* @param {Object} context - Action context with `setting` and `value`.
*/
function actionSettingChange(context) {
const key = context.setting;
const converted = convertSettingValue(key, context.value);
if (!converted.ok || !isAllowedSettingValue(key, converted.value)) {
logWarn('setting-change: ignored invalid value', { setting: key, value: context.value });
return;
}
saveSetting(key, converted.value);
if (key === 'viewMode') {
switchViewMode(converted.value);
}
const notice = SETTING_CHOICE_NOTICES[key] ? SETTING_CHOICE_NOTICES[key](converted.value) : null;
if (notice) {
showPopup(notice);
}
}
/**
* Scope control: 'local' = this site uses its own settings, 'global' = shared settings
* (NEW:3829-3839). The screen follows the new effective settings; the panel is redrawn.
* @param {Object} context - Action context with value 'local' or 'global'.
*/
function actionSettingsScope(context) {
const useLocal = context.value === 'local';
if (useLocal === isLocalScopeEnabled()) {
return;
}
setLocalScope(useLocal);
refreshSettingsPanel();
log('Settings scope: ' + (useLocal ? 'this site (' + window.location.hostname + ')' : 'global'));
showToast({ type: 'info', title: getLocaleString('SCOPE_CHANGED').replace('##scope##', useLocal ? window.location.hostname : getLocaleString('GLOBAL')), durationMs: CONFIG.settingToastMs });
}
/**
* Reset button: asks first (the 2026 version reset without asking), then resets the settings
* of the active scope. The screen follows at once (no reload needed); the panel is redrawn.
* @returns {Promise<void>}
*/
async function actionResetSettings() {
const answer = await showPopup({
title: getLocaleString('BUTTON_RESET_SETTINGS'),
contentHtml: escapeHtml(getLocaleString('SCOPE') + ': ' + (isLocalScopeEnabled() ? window.location.hostname : getLocaleString('GLOBAL'))),
icon: 'warning',
confirmText: getLocaleString('BUTTON_RESET_SETTINGS'),
cancelText: getLocaleString('CANCEL')
});
if (answer.result !== POPUP_RESULT.CONFIRM) {
log('Reset settings cancelled');
return;
}
resetSettings();
refreshSettingsPanel();
}
/** Keyboard SETTINGS: opens the settings panel, or closes any open panel (NEW:7829-7831). */
function actionToggleSettings() {
setOpenPanel(APP_STATE.panel === 'none' ? 'settings' : 'none');
}
/** THE TABLE. Action name -> function(context). */
const ACTIONS = {
'go-next-chapter': actionNextChapter,
'go-previous-chapter': actionPreviousChapter,
'go-series': actionSeries,
'reload-page': actionReloadPage,
'enlarge': function () {
changeGlobalZoomByStep(1);
},
'reduce': function () {
changeGlobalZoomByStep(-1);
},
'restore': function () {
setGlobalZoom('percent', 100);
},
'fit-width': function () {
setGlobalZoom('width');
},
'fit-height': function () {
setGlobalZoom('height');
},
'zoom-slider': actionZoomSlider,
'toggle-header': toggleHeaderVisibility,
'toggle-page-controls': actionTogglePageControls,
'go-to-page': actionGoToPage,
'previous-page': function () {
goToNeighborPage(-1);
},
'next-page': function () {
goToNeighborPage(1);
},
'side-arrow-previous': actionSideArrowPrevious,
'side-arrow-next': actionSideArrowNext,
'page-bookmark-toggle': createPageAction('page-bookmark-toggle', toggleBookmarkForPage),
'page-zoom-in': createPageAction('page-zoom-in', function (page) {
zoomPageByStep(page, 1);
}),
'page-zoom-out': createPageAction('page-zoom-out', function (page) {
zoomPageByStep(page, -1);
}),
'page-zoom-restore': createPageAction('page-zoom-restore', restorePageZoom),
'page-zoom-width': createPageAction('page-zoom-width', fitPageToWidth),
'page-zoom-height': createPageAction('page-zoom-height', fitPageToHeight),
'page-double-toggle': createPageAction('page-double-toggle', togglePageDoublePage),
'page-hide-toggle': createPageAction('page-hide-toggle', togglePageHidden),
'scroll-up': function () {
scrollReader(-1);
},
'scroll-down': function () {
scrollReader(1);
},
'increase-scroll-speed': function () {
changeAutoScrollSpeed(1);
},
'decrease-scroll-speed': function () {
changeAutoScrollSpeed(-1);
},
'open-settings': function () {
setOpenPanel('settings');
},
'open-keybindings': function () {
setOpenPanel('keybindings');
},
'close-panels': function () {
setOpenPanel('none');
},
'toggle-settings': actionToggleSettings,
'setting-change': actionSettingChange,
'settings-scope': actionSettingsScope,
'reset-settings': function () {
safeRunAsync('action:reset-settings', actionResetSettings);
},
'toast-close': function (context) {
closeToast(context.value || '');
}
// keybinds-edit, keybinds-save, keybinds-cancel: added by section 17
// download, download-cancel: added by section 19
// toggle-auto-scroll, open-bookmarks, bookmark-remove, bookmark-toggle-current,
// bookmarks-clear, open-comments, comments-color-scheme: added by section 20
};
// One action per view mode ('view-webcomic', 'view-fluid-ltr', ...), from the buttons table.
VIEW_MODE_BUTTONS.forEach(function (button) {
ACTIONS[button.action] = createViewModeAction(button.value);
});
/**
* Keyboard action ids (keys of DEFAULT_SETTINGS.keybinds, the names users see and edit) ->
* ACTIONS names. Used by the keyboard engine (section 17). 'toggle-auto-scroll' is registered
* by section 20 (auto-scroll).
*/
const KEYBIND_ACTIONS = {
SCROLL_UP: 'scroll-up',
SCROLL_DOWN: 'scroll-down',
NEXT_CHAPTER: 'go-next-chapter',
PREVIOUS_CHAPTER: 'go-previous-chapter',
RETURN_CHAPTER_LIST: 'go-series',
ENLARGE: 'enlarge',
REDUCE: 'reduce',
RESTORE: 'restore',
FIT_WIDTH: 'fit-width',
FIT_HEIGHT: 'fit-height',
SETTINGS: 'toggle-settings',
VIEW_MODE_WEBCOMIC: 'view-webcomic',
VIEW_MODE_VERTICAL: 'view-vertical',
VIEW_MODE_LEFT: 'view-fluid-ltr',
VIEW_MODE_RIGHT: 'view-fluid-rtl',
VIEW_MODE_GALLERY: 'view-gallery',
SCROLL_START: 'toggle-auto-scroll',
INCREASE_SPEED: 'increase-scroll-speed',
DECREASE_SPEED: 'decrease-scroll-speed',
TOGGLE_CONTROLS: 'toggle-page-controls'
};
/**
* Runs one action by name, with error logging. Used by clicks, value events and keys.
* @param {string} name - Key of ACTIONS.
* @param {Object} [context] - { element, page, value, event }.
* @returns {boolean} True when the action exists and ran without error.
*/
function runAction(name, context) {
const action = ACTIONS[name];
if (!action) {
logWarn('Unknown action', name);
return false;
}
logDebug('action', name, context && context.page ? 'page ' + context.page : '');
return safeRun('action:' + name, function () {
action(context || {});
return true;
}, false, { action: name });
}
// ==========================================================================================
// ==== 17. KEYBOARD =========================================================================
// Own replacement for the hotkeys-js library used by the 2025/2026 versions.
// - Key names are the ones stored in the `keybinds` setting (docs/04 section 5): letters,
// digits, punctuation ('/', ';', '-', '=', '.', ','), named keys ('up', 'space', 'del',
// 'num_0'..'num_9', 'num_add'...), and modifier combos ('ctrl+x', 'shift+a', 'alt+1').
// - parseKeyBinding() turns a name into a canonical combo ('ctrl+shift+a');
// getEventCombos() turns a keydown event into the combos it can mean; a table
// combo -> keybind id connects both. Modifiers must match exactly (as in hotkeys-js).
// - ONE keydown listener (attached once). It ignores typing in inputs, selects, text areas
// and editable elements, and does nothing while a popup or the keybindings editor is open.
// Escape closes the open panel. A match runs runAction(KEYBIND_ACTIONS[id]).
// - The keybindings panel (list, editor with validation, save) also lives here.
// Pure functions (no DOM): parseKeyBinding, getEventKeyNames, getEventCombos,
// buildKeybindTable, findKeybindForEvent, formatKeyListForEditor, parseKeyListText,
// validateKeybindEdits. They are exposed to tests/logic-tests.html.
// ==========================================================================================
/** The keybind ids the script knows (stored ids that are not here are kept but never bound). */
const KEYBIND_IDS = Object.keys(DEFAULT_SETTINGS.keybinds);
/** Modifier names, in the order used by canonical combos. */
const MODIFIER_NAMES = ['ctrl', 'alt', 'shift', 'meta'];
/** Other spellings accepted in key names (hotkeys-js names, KEYBIND_RULES text) -> our name. */
const KEY_NAME_ALIASES = {
esc: 'escape',
return: 'enter',
del: 'delete',
ins: 'insert',
option: 'alt',
control: 'ctrl',
command: 'meta',
cmd: 'meta',
'⌘': 'meta',
'⇧': 'shift',
'⌥': 'alt',
'⌃': 'ctrl',
comma: ','
};
/** Named (non-character) keys a binding may use. */
const NAMED_KEYS = (function buildNamedKeys() {
const names = ['backspace', 'tab', 'clear', 'enter', 'escape', 'space', 'up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown', 'delete', 'insert',
'num_multiply', 'num_add', 'num_enter', 'num_subtract', 'num_decimal', 'num_divide'];
for (let number = 0; number <= 9; number++) {
names.push('num_' + number);
}
for (let functionKey = 1; functionKey <= 19; functionKey++) {
names.push('f' + functionKey);
}
return names;
})();
/** KeyboardEvent.key values of named keys -> our name. */
const EVENT_KEY_NAMES = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
Up: 'up',
Down: 'down',
Left: 'left',
Right: 'right',
' ': 'space',
Spacebar: 'space',
Backspace: 'backspace',
Tab: 'tab',
Enter: 'enter',
Escape: 'escape',
Esc: 'escape',
Delete: 'delete',
Del: 'delete',
Insert: 'insert',
Home: 'home',
End: 'end',
PageUp: 'pageup',
PageDown: 'pagedown',
Clear: 'clear'
};
/** KeyboardEvent.code of numeric keypad keys -> our name (the keypad is told apart by code). */
const NUMPAD_CODE_NAMES = {
NumpadMultiply: 'num_multiply',
NumpadAdd: 'num_add',
NumpadSubtract: 'num_subtract',
NumpadDecimal: 'num_decimal',
NumpadDivide: 'num_divide',
NumpadEnter: 'num_enter',
NumpadComma: ','
};
/** KeyboardEvent.code of punctuation keys (US layout position) -> character. */
const PUNCTUATION_CODE_NAMES = {
Minus: '-',
Equal: '=',
Slash: '/',
Backslash: '\\',
Semicolon: ';',
Quote: '\'',
Period: '.',
Comma: ',',
BracketLeft: '[',
BracketRight: ']',
Backquote: '\x60', // the backtick character
Space: 'space'
};
/** Minimum time between two runs of the same keyboard action (NEW:7870, 100 ms). */
const KEY_REPEAT_LIMIT_MS = 100;
/**
* Lower-cases a key name part and resolves its alias (pure).
* @param {string} part - E.g. 'Del', 'Ctrl', 'W'.
* @returns {string}
*/
function normalizeKeyNamePart(part) {
const lower = part.trim().toLowerCase();
return Object.prototype.hasOwnProperty.call(KEY_NAME_ALIASES, lower) ? KEY_NAME_ALIASES[lower] : lower;
}
/**
* True for a key (not modifier) name a binding may use: a named key or one visible character.
* @param {string} key - Normalized key name.
* @returns {boolean}
*/
function isValidKeyName(key) {
if (NAMED_KEYS.indexOf(key) !== -1) {
return true;
}
return key.length === 1 && key.trim() !== '';
}
/**
* Canonical combo text: modifiers in a fixed order, then the key ('ctrl+shift+a').
* @param {{ctrl: boolean, alt: boolean, shift: boolean, meta: boolean}} modifiers - Pressed modifiers.
* @param {string} key - Key name.
* @returns {string}
*/
function buildComboText(modifiers, key) {
const parts = MODIFIER_NAMES.filter(function (name) {
return modifiers[name] === true;
});
parts.push(key);
return parts.join('+');
}
/**
* Parses a stored key name like 'W', 'num_2', 'ctrl+x', 'Shift+A', 'del' or 'ctrl++' (pure).
* @param {string} text - Key name.
* @returns {{key: string, ctrl: boolean, alt: boolean, shift: boolean, meta: boolean, combo: string}|null}
* null when the text is not a valid key name.
*/
function parseKeyBinding(text) {
if (typeof text !== 'string' || text.trim() === '') {
return null;
}
const trimmed = text.trim();
let modifierParts = [];
let keyPart = '';
if (trimmed === '+') {
keyPart = '+';
} else if (/\+\+$/.test(trimmed)) {
modifierParts = trimmed.slice(0, -2).split('+');
keyPart = '+';
} else {
const parts = trimmed.split('+');
keyPart = parts.pop();
modifierParts = parts;
}
const result = { key: '', ctrl: false, alt: false, shift: false, meta: false, combo: '' };
for (let i = 0; i < modifierParts.length; i++) {
const modifier = normalizeKeyNamePart(modifierParts[i]);
if (MODIFIER_NAMES.indexOf(modifier) === -1) {
return null;
}
result[modifier] = true;
}
const key = keyPart === '+' ? '+' : normalizeKeyNamePart(keyPart);
if (!isValidKeyName(key)) {
return null;
}
result.key = key;
result.combo = buildComboText(result, key);
return result;
}
/**
* The key names a keydown event can stand for (pure; works on any object with key/code).
* Rules: the numeric keypad is recognized by `code` ('Numpad2' -> 'num_2', never '2'; with
* NumLock off its arrow/home/end meaning is added); named keys and characters come from `key`
* (lower-cased); the key POSITION (`code`) is a fallback only when `key` is not a plain
* character (for example Alt+A on a Mac gives key 'Ã¥', or a dead key).
* @param {{key: string, code: string}} event - The keydown event.
* @returns {string[]}
*/
function getEventKeyNames(event) {
const names = [];
const key = typeof event.key === 'string' ? event.key : '';
const code = typeof event.code === 'string' ? event.code : '';
/** Adds a name once. */
function add(name) {
if (name && names.indexOf(name) === -1) {
names.push(name);
}
}
if (code.indexOf('Numpad') === 0) {
const digit = /^Numpad(\d)$/.exec(code);
add(digit ? 'num_' + digit[1] : NUMPAD_CODE_NAMES[code]);
add(EVENT_KEY_NAMES[key]);
return names;
}
if (Object.prototype.hasOwnProperty.call(EVENT_KEY_NAMES, key)) {
add(EVENT_KEY_NAMES[key]);
} else if (/^F([1-9]|1[0-9])$/.test(key)) {
add(key.toLowerCase());
} else if (key.length === 1) {
add(key.toLowerCase());
}
const plainCharacter = key.length === 1 && /^[\x21-\x7e]$/.test(key);
if (!plainCharacter) {
const letter = /^Key([A-Z])$/.exec(code);
const digit = /^Digit(\d)$/.exec(code);
if (letter) {
add(letter[1].toLowerCase());
} else if (digit) {
add(digit[1]);
} else if (PUNCTUATION_CODE_NAMES[code]) {
add(PUNCTUATION_CODE_NAMES[code]);
}
}
return names;
}
/**
* The canonical combos a keydown event can match (pure). Modifiers must match exactly, except
* that Shift is ignored for punctuation characters (on many keyboards '/' or ';' need Shift).
* @param {{key: string, code: string, ctrlKey: boolean, altKey: boolean, shiftKey: boolean, metaKey: boolean}} event
* @returns {string[]}
*/
function getEventCombos(event) {
const modifiers = { ctrl: event.ctrlKey === true, alt: event.altKey === true, shift: event.shiftKey === true, meta: event.metaKey === true };
const combos = [];
getEventKeyNames(event).forEach(function (name) {
const combo = buildComboText(modifiers, name);
if (combos.indexOf(combo) === -1) {
combos.push(combo);
}
if (modifiers.shift && name.length === 1 && !/[a-z0-9]/.test(name)) {
const withoutShift = buildComboText({ ctrl: modifiers.ctrl, alt: modifiers.alt, shift: false, meta: modifiers.meta }, name);
if (combos.indexOf(withoutShift) === -1) {
combos.push(withoutShift);
}
}
});
return combos;
}
/**
* Builds the lookup table combo -> keybind id (pure). Only known ids are bound, in the order
* of `ids`; when two actions use the same key the first one keeps it (reported in
* `duplicates`); names that cannot be parsed are reported in `invalid`.
* @param {Object<string, string[]>} keybinds - Setting `keybinds`.
* @param {string[]} ids - Known keybind ids, in priority order.
* @returns {{table: Object<string, string>, invalid: {id: string, key: string}[], duplicates: {id: string, key: string, usedBy: string}[]}}
*/
function buildKeybindTable(keybinds, ids) {
const result = { table: {}, invalid: [], duplicates: [] };
ids.forEach(function (id) {
const keys = keybinds && Array.isArray(keybinds[id]) ? keybinds[id] : [];
keys.forEach(function (keyName) {
const parsed = parseKeyBinding(keyName);
if (!parsed) {
result.invalid.push({ id: id, key: String(keyName) });
return;
}
if (Object.prototype.hasOwnProperty.call(result.table, parsed.combo)) {
if (result.table[parsed.combo] !== id) {
result.duplicates.push({ id: id, key: String(keyName), usedBy: result.table[parsed.combo] });
}
return;
}
result.table[parsed.combo] = id;
});
});
return result;
}
/**
* The keybind id a keydown event triggers, or '' (pure).
* @param {Object<string, string>} table - From buildKeybindTable().table.
* @param {Object} event - The keydown event (or an object with the same fields).
* @returns {string}
*/
function findKeybindForEvent(table, event) {
const combos = getEventCombos(event);
for (let i = 0; i < combos.length; i++) {
if (Object.prototype.hasOwnProperty.call(table, combos[i])) {
return table[combos[i]];
}
}
return '';
}
// ---- Editor text format ----
// The editor shows the keys of one action separated by commas. The comma KEY therefore is
// written 'comma' in the editor ('ctrl+comma' for 'ctrl+,') and stored as ',' (the stored
// format of both earlier versions). The 2026 editor lost the ',' key on every save.
/**
* One stored key name as written in the editor (pure).
* @param {string} keyName - Stored name.
* @returns {string}
*/
function formatKeyForEditor(keyName) {
const text = String(keyName);
if (text === ',') {
return 'comma';
}
return /\+,$/.test(text) ? text.slice(0, -1) + 'comma' : text;
}
/**
* The keys of one action as editor text: 'up, w, num_8' (pure).
* @param {string[]} keys - Stored names.
* @returns {string}
*/
function formatKeyListForEditor(keys) {
return (Array.isArray(keys) ? keys : []).map(formatKeyForEditor).join(', ');
}
/**
* Splits editor text into stored key names: commas separate, spaces are trimmed, empty parts
* are dropped, 'comma' becomes ',' (pure). '' gives [] (the action has no key).
* @param {string} text - Editor text.
* @returns {string[]}
*/
function parseKeyListText(text) {
return String(text || '').split(',').map(function (part) {
return part.trim();
}).filter(function (part) {
return part !== '';
}).map(function (part) {
if (part.toLowerCase() === 'comma') {
return ',';
}
return /\+comma$/i.test(part) ? part.slice(0, -5) + ',' : part;
});
}
/**
* Checks the editor texts of all actions (pure apart from the localized messages).
* @param {Object<string, string>} edits - Keybind id -> editor text.
* @returns {{keybinds: Object<string, string[]>, errors: Object<string, string>}} errors is
* empty when everything is valid; keybinds holds the parsed lists ([] for an empty field).
*/
function validateKeybindEdits(edits) {
const result = { keybinds: {}, errors: {} };
const usedBy = {};
Object.keys(edits).forEach(function (id) {
const keys = parseKeyListText(edits[id]);
result.keybinds[id] = keys;
keys.forEach(function (keyName) {
if (result.errors[id]) {
return;
}
const parsed = parseKeyBinding(keyName);
if (!parsed) {
result.errors[id] = getLocaleString('KEYBIND_INVALID').replace('##key##', keyName);
return;
}
if (usedBy[parsed.combo] && usedBy[parsed.combo] !== id) {
result.errors[id] = getLocaleString('KEYBIND_DUPLICATE').replace('##key##', keyName).replace('##action##', getLocaleString(usedBy[parsed.combo]));
return;
}
usedBy[parsed.combo] = id;
});
});
return result;
}
// ---- Keybindings panel ----
/**
* HTML of the read-only list: "<action>: [key] / [key]" (NEW:9698-9704).
* @returns {string}
*/
function buildKeybindListHtml() {
const keybinds = getSetting('keybinds') || {};
return [
'<div id="KeybindingsList">',
KEYBIND_IDS.map(function (id) {
const keys = Array.isArray(keybinds[id]) ? keybinds[id] : [];
return [
'<span class="mvm-keybind-name">', escapeHtml(getLocaleString(id)), ':</span>',
'<span class="mvm-keybind-keys">', keys.map(function (keyName) {
return '<kbd>' + escapeHtml(keyName) + '</kbd>';
}).join(' / '), '</span>'
].join('');
}).join(''),
'</div>'
].join('');
}
/**
* HTML of the editor: one text field per action, keys separated by commas (NEW:9712-9723),
* with a place for its validation message.
* @returns {string}
*/
function buildKeybindEditorHtml() {
const keybinds = getSetting('keybinds') || {};
return [
'<div id="KeybindingsEditor">',
KEYBIND_IDS.map(function (id) {
const inputId = 'Keybind_' + id;
return [
'<label for="', inputId, '">', escapeHtml(getLocaleString(id)), ':</label>',
'<div>',
'<input type="text" class="KeybindInput" id="', inputId, '" data-keybind="', id, '" value="', escapeHtml(formatKeyListForEditor(keybinds[id])), '" autocomplete="off" spellcheck="false" />',
'<div class="mvm-keybind-error" id="KeybindError_', id, '"></div>',
'</div>'
].join('');
}).join(''),
'</div>'
].join('');
}
/** Draws the panel's buttons and body for the current mode (list or editor). */
function renderKeybindingsPanel() {
const actions = document.getElementById('KeybindingsActions');
const body = document.getElementById('KeybindingsBody');
if (!actions || !body) {
return;
}
if (APP_STATE.keybindsEditing) {
setElementHtml(actions, buildButtonHtml('keybinds-save', getLocaleString('BUTTON_SAVE'), { id: 'SaveKeybindings', icon: 'deviceFloppy', title: getLocaleString('SAVE_KEYBINDS') }) +
buildButtonHtml('keybinds-cancel', getLocaleString('CANCEL'), { id: 'CancelKeybindings', icon: 'x' }));
setElementHtml(body, buildKeybindEditorHtml());
} else {
setElementHtml(actions, buildButtonHtml('keybinds-edit', getLocaleString('BUTTON_EDIT'), { id: 'EditKeybindings', icon: 'pencil', title: getLocaleString('EDIT_KEYBINDS') }));
setElementHtml(body, buildKeybindListHtml());
}
}
/** "Edit": switches the panel to the editor; shortcut keys pause meanwhile (NEW:7871). */
function actionEditKeybinds() {
APP_STATE.keybindsEditing = true;
renderKeybindingsPanel();
const firstInput = document.querySelector('#KeybindingsEditor .KeybindInput');
if (firstInput) {
firstInput.focus();
}
}
/** "Cancel": leaves the editor without saving. */
function actionCancelKeybinds() {
APP_STATE.keybindsEditing = false;
renderKeybindingsPanel();
}
/**
* Shows the validation messages in the editor (one per action, '' clears it).
* @param {Object<string, string>} errors - Keybind id -> message.
*/
function showKeybindErrors(errors) {
KEYBIND_IDS.forEach(function (id) {
const message = errors[id] || '';
const box = document.getElementById('KeybindError_' + id);
if (box) {
box.textContent = message;
}
const input = document.getElementById('Keybind_' + id);
if (input) {
input.classList.toggle('invalid', message !== '');
}
});
}
/**
* "Save": validates every field; on errors shows them and keeps the editor open; otherwise
* SAVES the keybinds (always global) in the stored format { id: [names] } - an emptied field
* is stored as [] so it stays empty after a reload (the 2026 version stored undefined and the
* defaults came back, docs/04 section 5.4). Unknown stored ids are kept untouched.
*/
function actionSaveKeybinds() {
const edits = {};
queryAll('#KeybindingsEditor [data-keybind]').forEach(function (input) {
edits[input.getAttribute('data-keybind')] = input.value;
});
const validation = validateKeybindEdits(edits);
showKeybindErrors(validation.errors);
const errorIds = Object.keys(validation.errors);
if (errorIds.length > 0) {
logWarn('Keybindings not saved: ' + errorIds.length + ' field(s) need a fix', validation.errors);
return;
}
const keybinds = Object.assign(getSetting('keybinds') || {}, validation.keybinds);
APP_STATE.keybindsEditing = false;
saveSetting('keybinds', keybinds);
renderKeybindingsPanel();
}
ACTIONS['keybinds-edit'] = actionEditKeybinds;
ACTIONS['keybinds-save'] = actionSaveKeybinds;
ACTIONS['keybinds-cancel'] = actionCancelKeybinds;
// A keybinds change (save here, reset, another tab) redraws the list; not while editing.
SETTING_APPLIERS.keybinds = function () {
if (!APP_STATE.keybindsEditing) {
renderKeybindingsPanel();
}
};
// Redraw after the panel was rebuilt for a language change, and leave the editor when the
// panel closes (setOpenPanel already cleared the flag).
onAppStateChange(function (what) {
if (what === 'textsRefreshed' || what === 'panel') {
renderKeybindingsPanel();
}
});
// ---- The keydown listener ----
/** Last run time per keybind id (repeat limit). */
const KEY_LAST_RUN = {};
/** True after attachKeyboardEvents ran. */
let keyboardEventsAttached = false;
/**
* True when a key typed into this element belongs to the element (text field, select,
* editable content), so it must not trigger a shortcut.
* @param {EventTarget} target - event.target.
* @returns {boolean}
*/
function isEditableTarget(target) {
if (!target || !target.tagName) {
return false;
}
if (target.isContentEditable) {
return true;
}
const tag = target.tagName;
if (tag === 'TEXTAREA' || tag === 'SELECT') {
return true;
}
if (tag !== 'INPUT') {
return false;
}
return ['checkbox', 'radio', 'range', 'button', 'submit', 'reset', 'color'].indexOf(String(target.type).toLowerCase()) === -1;
}
/** True while a popup (section 13) is on screen: it handles its own keys. */
function isPopupOpen() {
return document.querySelector('.mvm-popup-backdrop') !== null;
}
/**
* The keydown handler: Escape closes panels; otherwise the pressed combo runs its action.
* @param {KeyboardEvent} event - keydown.
*/
function onKeyDown(event) {
if (event.isComposing || !getReaderRoot() || isPopupOpen()) {
return;
}
const editable = isEditableTarget(event.target);
if (event.key === 'Escape' || event.key === 'Esc') {
if (!editable && APP_STATE.panel !== 'none') {
event.preventDefault();
runAction('close-panels', { event: event });
}
return;
}
if (editable || APP_STATE.keybindsEditing) {
return;
}
const id = findKeybindForEvent(buildKeybindTable(getSetting('keybinds'), KEYBIND_IDS).table, event);
if (!id) {
return;
}
event.preventDefault();
event.stopPropagation();
const now = Date.now();
if (KEY_LAST_RUN[id] && now - KEY_LAST_RUN[id] < KEY_REPEAT_LIMIT_MS) {
return;
}
KEY_LAST_RUN[id] = now;
logDebug('key', getEventCombos(event).join(' | '), '->', id);
runAction(KEYBIND_ACTIONS[id], { event: event });
}
/**
* Removes the site's own key handlers set as properties (NEW:7862-7867) and attaches the one
* keydown listener (capture phase on window, so the site's listeners do not see our keys).
* Logs keybinds that cannot be used (bad names, keys used twice).
*/
function attachKeyboardEvents() {
if (keyboardEventsAttached) {
return;
}
safeRun('keyboard.removeSiteHandlers', function () {
[document, window, pageWindow].forEach(function (target) {
target.onkeydown = null;
target.onkeyup = null;
target.onkeypress = null;
});
});
window.addEventListener('keydown', guardHandler('keyboard.keydown', onKeyDown), true);
keyboardEventsAttached = true;
const check = buildKeybindTable(getSetting('keybinds'), KEYBIND_IDS);
check.invalid.forEach(function (entry) {
logWarn('Keybind ignored, unknown key name "' + entry.key + '" for ' + entry.id);
});
check.duplicates.forEach(function (entry) {
logWarn('Keybind ignored, "' + entry.key + '" of ' + entry.id + ' is already used by ' + entry.usedBy);
});
logDebug('keyboard listener attached', Object.keys(check.table).length + ' keys bound');
}
// ==========================================================================================
// ==== 18. EVENTS ===========================================================================
// Listeners are attached ONCE to the reader root (event delegation): clicks anywhere inside
// are routed through data-action, so re-rendering parts of the reader never needs re-binding.
// The keydown listener lives in section 17 and is attached from here (attachKeyboardEvents).
// Also attached once: slider/select value events (same data-action routing), the mouse wheel
// (horizontal modes, thumbnails), window resize (device class, page re-fit), and the throttled
// scroll/mousemove handlers of the header (headroom) and of current-page tracking.
// ==========================================================================================
/** True after attachReaderEvents ran (protects against double binding). */
let readerEventsAttached = false;
/** Form controls report their value through 'input'/'change' events, not through clicks. */
const VALUE_CONTROL_TAGS = ['INPUT', 'SELECT', 'TEXTAREA'];
/**
* Builds the action context of an element with data-action. `value` is the control's value
* (a checkbox gives 'true'/'false'), else its data-value; `setting` is its data-setting
* (settings panel controls, section 12).
* @param {Element} element - The element.
* @param {Event} event - The DOM event.
* @returns {{element: Element, page: number, value: (string|undefined), setting: string, event: Event}}
*/
function buildActionContext(element, event) {
const pageText = element.getAttribute('data-page');
const valueText = element.getAttribute('data-value');
const isControl = VALUE_CONTROL_TAGS.indexOf(element.tagName) !== -1;
let value = valueText === null ? undefined : valueText;
if (isControl) {
value = element.type === 'checkbox' ? (element.checked ? 'true' : 'false') : element.value;
}
return {
element: element,
page: pageText ? parseInt(pageText, 10) : 0,
value: value,
setting: element.getAttribute('data-setting') || '',
event: event
};
}
/**
* Handles every click inside the reader: finds the closest element with data-action.
* Clicks on sliders and selects are ignored here; their value events run the action.
* Link buttons (<a href data-action>, e.g. previous/next chapter): a plain left click runs the
* action (logged navigation in this tab); Ctrl/Cmd/Shift/Alt clicks are left to the browser so
* it can open a new tab or window (middle clicks fire 'auxclick', which is never handled here).
* @param {MouseEvent} event - The click.
*/
function onReaderClick(event) {
const element = event.target && event.target.closest ? event.target.closest('[data-action]') : null;
if (!element || VALUE_CONTROL_TAGS.indexOf(element.tagName) !== -1) {
return;
}
if (element.disabled || element.classList.contains('disabled')) {
logDebug('click on a disabled control ignored', element.getAttribute('data-action'));
return;
}
if (element.tagName === 'A' && element.hasAttribute('href')) {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
logDebug('link button clicked with a modifier key or another button: the browser opens it', element.getAttribute('data-action'), element.getAttribute('href'));
return;
}
event.preventDefault();
}
runAction(element.getAttribute('data-action'), buildActionContext(element, event));
}
/**
* Handles value changes: sliders run their action on 'input' (live while dragging), except
* sliders marked data-commit="change" (settings panel), which only update their value badge
* while dragging and run the action on release; every other control runs on 'change'.
* @param {Event} event - input or change.
*/
function onReaderValueEvent(event) {
const element = event.target;
if (!element || !element.getAttribute || !element.hasAttribute('data-action')) {
return;
}
const isRange = element.tagName === 'INPUT' && element.type === 'range';
const commitOnChange = isRange && element.getAttribute('data-commit') === 'change';
if (commitOnChange && event.type === 'input') {
updateRangeOutput(element);
return;
}
if ((event.type === 'input') !== (isRange && !commitOnChange)) {
return;
}
runAction(element.getAttribute('data-action'), buildActionContext(element, event));
}
/**
* Mouse wheel: in the horizontal modes the vertical wheel scrolls the chapter sideways (reversed
* for right to left, NEW:9956-9970); over the bottom thumbnails bar it scrolls the thumbnails.
* @param {WheelEvent} event - The wheel event.
*/
function onReaderWheel(event) {
if (!event.deltaY) {
return;
}
const target = event.target && event.target.closest ? event.target : null;
const thumbnails = target ? target.closest('#Thumbnails') : null;
if (thumbnails && getSetting('navbar') === 'bottom') {
thumbnails.scrollLeft += event.deltaY + event.deltaX;
event.preventDefault();
return;
}
const chapter = target ? target.closest('#Chapter') : null;
const viewMode = getSetting('viewMode');
if (!chapter || !isFluidViewMode(viewMode)) {
return;
}
if (viewMode === 'FluidLTR') {
chapter.scrollLeft += event.deltaY + event.deltaX;
} else {
chapter.scrollLeft -= event.deltaY - event.deltaX;
}
event.preventDefault();
}
/**
* Updates the device class when the window is resized (phone rotated, window narrowed).
* A device forced by the `deviceMode` setting stays.
*/
function onWindowResize() {
const device = resolveDeviceMode(getSetting('deviceMode'), detectDevice());
if (device !== APP_STATE.device) {
APP_STATE.device = device;
setExclusiveClass(getReaderRoot(), ['mobile', 'tablet', 'desktop'], device);
logDebug('device changed to', device);
}
}
/** After the window size settled: re-fit every page and recompute the page in view (NEW:11086). */
function onWindowResizeSettled() {
applyGlobalZoomToAllPages();
updateCurrentPage();
}
/**
* Returns a handler that runs `action` at most once per animation frame, and at most once per
* `waitMs` (current-page tracking, NEW:11060-11068).
* @param {function(): void} action - Work to do.
* @param {number} waitMs - Throttle time.
* @returns {function(): void}
*/
function createFrameThrottledHandler(action, waitMs) {
return throttle(function () {
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(action);
} else {
action();
}
}, waitMs);
}
/** Attaches the reader listeners once. */
function attachReaderEvents() {
if (readerEventsAttached) {
logDebug('attachReaderEvents: already attached');
return;
}
const root = getReaderRoot();
if (!root) {
logWarn('attachReaderEvents: reader root not found');
return;
}
root.addEventListener('click', guardHandler('reader.click', onReaderClick));
root.addEventListener('input', guardHandler('reader.input', onReaderValueEvent));
root.addEventListener('change', guardHandler('reader.change', onReaderValueEvent));
root.addEventListener('wheel', guardHandler('reader.wheel', onReaderWheel), { passive: false });
window.addEventListener('resize', guardHandler('window.resize', throttle(onWindowResize, 300)));
window.addEventListener('resize', guardHandler('window.resizeSettled', debounce(onWindowResizeSettled, CONFIG.resizeDebounceMs)));
window.addEventListener('orientationchange', guardHandler('window.orientationchange', debounce(onWindowResizeSettled, CONFIG.resizeDebounceMs)));
window.addEventListener('scroll', guardHandler('headroom.scroll', throttle(updateHeadroomOnScroll, CONFIG.headroomThrottleMs)), { passive: true });
window.addEventListener('mousemove', guardHandler('headroom.mousemove', throttle(updateHeadroomOnMouseMove, CONFIG.headroomThrottleMs)), { passive: true });
const trackCurrentPage = guardHandler('currentPage.track', createFrameThrottledHandler(updateCurrentPage, CONFIG.currentPageThrottleMs));
window.addEventListener('scroll', trackCurrentPage, { passive: true });
window.addEventListener('resize', trackCurrentPage);
const chapter = getChapterElement();
if (chapter) {
chapter.addEventListener('scroll', trackCurrentPage, { passive: true });
}
attachKeyboardEvents();
renderPanelState();
readerEventsAttached = true;
updateCurrentPage();
logDebug('reader events attached');
}
// ==========================================================================================
// ==== 19. ZIP AND DOWNLOAD =================================================================
// PART A - self-contained ZIP tools (generalization approved, rule 53): every function of
// part A depends ONLY on its parameters and on the other functions of part A, so part A can be
// copied into another script unchanged. No library and no CompressionStream /
// DecompressionStream (too new for the ES2017 floor of this script).
// computeCrc32(bytes) - CRC-32 (IEEE 802.3, the checksum ZIP uses)
// encodeUtf8(text) / decodeUtf8(bytes) - text <-> bytes (file names inside the ZIP)
// buildZipParts(files) - a ZIP archive (method STORE) as a list of chunks
// concatenateBytes(chunks) - joins chunks into one Uint8Array
// inflateRaw(bytes, expectedSize) - raw DEFLATE decoder (RFC 1951)
// readZipDirectory(bytes) - the entries listed in the central directory
// extractZipEntry(bytes, entry) - the bytes of one entry (STORE or DEFLATE, CRC checked)
// Limits: no ZIP64 (archives below 4 GB and below 65535 files), no encryption, no
// multi-volume archives. Errors are thrown with a message that names the problem.
// PART B - the chapter download (uses the reader): get every page's bytes, name the files,
// build the ZIP, save it with <a download>, report skipped pages, keep the button state right.
// ==========================================================================================
// ---- Part A.1: CRC-32 and UTF-8 ----
/** CRC-32 lookup table, built on first use by buildCrc32Table(). */
let crc32Table = null;
/**
* Builds the 256-entry CRC-32 table (reflected polynomial 0xEDB88320).
* @returns {Uint32Array}
*/
function buildCrc32Table() {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let value = n;
for (let bit = 0; bit < 8; bit++) {
value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
}
table[n] = value >>> 0;
}
return table;
}
/**
* CRC-32 of a byte array. Pass the previous result as `previous` to continue a checksum over
* several chunks: computeCrc32(b, computeCrc32(a)) === computeCrc32(a + b).
* Known value: '123456789' -> 0xCBF43926.
* @param {Uint8Array} bytes - Data.
* @param {number} [previous=0] - CRC of the data before these bytes.
* @returns {number} Unsigned 32-bit CRC.
*/
function computeCrc32(bytes, previous) {
if (crc32Table === null) {
crc32Table = buildCrc32Table();
}
let crc = ((typeof previous === 'number' ? previous : 0) ^ 0xFFFFFFFF) >>> 0;
for (let i = 0; i < bytes.length; i++) {
crc = crc32Table[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
/**
* Encodes text as UTF-8 bytes (surrogate pairs become one 4-byte character).
* @param {string} text - Text.
* @returns {Uint8Array}
*/
function encodeUtf8(text) {
const value = String(text);
const bytes = [];
for (let i = 0; i < value.length; i++) {
let code = value.charCodeAt(i);
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < value.length) {
const low = value.charCodeAt(i + 1);
if (low >= 0xDC00 && low <= 0xDFFF) {
code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
i += 1;
}
}
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
} else if (code < 0x10000) {
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
} else {
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
}
}
return new Uint8Array(bytes);
}
/**
* Decodes UTF-8 bytes. Returns null when the bytes are not valid UTF-8 (the caller can then
* use another encoding).
* @param {Uint8Array} bytes - Encoded text.
* @returns {string|null}
*/
function decodeUtf8(bytes) {
let result = '';
let i = 0;
while (i < bytes.length) {
const first = bytes[i];
let code = 0;
let extra = 0;
if (first < 0x80) {
code = first;
} else if (first >= 0xC2 && first < 0xE0) {
code = first & 0x1F;
extra = 1;
} else if (first >= 0xE0 && first < 0xF0) {
code = first & 0x0F;
extra = 2;
} else if (first >= 0xF0 && first < 0xF5) {
code = first & 0x07;
extra = 3;
} else {
return null;
}
if (extra > 0 && i + extra >= bytes.length) {
return null;
}
for (let k = 1; k <= extra; k++) {
const next = bytes[i + k];
if ((next & 0xC0) !== 0x80) {
return null;
}
code = (code << 6) | (next & 0x3F);
}
i += extra + 1;
if (code > 0xFFFF) {
code -= 0x10000;
result += String.fromCharCode(0xD800 + (code >> 10), 0xDC00 + (code & 0x3FF));
} else {
result += String.fromCharCode(code);
}
}
return result;
}
/**
* Decodes a file name stored in a ZIP: UTF-8 when the entry says so (flag bit 11) or when the
* bytes are valid UTF-8 (many tools write UTF-8 without the flag), else one byte per character
* (ZIP's official code page 437 is approximated by Latin-1 here).
* @param {Uint8Array} bytes - Name bytes.
* @param {boolean} utf8Flag - Flag bit 11 of the entry.
* @returns {string}
*/
function decodeZipFileName(bytes, utf8Flag) {
const text = decodeUtf8(bytes);
if (text !== null) {
return text;
}
let latin = '';
for (let i = 0; i < bytes.length; i++) {
latin += String.fromCharCode(bytes[i]);
}
return utf8Flag ? latin.replace(/[\u0080-ÿ]/g, '?') : latin;
}
// ---- Part A.2: ZIP writer (method STORE: the images are already compressed) ----
/** ZIP record signatures and the "file name is UTF-8" flag (PKWARE APPNOTE 4.3.7, 4.3.12, 4.3.16, 4.4.4). */
const ZIP_FORMAT = {
localSignature: 0x04034b50,
centralSignature: 0x02014b50,
endSignature: 0x06054b50,
utf8Flag: 0x0800,
version: 20,
maxEntries: 0xFFFF,
maxSize: 0xFFFFFFFF
};
/**
* MS-DOS time and date of a Date (what ZIP stores). Years outside 1980-2107 are clamped.
* @param {Date} [date] - Time stamp (default: now).
* @returns {{time: number, date: number}}
*/
function toDosDateTime(date) {
const value = date instanceof Date && !isNaN(date.getTime()) ? date : new Date();
const year = Math.max(1980, Math.min(2107, value.getFullYear()));
return {
time: ((value.getHours() << 11) | (value.getMinutes() << 5) | Math.floor(value.getSeconds() / 2)) & 0xFFFF,
date: (((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate()) & 0xFFFF
};
}
/**
* Builds a ZIP archive without compression: for every file a local header followed by its
* bytes, then the central directory and the end record. The file bytes are NOT copied (the
* chunks reference them), so a Blob can be made from the list without doubling memory.
* @param {{name: string, bytes: Uint8Array, date: (Date|undefined)}[]} files - Files in order.
* @returns {Uint8Array[]} Chunks; join with concatenateBytes() or new Blob(chunks).
*/
function buildZipParts(files) {
if (!Array.isArray(files)) {
throw new Error('ZIP writer: files must be an array');
}
if (files.length > ZIP_FORMAT.maxEntries) {
throw new Error('ZIP writer: ' + files.length + ' files; the limit without ZIP64 is ' + ZIP_FORMAT.maxEntries);
}
const parts = [];
const centralRecords = [];
let offset = 0;
files.forEach(function (file, index) {
if (!file || typeof file.name !== 'string' || file.name === '' || !(file.bytes instanceof Uint8Array)) {
throw new Error('ZIP writer: file #' + index + ' needs a non-empty name and Uint8Array bytes');
}
const nameBytes = encodeUtf8(file.name.replace(/\\/g, '/'));
const size = file.bytes.length;
if (offset + 30 + nameBytes.length + size > ZIP_FORMAT.maxSize) {
throw new Error('ZIP writer: the archive would be larger than 4 GB (ZIP64 is not supported)');
}
const crc = computeCrc32(file.bytes);
const stamp = toDosDateTime(file.date);
const local = new Uint8Array(30 + nameBytes.length);
const view = new DataView(local.buffer);
view.setUint32(0, ZIP_FORMAT.localSignature, true);
view.setUint16(4, ZIP_FORMAT.version, true);
view.setUint16(6, ZIP_FORMAT.utf8Flag, true);
view.setUint16(8, 0, true);
view.setUint16(10, stamp.time, true);
view.setUint16(12, stamp.date, true);
view.setUint32(14, crc, true);
view.setUint32(18, size, true);
view.setUint32(22, size, true);
view.setUint16(26, nameBytes.length, true);
view.setUint16(28, 0, true);
local.set(nameBytes, 30);
parts.push(local, file.bytes);
centralRecords.push({ nameBytes: nameBytes, crc: crc, size: size, stamp: stamp, offset: offset });
offset += local.length + size;
});
const centralStart = offset;
centralRecords.forEach(function (record) {
const central = new Uint8Array(46 + record.nameBytes.length);
const view = new DataView(central.buffer);
view.setUint32(0, ZIP_FORMAT.centralSignature, true);
view.setUint16(4, ZIP_FORMAT.version, true);
view.setUint16(6, ZIP_FORMAT.version, true);
view.setUint16(8, ZIP_FORMAT.utf8Flag, true);
view.setUint16(10, 0, true);
view.setUint16(12, record.stamp.time, true);
view.setUint16(14, record.stamp.date, true);
view.setUint32(16, record.crc, true);
view.setUint32(20, record.size, true);
view.setUint32(24, record.size, true);
view.setUint16(28, record.nameBytes.length, true);
view.setUint16(30, 0, true);
view.setUint16(32, 0, true);
view.setUint16(34, 0, true);
view.setUint16(36, 0, true);
view.setUint32(38, 0, true);
view.setUint32(42, record.offset, true);
central.set(record.nameBytes, 46);
parts.push(central);
offset += central.length;
});
if (offset + 22 > ZIP_FORMAT.maxSize) {
throw new Error('ZIP writer: the archive would be larger than 4 GB (ZIP64 is not supported)');
}
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
endView.setUint32(0, ZIP_FORMAT.endSignature, true);
endView.setUint16(4, 0, true);
endView.setUint16(6, 0, true);
endView.setUint16(8, files.length, true);
endView.setUint16(10, files.length, true);
endView.setUint32(12, offset - centralStart, true);
endView.setUint32(16, centralStart, true);
endView.setUint16(20, 0, true);
parts.push(end);
return parts;
}
/**
* Joins byte chunks into one array.
* @param {Uint8Array[]} chunks - Chunks in order.
* @returns {Uint8Array}
*/
function concatenateBytes(chunks) {
let total = 0;
chunks.forEach(function (chunk) {
total += chunk.length;
});
const result = new Uint8Array(total);
let position = 0;
chunks.forEach(function (chunk) {
result.set(chunk, position);
position += chunk.length;
});
return result;
}
// ---- Part A.3: DEFLATE decoder (RFC 1951: stored, fixed Huffman and dynamic Huffman blocks) ----
// Huffman codes are decoded bit by bit with canonical code counts (the method of RFC 1951
// section 3.2.2), which needs no lookup table and is easy to follow. Fast enough for comic
// archives, whose images are already compressed (most entries are STORE or barely deflated).
/** Length codes 257..285: base length and extra bits (RFC 1951 section 3.2.5). */
const INFLATE_LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
const INFLATE_LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
/** Distance codes 0..29: base distance and extra bits (RFC 1951 section 3.2.5). */
const INFLATE_DISTANCE_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
const INFLATE_DISTANCE_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
/** Order in which a dynamic block lists the code-length code lengths (RFC 1951 section 3.2.7). */
const INFLATE_CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
/** The fixed Huffman tables (RFC 1951 section 3.2.6), built on first use. */
let fixedInflateTables = null;
/**
* Builds a canonical Huffman decoding table from code lengths.
* @param {Uint8Array} lengths - Code length per symbol (0 = unused).
* @param {number} start - Index of the first symbol in `lengths`.
* @param {number} count - Number of symbols.
* @returns {{counts: Uint16Array, symbols: Uint16Array}} Codes per length, symbols by code.
*/
function buildHuffmanTable(lengths, start, count) {
const counts = new Uint16Array(16);
const offsets = new Uint16Array(16);
const symbols = new Uint16Array(count);
for (let i = 0; i < count; i++) {
counts[lengths[start + i]] += 1;
}
counts[0] = 0;
let available = 1;
for (let length = 1; length < 16; length++) {
available = (available << 1) - counts[length];
if (available < 0) {
throw new Error('inflate: over-subscribed Huffman code');
}
}
for (let length = 1; length < 15; length++) {
offsets[length + 1] = offsets[length] + counts[length];
}
for (let i = 0; i < count; i++) {
const length = lengths[start + i];
if (length !== 0) {
symbols[offsets[length]] = i;
offsets[length] += 1;
}
}
return { counts: counts, symbols: symbols };
}
/**
* Reads `count` bits (least significant bit first, as DEFLATE packs them).
* @param {Object} state - Decoder state.
* @param {number} count - 0 to 16 bits.
* @returns {number}
*/
function readInflateBits(state, count) {
while (state.bitCount < count) {
if (state.position >= state.input.length) {
throw new Error('inflate: unexpected end of data');
}
state.bitBuffer |= state.input[state.position] << state.bitCount;
state.position += 1;
state.bitCount += 8;
}
const value = state.bitBuffer & ((1 << count) - 1);
state.bitBuffer >>>= count;
state.bitCount -= count;
return value;
}
/**
* Decodes one symbol with a Huffman table (one bit at a time, codes are most significant bit
* first).
* @param {Object} state - Decoder state.
* @param {{counts: Uint16Array, symbols: Uint16Array}} table - From buildHuffmanTable.
* @returns {number}
*/
function decodeInflateSymbol(state, table) {
let code = 0;
let first = 0;
let index = 0;
for (let length = 1; length < 16; length++) {
if (state.bitCount === 0) {
if (state.position >= state.input.length) {
throw new Error('inflate: unexpected end of data');
}
state.bitBuffer = state.input[state.position];
state.position += 1;
state.bitCount = 8;
}
code |= state.bitBuffer & 1;
state.bitBuffer >>>= 1;
state.bitCount -= 1;
const count = table.counts[length];
if (code - first < count) {
return table.symbols[index + code - first];
}
index += count;
first = (first + count) << 1;
code <<= 1;
}
throw new Error('inflate: invalid Huffman code');
}
/**
* Makes room for `extra` more output bytes (doubles the buffer when needed).
* @param {Object} state - Decoder state.
* @param {number} extra - Bytes about to be written.
*/
function reserveInflateOutput(state, extra) {
const needed = state.outputLength + extra;
if (needed <= state.output.length) {
return;
}
let size = Math.max(1024, state.output.length * 2);
while (size < needed) {
size *= 2;
}
const bigger = new Uint8Array(size);
bigger.set(state.output.subarray(0, state.outputLength));
state.output = bigger;
}
/**
* Copies a stored (uncompressed) block (RFC 1951 section 3.2.4).
* @param {Object} state - Decoder state.
*/
function inflateStoredBlock(state) {
state.bitBuffer = 0;
state.bitCount = 0;
const input = state.input;
if (state.position + 4 > input.length) {
throw new Error('inflate: unexpected end of data');
}
const length = input[state.position] | (input[state.position + 1] << 8);
const inverse = input[state.position + 2] | (input[state.position + 3] << 8);
if ((length ^ 0xFFFF) !== inverse) {
throw new Error('inflate: stored block length check failed');
}
state.position += 4;
if (state.position + length > input.length) {
throw new Error('inflate: unexpected end of data');
}
reserveInflateOutput(state, length);
state.output.set(input.subarray(state.position, state.position + length), state.outputLength);
state.outputLength += length;
state.position += length;
}
/**
* Decodes a Huffman-coded block until its end-of-block symbol (256).
* @param {Object} state - Decoder state.
* @param {Object} literalTable - Literal/length table.
* @param {Object} distanceTable - Distance table.
*/
function inflateHuffmanBlock(state, literalTable, distanceTable) {
for (;;) {
const symbol = decodeInflateSymbol(state, literalTable);
if (symbol < 256) {
reserveInflateOutput(state, 1);
state.output[state.outputLength] = symbol;
state.outputLength += 1;
continue;
}
if (symbol === 256) {
return;
}
const lengthIndex = symbol - 257;
if (lengthIndex >= INFLATE_LENGTH_BASE.length) {
throw new Error('inflate: invalid length symbol ' + symbol);
}
const length = INFLATE_LENGTH_BASE[lengthIndex] + readInflateBits(state, INFLATE_LENGTH_EXTRA[lengthIndex]);
const distanceSymbol = decodeInflateSymbol(state, distanceTable);
if (distanceSymbol >= INFLATE_DISTANCE_BASE.length) {
throw new Error('inflate: invalid distance symbol ' + distanceSymbol);
}
const distance = INFLATE_DISTANCE_BASE[distanceSymbol] + readInflateBits(state, INFLATE_DISTANCE_EXTRA[distanceSymbol]);
if (distance > state.outputLength) {
throw new Error('inflate: distance ' + distance + ' points before the start of the data');
}
reserveInflateOutput(state, length);
let from = state.outputLength - distance;
for (let k = 0; k < length; k++) {
state.output[state.outputLength] = state.output[from];
state.outputLength += 1;
from += 1;
}
}
}
/**
* The fixed literal/length and distance tables (RFC 1951 section 3.2.6).
* @returns {{literal: Object, distance: Object}}
*/
function getFixedInflateTables() {
if (fixedInflateTables === null) {
const lengths = new Uint8Array(288 + 30);
for (let symbol = 0; symbol < 288; symbol++) {
if (symbol < 144) {
lengths[symbol] = 8;
} else if (symbol < 256) {
lengths[symbol] = 9;
} else if (symbol < 280) {
lengths[symbol] = 7;
} else {
lengths[symbol] = 8;
}
}
for (let symbol = 288; symbol < 288 + 30; symbol++) {
lengths[symbol] = 5;
}
fixedInflateTables = { literal: buildHuffmanTable(lengths, 0, 288), distance: buildHuffmanTable(lengths, 288, 30) };
}
return fixedInflateTables;
}
/**
* Reads the code lengths of a dynamic block and builds its two tables (RFC 1951 section 3.2.7).
* @param {Object} state - Decoder state.
* @returns {{literal: Object, distance: Object}}
*/
function readDynamicInflateTables(state) {
const literalCount = readInflateBits(state, 5) + 257;
const distanceCount = readInflateBits(state, 5) + 1;
const codeLengthCount = readInflateBits(state, 4) + 4;
if (literalCount > 286 || distanceCount > 30) {
throw new Error('inflate: invalid dynamic block header (' + literalCount + ' literal codes, ' + distanceCount + ' distance codes)');
}
const codeLengths = new Uint8Array(19);
for (let i = 0; i < codeLengthCount; i++) {
codeLengths[INFLATE_CODE_LENGTH_ORDER[i]] = readInflateBits(state, 3);
}
const codeLengthTable = buildHuffmanTable(codeLengths, 0, 19);
const total = literalCount + distanceCount;
const lengths = new Uint8Array(total);
let index = 0;
while (index < total) {
const symbol = decodeInflateSymbol(state, codeLengthTable);
if (symbol < 16) {
lengths[index] = symbol;
index += 1;
continue;
}
let repeat = 0;
let value = 0;
if (symbol === 16) {
if (index === 0) {
throw new Error('inflate: repeat code with no previous length');
}
value = lengths[index - 1];
repeat = 3 + readInflateBits(state, 2);
} else if (symbol === 17) {
repeat = 3 + readInflateBits(state, 3);
} else {
repeat = 11 + readInflateBits(state, 7);
}
if (index + repeat > total) {
throw new Error('inflate: too many code lengths');
}
for (let k = 0; k < repeat; k++) {
lengths[index] = value;
index += 1;
}
}
if (lengths[256] === 0) {
throw new Error('inflate: the block has no end-of-block code');
}
return { literal: buildHuffmanTable(lengths, 0, literalCount), distance: buildHuffmanTable(lengths, literalCount, distanceCount) };
}
/**
* Decompresses raw DEFLATE data (no zlib/gzip header), as stored in ZIP entries with method 8.
* @param {Uint8Array} input - Compressed bytes.
* @param {number} [expectedSize] - Uncompressed size when known (avoids growing the buffer).
* @param {{stored: number, fixed: number, dynamic: number}} [stats] - Optional counters of the
* block types met (used by the tests).
* @returns {Uint8Array}
*/
function inflateRaw(input, expectedSize, stats) {
if (!(input instanceof Uint8Array)) {
throw new Error('inflate: input must be a Uint8Array');
}
const initialSize = typeof expectedSize === 'number' && expectedSize > 0 ? expectedSize : Math.max(1024, input.length * 4);
const state = { input: input, position: 0, bitBuffer: 0, bitCount: 0, output: new Uint8Array(initialSize), outputLength: 0 };
const counters = stats || { stored: 0, fixed: 0, dynamic: 0 };
let isFinal = 0;
do {
isFinal = readInflateBits(state, 1);
const type = readInflateBits(state, 2);
if (type === 0) {
counters.stored = (counters.stored || 0) + 1;
inflateStoredBlock(state);
} else if (type === 1) {
counters.fixed = (counters.fixed || 0) + 1;
const fixed = getFixedInflateTables();
inflateHuffmanBlock(state, fixed.literal, fixed.distance);
} else if (type === 2) {
counters.dynamic = (counters.dynamic || 0) + 1;
const dynamic = readDynamicInflateTables(state);
inflateHuffmanBlock(state, dynamic.literal, dynamic.distance);
} else {
throw new Error('inflate: invalid block type 3');
}
} while (isFinal === 0);
return state.outputLength === state.output.length ? state.output : state.output.slice(0, state.outputLength);
}
// ---- Part A.4: ZIP reader ----
/**
* Little-endian unsigned 16-bit number at an offset. Throws past the end.
* @param {Uint8Array} bytes - Data.
* @param {number} offset - Position.
* @returns {number}
*/
function readZipUint16(bytes, offset) {
if (offset < 0 || offset + 2 > bytes.length) {
throw new Error('ZIP reader: unexpected end of file at byte ' + offset);
}
return bytes[offset] | (bytes[offset + 1] << 8);
}
/**
* Little-endian unsigned 32-bit number at an offset. Throws past the end.
* @param {Uint8Array} bytes - Data.
* @param {number} offset - Position.
* @returns {number}
*/
function readZipUint32(bytes, offset) {
if (offset < 0 || offset + 4 > bytes.length) {
throw new Error('ZIP reader: unexpected end of file at byte ' + offset);
}
return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
}
/**
* Position of the "end of central directory" record, searched backwards (it may be followed
* by a comment of up to 65535 bytes), or -1.
* @param {Uint8Array} bytes - Archive.
* @returns {number}
*/
function findZipEndRecord(bytes) {
const lowest = Math.max(0, bytes.length - 22 - 0xFFFF);
for (let offset = bytes.length - 22; offset >= lowest; offset--) {
if (bytes[offset] === 0x50 && bytes[offset + 1] === 0x4B && bytes[offset + 2] === 0x05 && bytes[offset + 3] === 0x06) {
return offset;
}
}
return -1;
}
/**
* Lists the entries of a ZIP archive from its central directory.
* @param {Uint8Array} bytes - The whole archive.
* @returns {{name: string, isDirectory: boolean, method: number, flags: number, encrypted: boolean, crc32: number, compressedSize: number, size: number, localHeaderOffset: number}[]}
*/
function readZipDirectory(bytes) {
if (!(bytes instanceof Uint8Array)) {
throw new Error('ZIP reader: input must be a Uint8Array');
}
const endOffset = findZipEndRecord(bytes);
if (endOffset < 0) {
throw new Error('ZIP reader: end of central directory not found (not a ZIP file, or damaged)');
}
const entryCount = readZipUint16(bytes, endOffset + 10);
const directorySize = readZipUint32(bytes, endOffset + 12);
const directoryOffset = readZipUint32(bytes, endOffset + 16);
if (entryCount === 0xFFFF || directorySize === 0xFFFFFFFF || directoryOffset === 0xFFFFFFFF) {
throw new Error('ZIP reader: ZIP64 archives are not supported');
}
if (directoryOffset + directorySize > endOffset) {
throw new Error('ZIP reader: the central directory lies outside the file (damaged or multi-volume archive)');
}
const entries = [];
let position = directoryOffset;
for (let i = 0; i < entryCount; i++) {
if (readZipUint32(bytes, position) !== ZIP_FORMAT.centralSignature) {
throw new Error('ZIP reader: bad central directory record #' + i + ' at byte ' + position);
}
const flags = readZipUint16(bytes, position + 8);
const nameLength = readZipUint16(bytes, position + 28);
const extraLength = readZipUint16(bytes, position + 30);
const commentLength = readZipUint16(bytes, position + 32);
const nameStart = position + 46;
if (nameStart + nameLength > bytes.length) {
throw new Error('ZIP reader: unexpected end of file in record #' + i);
}
const name = decodeZipFileName(bytes.subarray(nameStart, nameStart + nameLength), (flags & ZIP_FORMAT.utf8Flag) !== 0);
entries.push({
name: name,
isDirectory: /\/$/.test(name),
method: readZipUint16(bytes, position + 10),
flags: flags,
encrypted: (flags & 1) !== 0,
crc32: readZipUint32(bytes, position + 16),
compressedSize: readZipUint32(bytes, position + 20),
size: readZipUint32(bytes, position + 24),
localHeaderOffset: readZipUint32(bytes, position + 42)
});
position = nameStart + nameLength + extraLength + commentLength;
}
return entries;
}
/**
* The uncompressed bytes of one entry (method 0 STORE or 8 DEFLATE), checked against the
* size and CRC-32 of the central directory.
* @param {Uint8Array} bytes - The whole archive.
* @param {Object} entry - One result of readZipDirectory().
* @returns {Uint8Array}
*/
function extractZipEntry(bytes, entry) {
if (entry.encrypted) {
throw new Error('ZIP reader: "' + entry.name + '" is encrypted (not supported)');
}
const headerOffset = entry.localHeaderOffset;
if (readZipUint32(bytes, headerOffset) !== ZIP_FORMAT.localSignature) {
throw new Error('ZIP reader: bad local header for "' + entry.name + '"');
}
const dataStart = headerOffset + 30 + readZipUint16(bytes, headerOffset + 26) + readZipUint16(bytes, headerOffset + 28);
const dataEnd = dataStart + entry.compressedSize;
if (dataEnd > bytes.length) {
throw new Error('ZIP reader: data of "' + entry.name + '" lies outside the file');
}
const data = bytes.subarray(dataStart, dataEnd);
let output = null;
if (entry.method === 0) {
output = data.slice();
} else if (entry.method === 8) {
output = inflateRaw(data, entry.size);
} else {
throw new Error('ZIP reader: "' + entry.name + '" uses compression method ' + entry.method + ' (only 0 STORE and 8 DEFLATE are supported)');
}
if (output.length !== entry.size) {
throw new Error('ZIP reader: "' + entry.name + '" has ' + output.length + ' bytes, the directory says ' + entry.size);
}
if (computeCrc32(output) !== entry.crc32) {
throw new Error('ZIP reader: CRC-32 mismatch in "' + entry.name + '" (damaged archive)');
}
return output;
}
// ---- Part B.1: file names and image types (pure) ----
/** Image MIME type -> file extension (jpeg -> jpg, as both earlier versions did). */
const IMAGE_EXTENSIONS_BY_TYPE = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/pjpeg': 'jpg',
'image/png': 'png',
'image/apng': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/avif': 'avif',
'image/svg+xml': 'svg',
'image/x-icon': 'ico',
'image/tiff': 'tif'
};
/** File extensions accepted as images when read from a URL or a file name. */
const KNOWN_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'jfif', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg', 'ico', 'tif', 'tiff'];
/**
* Image type from the first bytes of the file ("magic numbers"), or '' when unknown. More
* reliable than the server's content type (often application/octet-stream).
* @param {Uint8Array} bytes - File start.
* @returns {string} Extension without dot.
*/
function detectImageExtension(bytes) {
if (!bytes || bytes.length < 4) {
return '';
}
/**
* Bytes start..end-1 read as ASCII text.
* @param {number} start - First byte index.
* @param {number} end - Index after the last byte.
* @returns {string}
*/
const ascii = function (start, end) {
let text = '';
for (let i = start; i < end && i < bytes.length; i++) {
text += String.fromCharCode(bytes[i]);
}
return text;
};
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) {
return 'jpg';
}
if (bytes[0] === 0x89 && ascii(1, 4) === 'PNG') {
return 'png';
}
if (ascii(0, 4) === 'GIF8') {
return 'gif';
}
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') {
return 'webp';
}
if (ascii(0, 2) === 'BM') {
return 'bmp';
}
if (ascii(4, 8) === 'ftyp' && (ascii(8, 12) === 'avif' || ascii(8, 12) === 'avis')) {
return 'avif';
}
if (/^\s*(<\?xml|<svg)/i.test(ascii(0, 64))) {
return 'svg';
}
return '';
}
/**
* Extension of a URL path or file name, lower case, '' when none ('jpeg'/'jfif' -> 'jpg').
* data: URLs give the extension of their MIME type.
* @param {string} url - URL or file name.
* @returns {string}
*/
function getExtensionFromName(url) {
const text = String(url || '');
if (text.indexOf('data:') === 0) {
const type = text.slice(5).split(/[;,]/)[0].toLowerCase();
return IMAGE_EXTENSIONS_BY_TYPE[type] || '';
}
const path = text.split('#')[0].split('?')[0];
const match = /\.([a-z0-9]{2,5})$/i.exec(path);
if (!match) {
return '';
}
const extension = match[1].toLowerCase();
if (extension === 'jpeg' || extension === 'jfif') {
return 'jpg';
}
return KNOWN_IMAGE_EXTENSIONS.indexOf(extension) === -1 ? '' : extension;
}
/**
* File extension of a downloaded page (pure): from the bytes, else the content type, else the
* URL, else 'png' (the 2026 default, NEW:6703-6712).
* @param {Uint8Array} bytes - Image bytes (may be null).
* @param {string} contentType - Response content type (may be '').
* @param {string} url - Image URL.
* @returns {string}
*/
function getImageExtension(bytes, contentType, url) {
const fromBytes = detectImageExtension(bytes);
if (fromBytes) {
return fromBytes;
}
const type = String(contentType || '').split(';')[0].trim().toLowerCase();
if (IMAGE_EXTENSIONS_BY_TYPE[type]) {
return IMAGE_EXTENSIONS_BY_TYPE[type];
}
return getExtensionFromName(url) || 'png';
}
/**
* Name of a page file in the ZIP (pure): 'Page-' + page number zero-padded to the digit count
* of the last page number (NEW:6800, 6833), e.g. (7, 120, 'jpg') -> 'Page-007.jpg'.
* @param {number} page - Page number.
* @param {number} lastPage - Last page number of the chapter.
* @param {string} extension - File extension without dot.
* @returns {string}
*/
function buildPageFileName(page, lastPage, extension) {
let number = String(page);
const digits = String(Math.max(lastPage, page)).length;
while (number.length < digits) {
number = '0' + number;
}
return 'Page-' + number + '.' + extension;
}
/** Windows device names that cannot be file names. */
const RESERVED_FILE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
/**
* Makes a text safe as a file name on Windows, macOS and Linux (pure): removes control
* characters, replaces \ / : * ? " < > | with '_', trims spaces and trailing dots, avoids
* device names, limits the length. The 2026 version did not sanitize (NEW:6866).
* @param {string} name - Wanted name (a chapter title).
* @param {string} fallback - Used when nothing is left.
* @returns {string}
*/
function sanitizeFileName(name, fallback) {
let text = String(name === null || name === undefined ? '' : name);
text = text.replace(/[\u0000-\u001F\u007F]/g, '').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '');
if (RESERVED_FILE_NAMES.test(text)) {
text = '_' + text;
}
if (text.length > CONFIG.maxFileNameLength) {
text = text.slice(0, CONFIG.maxFileNameLength).trim();
}
return text === '' ? (fallback || 'download') : text;
}
/**
* Name of the chapter ZIP: title + '.zip', sanitized (pure).
* @param {string} title - Chapter title.
* @returns {string}
*/
function buildZipFileName(title) {
return sanitizeFileName(title, 'manga') + '.zip';
}
// ---- Part B.2: getting the bytes of an image ----
/**
* Bytes of a data: URL (base64 or percent-encoded).
* @param {string} url - data: URL.
* @returns {{bytes: Uint8Array, contentType: string}}
*/
function decodeDataUrl(url) {
const comma = url.indexOf(',');
if (url.indexOf('data:') !== 0 || comma < 0) {
throw new Error('not a data: URL');
}
const header = url.slice(5, comma);
const data = url.slice(comma + 1);
const contentType = header.split(';')[0] || 'text/plain';
if (/;base64$/i.test(header)) {
const binary = window.atob(data.replace(/\s+/g, ''));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return { bytes: bytes, contentType: contentType };
}
return { bytes: encodeUtf8(decodeURIComponent(data)), contentType: contentType };
}
/**
* Value of one header in a raw header block ("Name: value" lines), or ''.
* @param {string} headersText - responseHeaders of GM_xmlhttpRequest.
* @param {string} name - Header name, any case.
* @returns {string}
*/
function readResponseHeader(headersText, name) {
const wanted = name.toLowerCase() + ':';
const lines = String(headersText || '').split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().indexOf(wanted) === 0) {
return lines[i].slice(wanted.length).trim();
}
}
return '';
}
/**
* Downloads bytes with the userscript manager (not limited by CORS; sends the chapter page
* as Referer, as the 2025 version did, OLD:3109-3125). Rejects on HTTP errors, network errors,
* timeouts and aborts. `abortHolder.abort` is set so a cancel can stop the request.
* @param {string} url - Image URL.
* @param {{abort: (function|null)}} abortHolder - Receives the abort function.
* @returns {Promise<{bytes: Uint8Array, contentType: string}>}
*/
function fetchBytesWithManager(url, abortHolder) {
const request = new Promise(function (resolve, reject) {
const handle = GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
timeout: CONFIG.downloadRequestTimeoutMs,
headers: { Referer: window.location.href },
onload: function (response) {
if (response.status >= 200 && response.status < 300 && response.response) {
resolve({ bytes: new Uint8Array(response.response), contentType: readResponseHeader(response.responseHeaders, 'content-type') });
} else {
reject(new Error('HTTP ' + response.status + ' for ' + url));
}
},
onerror: function () {
reject(new Error('network error for ' + url));
},
ontimeout: function () {
reject(new Error('timeout after ' + CONFIG.downloadRequestTimeoutMs + ' ms for ' + url));
},
onabort: function () {
reject(new Error('aborted: ' + url));
}
});
if (abortHolder) {
abortHolder.abort = handle && typeof handle.abort === 'function' ? function () {
handle.abort();
} : null;
}
});
// Some managers never call back for some errors: never wait forever (the 2025 bug).
return withTimeout(request, CONFIG.downloadRequestTimeoutMs + 5000, 'GM_xmlhttpRequest ' + url);
}
/**
* Downloads bytes with the page's fetch (works for same-origin, CORS-enabled and blob: URLs).
* @param {string} url - Image URL.
* @returns {Promise<{bytes: Uint8Array, contentType: string}>}
*/
async function fetchBytesWithBrowser(url) {
const response = await fetchOrThrow(url, { credentials: 'same-origin' }, CONFIG.downloadRequestTimeoutMs);
const buffer = await response.arrayBuffer();
return { bytes: new Uint8Array(buffer), contentType: response.headers.get('content-type') || '' };
}
/**
* True when a URL has the same origin as this page.
* @param {string} url - URL.
* @returns {boolean}
*/
function isSameOriginUrl(url) {
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (error) {
return false;
}
}
/**
* Bytes of one page image: data: URLs are decoded, blob: URLs and pages without a userscript
* manager use fetch, everything else uses GM_xmlhttpRequest (fallback: fetch for same-origin).
* @param {string} url - Image URL.
* @param {{abort: (function|null)}} abortHolder - See fetchBytesWithManager.
* @returns {Promise<{bytes: Uint8Array, contentType: string}>}
*/
async function fetchImageBytes(url, abortHolder) {
if (url.indexOf('data:') === 0) {
return decodeDataUrl(url);
}
if (url.indexOf('blob:') === 0 || typeof GM_xmlhttpRequest === 'undefined') {
return fetchBytesWithBrowser(url);
}
try {
return await fetchBytesWithManager(url, abortHolder);
} catch (managerError) {
if (!isSameOriginUrl(url) || (abortHolder && abortHolder.cancelled)) {
throw managerError;
}
logDebug('download: GM_xmlhttpRequest failed, trying fetch for the same-origin URL', url, describeError(managerError));
return fetchBytesWithBrowser(url);
}
}
/**
* Saves bytes as a file: Blob + object URL + a click on <a download>; the URL is released
* after CONFIG.objectUrlRevokeMs (replaces FileSaver).
* @param {Uint8Array[]} chunks - File content.
* @param {string} fileName - Suggested file name.
* @param {string} mimeType - Content type.
*/
function saveBytesAsFile(chunks, fileName, mimeType) {
const blob = new Blob(chunks, { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.rel = 'noopener';
link.style.display = 'none';
(document.body || document.documentElement).appendChild(link);
link.click();
link.remove();
setTimeout(function () {
URL.revokeObjectURL(objectUrl);
}, CONFIG.objectUrlRevokeMs);
log('Saved file "' + fileName + '" (' + blob.size + ' bytes)');
}
// ---- Part B.3: the download feature ----
// Button states:
// 'unavailable' - no chapter is open (disabled);
// 'loading' - pages are still loading (enabled, dimmed: a click STARTS a partial
// download at once with the loaded pages, shows a toast "Downloading N of
// M pages; X are still loading", and lists the pages not loaded yet in
// failed_pages.txt; with zero loaded pages it only shows a toast). M3 kept
// the button disabled here, so a click did nothing (M4 fix; the M4 "offer"
// toast step was removed by the M4 follow-up decision);
// 'available' - every page finished (loaded or failed for good);
// 'working' - progress shown, a second click only shows "already running".
// After finishing, cancelling or failing, the state is recomputed ('available' or 'loading';
// the 2026 button stayed disabled, docs/05 section 4 #8). Auto-download (`downloadZip`) runs
// ONCE per chapter (docs/05 section 4 #9); any download, also a partial one, uses it up.
/** Download state. */
const DOWNLOAD = {
state: 'unavailable',
cancelled: false,
toastId: '',
abortHolder: null,
autoDownloadDone: false,
lastProgress: ''
};
/**
* How far the chapter is: pages loaded, failed, still pending, and whether every page has
* finished (loaded or failed for good) with at least one loaded.
* @returns {{loaded: number, failed: number, pending: number, total: number, settled: boolean}}
*/
function getDownloadReadiness() {
const manga = APP_STATE.manga;
if (!manga) {
return { loaded: 0, failed: 0, pending: 0, total: 0, settled: false };
}
const counts = countImages();
const total = manga.pages - manga.begin + 1;
const pending = Math.max(0, total - counts.loaded - counts.failed);
return { loaded: counts.loaded, failed: counts.failed, pending: pending, total: total, settled: counts.loaded > 0 && pending === 0 };
}
/**
* True when every page of the chapter has finished (loaded or failed for good) and at least
* one loaded. With lazy loading, pages that never loaded keep this false; the download is
* then still possible as a partial download (see announcePartialDownload).
* @returns {boolean}
*/
function isChapterSettled() {
return getDownloadReadiness().settled;
}
/** Writes the download button state (enabled, partial/working class, progress text). */
function renderDownloadButton() {
const button = document.getElementById('download');
if (!button) {
return;
}
const unavailable = DOWNLOAD.state === 'unavailable';
button.disabled = unavailable;
button.classList.toggle('disabled', unavailable);
button.classList.toggle('partial', DOWNLOAD.state === 'loading');
button.classList.toggle('working', DOWNLOAD.state === 'working');
const progress = button.querySelector('.mvm-download-progress');
if (progress) {
progress.textContent = DOWNLOAD.state === 'working' ? DOWNLOAD.lastProgress : '';
}
}
/**
* Recomputes 'unavailable' / 'loading' / 'available' (not while working) and starts the
* automatic download once when the chapter is complete and `downloadZip` is on.
*/
function updateDownloadAvailability() {
if (DOWNLOAD.state !== 'working') {
if (!APP_STATE.manga) {
DOWNLOAD.state = 'unavailable';
} else {
DOWNLOAD.state = isChapterSettled() ? 'available' : 'loading';
}
}
renderDownloadButton();
if (DOWNLOAD.state === 'available' && getSetting('downloadZip') === true && !DOWNLOAD.autoDownloadDone) {
DOWNLOAD.autoDownloadDone = true;
log('All pages finished and "Download Images as Zip Automatically" is on: downloading once');
safeRunAsync('download.auto', function () {
return runDownload('auto');
});
}
}
/**
* Download clicked while pages are still loading: never a silent no-op. Shows an informative
* toast and tells the caller whether a partial download can start (at least one page loaded).
* With zero loaded pages the toast says so and nothing is downloaded.
* @param {{loaded: number, failed: number, pending: number, total: number}} readiness - From getDownloadReadiness.
* @returns {boolean} True when the partial download should start now.
*/
function announcePartialDownload(readiness) {
log('Download requested while loading: ' + readiness.loaded + ' loaded, ' + readiness.failed + ' failed, ' + readiness.pending + ' still loading, of ' + readiness.total + ' pages');
if (readiness.loaded === 0) {
showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_NOTHING_LOADED') });
return false;
}
showToast({
type: 'warning',
title: getLocaleString('DOWNLOAD_PARTIAL_STARTED').replace('##loaded##', String(readiness.loaded)).replace('##total##', String(readiness.total)).replace('##pending##', String(readiness.pending)),
message: getLocaleString('DOWNLOAD_PARTIAL_NOTE'),
durationMs: CONFIG.bookmarkToastMs
});
return true;
}
/**
* Shows the progress in the toast and on the button.
* @param {number} done - Pages processed.
* @param {number} total - Pages in the chapter.
*/
function reportDownloadProgress(done, total) {
DOWNLOAD.lastProgress = done + '/' + total;
updateToast(DOWNLOAD.toastId, {
message: getLocaleString('DOWNLOAD_PROGRESS').replace('##num##', String(done)).replace('##total##', String(total)),
progressPercent: total > 0 ? (done / total) * 100 : 0
});
renderDownloadButton();
}
/**
* True when a page must be left out of a partial download: it is neither loaded nor failed
* for good (waiting, loading, retry pending, or deferred by lazy loading).
* @param {{status: string}} record - Image record of the page.
* @returns {boolean}
*/
function isPagePending(record) {
return record.status !== IMAGE_STATUS.LOADED && record.status !== IMAGE_STATUS.FAILED;
}
/**
* Gets the bytes of every page in page order, skipping (and listing) the ones that fail.
* In a partial download, pages still pending are not requested; they are listed with the
* reason "not loaded yet".
* @param {Object} manga - The manga object.
* @param {boolean} partial - True for a download started while pages were still loading.
* @returns {Promise<{files: Object[], failed: {page: number, url: string, reason: string}[], cancelled: boolean}>}
*/
async function collectPageFiles(manga, partial) {
const pages = sequence(manga.pages, manga.begin);
const files = [];
const failed = [];
for (let i = 0; i < pages.length; i++) {
if (DOWNLOAD.cancelled) {
return { files: files, failed: failed, cancelled: true };
}
const page = pages[i];
const record = getImageRecord(page);
const source = record.src;
reportDownloadProgress(i, pages.length);
if (partial && isPagePending(record)) {
// The site's image (or page) URL, so the user can still fetch it by hand.
const siteUrl = source || (manga.listImages && manga.listImages[page - 1]) || (manga.listPages && manga.listPages[page - 1]) || '';
failed.push({ page: page, url: String(siteUrl), reason: 'not loaded yet (still ' + record.status + ' when the download started)' });
logDebug('download: page ' + page + ' skipped, still ' + record.status);
continue;
}
if (!source) {
failed.push({ page: page, url: '', reason: 'no image URL (page never loaded)' });
logWarn('Download: page ' + page + ' skipped, it has no image URL');
continue;
}
DOWNLOAD.abortHolder = { abort: null, cancelled: false };
try {
const data = await fetchImageBytes(source, DOWNLOAD.abortHolder);
const extension = getImageExtension(data.bytes, data.contentType, source);
files.push({ name: buildPageFileName(page, manga.pages, extension), bytes: data.bytes });
logDebug('download: page ' + page + ' ok', data.bytes.length + ' bytes', extension, source);
} catch (error) {
if (DOWNLOAD.cancelled) {
return { files: files, failed: failed, cancelled: true };
}
failed.push({ page: page, url: source, reason: describeError(error) });
logError('download.page', error, { page: page, url: source });
}
}
reportDownloadProgress(pages.length, pages.length);
return { files: files, failed: failed, cancelled: DOWNLOAD.cancelled };
}
/**
* Text file listing the skipped pages (added to the ZIP like the 2026 version's
* failed_pages.txt, NEW:6859-6862).
* @param {{page: number, url: string, reason: string}[]} failed - Skipped pages.
* @returns {{name: string, bytes: Uint8Array}}
*/
function buildFailedPagesFile(failed) {
const lines = failed.map(function (entry) {
return 'Page ' + entry.page + ': ' + (entry.url || '(no URL)') + ' - ' + entry.reason;
});
return { name: 'failed_pages.txt', bytes: encodeUtf8(lines.join('\r\n') + '\r\n') };
}
/**
* Builds and saves the ZIP from collected pages and shows the result toasts.
* @param {{files: Object[], failed: Object[]}} result - Output of collectPageFiles.
* @param {string} zipName - File name of the ZIP.
*/
function saveCollectedPages(result, zipName) {
if (result.files.length === 0) {
throw new Error('no page could be downloaded (' + result.failed.length + ' failed)');
}
updateToast(DOWNLOAD.toastId, { message: getLocaleString('GENERATING_ZIP') });
const files = result.failed.length > 0 ? result.files.concat([buildFailedPagesFile(result.failed)]) : result.files;
saveBytesAsFile(buildZipParts(files), zipName, 'application/zip');
closeToast(DOWNLOAD.toastId);
showToast({ type: 'success', title: getLocaleString('DOWNLOAD_SAVED').replace('##file##', zipName) });
if (result.failed.length > 0) {
const skipped = result.failed.map(function (entry) {
return entry.page;
}).join(', ');
logWarn('Download incomplete: skipped pages ' + skipped, result.failed);
showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_INCOMPLETE'), message: getLocaleString('DOWNLOAD_SKIPPED_PAGES').replace('##pages##', skipped), durationMs: CONFIG.bookmarkToastMs });
}
}
/**
* Downloads the chapter as a ZIP. Never hangs: every request has a timeout, failed pages are
* skipped and reported (toast + log + failed_pages.txt), and the button always returns to its
* normal state. While pages are still loading, the download starts at once as a PARTIAL
* download (loaded pages only; the others are listed in failed_pages.txt), announced by a
* toast; with zero loaded pages only a toast is shown.
* @param {string} trigger - 'button' or 'auto' (for the log).
* @returns {Promise<void>}
*/
async function runDownload(trigger) {
if (DOWNLOAD.state === 'working') {
showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_BUSY') });
return;
}
const readiness = getDownloadReadiness();
if (!readiness.settled && !announcePartialDownload(readiness)) {
return;
}
// Any download (button, partial or automatic) uses up the automatic one for this chapter.
DOWNLOAD.autoDownloadDone = true;
const manga = APP_STATE.manga;
const total = readiness.total;
const partial = !readiness.settled;
const zipName = buildZipFileName(manga.title || document.title);
log('Download started (' + trigger + (partial ? ', partial: ' + readiness.pending + ' pages still pending' : '') + '): ' + total + ' pages -> "' + zipName + '"');
DOWNLOAD.state = 'working';
DOWNLOAD.cancelled = false;
DOWNLOAD.lastProgress = '0/' + total;
DOWNLOAD.toastId = showToast({
type: 'info',
title: getLocaleString('DOWNLOAD_ZIP'),
message: getLocaleString('DOWNLOAD_PROGRESS').replace('##num##', '0').replace('##total##', String(total)),
durationMs: 0,
progress: true,
button: { action: 'download-cancel', label: getLocaleString('CANCEL') }
});
renderDownloadButton();
try {
const result = await collectPageFiles(manga, partial);
if (result.cancelled) {
log('Download cancelled after ' + result.files.length + ' pages; nothing saved');
closeToast(DOWNLOAD.toastId);
showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_CANCELLED') });
return;
}
saveCollectedPages(result, zipName);
} catch (error) {
logError('download', error, { title: manga.title, pages: total, file: zipName, partial: partial });
closeToast(DOWNLOAD.toastId);
showToast({ type: 'error', title: getLocaleString('DOWNLOAD_FAILED').replace('##error##', describeError(error)), durationMs: CONFIG.bookmarkToastMs });
} finally {
DOWNLOAD.state = 'unavailable';
DOWNLOAD.cancelled = false;
DOWNLOAD.toastId = '';
DOWNLOAD.abortHolder = null;
DOWNLOAD.lastProgress = '';
updateDownloadAvailability();
}
}
/** Cancel button of the progress toast: stops before the next page and aborts the request. */
function cancelDownload() {
if (DOWNLOAD.state !== 'working') {
return;
}
DOWNLOAD.cancelled = true;
if (DOWNLOAD.abortHolder) {
DOWNLOAD.abortHolder.cancelled = true;
if (typeof DOWNLOAD.abortHolder.abort === 'function') {
safeRun('download.abort', DOWNLOAD.abortHolder.abort);
}
}
log('Download cancel requested');
}
ACTIONS.download = function () {
safeRunAsync('action:download', function () {
return runDownload('button');
});
};
ACTIONS['download-cancel'] = cancelDownload;
// Every image change can complete the chapter; a language change redraws the header button.
onAppStateChange(function (what) {
if (what === 'image') {
updateDownloadAvailability();
} else if (what === 'textsRefreshed') {
renderDownloadButton();
}
});
// ==========================================================================================
// ==== 20. FEATURES =========================================================================
// Reader features of milestone M3 that sit on top of the sections above. Each block registers
// its own ACTIONS / SETTING_APPLIERS entries and app-state listeners (like section 17):
// 20.1 Setting-change toasts - a toast after each SAVED setting (2026 version showed one
// for every change, NEW:3871-3881; docs/04 8.2 item 12)
// 20.2 Bookmarks - panel actions, remove/clear, one-time removal at the end
// 20.3 Auto-scroll - continuous scrolling, pause on wheel/touch, stop at the end
// 20.4 Comments - capture Disqus/Facebook comments before the takeover and
// show them in the Comments panel (2025 version, decision D4)
// 20.5 Local files - open images, a folder or a ZIP/CBZ archive on the local
// page (2025 version, decision D4); section 21 connects it
// ==========================================================================================
// ---- 20.1 Setting-change toasts ----
/** Settings whose saves show no toast (same list as the 2026 version, NEW:3872). */
const SETTING_TOAST_EXCLUDED_KEYS = ['bookmarks', 'zoomValue'];
/**
* Localized label of a setting: its settings-panel row label, else the key itself.
* @param {string} key - Setting name.
* @returns {string}
*/
function getSettingLabel(key) {
if (key === 'keybinds') {
return getLocaleString('KEYBINDINGS');
}
for (let g = 0; g < SETTINGS_PANEL_LAYOUT.length; g++) {
const rows = SETTINGS_PANEL_LAYOUT[g].rows;
for (let r = 0; r < rows.length; r++) {
if (rows[r].setting === key) {
return getLocaleString(rows[r].labelKey);
}
}
}
return key;
}
/**
* Short text of a setting value for a toast: the choice label when the setting has a fixed
* list of choices, the value for numbers/booleans/strings, '...' for objects and arrays.
* @param {string} key - Setting name.
* @param {*} value - Value.
* @returns {string}
*/
function formatSettingValue(key, value) {
const choices = SETTING_CHOICES[key] || [];
for (let i = 0; i < choices.length; i++) {
if (String(choices[i].value) === String(value)) {
return getChoiceLabel(choices[i]);
}
}
if (value === null || value === undefined) {
return '-';
}
if (typeof value === 'object') {
return '...';
}
return String(value);
}
onSettingSaved(function (key, value, previous) {
if (SETTING_TOAST_EXCLUDED_KEYS.indexOf(key) !== -1 || deepEqual(value, previous)) {
return;
}
showToast({
type: 'info',
title: getLocaleString('SETTING_CHANGED').replace('##setting##', getSettingLabel(key)),
message: getLocaleString('SETTING_CHANGED_DETAIL').replace('##old##', formatSettingValue(key, previous)).replace('##new##', formatSettingValue(key, value)),
durationMs: CONFIG.settingToastMs
});
});
// ---- 20.2 Bookmarks ----
// Saving and the per-page toggle live in sections 08 and 16; the list is drawn by the
// `bookmarks` applier (section 14). One-time use: the bookmark that resumed this chapter
// (APP_STATE.resumedBookmarkPage, set by preparePage in section 21) is removed when the LAST
// page is reached (docs/04 section 8.2 item 13: when it is reached, not when it is queued as
// NEW:10914 did). A bookmark changed by hand during the visit is never removed automatically.
/**
* True when the resume bookmark must now be removed (pure).
* @param {number} currentPage - Page in view.
* @param {number} lastPage - Last page of the chapter.
* @param {number} resumedPage - Page of the bookmark used to resume (0 = none).
* @param {number} storedPage - Page bookmarked for this URL now (0 = none).
* @returns {boolean}
*/
function shouldRemoveResumeBookmark(currentPage, lastPage, resumedPage, storedPage) {
return resumedPage > 0 && storedPage === resumedPage && currentPage >= lastPage;
}
/**
* Removes the resume bookmark once the last page is in view.
* @param {number} currentPage - Page in view.
*/
function removeResumeBookmarkAtEnd(currentPage) {
const manga = APP_STATE.manga;
if (!manga || APP_STATE.resumedBookmarkPage <= 0) {
return;
}
if (!shouldRemoveResumeBookmark(currentPage, manga.pages, APP_STATE.resumedBookmarkPage, getBookmarkedPage())) {
return;
}
APP_STATE.resumedBookmarkPage = 0;
if (removeBookmark()) {
log('Last page reached: the bookmark used to resume this chapter was removed (only once per bookmark)', window.location.href);
showToast({ type: 'info', title: getLocaleString('BOOKMARK_REMOVED'), durationMs: CONFIG.toastDurationMs });
}
}
/**
* Trash button of a row: removes that URL's bookmark.
* @param {Object} context - Action context; value = bookmark URL.
*/
function actionRemoveBookmark(context) {
const url = context.value || '';
if (!url) {
logWarn('bookmark-remove: no URL on the button');
return;
}
if (url === window.location.href) {
APP_STATE.resumedBookmarkPage = 0;
}
if (removeBookmark(url)) {
log(getLocaleString('BOOKMARK_REMOVED'), url);
showToast({ type: 'error', title: getLocaleString('BOOKMARK_REMOVED'), message: url, durationMs: CONFIG.bookmarkToastMs });
} else {
logWarn('bookmark-remove: no bookmark for', url);
}
}
/** Panel header button: bookmark (or un-bookmark) the page in view. */
function actionToggleCurrentBookmark() {
const manga = APP_STATE.manga;
const page = APP_STATE.currentPage || (manga ? manga.begin : 1);
toggleBookmarkForPage(page);
}
/**
* "Remove all bookmarks", after a confirmation.
* @returns {Promise<void>}
*/
async function actionClearBookmarks() {
const count = (getSetting('bookmarks') || []).length;
if (count === 0) {
showToast({ type: 'info', title: getLocaleString('LIST_EMPTY') });
return;
}
const answer = await showPopup({
title: getLocaleString('BOOKMARKS_CLEAR'),
contentHtml: escapeHtml(getLocaleString('BOOKMARKS_CLEAR_CONFIRM').replace('##count##', String(count))),
icon: 'warning',
confirmText: getLocaleString('BOOKMARKS_CLEAR'),
cancelText: getLocaleString('CANCEL')
});
if (answer.result !== POPUP_RESULT.CONFIRM) {
log('Remove all bookmarks cancelled');
return;
}
APP_STATE.resumedBookmarkPage = 0;
saveSetting('bookmarks', []);
log('All ' + count + ' bookmarks removed');
showToast({ type: 'error', title: getLocaleString('BOOKMARK_REMOVED'), message: String(count), durationMs: CONFIG.settingToastMs });
}
ACTIONS['open-bookmarks'] = function () {
applyBookmarksState();
setOpenPanel('bookmarks');
};
ACTIONS['bookmark-remove'] = actionRemoveBookmark;
ACTIONS['bookmark-toggle-current'] = actionToggleCurrentBookmark;
ACTIONS['bookmarks-clear'] = function () {
safeRunAsync('action:bookmarks-clear', actionClearBookmarks);
};
onAppStateChange(function (what, value) {
if (what === 'currentPage') {
removeResumeBookmarkAtEnd(value);
}
});
// ---- 20.3 Auto-scroll ----
// Each frame scrolls `scrollHeight` pixels per 60 Hz "tick" (scaled by the real frame time,
// so 120 Hz screens do not scroll twice as fast): the window in the vertical modes, the
// chapter sideways in the horizontal modes (to the left in FluidRTL). Instant steps instead
// of the earlier versions' "smooth" step per frame (NEW:7000-7024, OLD:2753-2775): a new smooth
// scroll every frame cancels the previous one, so the real speed depended on the browser.
// The mouse wheel or a touch pauses it; it resumes CONFIG.autoScrollResumeMs after the last
// wheel/touch event (2025/2026 behavior). It stops at the end, detected two ways: the
// remaining distance (handles the negative scrollLeft of FluidRTL in current browsers) and
// "the position did not move for a few frames" (covers browsers with another sign convention).
/** Auto-scroll runtime state. */
const AUTO_SCROLL = {
running: false,
pausedByUser: false,
touching: false,
frameId: 0,
lastTime: 0,
carry: 0,
stuckFrames: 0,
resumeTimer: null,
listenersAttached: false
};
/**
* Whole pixels to scroll in one frame (pure). The fraction is carried to the next frame so a
* slow speed still moves. Frame times above 100 ms (tab in background) count as 100 ms.
* @param {number} speed - Setting `scrollHeight` (pixels per 60 Hz tick).
* @param {number} elapsedMs - Time since the previous frame.
* @param {number} carry - Fraction left from the previous frame.
* @returns {{pixels: number, carry: number}}
*/
function computeAutoScrollStep(speed, elapsedMs, carry) {
const exact = Math.max(0, Number(speed) || 0) * (Math.min(Math.max(elapsedMs, 0), 100) / CONFIG.autoScrollTickMs) + (carry || 0);
const pixels = Math.floor(exact);
return { pixels: pixels, carry: exact - pixels };
}
/**
* Distance left to the end of a scroll area (pure). `direction` -1 = scrolling toward
* negative positions (FluidRTL: a row-reverse flex box scrolls from 0 down to -(max) in
* current browsers).
* @param {number} position - scrollTop / scrollLeft / pageYOffset.
* @param {number} viewportSize - Visible size.
* @param {number} contentSize - Total scrollable size.
* @param {number} direction - 1 or -1.
* @returns {number}
*/
function computeScrollRemaining(position, viewportSize, contentSize, direction) {
const maximum = Math.max(0, contentSize - viewportSize);
return direction < 0 ? maximum + position : maximum - position;
}
/**
* What auto-scroll moves now, from the view mode.
* @returns {{horizontal: boolean, direction: number, element: (Element|null)}}
*/
function getAutoScrollTarget() {
const viewMode = getSetting('viewMode');
const chapter = getChapterElement();
if (isFluidViewMode(viewMode) && chapter) {
return { horizontal: true, direction: viewMode === 'FluidRTL' ? -1 : 1, element: chapter };
}
return { horizontal: false, direction: 1, element: null };
}
/**
* Scrolls the target by some pixels and returns the remaining distance and whether it moved.
* @param {{horizontal: boolean, direction: number, element: (Element|null)}} target - From getAutoScrollTarget.
* @param {number} pixels - Whole pixels (> 0).
* @returns {{moved: boolean, remaining: number}}
*/
function scrollAutoScrollTarget(target, pixels) {
if (target.horizontal) {
const before = target.element.scrollLeft;
target.element.scrollLeft = before + pixels * target.direction;
const after = target.element.scrollLeft;
return { moved: after !== before, remaining: computeScrollRemaining(after, target.element.clientWidth, target.element.scrollWidth, target.direction) };
}
const beforeY = window.pageYOffset;
window.scrollBy(0, pixels);
const afterY = window.pageYOffset;
const contentHeight = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
return { moved: afterY !== beforeY, remaining: computeScrollRemaining(afterY, window.innerHeight, contentHeight, 1) };
}
/** Calls the next frame (requestAnimationFrame, or a 16 ms timer where it is missing). */
function requestAutoScrollFrame() {
if (typeof window.requestAnimationFrame === 'function') {
AUTO_SCROLL.frameId = window.requestAnimationFrame(autoScrollFrame);
} else {
AUTO_SCROLL.frameId = setTimeout(function () {
autoScrollFrame(Date.now());
}, 16);
}
}
/** Cancels the pending frame. */
function cancelAutoScrollFrame() {
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(AUTO_SCROLL.frameId);
}
clearTimeout(AUTO_SCROLL.frameId);
AUTO_SCROLL.frameId = 0;
}
/**
* One animation frame of auto-scroll.
* @param {number} time - Frame time in milliseconds.
*/
function autoScrollFrame(time) {
if (!AUTO_SCROLL.running) {
return;
}
safeRun('autoScroll.frame', function () {
const elapsed = AUTO_SCROLL.lastTime ? time - AUTO_SCROLL.lastTime : CONFIG.autoScrollTickMs;
AUTO_SCROLL.lastTime = time;
const step = computeAutoScrollStep(getSetting('scrollHeight'), elapsed, AUTO_SCROLL.carry);
AUTO_SCROLL.carry = step.carry;
if (step.pixels > 0) {
const result = scrollAutoScrollTarget(getAutoScrollTarget(), step.pixels);
AUTO_SCROLL.stuckFrames = result.moved ? 0 : AUTO_SCROLL.stuckFrames + 1;
if (result.remaining <= 1 || AUTO_SCROLL.stuckFrames >= CONFIG.autoScrollStuckFrames) {
stopAutoScroll('reached the end');
return;
}
}
requestAutoScrollFrame();
});
}
/** Header button: play icon while stopped, pause icon while running or paused by the user. */
function renderAutoScrollButton() {
const button = document.getElementById('AutoScroll');
if (button) {
button.classList.toggle('running', AUTO_SCROLL.running || AUTO_SCROLL.pausedByUser);
}
}
/** Starts (or resumes) the frame loop. */
function runAutoScrollLoop() {
AUTO_SCROLL.running = true;
AUTO_SCROLL.lastTime = 0;
AUTO_SCROLL.carry = 0;
AUTO_SCROLL.stuckFrames = 0;
cancelAutoScrollFrame();
requestAutoScrollFrame();
}
/** Starts auto-scroll. */
function startAutoScroll() {
attachAutoScrollListeners();
AUTO_SCROLL.pausedByUser = false;
runAutoScrollLoop();
renderAutoScrollButton();
log('Auto-scroll started: ' + getSetting('scrollHeight') + ' px per tick, ' + (getAutoScrollTarget().horizontal ? 'sideways' : 'down'));
}
/**
* Stops auto-scroll completely.
* @param {string} reason - For the log.
*/
function stopAutoScroll(reason) {
AUTO_SCROLL.running = false;
AUTO_SCROLL.pausedByUser = false;
clearTimeout(AUTO_SCROLL.resumeTimer);
AUTO_SCROLL.resumeTimer = null;
cancelAutoScrollFrame();
renderAutoScrollButton();
log('Auto-scroll stopped: ' + reason);
}
/** Space key / header button: start, or stop when running or paused by the user. */
function toggleAutoScroll() {
if (AUTO_SCROLL.running || AUTO_SCROLL.pausedByUser) {
stopAutoScroll('toggled off');
} else {
startAutoScroll();
}
}
/** Resumes after a pause caused by the wheel or a touch (unless a finger is still down). */
function resumeAutoScrollAfterPause() {
AUTO_SCROLL.resumeTimer = null;
if (!AUTO_SCROLL.pausedByUser || AUTO_SCROLL.touching) {
return;
}
AUTO_SCROLL.pausedByUser = false;
logDebug('auto-scroll resumed after manual scrolling');
runAutoScrollLoop();
renderAutoScrollButton();
}
/**
* Wheel / touch while auto-scroll runs: pause now, resume CONFIG.autoScrollResumeMs after the
* last such event (NEW:7040-7061, OLD:2791-2804).
* @param {Event} event - wheel, touchstart, touchmove or touchend.
*/
function onManualScrollInput(event) {
if (event.type === 'touchstart') {
AUTO_SCROLL.touching = true;
} else if (event.type === 'touchend' || event.type === 'touchcancel') {
AUTO_SCROLL.touching = false;
}
if (AUTO_SCROLL.running) {
AUTO_SCROLL.running = false;
AUTO_SCROLL.pausedByUser = true;
cancelAutoScrollFrame();
logDebug('auto-scroll paused by ' + event.type);
}
if (AUTO_SCROLL.pausedByUser) {
clearTimeout(AUTO_SCROLL.resumeTimer);
AUTO_SCROLL.resumeTimer = setTimeout(resumeAutoScrollAfterPause, CONFIG.autoScrollResumeMs);
}
}
/** Attaches the wheel/touch listeners once (on the first start). */
function attachAutoScrollListeners() {
if (AUTO_SCROLL.listenersAttached) {
return;
}
const handler = guardHandler('autoScroll.manualInput', onManualScrollInput);
['wheel', 'touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(function (type) {
window.addEventListener(type, handler, { passive: true });
});
AUTO_SCROLL.listenersAttached = true;
}
ACTIONS['toggle-auto-scroll'] = toggleAutoScroll;
onAppStateChange(function (what) {
if (what === 'textsRefreshed') {
renderAutoScrollButton();
}
});
// ---- 20.4 Comments ----
// Before the takeover the site's comments section (CONFIG.commentsSelector: Disqus or
// Facebook) is scrolled into view so it loads, awaited at most CONFIG.commentsWaitTimeoutMs,
// and KEPT (the element object survives the takeover). A site module may return it as
// `manga.comments` (an element) instead. After the takeover it is moved into #CommentsArea.
// Setting `enableComments` (default true, as in the 2025 version) switches capture off.
/**
* True for a DOM element (works across script and page worlds, unlike instanceof).
* @param {*} value - Anything.
* @returns {boolean}
*/
function isElementNode(value) {
return value !== null && typeof value === 'object' && value.nodeType === 1 && typeof value.appendChild === 'function';
}
/**
* True when a comments element shows content (an iframe of the comment service, or text).
* @param {Element|null} element - Comments container.
* @returns {boolean}
*/
function hasCommentsContent(element) {
if (!element) {
return false;
}
return element.querySelector('iframe:not(#indicator-north):not(#indicator-south)') !== null || readText(element) !== '';
}
/**
* Finds and waits for the site's comments (never longer than CONFIG.commentsWaitTimeoutMs).
* @param {Object} manga - The manga object (its `comments` element wins when present).
* @returns {Promise<Element|null>}
*/
async function captureComments(manga) {
if (getSetting('enableComments') !== true) {
logDebug('comments: capture is off (setting enableComments)');
return null;
}
if (manga && isElementNode(manga.comments)) {
logDebug('comments: provided by the site module');
return manga.comments;
}
const found = query(CONFIG.commentsSelector);
if (!found) {
logDebug('comments: no comments section on this page', CONFIG.commentsSelector);
return null;
}
log('Comments: waiting up to ' + CONFIG.commentsWaitTimeoutMs + ' ms for ' + CONFIG.commentsSelector + ' to load');
window.scrollTo(0, document.body ? document.body.scrollHeight : 0);
try {
await waitUntil(function () {
return hasCommentsContent(query(CONFIG.commentsSelector)) ? true : null;
}, 'comments (' + CONFIG.commentsSelector + ')', CONFIG.commentsWaitTimeoutMs);
log('Comments captured');
} catch (error) {
log('Comments not loaded in time; continuing without waiting longer (' + describeError(error) + ')');
}
window.scrollTo(0, 0);
const element = query(CONFIG.commentsSelector) || found;
return element.children.length > 0 ? element : null;
}
/** Enables the header Comments button when comments exist and the setting is on. */
function renderCommentsButton() {
const button = document.getElementById('CommentsButton');
if (!button) {
return;
}
const available = isElementNode(APP_STATE.comments) && getSetting('enableComments') === true;
button.disabled = !available;
button.classList.toggle('disabled', !available);
}
/** Moves the captured comments into the Comments panel (again after a panel redraw). */
function mountComments() {
const area = document.getElementById('CommentsArea');
if (area && isElementNode(APP_STATE.comments) && APP_STATE.comments.parentNode !== area) {
area.appendChild(APP_STATE.comments);
logDebug('comments: placed in the Comments panel');
}
renderCommentsButton();
}
/** Light/dark switch of the comments area only (2025 version, OLD:3212-3216). */
function toggleCommentsColorScheme() {
const area = document.getElementById('CommentsArea');
if (area) {
const light = !area.classList.contains('light');
area.classList.toggle('light', light);
area.classList.toggle('dark', !light);
}
}
ACTIONS['open-comments'] = function () {
if (!isElementNode(APP_STATE.comments)) {
logWarn('open-comments: no comments were captured');
return;
}
mountComments();
setOpenPanel('comments');
};
ACTIONS['comments-color-scheme'] = toggleCommentsColorScheme;
SETTING_APPLIERS.enableComments = renderCommentsButton;
onAppStateChange(function (what) {
if (what === 'textsRefreshed') {
mountComments();
}
});
// ---- 20.5 Local files ----
// The 2025 version (OLD:4897-4979) read images, a folder of images or a ZIP file chosen on a
// local HTML page (inputs #file, #folder, #images of that page), sorted the files naturally
// and showed them. It used JSZip for ZIP files; it never supported RAR/CBR or 7z. Here: the
// same, with the own ZIP reader of section 19 (STORE and DEFLATE entries), plus an own panel
// with the three inputs. Nothing leaves the computer: files become object URLs.
// These functions do not open the reader themselves: they call `onImagesReady(title, urls)`,
// which section 21 provides.
/** Image file extensions accepted from folders and archives (2025 list OLD:4909, plus avif, jfif). */
const LOCAL_IMAGE_NAME_PATTERN = /\.(png|jpe?g|jfif|gif|bmp|webp|avif)$/i;
/** Image MIME types accepted from the file inputs (2025 list OLD:4897-4908, plus avif). */
const LOCAL_IMAGE_TYPES = ['image/apng', 'image/avif', 'image/bmp', 'image/gif', 'image/jpeg', 'image/pjpeg', 'image/png', 'image/svg+xml', 'image/tiff', 'image/webp', 'image/x-icon'];
/**
* Natural, number-aware comparison of two names (pure): 'page2' < 'page10', case-insensitive,
* punctuation ignored like the 2025 version's localeCompare(..., {numeric, ignorePunctuation})
* (OLD:4910-4914), but the same result in every browser.
* @param {string} first - Name.
* @param {string} second - Name.
* @returns {number} Negative, 0 or positive.
*/
function compareNaturally(first, second) {
/**
* Lower case without spaces and punctuation, for the comparison.
* @param {string} text - File name.
* @returns {string}
*/
const simplify = function (text) {
return String(text).toLowerCase().replace(/[\s_\-.,;:'"()[\]{}!?#~+]+/g, '');
};
const chunksA = simplify(first).match(/\d+|\D+/g) || [];
const chunksB = simplify(second).match(/\d+|\D+/g) || [];
const length = Math.min(chunksA.length, chunksB.length);
for (let i = 0; i < length; i++) {
const a = chunksA[i];
const b = chunksB[i];
const aIsNumber = /^\d/.test(a);
const bIsNumber = /^\d/.test(b);
if (aIsNumber && bIsNumber) {
const difference = parseInt(a, 10) - parseInt(b, 10);
if (difference !== 0) {
return difference;
}
if (a.length !== b.length) {
return a.length - b.length;
}
} else if (a !== b) {
if (aIsNumber !== bIsNumber) {
return aIsNumber ? -1 : 1;
}
return a < b ? -1 : 1;
}
}
if (chunksA.length !== chunksB.length) {
return chunksA.length - chunksB.length;
}
return String(first) < String(second) ? -1 : (String(first) > String(second) ? 1 : 0);
}
/**
* Archive type from the first bytes of a file (pure): 'zip', 'rar', '7z' or 'unknown'.
* @param {Uint8Array} bytes - File start.
* @returns {string}
*/
function detectArchiveFormat(bytes) {
if (!bytes || bytes.length < 4) {
return 'unknown';
}
if (bytes[0] === 0x50 && bytes[1] === 0x4B && ((bytes[2] === 0x03 && bytes[3] === 0x04) || (bytes[2] === 0x05 && bytes[3] === 0x06) || (bytes[2] === 0x07 && bytes[3] === 0x08))) {
return 'zip';
}
if (bytes.length >= 6 && bytes[0] === 0x52 && bytes[1] === 0x61 && bytes[2] === 0x72 && bytes[3] === 0x21 && bytes[4] === 0x1A && bytes[5] === 0x07) {
return 'rar';
}
if (bytes.length >= 6 && bytes[0] === 0x37 && bytes[1] === 0x7A && bytes[2] === 0xBC && bytes[3] === 0xAF && bytes[4] === 0x27 && bytes[5] === 0x1C) {
return '7z';
}
return 'unknown';
}
/**
* True for a chosen file that is an image (by MIME type, else by name).
* @param {File} file - Chosen file.
* @returns {boolean}
*/
function isLocalImageFile(file) {
return LOCAL_IMAGE_TYPES.indexOf(String(file.type).toLowerCase()) !== -1 || LOCAL_IMAGE_NAME_PATTERN.test(file.name);
}
/**
* MIME type of an image file name ('' when unknown), so the browser shows archive images.
* @param {string} name - File name.
* @returns {string}
*/
function getImageMimeTypeFromName(name) {
const extension = getExtensionFromName(name);
const types = Object.keys(IMAGE_EXTENSIONS_BY_TYPE);
for (let i = 0; i < types.length; i++) {
if (IMAGE_EXTENSIONS_BY_TYPE[types[i]] === extension) {
return types[i];
}
}
return '';
}
/**
* Reads a chosen file into bytes (FileReader: works in old browsers without Blob.arrayBuffer).
* @param {File} file - Chosen file.
* @returns {Promise<Uint8Array>}
*/
function readFileBytes(file) {
return new Promise(function (resolve, reject) {
const reader = new FileReader();
reader.onload = function () {
resolve(new Uint8Array(reader.result));
};
reader.onerror = function () {
reject(reader.error || new Error('FileReader failed'));
};
reader.readAsArrayBuffer(file);
});
}
/**
* An error whose message is shown to the user as is (unsupported file, empty archive...).
* @param {string} message - Localized text.
* @returns {Error}
*/
function createUserFacingError(message) {
const error = new Error(message);
error.userFacing = true;
return error;
}
/**
* Images of a ZIP/CBZ archive, sorted naturally by their path inside the archive, as object
* URLs. Entries that cannot be read are logged and skipped.
* @param {File} file - The archive.
* @returns {Promise<{title: string, listImages: string[]}>}
*/
async function loadImagesFromArchive(file) {
const bytes = await readFileBytes(file);
const format = detectArchiveFormat(bytes);
if (format !== 'zip') {
throw createUserFacingError(getLocaleString('LOCAL_FILES_UNSUPPORTED').replace('##file##', file.name));
}
const entries = readZipDirectory(bytes).filter(function (entry) {
return !entry.isDirectory && LOCAL_IMAGE_NAME_PATTERN.test(entry.name);
}).sort(function (first, second) {
return compareNaturally(first.name, second.name);
});
log('Local archive "' + file.name + '": ' + entries.length + ' images');
const listImages = [];
entries.forEach(function (entry) {
safeRun('localFiles.extract', function () {
const content = extractZipEntry(bytes, entry);
const type = getImageMimeTypeFromName(entry.name);
listImages.push(URL.createObjectURL(type ? new Blob([content], { type: type }) : new Blob([content])));
}, undefined, { archive: file.name, entry: entry.name, method: entry.method });
});
if (listImages.length === 0) {
throw createUserFacingError(getLocaleString('LOCAL_FILES_EMPTY').replace('##file##', file.name));
}
return { title: file.name.replace(/\.[^.]+$/, ''), listImages: listImages };
}
/**
* Chosen image files (or a folder), sorted naturally by their path, as object URLs. The title
* is the folder name, else "Local Images" (OLD:4947-4963).
* @param {File[]} files - Chosen files.
* @returns {{title: string, listImages: string[]}}
*/
function loadImagesFromFiles(files) {
const images = files.filter(isLocalImageFile).sort(function (first, second) {
return compareNaturally(first.webkitRelativePath || first.name, second.webkitRelativePath || second.name);
});
if (images.length === 0) {
throw createUserFacingError(getLocaleString('LOCAL_FILES_EMPTY').replace('##file##', files.length === 1 ? files[0].name : String(files.length)));
}
const folder = images[0].webkitRelativePath ? images[0].webkitRelativePath.split('/')[0] : '';
log('Local images: ' + images.length + ' of ' + files.length + ' chosen files', folder ? '(folder ' + folder + ')' : '');
return {
title: folder || getLocaleString('LOCAL_FILES_TITLE'),
listImages: images.map(function (file) {
return URL.createObjectURL(file);
})
};
}
/**
* Handles a file selection: one non-image file is read as an archive; otherwise the images
* among the files are used. Errors are logged and shown (toast and the panel status line).
* @param {FileList|File[]} fileList - Chosen files.
* @param {function(string, string[]): void} onImagesReady - Opens the reader.
* @param {HTMLElement|null} statusElement - Where to show progress and errors (may be null).
* @returns {Promise<void>}
*/
async function openLocalFileSelection(fileList, onImagesReady, statusElement) {
const files = Array.prototype.slice.call(fileList || []);
if (files.length === 0) {
return;
}
/**
* Writes the status line of the local files panel.
* @param {string} text - Message.
* @param {boolean} [isError] - Error look.
*/
const setStatus = function (text, isError) {
if (statusElement) {
statusElement.textContent = text;
statusElement.classList.toggle('error', isError === true);
}
};
const first = files[0];
try {
let result = null;
if (files.length === 1 && !isLocalImageFile(first)) {
setStatus(getLocaleString('LOCAL_FILES_READING').replace('##file##', first.name), false);
result = await loadImagesFromArchive(first);
} else {
result = loadImagesFromFiles(files);
}
setStatus('', false);
onImagesReady(result.title, result.listImages);
} catch (error) {
const message = error && error.userFacing ? error.message : getLocaleString('LOCAL_FILES_ERROR').replace('##file##', first.name).replace('##error##', describeError(error));
logError('localFiles.open', error, { files: files.length, first: first.name, size: first.size, type: first.type });
setStatus(message, true);
showToast({ type: 'error', title: message, durationMs: CONFIG.bookmarkToastMs });
}
}
/**
* HTML of the local files panel: three inputs (images, folder, ZIP/CBZ archive).
* @returns {string}
*/
function buildLocalFilesPanelHtml() {
/**
* HTML of one file input with its label.
* @param {string} labelKey - Locale key of the label.
* @param {string} attributes - Extra input attributes.
* @returns {string}
*/
const option = function (labelKey, attributes) {
return '<label class="mvm-upload-option"><span>' + escapeHtml(getLocaleString(labelKey)) + '</span><input type="file" ' + attributes + ' /></label>';
};
return [
'<h2>', escapeHtml(getLocaleString('LOCAL_FILES_OPEN')), '</h2>',
'<p class="mvm-upload-hint">', escapeHtml(getLocaleString('LOCAL_FILES_HINT')), '</p>',
option('LOCAL_FILES_IMAGES', 'class="mvm-upload-images" accept="image/*" multiple'),
option('LOCAL_FILES_FOLDER', 'class="mvm-upload-folder" webkitdirectory directory multiple'),
option('LOCAL_FILES_ARCHIVE', 'class="mvm-upload-archive" accept=".zip,.cbz,application/zip,application/x-cbz,application/vnd.comicbook+zip"'),
'<div class="mvm-upload-status" role="status"></div>'
].join('');
}
/**
* Shows the local files panel on the current (not taken over) page.
* @param {function(string, string[]): void} onImagesReady - Opens the reader.
* @returns {HTMLElement} The panel.
*/
function showLocalFilesPanel(onImagesReady) {
installStyle('MVMPopupStyle', POPUP_CSS);
let panel = document.getElementById('MVMLocalFiles');
if (panel) {
return panel;
}
panel = document.createElement('div');
panel.id = 'MVMLocalFiles';
panel.className = 'mvm-upload';
setElementHtml(panel, buildLocalFilesPanelHtml());
const status = panel.querySelector('.mvm-upload-status');
panel.addEventListener('change', guardHandler('localFiles.change', function (event) {
if (event.target && event.target.type === 'file') {
openLocalFileSelection(event.target.files, onImagesReady, status);
}
}));
(document.body || document.documentElement).appendChild(panel);
log('Local files panel shown: choose images, a folder or a ZIP/CBZ archive');
return panel;
}
/** Removes the local files panel (when the reader opens). */
function hideLocalFilesPanel() {
const panel = document.getElementById('MVMLocalFiles');
if (panel) {
panel.remove();
}
}
/**
* Connects the inputs of a local HTML page made for the 2025 version: #file (ZIP),
* #folder and #images (OLD:4964-4979), and hides its #LocalTest block as that version did.
* @param {function(string, string[]): void} onImagesReady - Opens the reader.
* @returns {number} How many inputs were connected.
*/
function bindLegacyUploadInputs(onImagesReady) {
let bound = 0;
['#file', '#folder', '#images'].forEach(function (selector) {
const input = query(selector);
if (input && input.tagName === 'INPUT' && input.type === 'file') {
input.addEventListener('change', guardHandler('localFiles.legacyInput', function () {
openLocalFileSelection(input.files, onImagesReady, null);
}));
bound += 1;
}
});
const localTest = query('#LocalTest');
if (bound > 0 && localTest) {
localTest.style.display = 'none';
}
if (bound > 0) {
log('Local files: connected ' + bound + ' file inputs of this page');
}
return bound;
}
// ==========================================================================================
// ==== 21. STARTUP ==========================================================================
// From page load to a running reader, in this order (docs/02-how-the-viewer-works.md):
// 1. findMatchingSites - site modules whose `url` regex matches this address
// 2. runSite (per site) - its waits (each with a timeout), then site.run()
// 3. validateMangaResult - checks the returned object; a bad result is a failure
// 4. firstSuccessfulSite - the FIRST site that succeeds wins; all failures are logged
// 5. preparePage - title, resume page (bookmark), before() hook, load mode
// 6. openViewer - capture comments (section 20), take over the page, draw the
// reader, load images
// On a local page (isStandalone) the local file reader is set up first (section 20.5).
// Every step logs what it does when verbose logging is on, and every failure names the site
// and the step. MVMDiagnose() runs steps 1-3 for every matching site without taking over the
// page, which is the fastest way to test a new site module.
// ==========================================================================================
/**
* Display name of a site (its `name` may be an array of brand names).
* @param {Object} site - Site module.
* @returns {string}
*/
function getSiteName(site) {
if (!site) {
return '(no site)';
}
return Array.isArray(site.name) ? site.name.join(' / ') : String(site.name);
}
/**
* Site modules whose `url` regex matches the current address. A broken site object is logged
* and skipped, never fatal.
* @param {Object[]} sites - All site modules.
* @param {string} [address=window.location.href] - Address to test.
* @returns {Object[]}
*/
function findMatchingSites(sites, address) {
const href = address || window.location.href;
return sites.filter(function (site) {
return safeRun('startup.matchSite', function () {
if (!site || !(site.url instanceof RegExp) || typeof site.run !== 'function') {
logWarn('Invalid site module skipped (needs url RegExp and run function):', site);
return false;
}
return site.url.test(href);
}, false, { site: getSiteName(site) });
});
}
/**
* Builds the list of wait conditions declared by a site, in the order they run.
* @param {Object} site - Site module.
* @returns {{label: string, check: function(): *}[]}
*/
function describeSiteWaits(site) {
const waits = [];
if (site.waitAttr !== undefined) {
const selector = site.waitAttr[0];
const attribute = site.waitAttr[1];
waits.push({
label: 'attribute "' + attribute + '" on "' + selector + '"',
check: function () {
return readAttribute(document.querySelector(selector), attribute);
}
});
}
if (site.waitEle !== undefined) {
waits.push({
label: 'element "' + site.waitEle + '"',
check: function () {
return document.querySelector(site.waitEle);
}
});
}
if (site.waitVar !== undefined) {
waits.push({
label: 'page variable "' + site.waitVar + '"',
check: function () {
return pageWindow[site.waitVar];
}
});
}
if (site.waitFunc !== undefined) {
waits.push({
label: 'waitFunc condition',
check: function () {
return site.waitFunc() ? true : null;
}
});
}
return waits;
}
/**
* Runs one site: its waits (sequentially, each with a timeout), then run(), then validation.
* Rejects with an Error whose message names the site and the failed step.
* @param {Object} site - Site module.
* @returns {Promise<{site: Object, manga: Object, report: Object}>}
*/
async function runSite(site) {
const siteName = getSiteName(site);
const report = { site: siteName, steps: [], warnings: [] };
const startedAt = Date.now();
logDebug('[' + siteName + '] starting checks');
const waits = describeSiteWaits(site);
for (let i = 0; i < waits.length; i++) {
const stepStart = Date.now();
try {
await waitUntil(waits[i].check, '[' + siteName + '] ' + waits[i].label, CONFIG.siteWaitTimeoutMs);
report.steps.push('wait ' + waits[i].label + ': ok in ' + (Date.now() - stepStart) + ' ms');
} catch (error) {
report.steps.push('wait ' + waits[i].label + ': FAILED (' + error.message + ')');
throw new Error('[' + siteName + '] step "wait for ' + waits[i].label + '" failed: ' + error.message);
}
}
if (typeof site.waitTime === 'number' && site.waitTime > 0) {
logDebug('[' + siteName + '] waiting fixed time ' + site.waitTime + ' ms');
await sleep(site.waitTime);
report.steps.push('fixed wait ' + site.waitTime + ' ms');
}
let manga = null;
try {
manga = await site.run();
} catch (error) {
report.steps.push('run(): THREW ' + describeError(error));
throw new Error('[' + siteName + '] step "run()" threw: ' + describeError(error));
}
report.steps.push('run(): returned in ' + (Date.now() - startedAt) + ' ms total');
const validation = validateMangaResult(manga);
report.warnings = validation.warnings;
report.strategy = validation.strategy;
report.pages = manga && typeof manga.pages === 'number' ? manga.pages : 0;
validation.warnings.forEach(function (warning) {
logWarn('[' + siteName + '] ' + warning);
});
if (validation.errors.length > 0) {
throw new Error('[' + siteName + '] result rejected: ' + validation.errors.join(' | '));
}
logDebug('[' + siteName + '] accepted', report);
return { site: site, manga: manga, report: report };
}
/**
* Checks what a site's run() returned. ERRORS make the site fail (so another matching site
* can win); WARNINGS are only logged. This is the first thing to read when a new site does
* not work: every message says what is wrong and how to fix it.
* @param {*} manga - Whatever run() returned.
* @returns {{errors: string[], warnings: string[], strategy: string}}
*/
function validateMangaResult(manga) {
const errors = [];
const warnings = [];
if (manga === null || typeof manga !== 'object' || Array.isArray(manga)) {
errors.push('run() must return an object, got ' + (manga === null ? 'null' : typeof manga));
return { errors: errors, warnings: warnings, strategy: '' };
}
if (typeof manga.pages !== 'number' || isNaN(manga.pages) || manga.pages <= 0) {
errors.push('pages must be a number > 0 (got ' + manga.pages + '). A site returns 0 on purpose to decline.');
} else if (Math.floor(manga.pages) !== manga.pages) {
errors.push('pages must be a whole number (got ' + manga.pages + ')');
}
const strategy = getLoadingStrategy(manga);
if (strategy === '') {
errors.push('no loading strategy: return a non-empty listImages, or listPages + img, or a bruteForce function');
}
const list = strategy === 'images' ? manga.listImages : strategy === 'pages' ? manga.listPages : null;
if (list) {
if (typeof manga.pages === 'number' && list.length < manga.pages) {
warnings.push('pages (' + manga.pages + ') is larger than the list (' + list.length + '): the last pages will be empty. Use list.length.');
} else if (typeof manga.pages === 'number' && list.length > manga.pages) {
warnings.push('the list has ' + list.length + ' entries but pages is ' + manga.pages + ': extra entries are ignored.');
}
const empty = list.filter(function (entry) {
return typeof entry !== 'string' || entry.trim() === '';
}).length;
if (empty > 0) {
warnings.push(empty + ' empty or non-text entries in the list (they will show as failed pages). Filter them out.');
}
const invalid = list.filter(function (entry) {
return typeof entry === 'string' && entry.trim() !== '' && !isInMemoryUrl(entry) && !isValidUrl(entry);
}).length;
if (invalid > 0) {
warnings.push(invalid + ' entries are not valid URLs.');
}
if (uniqueValues(list).length < list.length) {
warnings.push('the list contains duplicates (' + (list.length - uniqueValues(list).length) + '). Check the selector or deduplicate.');
}
}
if (strategy === 'pages' && (typeof manga.img !== 'string' || manga.img.trim() === '')) {
errors.push('listPages needs `img`: the CSS selector of the image inside each page');
}
['title', 'series', 'prev', 'next'].forEach(function (field) {
if (manga[field] !== undefined && manga[field] !== null && typeof manga[field] !== 'string') {
warnings.push(field + ' should be a string (got ' + typeof manga[field] + ')');
}
});
['prev', 'next'].forEach(function (field) {
if (typeof manga[field] === 'string' && /undefined|null/.test(manga[field])) {
warnings.push(field + ' contains "undefined" or "null" (' + manga[field] + '): a value was missing when the link was built.');
}
});
if (manga.begin !== undefined && (typeof manga.begin !== 'number' || manga.begin < 1)) {
warnings.push('begin should be a page number >= 1 (got ' + manga.begin + '); using 1');
}
if (manga.before !== undefined && typeof manga.before !== 'function') {
warnings.push('before should be a function');
}
if (manga.reload !== undefined && typeof manga.reload !== 'function') {
warnings.push('reload should be a function(page) returning an image URL (it is ignored)');
}
if (manga.comments !== undefined && manga.comments !== null && !isElementNode(manga.comments)) {
warnings.push('comments should be a DOM element (the comments container); it is ignored');
}
if (manga.lazy !== undefined && typeof manga.lazy !== 'boolean') {
warnings.push('lazy should be true or false (it overrides the lazy load setting); it is ignored');
}
return { errors: errors, warnings: warnings, strategy: strategy };
}
/**
* Runs all matching sites at the same time; resolves with the FIRST success. Rejects only
* when every site failed, with all failure messages (written for ES2017: no Promise.any).
* @param {Object[]} sites - Matching site modules.
* @returns {Promise<{site: Object, manga: Object, report: Object}>}
*/
function firstSuccessfulSite(sites) {
return new Promise(function (resolve, reject) {
const failures = [];
let settled = false;
if (sites.length === 0) {
reject(new Error('No site module matches this address'));
return;
}
sites.forEach(function (site) {
runSite(site).then(function (result) {
if (!settled) {
settled = true;
resolve(result);
} else {
logDebug('[' + getSiteName(site) + '] also succeeded, but another site won first');
}
}, function (error) {
failures.push(describeError(error));
logDebug('site failed', describeError(error));
if (failures.length === sites.length && !settled) {
settled = true;
reject(new Error('All matching sites failed:\n - ' + failures.join('\n - ')));
}
});
});
});
}
/**
* Fills defaults on the winning manga object and runs its before() hook.
* @param {Object} site - Winning site.
* @param {Object} manga - Its manga object (changed in place).
*/
async function preparePage(site, manga) {
log('Found ' + manga.pages + ' pages in ' + getSiteName(site));
if (typeof manga.title !== 'string' || manga.title.trim() === '') {
manga.title = getPageTitle();
}
const bookmarkedPage = getBookmarkedPage();
const siteBegin = typeof manga.begin === 'number' && manga.begin >= 1 ? manga.begin : 1;
manga.begin = clamp(bookmarkedPage > 0 ? bookmarkedPage : siteBegin, 1, manga.pages);
APP_STATE.resumedBookmarkPage = bookmarkedPage > 0 ? bookmarkedPage : 0;
if (bookmarkedPage > 0) {
log('Resuming from bookmarked page ' + manga.begin + ' (the bookmark is removed when the last page is reached)');
}
if (typeof manga.before === 'function') {
logDebug('Running the site before() hook');
await safeRunAsync('site.before', function () {
return Promise.resolve(manga.before(manga.begin));
}, undefined, { site: getSiteName(site) });
}
APP_STATE.site = site;
APP_STATE.manga = manga;
}
/**
* Removes the site's page and writes the reader (2025 method: clean document, own HTML).
* Cloning the whole document drops every event listener the site attached to elements.
* @param {Object} manga - The prepared manga object.
*/
function takeOverPage(manga) {
const oldRoot = document.documentElement;
const newRoot = oldRoot.cloneNode(false);
document.replaceChild(newRoot, oldRoot);
oldRoot.getAttributeNames().forEach(function (name) {
newRoot.removeAttribute(name);
});
newRoot.setAttribute(CONFIG.takeoverMarkerAttribute, '');
setElementHtml(newRoot, '<head></head><body></body>');
window.scrollTo(0, 0);
setElementHtml(document.head, buildHeadHtml(manga));
setElementHtml(document.body, buildAppHtml(manga));
APP_STATE.device = resolveDeviceMode(getSetting('deviceMode'), detectDevice());
setExclusiveClass(getReaderRoot(), ['mobile', 'tablet', 'desktop'], APP_STATE.device);
logDebug('Page taken over');
}
/**
* Opens the reader for the prepared manga, optionally for a page range. Before the takeover
* the site's comments are captured (at most CONFIG.commentsWaitTimeoutMs, section 20).
* @param {number} [begin] - First page (default: manga.begin).
* @param {number} [end] - Last page (default: manga.pages).
* @returns {Promise<void>}
*/
async function openViewer(begin, end) {
const manga = APP_STATE.manga;
if (!manga) {
logWarn('openViewer: no chapter prepared yet');
return;
}
if (document.documentElement.hasAttribute(CONFIG.takeoverMarkerAttribute) || APP_STATE.opening) {
logWarn('openViewer: the reader is already open (or opening)');
return;
}
APP_STATE.opening = true;
try {
if (typeof begin === 'number' && begin >= 1) {
manga.begin = clamp(begin, 1, manga.pages);
}
if (typeof end === 'number' && end >= manga.begin) {
manga.pages = clamp(end, manga.begin, manga.pages);
}
APP_STATE.comments = await safeRunAsync('startup.captureComments', function () {
return captureComments(manga);
}, null);
log('Opening the reader: pages ' + manga.begin + '-' + manga.pages);
safeRun('startup.takeOverPage', function () {
takeOverPage(manga);
applyAllSettings();
attachReaderEvents();
mountComments();
startImageLoading(manga);
updateDownloadAvailability();
}, undefined, { begin: manga.begin, end: manga.pages });
} finally {
APP_STATE.opening = false;
}
}
/** Floating "Start" button (load mode 'never', or after cancelling the wait prompt). */
function showStartButton() {
if (document.getElementById('StartMOV')) {
return;
}
installStyle('MVMPopupStyle', POPUP_CSS);
const button = document.createElement('button');
button.id = 'StartMOV';
button.type = 'button';
button.textContent = getLocaleString('BUTTON_START');
button.addEventListener('click', guardHandler('startButton.click', function () {
startWithPageRange();
}));
(document.body || document.documentElement).appendChild(button);
log('Start button added to the page');
}
/** Asks for a page range, then opens the reader. */
async function startWithPageRange() {
const manga = APP_STATE.manga;
if (!manga) {
return;
}
const range = await showPageRangePrompt(manga.pages, manga.begin);
if (!range) {
log('Page range prompt cancelled');
return;
}
const button = document.getElementById('StartMOV');
if (button) {
button.remove();
}
openViewer(range.begin, range.end);
}
/**
* Applies the load mode: 'always' opens at once, 'wait' asks first (auto-start after 3 s),
* 'never' shows only the Start button.
* @param {Object} site - Winning site (its `start` overrides the setting).
*/
async function applyLoadMode(site) {
const mode = site.start || getSetting('loadMode');
log('Load mode: ' + mode);
if (mode === 'always') {
openViewer();
return;
}
if (mode === 'never') {
showStartButton();
return;
}
const start = await showWaitPrompt(APP_STATE.manga);
if (start) {
openViewer();
} else {
showStartButton();
}
}
/** The pseudo site module used for files opened from the computer (logs, MVMDiagnose). */
const LOCAL_FILES_SITE = {
name: 'Local Files (opened from this computer)',
url: /^$/,
run: function () {
return null;
}
};
/**
* Opens the reader with images chosen on the local page (callback of section 20.5).
* @param {string} title - Folder, archive or default title.
* @param {string[]} listImages - Object URLs, in reading order.
* @returns {boolean} True when the reader opens.
*/
function openLocalImages(title, listImages) {
if (document.documentElement.hasAttribute(CONFIG.takeoverMarkerAttribute) || APP_STATE.opening) {
showToast({ type: 'warning', title: getLocaleString('LOCAL_FILES_ALREADY_OPEN') });
return false;
}
const manga = { title: title, series: '?reload', pages: listImages.length, begin: 1, prev: '', next: '', lazy: false, listImages: listImages };
const validation = validateMangaResult(manga);
if (validation.errors.length > 0) {
logError('localFiles.validate', new Error(validation.errors.join(' | ')), { title: title, pages: listImages.length });
return false;
}
log('Opening ' + listImages.length + ' local images: "' + title + '"');
APP_STATE.site = LOCAL_FILES_SITE;
APP_STATE.manga = manga;
APP_STATE.resumedBookmarkPage = 0;
hideLocalFilesPanel();
openViewer();
return true;
}
/** True on the standalone local reader page (Manga_Viewer_MM_Local.html) or a page made for the 2025 local reader. */
function isLocalReaderPage() {
return /Manga_Viewer_MM_Local\.html$/i.test(window.location.pathname) || query('#LocalTest') !== null;
}
/**
* Local file reader (decision D4) on a local page: connects the inputs of a page made for the
* 2025 version, or shows the own panel on the local reader page. Other local HTML files are
* left alone (the script's @include matches every file:///...html); MVMOpenFiles() opens the
* panel on any page. The site checks still run afterwards (the 'Local Files' test site).
*/
function setupLocalFilesReader() {
const bound = bindLegacyUploadInputs(openLocalImages);
if (bound === 0 && isLocalReaderPage()) {
showLocalFilesPanel(openLocalImages);
}
}
/**
* MAIN ENTRY: find the site, read the chapter, then start according to the load mode.
* @param {Object[]} sites - All site modules.
*/
async function start(sites) {
log('Starting ' + SCRIPT_INFO.name + ' ' + SCRIPT_INFO.version + ' on ' + detectDevice() + ', ' + getBrowserName() + ', with ' + SCRIPT_INFO.manager + (isVerbose() ? ' (verbose logging ON)' : ''));
if (document.documentElement.hasAttribute(CONFIG.takeoverMarkerAttribute)) {
log('The reader is already running on this page; nothing to do');
return;
}
if (isStandalone()) {
safeRun('startup.localFiles', setupLocalFilesReader);
}
const matching = findMatchingSites(sites);
log(matching.length + ' of ' + sites.length + ' site modules match this address:', matching.map(getSiteName).join(', ') || '(none)');
if (matching.length === 0) {
return;
}
let winner = null;
try {
winner = await firstSuccessfulSite(matching);
} catch (error) {
log(describeError(error));
log('Tip: run MVMDiagnose() in the console for a step-by-step report.');
return;
}
log('Using site module: ' + getSiteName(winner.site));
await preparePage(winner.site, winner.manga);
exposeToConsole('MVM', function (begin, end) {
openViewer(begin, end);
});
await sleep(CONFIG.startDelayMs);
await safeRunAsync('startup.applyLoadMode', function () {
return applyLoadMode(winner.site);
});
}
/**
* Console helper for site developers: runs every matching site (waits, run, validation)
* WITHOUT taking over the page, and prints a table. Turns verbose logging on while it runs.
* @param {Object[]} sites - All site modules.
* @returns {Promise<Object[]>} One report per matching site.
*/
async function diagnose(sites) {
const previousVerbose = isVerbose();
setVerbose(true);
const matching = findMatchingSites(sites);
log('DIAGNOSE: ' + matching.length + ' site modules match ' + window.location.href);
const reports = [];
for (let i = 0; i < matching.length; i++) {
try {
const result = await runSite(matching[i]);
reports.push({ site: result.report.site, ok: true, pages: result.report.pages, strategy: result.report.strategy, warnings: result.report.warnings.join(' | '), steps: result.report.steps.join(' > ') });
console.log(CONFIG.logPrefix + ' DIAGNOSE manga object of ' + result.report.site + ':', result.manga);
} catch (error) {
reports.push({ site: getSiteName(matching[i]), ok: false, pages: 0, strategy: '', warnings: '', steps: describeError(error) });
}
}
if (typeof console.table === 'function') {
console.table(reports);
} else {
console.log(reports);
}
setVerbose(previousVerbose);
return reports;
}
// ==========================================================================================
// ==== 22. SITE HELPERS =====================================================================
// Helpers used only by site modules (section 23): language/category labels, text-content
// finders (same names as the original find.ts) and the scroll collector.
// ==========================================================================================
/** Languages a site can serve. Documentation only (not used for logic). */
const Language = {
ENGLISH: 'English',
SPANISH: 'Spanish',
PORTUGUESE: 'Portuguese',
CHINESE: 'Chinese',
RAW: 'Raw'
};
/** Kind of content a site serves. Documentation only (not used for logic). */
const Category = {
MANGA: 'manga',
COMIC: 'comic'
};
// ---- Text-content finders (same names as the original find.ts, which site code uses) ----
/** Ways to compare an element's trimmed text with the wanted text. */
const TEXT_MATCHERS = {
eq: function (text, wanted) {
return text === wanted;
},
starts: function (text, wanted) {
return text.indexOf(wanted) === 0;
},
ends: function (text, wanted) {
return text.length >= wanted.length && text.lastIndexOf(wanted) === text.length - wanted.length;
}
};
/**
* Finds all elements whose trimmed text matches one of the wanted texts.
* @param {string} selector - CSS selector of candidate elements.
* @param {string|string[]} content - Wanted text, or several alternatives.
* @param {string} matcherKey - 'eq', 'starts' or 'ends'.
* @returns {Element[]}
*/
function findElementsByContent(selector, content, matcherKey) {
const matcher = TEXT_MATCHERS[matcherKey];
if (!matcher) {
throw new Error('Invalid matcherKey: ' + matcherKey);
}
const wantedList = Array.isArray(content) ? content : [content];
return queryAll(selector).filter(function (element) {
const text = readText(element);
return wantedList.some(function (wanted) {
return matcher(text, wanted);
});
});
}
/**
* Finds the first matching element, then its closest ancestor matching `ancestor` (default 'a').
* @param {string} selector - CSS selector of candidate elements.
* @param {string|string[]} content - Wanted text(s).
* @param {string} matcherKey - 'eq', 'starts' or 'ends'.
* @param {string} [ancestor='a'] - Selector of the ancestor to return.
* @returns {Element|null}
*/
function findClosestByContent(selector, content, matcherKey, ancestor) {
const found = findElementsByContent(selector, content, matcherKey)[0];
if (!found) {
return null;
}
return found.closest(ancestor || 'a');
}
/** All elements whose text equals `content`. */
function findByContentEq(selector, content) {
return findElementsByContent(selector, content, 'eq');
}
/** First element whose text starts with `content`, or undefined. */
function findOneByContentStarts(selector, content) {
return findElementsByContent(selector, content, 'starts')[0];
}
/** Closest ancestor (default 'a') of the first element whose text equals `content`. */
function findClosestByContentEq(selector, content, ancestor) {
return findClosestByContent(selector, content, 'eq', ancestor);
}
/** Closest ancestor (default 'a') of the first element whose text starts with `content`. */
function findClosestByContentStarts(selector, content, ancestor) {
return findClosestByContent(selector, content, 'starts', ancestor);
}
/** Closest ancestor (default 'a') of the first element whose text ends with `content`. */
function findClosestByContentEnds(selector, content, ancestor) {
return findClosestByContent(selector, content, 'ends', ancestor);
}
// ---- Site data capture (debugging aid for writing or fixing site modules) ----
// When a site module finds raw data (a JSON payload, a page variable, an API answer), it can
// call recordSiteData(siteName, label, data). The data is kept for this page and, with verbose
// logging on, a readable report is printed: the SHAPE of the data (keys, types, array lengths),
// the first items of every array, and every value that looks like a URL or an image file name
// (with its path, e.g. items[0].url). That report is usually enough to turn a slow "scroll the
// page" site module into a direct list of image URLs. Console: MVMSiteData() prints all captures
// again and returns them; MVMSiteData(true) returns them as one JSON text (for copy()).
/** Every capture of this page: { time, site, label, data }. */
const SITE_DATA_CAPTURES = [];
/**
* Short type name for a value: 'null', 'array(12)', 'object', 'string', 'number'...
* @param {*} value - Any value.
* @returns {string}
*/
function describeValueType(value) {
if (value === null) {
return 'null';
}
if (Array.isArray(value)) {
return 'array(' + value.length + ')';
}
return typeof value;
}
/**
* Builds an indented outline of the data: keys with their types (and short sample values),
* array lengths, nested objects up to CONFIG.siteDataMaxDepth. Arrays are described by their
* FIRST item only.
* @param {*} value - Data to describe.
* @param {string} [indent=''] - Current indentation.
* @param {number} [depth=0] - Current depth.
* @returns {string[]} Outline lines.
*/
function describeDataShape(value, indent, depth) {
const prefix = indent || '';
const level = depth || 0;
const lines = [];
if (level >= CONFIG.siteDataMaxDepth) {
return lines;
}
if (Array.isArray(value)) {
if (value.length > 0) {
lines.push(prefix + '[0]: ' + describeValueType(value[0]));
describeDataShape(value[0], prefix + ' ', level + 1).forEach(function (line) {
lines.push(line);
});
}
return lines;
}
if (value !== null && typeof value === 'object') {
Object.keys(value).forEach(function (key) {
const child = value[key];
let sample = '';
if (typeof child === 'string') {
sample = ' = "' + child.slice(0, 80) + (child.length > 80 ? '..."' : '"');
} else if (typeof child === 'number' || typeof child === 'boolean') {
sample = ' = ' + child;
}
lines.push(prefix + key + ': ' + describeValueType(child) + sample);
describeDataShape(child, prefix + ' ', level + 1).forEach(function (line) {
lines.push(line);
});
});
}
return lines;
}
/**
* True for text that looks like a URL or an image file name.
* @param {string} text - Candidate.
* @returns {boolean}
*/
function looksLikeUrl(text) {
if (typeof text !== 'string' || text.length < 4) {
return false;
}
return /^(https?:)?\/\//i.test(text) || /^data:image\//i.test(text) || /^blob:/i.test(text) || /\.(jpe?g|png|webp|avif|gif|bmp)(\?|#|$)/i.test(text);
}
/**
* Collects every URL-like string inside the data, with its path (e.g. 'items[3].src').
* Stops after CONFIG.siteDataMaxUrls results.
* @param {*} value - Data to search.
* @returns {{path: string, value: string}[]}
*/
function findUrlLikeValues(value) {
const found = [];
const seen = [];
/**
* Visits one value recursively.
* @param {*} current - Value.
* @param {string} path - Its path from the root.
* @param {number} depth - Nesting level.
*/
function visit(current, path, depth) {
if (found.length >= CONFIG.siteDataMaxUrls || depth > CONFIG.siteDataMaxDepth + 4) {
return;
}
if (typeof current === 'string') {
if (looksLikeUrl(current)) {
found.push({ path: path || '(root)', value: current.slice(0, CONFIG.siteDataMaxStringLength) });
}
return;
}
if (current === null || typeof current !== 'object' || seen.indexOf(current) !== -1) {
return;
}
seen.push(current);
if (Array.isArray(current)) {
current.forEach(function (item, index) {
visit(item, path + '[' + index + ']', depth + 1);
});
return;
}
Object.keys(current).forEach(function (key) {
visit(current[key], path ? path + '.' + key : key, depth + 1);
});
}
safeRun('siteData.findUrls', function () {
visit(value, '', 0);
});
return found;
}
/**
* JSON text of a value, shortened for the console. Never throws.
* @param {*} value - Data.
* @param {number} maxLength - Maximum characters.
* @returns {string}
*/
function toShortJson(value, maxLength) {
let text = '';
try {
text = JSON.stringify(value);
} catch (error) {
text = '(not serializable: ' + describeError(error) + ')';
}
if (typeof text !== 'string') {
text = String(text);
}
return text.length > maxLength ? text.slice(0, maxLength) + '... (' + text.length + ' chars)' : text;
}
/**
* Prints the first items of an array (helper of logSiteDataReport).
* @param {string} name - Label for each line.
* @param {Array} list - The array.
*/
function logArraySample(name, list) {
list.slice(0, CONFIG.siteDataSampleItems).forEach(function (item, index) {
console.log(name + '[' + index + ']: ' + toShortJson(item, CONFIG.siteDataMaxStringLength * 3));
});
}
/**
* Prints the readable report of one capture (shape, array samples, URL-like values, raw data).
* @param {{time: string, site: string, label: string, data: *}} capture - The capture.
*/
function logSiteDataReport(capture) {
const header = CONFIG.logPrefix + ' [site data] ' + capture.site + ' - ' + capture.label;
const grouped = typeof console.groupCollapsed === 'function' && typeof console.groupEnd === 'function';
if (grouped) {
console.groupCollapsed(header);
} else {
console.log(header);
}
console.log('Type: ' + describeValueType(capture.data));
console.log('Shape:\n' + (describeDataShape(capture.data).join('\n') || '(plain value)'));
if (Array.isArray(capture.data)) {
logArraySample('item', capture.data);
} else if (capture.data !== null && typeof capture.data === 'object') {
Object.keys(capture.data).forEach(function (key) {
if (Array.isArray(capture.data[key])) {
logArraySample(key, capture.data[key]);
}
});
}
const urls = findUrlLikeValues(capture.data);
console.log('URL-like values (' + urls.length + (urls.length >= CONFIG.siteDataMaxUrls ? '+' : '') + '):');
urls.forEach(function (entry) {
console.log(' ' + entry.path + ' = ' + entry.value);
});
console.log('Raw data:', capture.data);
if (grouped) {
console.groupEnd();
}
}
/**
* Keeps a piece of raw site data for debugging and prints its report when verbose logging is
* on. Safe to call from any site module; never throws.
* @param {string} siteName - Site module name, e.g. 'Comix.to'.
* @param {string} label - What the data is, e.g. 'chapter pages (JSON.parse result.pages)'.
* @param {*} data - The data (kept by reference).
*/
function recordSiteData(siteName, label, data) {
safeRun('siteData.record', function () {
const capture = { time: new Date().toISOString(), site: siteName, label: label, data: data };
SITE_DATA_CAPTURES.push(capture);
logDebug('site data captured: ' + siteName + ' - ' + label + ' (' + describeValueType(data) + '). Run MVMSiteData() to print it again.');
if (isVerbose()) {
logSiteDataReport(capture);
}
}, undefined, { site: siteName, label: label });
}
/**
* Installs a wrapper around JSON.parse that hands every parsed result to `pick`; when pick
* returns something, it is recorded with recordSiteData and kept as the latest value. Use it
* when a site parses its chapter data with JSON.parse before the reader can read it. Runs only
* when the hostname contains `hostIncludes`. Call it at script load (top level of section 23),
* not inside run(), or the site may already have parsed its data.
* @param {Object} options
* @param {string} options.hostIncludes - Part of the hostname, e.g. 'comix.to'.
* @param {string} options.siteName - For logs.
* @param {string} options.label - What is captured, for logs.
* @param {function(*): *} options.pick - Returns the wanted part of a parsed value, or null.
* @returns {function(): *} Returns the latest captured value (null until something matched).
*/
function installJsonParseCapture(options) {
let latest = null;
/** Getter for the latest captured value. */
function getLatest() {
return latest;
}
if (window.location.hostname.indexOf(options.hostIncludes) === -1) {
return getLatest;
}
const originalParse = JSON.parse;
JSON.parse = function (text, reviver) {
const parsed = originalParse.call(JSON, text, reviver);
try {
const picked = options.pick(parsed);
if (picked !== null && picked !== undefined) {
latest = picked;
log(options.siteName + ': captured ' + options.label + ' from JSON.parse');
recordSiteData(options.siteName, options.label, picked);
}
} catch (error) {
logDebug(options.siteName + ': JSON.parse capture check failed', describeError(error));
}
return parsed;
};
logDebug(options.siteName + ': JSON.parse capture installed for ' + options.label);
return getLatest;
}
// ---- Scroll collector (for sites that only reveal/descramble images while scrolling) ----
/**
* Converts a blob: URL into a data: URL so it survives the page takeover.
* Returns the original URL on failure (and logs why).
* @param {string} blobUrl - A blob: URL.
* @returns {Promise<string>}
*/
async function blobUrlToDataUrl(blobUrl) {
try {
const response = await fetch(blobUrl);
const blob = await response.blob();
return await new Promise(function (resolve, reject) {
const reader = new FileReader();
reader.onloadend = function () {
resolve(reader.result);
};
reader.onerror = function () {
reject(reader.error);
};
reader.readAsDataURL(blob);
});
} catch (error) {
logError('blobUrlToDataUrl', error, { blobUrl: blobUrl });
return blobUrl;
}
}
/**
* Waits until the page has at least `pageIndex + 1` reader images and returns that one.
* @param {number} pageIndex - 0-based page index.
* @param {function(HTMLImageElement): boolean} isReaderImage - Filter for the site's page images.
* @param {number} timeoutMs - Give up after this time.
* @param {number} pollMs - Check interval.
* @returns {Promise<HTMLImageElement|null>} The image, or null after the timeout.
*/
async function waitForReaderImage(pageIndex, isReaderImage, timeoutMs, pollMs) {
let waited = 0;
while (waited < timeoutMs) {
const readerImages = queryAll('img').filter(isReaderImage);
if (readerImages[pageIndex]) {
return readerImages[pageIndex];
}
await sleep(pollMs);
waited += pollMs;
}
return null;
}
/**
* Waits until an image has a valid, loaded source at least 250 px wide (placeholders are
* smaller) and returns it.
* @param {HTMLImageElement} imageElement - The page image.
* @param {function(string): boolean} isValidSource - Accepts real page URLs.
* @param {number} timeoutMs - Give up after this time.
* @param {number} pollMs - Check interval.
* @returns {Promise<string>} The source, or '' after the timeout.
*/
async function waitForValidImageSource(imageElement, isValidSource, timeoutMs, pollMs) {
let waited = 0;
while (waited < timeoutMs) {
const source = imageElement.src || readAttribute(imageElement, 'src');
const width = imageElement.naturalWidth || imageElement.width || 0;
if (source && isValidSource(source) && (imageElement.naturalWidth > 250 || imageElement.complete) && (width === 0 || width >= 250)) {
return source;
}
await sleep(pollMs);
waited += pollMs;
}
return '';
}
/**
* Full-screen progress overlay used by the collectors (bruteforceScroll, bruteforceClick).
* @param {string} initialText - First line of text (already localized).
* @returns {{setText: function(string): void, setProgress: function(number): void, remove: function(): void}}
*/
function createCollectorOverlay(initialText) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(33,37,41,0.95);z-index:999999;display:flex;flex-direction:column;align-items:center;justify-content:center;color:white;font-family:sans-serif;font-size:20px;';
setElementHtml(overlay, [
'<div style="font-size:24px;font-weight:bold;margin-bottom:20px;">MangaViewerMM</div>',
'<div class="mvm-scroll-text">', escapeHtml(initialText), '</div>',
'<div style="margin-top:20px;width:200px;height:6px;background:#495057;border-radius:3px;overflow:hidden;">',
'<div class="mvm-scroll-bar" style="width:0;height:100%;background:#37b24d;"></div>',
'</div>'
].join(''));
document.body.appendChild(overlay);
return {
setText: function (text) {
const line = overlay.querySelector('.mvm-scroll-text');
if (line) {
line.textContent = text;
}
},
setProgress: function (fraction) {
const bar = overlay.querySelector('.mvm-scroll-bar');
if (bar) {
bar.style.width = Math.round(clamp(fraction, 0, 1) * 100) + '%';
}
},
remove: function () {
overlay.remove();
}
};
}
/**
* Converts the blob: URLs of a collected list into data: URLs (the site may revoke blob: URLs
* after the page takeover) and makes the others absolute. Empty entries stay empty, so page
* order is kept.
* @param {string[]} urls - Collected URLs.
* @returns {Promise<string[]>}
*/
function finalizeCollectedUrls(urls) {
return Promise.all(urls.map(function (url) {
if (url === '') {
return '';
}
if (url.indexOf('blob:') === 0) {
return blobUrlToDataUrl(url);
}
return toAbsoluteUrl(url);
}));
}
/**
* Scrolls the ORIGINAL page image by image, waits until each image has a valid source,
* and returns the collected URLs (blob: URLs converted to data: URLs). Shows a progress overlay.
* Cost: the site loads every image once while scrolling. Use only when nothing else works.
* @param {number} expectedPagesCount - How many pages to collect.
* @param {function(HTMLImageElement): boolean} isReaderImage - True for a reader page image.
* @param {function(string): boolean} isValidSource - True when the image source is final.
* @returns {Promise<string[]>}
*/
async function bruteforceScroll(expectedPagesCount, isReaderImage, isValidSource) {
const perImageTimeoutMs = CONFIG.scrollCollectTimeoutMs;
const pollMs = CONFIG.scrollCollectPollMs;
const startedAt = Date.now();
const overlay = createCollectorOverlay(getLocaleString('SCROLL_PREPARING'));
/**
* Updates the overlay text (localized, SCROLL_PROGRESS) and bar.
* @param {number} collected - Pages collected so far.
*/
function updateOverlay(collected) {
const total = expectedPagesCount > 0 ? String(expectedPagesCount) : '?';
overlay.setText(getLocaleString('SCROLL_PROGRESS').replace('##num##', String(collected)).replace('##total##', total));
overlay.setProgress(expectedPagesCount > 0 ? collected / expectedPagesCount : 0.5);
}
updateOverlay(0);
const originalScrollY = window.scrollY;
const collectedUrls = [];
try {
for (let pageIndex = 0; pageIndex < expectedPagesCount; pageIndex++) {
const imageElement = await waitForReaderImage(pageIndex, isReaderImage, perImageTimeoutMs, pollMs);
if (!imageElement) {
log('bruteforceScroll: no image element found for page ' + (pageIndex + 1));
continue;
}
imageElement.scrollIntoView({ behavior: 'auto', block: 'center' });
const validSource = await waitForValidImageSource(imageElement, isValidSource, perImageTimeoutMs, pollMs);
if (validSource) {
collectedUrls.push(validSource);
} else {
log('bruteforceScroll: timeout waiting for page ' + (pageIndex + 1) + ', keeping its current source');
collectedUrls.push(imageElement.src || readAttribute(imageElement, 'src'));
}
updateOverlay(collectedUrls.length);
}
} finally {
window.scrollTo(0, originalScrollY);
overlay.remove();
}
if (collectedUrls.length === 0) {
throw new Error('bruteforceScroll: no images collected from the page');
}
log('bruteforceScroll: collected ' + collectedUrls.length + ' of ' + expectedPagesCount + ' pages in ' + Math.round((Date.now() - startedAt) / 1000) + ' s');
recordSiteData('bruteforceScroll', 'image sources collected by scrolling (compare with the site data above)', collectedUrls.slice());
return finalizeCollectedUrls(collectedUrls);
}
/**
* Last-resort collector for readers that show one page at a time behind a "next" button:
* clicks "next" through every page and reads the image URL on each step. Use only when the
* image URLs are not available in the page source. Shows a progress overlay.
* Page order is always kept: a page whose image did not appear in time is returned as ''
* (the reader shows it as failed and it can be reloaded; the result checker warns about it).
* Paced by CONFIG.clickCollectDelayMs between clicks - do not make it faster: sites block
* fast collectors (Comix blocked fast scrolling around page 40).
* @param {function(): (void|Promise)} resetPosition - Brings the reader back to page 1 (e.g. clicks "previous" until the first page shows). May return a Promise; it is awaited.
* @param {number} pageCount - Total pages to collect.
* @param {string} nextSelector - CSS selector of the "next page" button.
* @param {string} targetSelector - CSS selector of the container holding the current image.
* @param {string} [imageSelector='img'] - Image element inside the container.
* @param {string} [imageAttribute='src'] - Attribute holding the URL.
* @returns {Promise<string[]>} One entry per page ('' for a page that timed out).
*/
async function bruteforceClick(resetPosition, pageCount, nextSelector, targetSelector, imageSelector, imageAttribute) {
const imgSelector = imageSelector || 'img';
const imgAttribute = imageAttribute || 'src';
const overlay = createCollectorOverlay(getLocaleString('SCROLL_PREPARING'));
const collected = [];
/**
* Finds the image currently shown by the reader.
* @returns {Element|null}
*/
function findShownImage() {
const container = document.querySelector(targetSelector);
return container ? container.querySelector(imgSelector) : document.querySelector(imgSelector);
}
try {
await Promise.resolve(resetPosition());
for (let page = 1; page <= pageCount; page++) {
overlay.setText(getLocaleString('CLICK_PROGRESS').replace('##num##', String(page)).replace('##total##', String(pageCount)));
overlay.setProgress(pageCount > 0 ? (page - 1) / pageCount : 0);
let source = '';
try {
source = await waitUntil(function () {
const value = readAttribute(findShownImage(), imgAttribute);
return value !== '' ? value : null;
}, 'bruteforceClick page ' + page + ' image', CONFIG.scrollCollectTimeoutMs);
} catch (error) {
log('bruteforceClick: timeout on page ' + page + ' (kept as an empty page so the order stays right): ' + error.message);
}
collected.push(source);
// Intentional: clear the shown image's attribute so the NEXT wait only succeeds once the
// site has put the next page's URL there (otherwise we would read the same page twice).
const shown = findShownImage();
if (shown) {
shown.removeAttribute(imgAttribute);
}
if (page < pageCount) {
const next = document.querySelector(nextSelector);
if (next) {
// .click() sends a real, bubbling click; frameworks (React, Vue) listen higher up.
next.click();
} else {
log('bruteforceClick: "next" button not found (' + nextSelector + ') after page ' + page);
}
await sleep(CONFIG.clickCollectDelayMs);
}
}
} finally {
overlay.remove();
}
const found = collected.filter(function (url) {
return url !== '';
}).length;
if (found === 0) {
throw new Error('bruteforceClick: no images collected from the page');
}
log('bruteforceClick: collected ' + found + ' of ' + pageCount + ' pages');
recordSiteData('bruteforceClick', 'image sources collected by clicking next', collected.slice());
return finalizeCollectedUrls(collected);
}
// ---- Placeholder images (used by the "Local Files" test site and by tests) ----
/**
* An SVG image as a data: URL, with its size written in the middle. No network needed.
* @param {number} width - Width in pixels.
* @param {number} height - Height in pixels.
* @param {string} [backgroundColor='#0F1C3F'] - Fill color.
* @param {string} [textColor='#ECEAD9'] - Text color.
* @returns {string}
*/
function placeholder(width, height, backgroundColor, textColor) {
const fill = backgroundColor || '#0F1C3F';
const ink = textColor || '#ECEAD9';
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" width="', width, '" height="', height, '" viewBox="0 0 ', width, ' ', height, '">',
'<rect width="100%" height="100%" fill="', fill, '"/>',
'<text x="50%" y="50%" fill="', ink, '" font-family="sans-serif" font-size="', Math.round(Math.min(width, height) / 10), '" text-anchor="middle" dominant-baseline="middle">',
width, ' x ', height,
'</text></svg>'
].join('');
return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
}
/**
* A placeholder with a random common page size and a random dark color.
* @returns {string}
*/
function randomPlaceholder() {
const widths = [985, 1970, 800];
const heights = [1400, 1200, 1600];
const colors = ['#2D1657', '#152C55', '#7A1420', '#806D15', '#0F5B30', '#1a3e3c', '#480f5b'];
/**
* Random item of a list.
* @param {Array} list - Items.
* @returns {*}
*/
const pick = function (list) {
return list[Math.floor(Math.random() * list.length)];
};
return placeholder(pick(widths), pick(heights), pick(colors));
}
// ==========================================================================================
// ==== 23. SITE MODULES ======================================================================
// One object per supported website. How to write one: docs/03-site-modules-guide.md.
// To add a site: 1) add its object here, 2) add it to SITES below, 3) add an @include line
// to the metadata block at the top, 4) add testUrls and run tests/logic-tests.html,
// 5) open a chapter, run MVMDiagnose() in the console.
// ==========================================================================================
// ---- Site modules, batch A (ported from NEW src/main/*.ts, NEW:12061-12838) ----
/**
* Turns a list of raw image URLs into absolute URLs and drops empty entries.
* Shared by the batch A site modules.
* @param {string[]} list - Raw URLs (may contain '', null or undefined).
* @returns {string[]}
*/
function sitesAImageList(list) {
return (list || []).map(function (url) {
return toAbsoluteUrl(url);
}).filter(function (url) {
return url !== '';
});
}
// Asura Scans: reads the Astro island "props" JSON of the ChapterReader component.
const siteAsura = {
name: 'Asura Scans',
url: /https?:\/\/(www\.)?(asurascans|asuracomics)\.(com|net)\/.+/,
homepage: 'https://asurascans.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'astro-island[component-url*="ChapterReader"]',
testUrls: {
match: ['https://asurascans.com/comics/some-series/chapter/12', 'https://www.asuracomics.net/comics/x-1234/chapter/1'],
noMatch: ['https://asurascans.com/', 'https://asurascansXcom/comics/a/chapter/1']
},
run: function () {
let props = {};
try {
props = JSON.parse(queryAttribute('astro-island[component-url*="ChapterReader"]', 'props') || '{}');
} catch (error) {
throw new Error('Asura Scans: parse ChapterReader props failed: ' + error.message);
}
/** Astro serializes every value as [type, value]; returns the value part. */
function astroValue(entry) {
return entry ? entry[1] : undefined;
}
const seriesSlug = astroValue(props.seriesSlug);
const rawUrls = (astroValue(props.pages) || []).map(function (page) {
const pageValue = astroValue(page);
return pageValue ? astroValue(pageValue.url) : undefined;
}).filter(Boolean);
const listImages = sitesAImageList(rawUrls);
return {
title: astroValue(props.seriesName) + ' - Chapter ' + astroValue(props.chapterName),
series: '/comics/' + seriesSlug,
pages: listImages.length,
prev: readAttribute(findClosestByContentEq('span', 'Prev', 'a'), 'href') || queryAttribute('link[rel="prev"]', 'href') || undefined,
next: readAttribute(findClosestByContentEq('span', 'Next', 'a'), 'href') || queryAttribute('link[rel="next"]', 'href') || undefined,
listImages: listImages
};
}
};
// Batoto: DOM query; v3 "/title" reader (forces ?load=2 = all pages) or v2 "/chapter" reader.
const siteBatoto = {
name: 'Batoto',
url: /https?:\/\/(?:www\.)?(?:fto|jto|hto|dto|mto|wto|bato|battwo|batotwo|comiko|batocomic|readtoto|zbato|xbato|mangatoto)\.(?:to|com|net|org)\/(chapter|title).*/,
homepage: 'https://rentry.co/batoto',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'div[name="image-item"] img, .page-img',
testUrls: {
match: ['https://bato.to/chapter/123456', 'https://www.mangatoto.com/title/12345-some-title/678910-ch_1'],
noMatch: ['https://bato.to/', 'https://example.com/chapter/1']
},
run: function () {
if (window.location.pathname.indexOf('/title') === 0) {
if (window.location.search !== '?load=2') {
window.location.search = '?load=2';
}
const titleImages = sitesAImageList(queryAll('div[name="image-item"] img').map(function (img) {
return readAttribute(img, 'src');
}));
return {
title: queryText('h6'),
series: queryAttribute('h3 a', 'href'),
pages: titleImages.length,
prev: readAttribute(findClosestByContentEnds('span', 'Prev Chapter', 'a'), 'href'),
next: readAttribute(findClosestByContentStarts('span', 'Next Chapter', 'a'), 'href'),
listImages: titleImages
};
}
const images = sitesAImageList(queryAll('.page-img').map(function (img) {
return readAttribute(img, 'src');
}));
return {
title: queryText('.nav-title a'),
series: queryAttribute('.nav-title a', 'href'),
pages: images.length,
prev: queryAttribute('.nav-prev a', 'href'),
next: queryAttribute('.nav-next a', 'href'),
listImages: images
};
}
};
// BilibiliComics: embedded Nuxt JSON (#__NUXT_DATA__), keeps entries that look like image paths.
const siteBilibilicomics = {
name: 'BilibiliComics',
url: /https?:\/\/(www\.)?(bilibilicomics)\.net\/episode\/.+/,
homepage: 'https://www.bilibilicomics.net/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#__NUXT_DATA__',
testUrls: {
match: ['https://www.bilibilicomics.net/episode/12345', 'https://bilibilicomics.net/episode/abc-1'],
noMatch: ['https://www.bilibilicomics.net/', 'https://bilibilicomicsXnet/episode/1']
},
run: async function () {
let nuxtData = [];
try {
const nuxtElement = query('#__NUXT_DATA__');
nuxtData = JSON.parse(nuxtElement ? nuxtElement.innerHTML : '');
} catch (error) {
throw new Error('BilibiliComics: parse #__NUXT_DATA__ failed: ' + error.message);
}
const images = (Array.isArray(nuxtData) ? nuxtData : []).filter(function (entry) {
return typeof entry === 'string' && /.(png|jpg|jpeg|gif|bmp|webp)$/i.exec(entry);
});
const buttons = queryAll('.pre-next-btns');
return {
title: queryText('.chapterTitle'),
series: queryAttribute('.book-name', 'href'),
pages: images.length,
prev: readAttribute(buttons[0], 'href'),
next: readAttribute(buttons[2], 'href'),
listImages: images.map(function (image) {
return 'https://static.comicfans.io/' + image;
})
};
}
};
// Comick: Next.js embedded JSON (#__NEXT_DATA__ pageProps), images on the meo.comick.pictures CDN.
const siteComick = {
name: 'Comick',
url: /https?:\/\/(www\.)?comick\.io\/.+/,
homepage: 'https://comick.io/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitFunc: function () {
return /\/([^/]+)-chapter.+$/.test(window.location.pathname);
},
waitEle: '#__NEXT_DATA__',
waitTime: 3000,
testUrls: {
match: ['https://comick.io/comic/some-series/AbCd-chapter-12-en', 'https://www.comick.io/comic/x'],
noMatch: ['https://comick.io/', 'https://comickXio/comic/x']
},
run: function () {
let nextData = null;
try {
const nextElement = document.getElementById('__NEXT_DATA__');
nextData = JSON.parse(nextElement ? nextElement.innerHTML : '');
} catch (error) {
throw new Error('Comick: parse #__NEXT_DATA__ failed: ' + error.message);
}
// FIX (vs NEW): NEW read data.chapter without a null check in the title fallback (docs/03 5.8, NEW:12155).
const data = (nextData && nextData.props && nextData.props.pageProps) || {};
const chapter = data.chapter || {};
const comic = chapter.md_comics || {};
const listImages = (chapter.md_images || []).map(function (image) {
return 'https://meo.comick.pictures/' + (image ? image.b2key : undefined);
});
const hasSeoTitle = data.seoTitle !== null && data.seoTitle !== undefined;
return {
title: hasSeoTitle ? data.seoTitle : comic.title + ' ' + chapter.chap,
series: '/comic/' + comic.slug,
pages: listImages.length,
prev: data.prev ? data.prev.href : undefined,
next: data.next ? data.next.href : undefined,
listImages: listImages
};
}
};
// ---- Comix.to helpers: React Query cache walk, JSON.parse interception, scroll collection ----
/**
* Comix.to: captures the chapter "pages" payload when the site parses it (installed at script
* load). Returns the latest payload or null. Also recorded for MVMSiteData().
*/
const getComixInterceptedPages = installJsonParseCapture({
hostIncludes: 'comix.to',
siteName: 'Comix.to',
label: 'chapter pages (JSON.parse result.pages)',
pick: function (parsed) {
return parsed && parsed.result && parsed.result.pages ? parsed.result.pages : null;
}
});
/**
* Walks the React fiber tree under #app-root and returns the first value accepted by 'pickFromClient'
* for any React Query client found in stateNode.props.client or memoizedProps.client/value.
* @param {function(Object): *} pickFromClient - Returns a value (found) or null (keep walking).
* @returns {*}
*/
function comixWalkQueryClients(pickFromClient) {
const root = document.querySelector('#app-root');
if (!root) {
return null;
}
const fiberKey = Object.keys(root).find(function (key) {
return key.indexOf('__reactContainer') === 0 || key.indexOf('__reactFiber') === 0;
});
if (!fiberKey) {
return null;
}
const stack = [root[fiberKey]];
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
if (current.stateNode && current.stateNode.props) {
const fromState = pickFromClient(current.stateNode.props.client);
if (fromState) {
return fromState;
}
}
if (current.memoizedProps) {
const fromMemo = pickFromClient(current.memoizedProps.client || current.memoizedProps.value);
if (fromMemo) {
return fromMemo;
}
}
if (current.child) {
stack.push(current.child);
}
if (current.sibling) {
stack.push(current.sibling);
}
}
return null;
}
/** Returns all cached queries of a React Query client, or [] when it is not a client. */
function comixClientQueries(client) {
if (client && typeof client.getQueryCache === 'function') {
return client.getQueryCache().getAll();
}
return [];
}
/** Chapter pages ({ items: [...] }) from the React Query cache, or null. */
function comixFindQueryData() {
return comixWalkQueryClients(function (client) {
const queries = comixClientQueries(client);
for (const queryEntry of queries) {
const data = queryEntry.state.data;
if (data && data.result && data.result.pages) {
return data.result.pages;
}
}
return null;
});
}
/** Chapter list (array of chapters) from the React Query cache, or null. */
function comixFindChapterListFromCache() {
return comixWalkQueryClients(function (client) {
const queries = comixClientQueries(client);
for (const queryEntry of queries) {
const data = queryEntry.state.data;
if (data) {
if (Array.isArray(data) && data.length > 0 && (data[0].chapterNumber !== undefined || data[0].number !== undefined)) {
return data;
}
if (data.result && Array.isArray(data.result.items) && data.result.items.length > 0) {
const first = data.result.items[0];
if (first.chapterNumber !== undefined || first.number !== undefined || first.mangaId !== undefined) {
return data.result.items;
}
}
}
}
return null;
});
}
/** Page count from a page <select>, or 0. */
function comixGetPageCountFromSelect() {
const select = query('select[aria-label*="page" i], select[class*="page" i], select[id*="page" i]');
if (select) {
return select.querySelectorAll('option').length;
}
return 0;
}
/** Page count from a "1 / N" or "1 of N" text in the DOM, or 0. */
function comixFindPageCountFromDom() {
const elements = queryAll('span, div, button, option');
for (const element of elements) {
const text = element.textContent || '';
const match = /^\s*1\s*(?:\/|of)\s*(\d+)\s*$/i.exec(text.trim());
if (match) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 500) {
return count;
}
}
}
return 0;
}
/** True when an href is a Comix chapter link. */
function comixIsChapterHref(href) {
return Boolean(href) && href.indexOf('/title/') !== -1 && /\/\d+-chapter-/.test(href);
}
/** prev/next from chapter links labelled next/prev (text, aria-label or icon class). */
function comixGetPrevNextLinks() {
let next = null;
let prev = null;
for (const anchor of queryAll('a')) {
const href = anchor.getAttribute('href');
if (!comixIsChapterHref(href)) {
continue;
}
const text = (anchor.textContent || '').toLowerCase().trim();
const ariaLabel = (anchor.getAttribute('aria-label') || '').toLowerCase();
if (text.indexOf('next') !== -1 || ariaLabel.indexOf('next') !== -1 || anchor.querySelector('[class*="next"]') || anchor.querySelector('[class*="right"]')) {
next = href;
}
if (text.indexOf('prev') !== -1 || text.indexOf('previous') !== -1 || ariaLabel.indexOf('prev') !== -1 || ariaLabel.indexOf('previous') !== -1 || anchor.querySelector('[class*="prev"]') || anchor.querySelector('[class*="left"]')) {
prev = href;
}
}
return { prev: prev, next: next };
}
/** prev/next by sorting every chapter link on the page by chapter number. */
function comixGetNavigationFromLinks() {
const chapterLinks = [];
for (const anchor of queryAll('a')) {
const href = anchor.getAttribute('href');
if (!comixIsChapterHref(href)) {
continue;
}
const match = /-chapter-(\d+(\.\d+)?)/.exec(href);
if (match) {
chapterLinks.push({ href: href, num: parseFloat(match[1]) });
}
}
if (chapterLinks.length === 0) {
return { prev: null, next: null };
}
const seen = new Set();
const uniqueChapters = chapterLinks.filter(function (item) {
const path = item.href.split('#')[0].split('?')[0];
if (seen.has(path)) {
return false;
}
seen.add(path);
return true;
});
uniqueChapters.sort(function (a, b) {
return a.num - b.num;
});
const currentCleanPath = window.location.pathname.split('#')[0].split('?')[0];
const index = uniqueChapters.findIndex(function (item) {
return item.href.indexOf(currentCleanPath) !== -1 || currentCleanPath.indexOf(item.href) !== -1;
});
let prev = null;
let next = null;
if (index !== -1) {
if (index > 0) {
prev = uniqueChapters[index - 1].href;
}
if (index < uniqueChapters.length - 1) {
next = uniqueChapters[index + 1].href;
}
}
return { prev: prev, next: next };
}
/** prev/next: labelled links first, then the sorted chapter links. */
function comixGetPrevNext() {
const labelled = comixGetPrevNextLinks();
if (labelled.prev || labelled.next) {
return labelled;
}
return comixGetNavigationFromLinks();
}
/** True for a reader page image (inside the app, not an icon/avatar, not narrower than 250 px). */
function comixIsReaderImage(img) {
if (!img.closest('#app-root') && !img.closest('.rpage-body')) {
return false;
}
const src = img.src || img.getAttribute('src') || '';
if (!src) {
return false;
}
if (src.indexOf('avatar') !== -1 || src.indexOf('logo') !== -1 || src.indexOf('icon') !== -1 || src.indexOf('placeholder') !== -1) {
return false;
}
if (src.indexOf('data:image/svg+xml') === 0) {
return false;
}
const width = img.naturalWidth || img.width || 0;
return !(width > 0 && width < 250);
}
/** True when an image source is final (data/blob URL or a Comix/wowpic host, not a placeholder). */
function comixIsValidSource(src) {
if (!src) {
return false;
}
if (src.indexOf('placeholder') !== -1 || src.indexOf('data:image/svg+xml') === 0 || src.indexOf('loading') !== -1) {
return false;
}
return src.indexOf('data:') === 0 || src.indexOf('blob:') === 0 || src.indexOf('comix.to') !== -1 || src.indexOf('static.comix.to') !== -1 || src.indexOf('wowpic') !== -1;
}
/** First of two values that is not null/undefined (replacement for nullish coalescing). */
function comixFirstDefined(first, second) {
return first !== null && first !== undefined ? first : second;
}
/**
* Chapter title and series link: from the #initial-data JSON, else the page title and the
* first series link.
* @returns {{title: string, series: string}}
*/
function comixFindTitleAndSeries() {
const initialDataElement = document.getElementById('initial-data');
let initialData = {};
try {
initialData = initialDataElement ? JSON.parse(initialDataElement.textContent || '{}') : {};
} catch (error) {
throw new Error('Comix.to: parse #initial-data failed: ' + error.message);
}
let title = '';
let series = '';
if (initialData && initialData.queries) {
const keys = Object.keys(initialData.queries);
for (let i = 0; i < keys.length; i++) {
const queryData = initialData.queries[keys[i]];
if (queryData && queryData.title && queryData.url) {
title = queryData.title;
series = queryData.url;
break;
}
}
}
return {
title: title || getPageTitle(),
series: series || queryAttribute('a[href^="/title/"]:not([href*="-chapter-"])', 'href')
};
}
/**
* Previous/next chapter links from the cached chapter list (sorted by chapter number).
* @param {Object[]|null} chapterList - Chapters from the React Query cache, or null.
* @returns {{prev: (string|null), next: (string|null)}}
*/
function comixFindNeighborChapters(chapterList) {
const result = { prev: null, next: null };
const slugMatch = /\/title\/([^/]+)/.exec(window.location.pathname);
const mangaSlug = slugMatch ? slugMatch[1] : '';
const chapterMatch = /\/(\d+)-chapter-/.exec(window.location.pathname);
const currentChapterId = chapterMatch ? parseInt(chapterMatch[1], 10) : 0;
if (!chapterList || !currentChapterId || !mangaSlug) {
return result;
}
/**
* Chapter number of a list item (number or chapterNumber field).
* @param {Object} chapterItem - Chapter from the list.
* @returns {number}
*/
const chapterNumber = function (chapterItem) {
return parseFloat(comixFirstDefined(comixFirstDefined(chapterItem.number, chapterItem.chapterNumber), '0'));
};
const sortedChapters = chapterList.slice().sort(function (a, b) {
return chapterNumber(a) - chapterNumber(b);
});
const index = sortedChapters.findIndex(function (chapterItem) {
return parseInt(chapterItem.id, 10) === currentChapterId;
});
if (index > 0) {
const prevChapter = sortedChapters[index - 1];
result.prev = '/title/' + mangaSlug + '/' + prevChapter.id + '-chapter-' + comixFirstDefined(prevChapter.number, prevChapter.chapterNumber);
}
if (index !== -1 && index < sortedChapters.length - 1) {
const nextChapter = sortedChapters[index + 1];
result.next = '/title/' + mangaSlug + '/' + nextChapter.id + '-chapter-' + comixFirstDefined(nextChapter.number, nextChapter.chapterNumber);
}
return result;
}
// Comix.to: React Query cache / intercepted JSON for counts and navigation, images by scrolling (bruteforceScroll).
const siteComix = {
name: 'Comix.to',
homepage: 'https://comix.to/',
url: /https?:\/\/comix\.to\/(title|comic)\/.+\/.+/,
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://comix.to/title/some-manga/12345-chapter-1', 'https://comix.to/comic/abc/1'],
noMatch: ['https://comix.to/', 'https://comix.to/title/some-manga']
},
run: async function () {
let elapsed = 0;
let pagesData = null;
let chapterList = null;
while (elapsed < 5000) {
if (!pagesData) {
pagesData = comixFindQueryData();
}
if (!chapterList) {
chapterList = comixFindChapterListFromCache();
}
if (pagesData && chapterList) {
break;
}
await sleep(100);
elapsed += 100;
}
if (pagesData) {
recordSiteData('Comix.to', 'chapter pages (React Query cache)', pagesData);
}
if (!pagesData && getComixInterceptedPages()) {
pagesData = getComixInterceptedPages();
}
let expectedPagesCount = (pagesData && pagesData.items && pagesData.items.length) || 0;
if (expectedPagesCount === 0) {
expectedPagesCount = comixGetPageCountFromSelect();
}
if (expectedPagesCount === 0) {
expectedPagesCount = comixFindPageCountFromDom();
}
if (expectedPagesCount === 0) {
expectedPagesCount = queryAll('#app-root img, .rpage-body img').filter(comixIsReaderImage).length || 40;
}
log('Comix.to: identified expected page count: ' + expectedPagesCount);
const listImages = await bruteforceScroll(expectedPagesCount, comixIsReaderImage, comixIsValidSource);
const names = comixFindTitleAndSeries();
let navigation = comixFindNeighborChapters(chapterList);
if (!navigation.prev && !navigation.next) {
navigation = comixGetPrevNext();
}
return {
title: names.title,
series: names.series,
pages: listImages.length,
prev: navigation.prev,
next: navigation.next,
listImages: listImages
};
}
};
// DemonicScans: DOM query; waits for images already pointing at the cdn.demoniclibs.com host.
const siteDemonicscans = {
name: 'DemonicScans',
url: /https?:\/\/(www\.)?demonicscans\.org\/(title\/.+\/chapter\/.+|chaptered\.php\?manga=\d+&chapter=.+)/,
homepage: 'https://demonicscans.org/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'img.imgholder[src*="cdn.demoniclibs.com"]',
testUrls: {
match: ['https://demonicscans.org/title/Some-Manga/chapter/12/1', 'https://demonicscans.org/chaptered.php?manga=123&chapter=4'],
noMatch: ['https://demonicscans.org/', 'https://demonicscans.org/manga/Some-Manga']
},
run: async function () {
const images = sitesAImageList(queryAll('img.imgholder[src*="cdn.demoniclibs.com"]').map(function (img) {
return readFirstAttribute(img, ['data-src', 'src']);
}));
return {
title: getPageTitle(),
series: queryAttribute('h1 a', 'href'),
pages: images.length,
prev: queryAttribute('.prevchap', 'href'),
next: queryAttribute('.nextchap', 'href'),
listImages: images
};
}
};
// Dynasty-Scans: page JavaScript variable 'pages' (array of { image }).
const siteDynastyscans = {
name: 'Dynasty-Scans',
url: /https?:\/\/(www\.)?dynasty-scans\.com\/chapters\/.+/,
homepage: 'https://dynasty-scans.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
// FIX (vs NEW): NEW read the 'pages' global with no wait, a TypeError if it appears late (docs/03 5.8, NEW:12560).
waitVar: 'pages',
testUrls: {
match: ['https://dynasty-scans.com/chapters/some_series_ch01', 'https://www.dynasty-scans.com/chapters/x'],
noMatch: ['https://dynasty-scans.com/', 'https://dynasty-scansXcom/chapters/x']
},
run: function () {
const pageList = pageWindow.pages;
if (!Array.isArray(pageList)) {
throw new Error('Dynasty-Scans: page variable "pages" is missing or not an array');
}
const images = sitesAImageList(pageList.map(function (item) {
return item ? item.image : '';
}));
return {
title: queryText('#chapter-title'),
series: queryAttribute('#chapter-title a', 'href'),
pages: images.length,
prev: queryAttribute('#prev_link', 'href'),
next: queryAttribute('#next_link', 'href'),
listImages: images
};
}
};
// Flame Comics: Next.js embedded JSON (#__NEXT_DATA__), image URLs built on the Flame CDN.
const siteFlamecomics = {
name: 'Flame Comics',
url: /https?:\/\/(www\.)?(flamecomics)\.(xyz)\/series\/.+/,
homepage: 'https://flamecomics.xyz/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://flamecomics.xyz/series/123/abcdef0123', 'https://www.flamecomics.xyz/series/45'],
noMatch: ['https://flamecomics.xyz/', 'https://flamecomicsXxyz/series/1']
},
run: function () {
const cdn = 'https://cdn.flamecomics.xyz/uploads/images/series';
let json = null;
try {
const nextElement = document.getElementById('__NEXT_DATA__');
json = JSON.parse(nextElement ? nextElement.innerHTML : '');
} catch (error) {
throw new Error('Flame Comics: parse #__NEXT_DATA__ failed: ' + error.message);
}
const pageProps = (json && json.props && json.props.pageProps) || {};
const chapter = pageProps.chapter || {};
const chapterImages = chapter.images;
const images = safeKeys(chapterImages).map(function (key) {
const image = chapterImages[key];
return cdn + '/' + chapter.series_id + '/' + chapter.token + '/' + (image ? image.name : undefined) + '?' + chapter.unix_timestamp;
});
return {
title: chapter.title + ' ' + chapter.chapter,
series: '../' + chapter.series_id,
pages: images.length,
prev: pageProps.previous,
next: pageProps.next,
listImages: images
};
}
};
// FoOlSlide: DOM query; listImages when the page shows every image, otherwise one HTML page per image.
const siteFoolslide = {
name: ['FoOlSlide', 'Kireicake'],
url: /^(?!.*jaiminisbox).*\/read\/.+/,
homepage: ['https://github.com/saintly2k/FoOlSlideX', 'https://reader.kireicake.com'],
language: [Language.ENGLISH],
obs: 'Any Site that uses FoOLSlide',
category: Category.MANGA,
waitEle: 'img.open',
testUrls: {
match: ['https://reader.kireicake.com/read/some_series/en/0/12/page/1', 'https://example.org/foolslide/read/abc/en/1/2/'],
noMatch: ['https://reader.kireicake.com/', 'https://jaiminisbox.com/reader/read/abc/en/0/1/']
},
run: function () {
const chapter = queryAll('.topbar_left .dropdown_parent:last-of-type li');
const origin = chapter.findIndex(function (item) {
const url = queryAttribute('a', 'href', item);
if (url) {
return window.location.href.indexOf(url) === 0;
}
return false;
});
// FIX (vs NEW): NEW used Array.at with origin +/- 1, which wraps for negative indexes (docs/03 5.6 and 5.8, NEW:12611-12615).
// Here a missing neighbor (newest chapter, oldest chapter, or chapter not found) gives no link instead of a wrong one.
/** Chapter list item at 'index', or null when out of range or the current chapter is unknown. */
function chapterAt(index) {
if (origin < 0 || index < 0 || index >= chapter.length) {
return null;
}
return chapter[index];
}
const pages = queryAll('.topbar_right .dropdown li');
const images = queryAll('.inner img:not(.open)');
const num = images.length > 1 ? images.length : pages.length;
const currentItem = chapterAt(origin);
const currentLink = currentItem ? query('a', currentItem) : null;
const prevItem = chapterAt(origin + 1);
const nextItem = chapterAt(origin - 1);
const manga = {
title: currentLink ? readText(currentLink) : getPageTitle(),
series: queryAttribute('div.tbtitle div.text a', 'href'),
pages: num,
prev: prevItem ? queryAttribute('a', 'href', prevItem) : undefined,
next: nextItem ? queryAttribute('a', 'href', nextItem) : undefined
};
if (images.length > 1) {
const listImages = sitesAImageList(images.map(function (img) {
return readAttribute(img, 'src');
}));
return Object.assign({}, manga, { pages: listImages.length, listImages: listImages });
}
const baseUrl = window.location.href.replace(/\/\d+$/, '');
return Object.assign({}, manga, {
listPages: Array(num).fill(0).map(function (unused, index) {
return baseUrl + '/' + (index + 1);
}),
img: 'img.open'
});
}
};
// Ikigai Mangas: parses the Qwik state script (script[type="qwik/json"]) for .webp URLs.
const siteIkigai = {
name: ['Ikigai Mangas - EltaNews', 'Ikigai Mangas - Ajaco'],
url: /https?:\/\/(visorikigai|visualikigai)\.(ajaco|eltanews|foodib|jobswu)\.(com|net|site)\/capitulo\/\d+/,
homepage: ['https://visorikigai.eltanews.com/', 'https://visorikigai.ajaco.net/'],
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://visorikigai.eltanews.com/capitulo/123456789', 'https://visualikigai.ajaco.net/capitulo/42'],
noMatch: ['https://visorikigai.eltanews.com/', 'https://visorikigaiXeltanews.com/capitulo/1']
},
run: function () {
const scriptText = queryText('script[type="qwik/json"]');
const images = scriptText.match(/http[^'"]+webp/gi) || [];
return {
title: getPageTitle().replace(' — Manga en lÃnea | MangaOni', ''),
pages: images.length,
// UNCLEAR (kept from NEW and OLD): prev comes from "Siguiente" and next from "Anterior" (docs/03 5.5).
prev: readAttribute(findClosestByContentEq('span', 'Siguiente'), 'href'),
next: readAttribute(findClosestByContentEq('span', 'Anterior'), 'href'),
listImages: images
};
}
};
/**
* Kagane: scrolls '.reader-content' 2000 px at a time (up to 200 times, 300 ms apart) and collects
* the 'src' of every '.reader-page img' until 'expectedCount' distinct sources are found.
* @param {number} expectedCount - Pages expected (from the API).
* @returns {Promise<string[]>}
*/
async function kaganeCollectAllSources(expectedCount) {
const container = query('.reader-content');
const sources = new Set();
const scrollIncrement = 2000;
const maxScrollAttempts = 200;
if (!container) {
logError('Kagane: collectAllSources', new Error('.reader-content container not found'), { url: window.location.href });
return [];
}
let attempts = 0;
while (sources.size < expectedCount && attempts < maxScrollAttempts) {
container.scrollTop += scrollIncrement;
attempts++;
await sleep(300);
for (const img of queryAll('.reader-page img')) {
const src = img.getAttribute('src');
if (src && src.length > 0) {
sources.add(src);
}
}
logDebug('Kagane: scroll attempt ' + attempts + ': collected ' + sources.size + ' of ' + expectedCount + ' sources');
if (container.scrollHeight - container.clientHeight <= container.scrollTop && sources.size < expectedCount) {
log('Kagane: reached end of scrollable content, stopping with ' + sources.size + ' of ' + expectedCount + ' sources');
break;
}
}
return Array.from(sources);
}
// Kagane: JSON API for title/navigation, images collected by scrolling the site's reader container.
const siteKagane = {
name: 'Kagane',
homepage: 'https://kagane.org/',
url: /https:\/\/(www\.)?kagane\.org\/series\/.+\/reader\/.+/,
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '.reader-page img',
testUrls: {
match: ['https://kagane.org/series/abc123/reader/def456', 'https://www.kagane.org/series/x/reader/y'],
noMatch: ['https://kagane.org/', 'http://kagane.org/series/x/reader/y']
},
run: async function () {
const url = window.location.href;
const seriesMatch = url.match(/series\/([^/]+)/);
const chapterMatch = url.match(/reader\/([^/]+)/);
const seriesId = seriesMatch ? seriesMatch[1] : undefined;
const chapterId = chapterMatch ? chapterMatch[1] : undefined;
const rschDid = localStorage.getItem('rsch_did');
const headers = {};
if (rschDid) {
headers['X-Rsch-Did'] = rschDid;
}
const seriesData = await fetchJson('https://api.kagane.org/api/v1/series/' + seriesId, { headers: headers });
const booksData = await fetchJson('https://api.kagane.org/api/v1/books/' + seriesId, { headers: headers });
if (!booksData || !booksData.data || !Array.isArray(booksData.data.content)) {
throw new Error('Kagane: books API returned no data.content list for series ' + seriesId);
}
const content = booksData.data.content;
const chapterIndex = content.findIndex(function (item) {
return item.id === chapterId;
});
const currentChapter = chapterIndex !== -1 ? content[chapterIndex] : undefined;
const prevChapter = content[chapterIndex + 1];
const nextChapter = chapterIndex > 0 ? content[chapterIndex - 1] : undefined;
const expectedCount = (currentChapter && currentChapter.media && currentChapter.media.pagesCount) || 0;
const seriesName = seriesData && seriesData.data ? seriesData.data.name : undefined;
const chapterTitle = currentChapter && currentChapter.metadata ? currentChapter.metadata.title : undefined;
// FIX (vs NEW): NEW returned pages from the API while the list came from scrolling and could stop early (docs/03 5.8 and 5.9, NEW:12704, 12707).
const listImages = sitesAImageList(await kaganeCollectAllSources(expectedCount));
return {
title: seriesName + ' - ' + chapterTitle,
series: '/series/' + seriesId,
pages: listImages.length,
// UNCLEAR (kept from NEW): links use /books/ while the url regex expects /reader/ (docs/03 5.8).
prev: prevChapter ? '/series/' + seriesId + '/books/' + prevChapter.id : undefined,
next: nextChapter ? '/series/' + seriesId + '/books/' + nextChapter.id : undefined,
listImages: listImages
};
}
};
// KuManga: page JavaScript variable 'pUrl' (array of { imgURL }), chapter select for navigation.
const siteKumanga = {
name: 'KuManga',
url: /https?:\/\/(www\.)?kumanga\.com\/manga\/leer\/.+/,
homepage: 'https://www.kumanga.com/',
language: [Language.SPANISH],
category: Category.MANGA,
// FIX (vs NEW): NEW read the 'pUrl' global with no wait, a TypeError if it appears late (docs/03 5.8, NEW:12724).
waitVar: 'pUrl',
testUrls: {
match: ['https://www.kumanga.com/manga/leer/123456', 'https://kumanga.com/manga/leer/abc'],
noMatch: ['https://www.kumanga.com/', 'https://www.kumangaXcom/manga/leer/1']
},
run: function () {
const pageList = pageWindow.pUrl;
if (!Array.isArray(pageList)) {
throw new Error('KuManga: page variable "pUrl" is missing or not an array');
}
const chapterSelect = queryAll('select')[1];
const chapter = chapterSelect ? query('option[selected]', chapterSelect) : null;
// FIX (vs NEW): NEW built '/manga/leer/undefined' when a neighbor option is missing (docs/03 5.6 and 5.8, NEW:12725-12726).
const prevValue = chapter ? readAttribute(chapter.previousElementSibling, 'value') : '';
const nextValue = chapter ? readAttribute(chapter.nextElementSibling, 'value') : '';
const images = sitesAImageList(pageList.map(function (item) {
return item ? item.imgURL : '';
}));
return {
title: getPageTitle(),
series: queryAttribute('h2 a', 'href'),
pages: images.length,
prev: prevValue ? '/manga/leer/' + prevValue : '',
next: nextValue ? '/manga/leer/' + nextValue : '',
listImages: images
};
}
};
// LeerCapitulo: DOM query; image URLs are the values of the page <select> options.
const siteLeercapitulo = {
name: 'LeerCapitulo',
url: /https?:\/\/(www\.)?leercapitulo\.co\/leer\/.+/,
homepage: 'https://www.leercapitulo.co/',
language: [Language.SPANISH],
category: Category.MANGA,
waitEle: '#page_select',
testUrls: {
match: ['https://www.leercapitulo.co/leer/abc123/some-manga/12/', 'https://leercapitulo.co/leer/x'],
noMatch: ['https://www.leercapitulo.co/', 'https://www.leercapituloXco/leer/x']
},
run: function () {
const images = sitesAImageList(queryAll('#page_select option').map(function (option) {
return readAttribute(option, 'value');
}));
return {
title: queryText('h1'),
series: queryAttribute('.chapter-title a', 'href'),
pages: images.length,
prev: queryAttribute('.pre', 'href'),
next: queryAttribute('.next', 'href'),
listImages: images
};
}
};
// LectorXD: DOM query of page images, falling back to image URLs inside the Astro island props.
const siteLectorxd = {
name: 'LectorXD',
url: /https?:\/\/(www\.)?lectorxd\.com\/.+\/leer\/.+/,
homepage: 'https://lectorxd.com/',
language: [Language.SPANISH],
category: Category.MANGA,
waitEle: '.page-container img, img.page-image, .page-container, astro-island',
testUrls: {
match: ['https://lectorxd.com/manga/some-title/leer/12', 'https://www.lectorxd.com/x/leer/y'],
noMatch: ['https://lectorxd.com/', 'https://lectorxd.com/manga/some-title']
},
run: function () {
let images = sitesAImageList(queryAll('.page-container img, img.page-image, .page-image img, div[class*="page-container"] img').map(function (img) {
return readFirstAttribute(img, ['data-src', 'src']);
}));
if (images.length === 0) {
const props = queryAttribute('astro-island', 'props');
if (props) {
const matches = props.match(/https?:\/\/[^"'\s\\]+\.(jpg|jpeg|png|webp|avif)[^"'\s\\]*/gi);
if (matches) {
images = matches;
}
}
}
const documentTitle = (document.title || '').replace(/\s*[-|]\s*Lector\s*XD.*$/i, '').trim();
return {
title: queryText('h1') || documentTitle || undefined,
series: queryAttribute('a[href*="/manga/"], a[href*="/manhwa/"], a[href*="/manhua/"], a[href*="/comic/"], a[href*="/series/"]', 'href') || window.location.pathname.replace(/\/leer\/.*/, ''),
pages: images.length,
prev: queryAttribute('a[title*="Anterior" i], a[aria-label*="Anterior" i], a[name*="anterior" i]', 'href') || readAttribute(findClosestByContentStarts('a, span, button', 'Anterior'), 'href') || queryAttribute('link[rel="prev"]', 'href') || undefined,
next: queryAttribute('a[title*="Siguiente" i], a[aria-label*="Siguiente" i], a[name*="siguiente" i]', 'href') || readAttribute(findClosestByContentStarts('a, span, button', 'Siguiente'), 'href') || queryAttribute('link[rel="next"]', 'href') || undefined,
listImages: images
};
}
};
// LHTranslation: DOM query; chapter <select> is newest first, so prev is the NEXT option.
const siteLhtranslation = {
name: 'LHTranslation',
url: /https?:\/\/(www\.)?lhtranslation\.net\/read.+/,
homepage: 'https://lhtranslation.net/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://lhtranslation.net/read-some-manga-chapter-12.html', 'https://www.lhtranslation.net/read/x'],
noMatch: ['https://lhtranslation.net/', 'https://lhtranslationXnet/read-x']
},
run: function () {
const chapter = query('.form-control option:checked');
const images = sitesAImageList(queryAll('img.chapter-img').map(function (img) {
return readAttribute(img, 'src');
}));
return {
title: queryText('.chapter-img.tieude font'),
series: queryAttribute('.navbar-brand.manga-name', 'href'),
pages: images.length,
prev: chapter ? readAttribute(chapter.nextElementSibling, 'value') : '',
next: chapter ? readAttribute(chapter.previousElementSibling, 'value') : '',
listImages: images
};
}
};
// Local Files: test page; generated SVG placeholders, declines unless the page is the script's own test page.
const siteLocalhost = {
name: 'Local Files',
url: /(file:\/\/\/.+(index)?\.html)/,
homepage: '/index.html?raw=1',
language: [Language.RAW],
category: Category.MANGA,
testUrls: {
match: ['file:///C:/test/index.html', 'file:///home/user/viewer/test.html?pages=10'],
noMatch: ['https://example.com/index.html', 'file:///C:/test/indexXhtml']
},
run: function (count) {
if (typeof placeholder !== 'function' || typeof randomPlaceholder !== 'function') {
throw new Error('Local Files: placeholder() / randomPlaceholder() are not provided by the core');
}
const searchNumber = /\d+/.exec(window.location.search);
const num = count !== null && count !== undefined ? count : parseInt(searchNumber ? searchNumber.toString() : '5', 10);
return {
title: 'Placeholder Manga Loaded',
series: '?reload',
// Deliberately not the list length (kept from NEW): declines (0) unless this is the script's own test page.
pages: document.title === 'MangaViewerMM Script' ? num : 0,
begin: 1,
prev: '?pages=50',
next: '?pages=1',
listImages: [
placeholder(1970, 1400, '#2D1657'),
placeholder(985, 1400, '#152C55'),
placeholder(985, 1400, '#7A1420'),
placeholder(1970, 1400, '#806D15'),
placeholder(985, 1400, '#0F5B30'),
placeholder(1970, 1400, '#1a3e3c'),
placeholder(985, 1400, '#480f5b'),
placeholder(985, 1400, '#a9bf7a'),
placeholder(985, 1400, '#147a56'),
placeholder(1970, 1400, '#190343'),
placeholder(985, 1400, '#d5b91e'),
placeholder(985, 1400, '#836ecd'),
placeholder(985, 1400, '#bf19b2'),
placeholder(985, 1400, '#152055'),
...Array(num).fill(0).map(function () {
return randomPlaceholder();
})
]
};
}
};
// ---- Site modules, batch B (ported from NEW:12839-13333) ----
/**
* Batch B local helper: maps a list of raw image URLs to absolute URLs and drops empty ones.
* @param {string[]} list - Raw URLs (may contain '' or relative paths).
* @returns {string[]}
*/
function toAbsoluteImageListB(list) {
return list.map(function (url) {
return toAbsoluteUrl(url);
}).filter(function (url) {
return url !== '';
});
}
// M440: DOM query of lazy images (data-src); chapter list is newest first.
const siteM440 = {
name: 'M440',
url: /https?:\/\/(www\.)?m440\.in\/manga\/.+\/.+\/\d+/,
homepage: 'https://m440.in/',
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://m440.in/manga/some-title/chapter-slug/12', 'https://www.m440.in/manga/a/b/3'],
noMatch: ['https://m440.in/', 'https://m440xin/manga/a/b/3']
},
run: function () {
const images = queryAll('#all img');
const chapter = query('#chapter-list li.active');
const prevItem = chapter ? chapter.nextElementSibling : null;
const nextItem = chapter ? chapter.previousElementSibling : null;
const listImages = toAbsoluteImageListB(images.map(function (img) {
return readAttribute(img, 'data-src');
}));
return {
title: queryText('title'),
series: queryAttribute('#navbar-collapse-1 ul:nth-child(2) a', 'href'),
pages: listImages.length,
prev: prevItem ? readAttribute(prevItem.firstElementChild, 'href') : '',
next: nextItem ? readAttribute(nextItem.firstElementChild, 'href') : '',
listImages: listImages
};
}
};
/** Madara: an image URL, possibly preceded by tabs/newlines and possibly without protocol. */
const madaraImageRegex = /^([\t\n])*(https?:\/\/)?.+\.(jpg|jpeg|png|gif|bmp|webp).*$/;
/**
* Madara: reads every reader image, choosing the first attribute whose NAME contains src/url,
* whose value is not a placeholder, and that looks like an image URL. Same logic as NEW:12862-12868.
* @returns {string[]} One entry per image element ('' when nothing usable was found).
*/
function findMadaraImages() {
const images = queryAll('.wp-manga-chapter-img, .blocks-gallery-item img, .reading-content img, #chapter-images img, #chapterContent img');
return images.map(function (img) {
const attributes = Array.prototype.slice.call(img.attributes).filter(function (attribute) {
return /.*(src|url).*/i.test(attribute.name) && !/^.*(blank|lazy|loading).*$/.test(attribute.value);
});
if (attributes.length === 0) {
return '';
}
const found = attributes.find(function (attribute) {
return madaraImageRegex.test(attribute.value);
});
if (found) {
return found.value;
}
return readAttribute(img, 'src');
});
}
// Madara WordPress plugin (generic): DOM query over several reader layouts, waits for real image URLs.
const siteMadarawp = {
name: [
'Madara WordPress Plugin',
'MangaHaus',
'Isekai Scan',
'Comic Kiba',
'Zinmanga',
'mangatx',
'Toonily',
'Mngazuki',
'JaiminisBox',
'DisasterScans',
'ManhuaPlus',
'TopManhua',
'NovelMic',
'Reset-Scans',
'LeviatanScans',
'Dragon Tea',
'SetsuScans',
'ToonGod',
'Hades Scans'
],
url: /https?:\/\/.+\/(manga|series|manhua|comic|ch|novel|webtoon|tmo)\/.+\/.+/,
homepage: [
'https://mangabooth.com/',
'https://manhuaus.com',
'https://isekaiscan.com/',
'https://comickiba.com/',
'https://zinmanga.com/',
'https://mangatx.com/',
'https://toonily.net/',
'https://mangazuki.me/',
'https://jaiminisbox.net',
'https://disasterscans.com/',
'https://manhuaplus.org/',
'https://www.topmanhua.com/',
'https://novelmic.com/',
'https://reset-scans.com/',
'https://leviatanscans.com/',
'https://dragontea.ink/',
'https://setsuscans.com/',
'https://toongod.org/home/',
'https://lectorhades.latamtoon.com'
],
language: [Language.ENGLISH],
obs: 'Any Site that uses Madara WordPress Plugin',
category: Category.MANGA,
testUrls: {
match: ['https://toonily.net/manga/some-title/chapter-1/', 'https://example.com/series/title/chapter-2'],
noMatch: ['https://toonily.net/', 'https://example.com/manga/title']
},
waitFunc: function () {
const images = findMadaraImages();
return images.length > 0 && images.every(function (source) {
return source !== '' && madaraImageRegex.test(source);
});
},
run: function () {
const listImages = toAbsoluteImageListB(findMadaraImages());
const seriesLink = query('.breadcrumb li:nth-child(3) a') || query('.breadcrumb li:nth-child(2) a');
return {
title: queryText('#chapter-heading'),
series: readAttribute(seriesLink, 'href'),
pages: listImages.length,
prev: queryAttribute('.prev_page', 'href'),
next: queryAttribute('.next_page', 'href'),
listImages: listImages
};
}
};
/**
* MangaBall: first capture group of a regex over a text, or undefined (same as NEW's optional [1]).
* @param {string} text - Text to search.
* @param {RegExp} regex - Regex with one capture group.
* @returns {string|undefined}
*/
function matchFirstGroupB(text, regex) {
const match = text.match(regex);
return match ? match[1] : undefined;
}
// MangaBall: inline script text parsed by regex, plus a POST API (CSRF token) for the chapter list.
const siteMangaball = {
name: 'MangaBall',
homepage: 'https://mangaball.net/',
url: /https?:\/\/mangaball\.net\/chapter-detail\/.+/,
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://mangaball.net/chapter-detail/abc123', 'http://mangaball.net/chapter-detail/x/y'],
noMatch: ['https://mangaball.net/', 'https://mangaball.net/title-detail/abc123']
},
run: async function () {
const scriptElement = queryAll('script').find(function (element) {
return element.textContent !== null && element.textContent.indexOf('chapterImages') !== -1;
});
const script = scriptElement ? scriptElement.textContent : '';
if (!script) {
// Declines on purpose (pages: 0) when the page has no chapter data, as NEW does.
return {
title: queryText('h1'),
series: queryAttribute('a[href*="/title-detail/"]', 'href') || queryAttribute('a[href*="/manga-detail/"]', 'href'),
pages: 0,
listImages: []
};
}
const titleId = matchFirstGroupB(script, /titleId\s*=\s*[\x60'"](.+?)[\x60'"]/);
const chapterNumber = matchFirstGroupB(script, /chapterNumber\s*=\s*[\x60'"](.+?)[\x60'"]/);
const chapterVolume = matchFirstGroupB(script, /chapterVolume\s*=\s*[\x60'"](.+?)[\x60'"]/);
const chapterLanguage = matchFirstGroupB(script, /chapterLanguage\s*=\s*[\x60'"](.+?)[\x60'"]/);
const imagesJson = matchFirstGroupB(script, /chapterImages\s*=\s*JSON\.parse\(\s*[\x60'"](.+?)[\x60'"]\s*\)/) || matchFirstGroupB(script, /chapterImages\s*=.*(\[.*?\])/) || '[]';
let images = [];
try {
images = JSON.parse(imagesJson);
} catch (error) {
throw new Error('MangaBall: chapterImages JSON parse failed: ' + error.message);
}
const csrfToken = queryAttribute('meta[name="csrf-token"]', 'content');
const listing = await fetchJson('/api/v1/chapter/chapter-listing-by-title-id/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-TOKEN': csrfToken
},
body: 'title_id=' + titleId + '&lang=' + chapterLanguage
});
const allChapters = ((listing && listing.ALL_CHAPTERS) || []).sort(function (a, b) {
return b.number_float - a.number_float;
});
/** Walks the descending chapter list to the neighbor with the same language and volume. */
function findChapter(direction) {
const currentNumberFloat = parseFloat(chapterNumber || '0');
const currentIndex = allChapters.findIndex(function (chapter) {
return chapter.number_float === currentNumberFloat;
});
if (currentIndex === -1) {
return '';
}
const step = direction === 'next' ? -1 : 1;
const volumeText = String(chapterVolume);
for (let i = currentIndex + step; i >= 0 && i < allChapters.length; i += step) {
const translations = allChapters[i].translations || [];
const translation = translations.find(function (item) {
return item.language === chapterLanguage && String(item.volume) === volumeText;
});
if (translation) {
return translation.url;
}
}
return '';
}
const listImages = toAbsoluteImageListB(images.filter(function (url) {
return typeof url === 'string';
}));
return {
title: queryText('h1'),
series: queryAttribute('a[href*="' + titleId + '"]', 'href') || queryAttribute('a[href*="/title-detail/"]', 'href') || queryAttribute('a[href*="/manga-detail/"]', 'href'),
pages: listImages.length,
prev: findChapter('prev'),
next: findChapter('next'),
listImages: listImages
};
}
};
// MangaBuddy: page global chapImages (comma-separated), host rewritten to the CDN.
const siteMangabuddy = {
name: 'MangaBuddy',
url: /https?:\/\/(www\.)?mangabuddy\.com\/.+\/chapter.+/,
homepage: 'https://mangabuddy.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'chapImages',
testUrls: {
match: ['https://mangabuddy.com/some-title/chapter-1', 'https://www.mangabuddy.com/a/chapter-10-5'],
noMatch: ['https://mangabuddy.com/', 'https://mangabuddyXcom/a/chapter-1']
},
run: function () {
const rawList = String(pageWindow.chapImages).split(',');
const listImages = toAbsoluteImageListB(rawList.map(function (entry) {
// FIX (vs NEW): NEW called new URL(s) directly, which throws on an empty or relative entry
// (docs/03 5.8 MangaBuddy). Resolve against the page first and skip empties.
const absolute = toAbsoluteUrl(entry);
if (absolute === '') {
return '';
}
return new URL(absolute).pathname.replace('/res/', 'https://sb.mbcdn.xyz/');
}));
return {
title: queryText('.chapter-info'),
series: queryAttribute('#breadcrumbs-container div:nth-child(2) a', 'href'),
pages: listImages.length,
prev: queryAttribute('a.prev', 'href'),
next: queryAttribute('a.next', 'href'),
listImages: listImages
};
}
};
// MangaDex: SPA gated by waitEle; images from the public at-home JSON API.
const siteMangadex = {
name: 'MangaDex',
url: /https?:\/\/(www\.)?mangadex\.org/,
homepage: 'https://mangadex.org/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#chapter-selector a',
testUrls: {
match: ['https://mangadex.org/chapter/0a1b2c3d-1111-2222-3333-444455556666/1', 'https://www.mangadex.org/chapter/abc'],
noMatch: ['https://example.com/', 'https://mangadexxorg.com/chapter/abc']
},
run: async function () {
const idMatch = /\/chapter\/([^/]+)(\/\d+)?/.exec(window.location.pathname);
if (!idMatch) {
throw new Error('MangaDex: chapter id not found in path ' + window.location.pathname);
}
const server = await fetchJson('https://api.mangadex.org/at-home/server/' + idMatch[1]);
let listImages = [];
try {
listImages = server.chapter.data.map(function (image) {
return server.baseUrl + '/data/' + server.chapter.hash + '/' + image;
});
} catch (error) {
throw new Error('MangaDex: unexpected at-home API shape: ' + error.message);
}
const chapters = queryAll('#chapter-selector a');
return {
title: getPageTitle().replace(' - MangaDex', ''),
series: queryAttribute('a.text-primary[href^=\'/title/\']', 'href'),
pages: listImages.length,
prev: readAttribute(chapters[0] || null, 'href'),
next: readAttribute(chapters[1] || null, 'href'),
listImages: listImages
};
}
};
// MangaFox / MangaHere: page globals + per-page API returning packed JavaScript, evaluated to get d.
const siteMangafox = {
name: ['MangaFox', 'MangaHere'],
url: /https?:\/\/(www\.)?(fanfox\.net|mangahere\.cc)\/manga\/.+\/.+\//,
homepage: ['https://fanfox.net/', 'https://www.mangahere.cc/'],
language: [Language.ENGLISH],
category: Category.MANGA,
// Kept as NEW. The rewrite's isNothing treats numbers as present, so a numeric chapterid passes.
waitVar: 'chapterid',
testUrls: {
match: ['https://fanfox.net/manga/some_title/c001/1.html', 'https://www.mangahere.cc/manga/some_title/v01/c002/'],
noMatch: ['https://fanfox.net/', 'https://fanfoxXnet/manga/a/b/']
},
run: async function () {
const key = queryAttribute('#dm5_key', 'value');
const options = {
method: 'GET',
headers: { 'Content-Type': 'text/plain' }
};
const imageCount = Number(pageWindow.imagecount);
if (!(imageCount > 0)) {
throw new Error('MangaFox: page global imagecount missing or invalid: ' + pageWindow.imagecount);
}
const chapterId = pageWindow.chapterid !== undefined && pageWindow.chapterid !== null ? pageWindow.chapterid : pageWindow.chapter_id;
const requests = [];
for (let i = 0; i < imageCount; i++) {
requests.push((async function (pageIndex) {
const url = 'chapterfun.ashx?cid=' + chapterId + '&page=' + pageIndex + '&key=' + key;
const api = await fetchTextOrThrow(url, options);
try {
// The API returns packed JS that defines a global d (array of image URLs).
// Appending ';d' makes the indirect eval return it, whatever scope the manager uses (docs/03 5.7).
return (0, eval)(api + '\n;d');
} catch (error) {
throw new Error('MangaFox: evaluating chapterfun response for page ' + pageIndex + ' failed: ' + error.message);
}
})(i));
}
const results = await Promise.all(requests);
// Page 1 takes d[0], the others d[1], as NEW does (NEW:13057).
const listImages = toAbsoluteImageListB(results.map(function (result, index) {
const entry = result ? result[index === 0 ? 0 : 1] : '';
return typeof entry === 'string' ? entry : '';
}));
return {
title: queryText('.reader-header-title div'),
series: queryAttribute('.reader-header-title a', 'href'),
// FIX (vs NEW): NEW returned imagecount; use the list length so no blank pages (docs/03 5.9).
pages: listImages.length,
prev: pageWindow.prechapterurl,
next: pageWindow.nextchapterurl,
listImages: listImages
};
}
};
// Mangago: page global imgsrcs, AES-decrypted with the PAGE's CryptoJS.
const siteMangago = {
name: 'Mangago',
url: /https?:\/\/(www\.)?mangago\.me\/.*\/.*\/.*/,
homepage: 'https://www.mangago.me/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'imgsrcs',
testUrls: {
match: ['https://www.mangago.me/read-manga/some_title/mf/v01/c001/', 'https://mangago.me/a/b/c'],
noMatch: ['https://www.mangago.me/', 'https://www.mangagoXme/a/b/c']
},
run: function () {
const CryptoJS = pageWindow.CryptoJS;
if (!CryptoJS) {
throw new Error('Mangago: page global CryptoJS not available');
}
let images = [];
try {
const key = CryptoJS.enc.Hex.parse('e11adc3949ba59abbe56e057f20f883e');
const cipherOptions = {
iv: CryptoJS.enc.Hex.parse('1234567890abcdef1234567890abcdef'),
padding: CryptoJS.pad.ZeroPadding
};
images = CryptoJS.AES.decrypt(pageWindow.imgsrcs, key, cipherOptions).toString(CryptoJS.enc.Utf8).split(',');
} catch (error) {
throw new Error('Mangago: imgsrcs decryption failed: ' + error.message);
}
const hasEmptyEntry = images.some(function (source) {
return source === '';
});
const listImages = toAbsoluteImageListB(images);
return {
title: pageWindow.manga_name + ' ' + pageWindow.chapter_name,
// UNCLEAR (docs/03 5.8): mid looks like an id rather than a URL; kept as NEW.
series: pageWindow.mid,
// FIX (vs NEW): NEW returned total_pages; use the list length (docs/03 5.8 Mangago, 5.9).
pages: listImages.length,
prev: queryAttribute('.recom p:nth-child(5) a', 'href'),
next: pageWindow.next_c_url,
listImages: listImages,
before: function () {
// Retry by submitting the site's form when a decrypted entry was empty, as NEW does.
if (hasEmptyEntry) {
const form = query('#nform');
if (form) {
form.submit();
}
}
}
};
}
};
// MangaHub: POST GraphQL API with an access header copied from a cookie.
const siteMangahub = {
name: 'MangaHub',
url: /https?:\/\/(www\.)?(mangahub)\.io\/chapter\/.+\/.+/,
homepage: 'https://mangahub.io/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#select-chapter',
testUrls: {
match: ['https://mangahub.io/chapter/some-title/chapter-12', 'https://www.mangahub.io/chapter/a/chapter-1.5'],
noMatch: ['https://mangahub.io/', 'https://mangahubXio/chapter/a/chapter-1']
},
run: async function () {
/** Reads a cookie value, or null. */
function getCookie(name) {
const value = new RegExp(name + '=([^;]+)').exec(document.cookie);
return value !== null ? decodeURIComponent(value[1]) : null;
}
const pathParts = window.location.pathname.split('/');
const slug = pageWindow.CURRENT_MANGA_SLUG !== undefined && pageWindow.CURRENT_MANGA_SLUG !== null ? pageWindow.CURRENT_MANGA_SLUG : pathParts[2];
const number = (pathParts[3] || '').replace('chapter-', '');
const data = { query: '{chapter(x:m01,slug:"' + slug + '",number:' + number + '){pages}}' };
const options = {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
'x-mhub-access': getCookie('mhub_access') || ''
}
};
const api = await fetchJson('https://api.mghcdn.com/graphql', options);
let images = null;
try {
images = JSON.parse(api.data.chapter.pages.toString());
} catch (error) {
throw new Error('MangaHub: chapter pages parse failed: ' + error.message);
}
const listImages = (images.i || []).map(function (item) {
return 'https://imgx.mghcdn.com/' + (images.p + item);
});
return {
title: queryText('#mangareader h3'),
series: queryAttribute('#mangareader a', 'href'),
pages: listImages.length,
prev: queryAttribute('.previous a', 'href'),
next: queryAttribute('.next a', 'href'),
listImages: listImages
};
}
};
// MangaKakalot family: DOM query of reader images; several brand layouts.
const siteMangakakalot = {
name: [
'MangaKakalot',
'NeloManga ',
'MangaNato',
'NatoManga',
'MangaBats'
],
url: /https?:\/\/(www\.)?(read|chap)?(nelomanga|mangakakalot|natomanga|manganato|mangabats|mangakakalove)\.(com|gg|net).*\/(chapter|manga)\/.+\/.+/,
homepage: [
'https://mangakakalot.gg/',
'https://www.nelomanga.com/',
'https://www.manganato.gg/',
'https://www.natomanga.com/',
'https://www.mangabats.com/'
],
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '.navi-change-chapter-btn-prev, .next, .navi-change-chapter-btn-next, .back',
testUrls: {
match: ['https://www.natomanga.com/manga/some-title/chapter-5', 'https://mangakakalot.gg/manga/a/chapter-1', 'https://chapmanganato.com/manga/ab123/chapter-1'],
noMatch: ['https://mangakakalot.gg/', 'https://mangakakalotXgg/manga/a/chapter-1']
},
run: function () {
const images = queryAll('#vungdoc img, .container-chapter-reader img');
const listImages = toAbsoluteImageListB(images.map(function (img) {
return readAttribute(img, 'src');
}));
// FIX (vs NEW): NEW called .item(1).getAttribute without a null check and threw a TypeError
// when fewer than two links exist (docs/03 5.8 MangaKakalot).
const seriesLinks = queryAll('span a[title]');
return {
title: queryText('.info-top-chapter h2, .imageOptions-chapter-info-top h1, .panel-chapter-info-top h1'),
series: readAttribute(seriesLinks[1] || null, 'href'),
pages: listImages.length,
// UNCLEAR (docs/03 5.5): prev includes .next and next includes .back; kept as NEW.
prev: queryAttribute('.navi-change-chapter-btn-prev, .next', 'href'),
next: queryAttribute('.navi-change-chapter-btn-next, .back', 'href'),
listImages: listImages
};
}
};
// MangaOni: DOM query in cascade mode; chapter <select> is newest first.
const siteMangaoni = {
name: 'MangaOni',
url: /https?:\/\/(www\.)?manga-oni\.com\/lector\/.+\/\d+\/cascada/,
homepage: 'https://manga-oni.com/',
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://manga-oni.com/lector/some-title/123456/cascada', 'https://www.manga-oni.com/lector/a/1/cascada/'],
noMatch: ['https://manga-oni.com/', 'https://manga-oni.com/lector/a/1/paginada']
},
run: function () {
const chapterList = query('#c_list');
if (chapterList) {
chapterList.dispatchEvent(new Event('mouseover'));
}
const chapter = query('#c_list option:checked');
const images = queryAll('#slider img');
const listImages = toAbsoluteImageListB(images.map(function (img) {
return readFirstAttribute(img, ['data-src', 'src']);
}));
return {
title: getPageTitle().replace(' — Manga en lÃnea | MangaOni', ''),
pages: listImages.length,
prev: chapter ? readAttribute(chapter.nextElementSibling, 'value') : '',
next: chapter ? readAttribute(chapter.previousElementSibling, 'value') : '',
listImages: listImages
};
}
};
// MangaPark: DOM query of full-width images; prev/next are the two primary buttons.
const siteMangapark = {
name: 'MangaPark',
url: /https?:\/\/(www\.)?(mangapark|mpark|comicpark|readpark|parkmanga)\.(com|me|org|net|io|to)\/title\/.+\/.+/,
homepage: 'https://mangapark.net/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'main div div a.btn-primary',
testUrls: {
match: ['https://mangapark.net/title/12345-en-some-title/678-chapter-1', 'https://www.mpark.to/title/a/b'],
noMatch: ['https://mangapark.net/', 'https://mangaparkXnet/title/a/b']
},
run: function () {
const images = queryAll('main div div > img.w-full');
const listImages = toAbsoluteImageListB(images.map(function (img) {
return readAttribute(img, 'src');
}));
const buttons = queryAll('main div div a.btn-primary');
return {
title: queryAll('.comic-detail h3 a, .comic-detail h6 span').map(function (element) {
return readText(element);
}).join(' '),
series: queryAttribute('.comic-detail a', 'href'),
pages: listImages.length,
prev: readAttribute(buttons[0] || null, 'href'),
next: readAttribute(buttons[1] || null, 'href'),
listImages: listImages
};
}
};
// MangaReader: DOM query of data-url; shuffled images unscrambled with the PAGE's imgReverser.
const siteMangareader = {
name: 'MangaReader',
url: /https?:\/\/(www\.)?mangareader\.to\/read\/.+\/.+\/.+/,
homepage: 'https://mangareader.to',
language: [Language.ENGLISH],
category: Category.MANGA,
obs: 'Some galleries will not be usable',
waitEle: '.ds-image, .iv-card',
testUrls: {
match: ['https://mangareader.to/read/some-title-123/en/chapter-1', 'https://www.mangareader.to/read/a/b/c'],
noMatch: ['https://mangareader.to/', 'https://mangareaderXto/read/a/b/c']
},
run: async function () {
const chapter = query('.chapter-item.active');
const sources = queryAll('.ds-image[data-url], .iv-card[data-url]').map(async function (img) {
const url = readAttribute(img, 'data-url');
if (url && img.classList.contains('shuffled')) {
const imgReverser = pageWindow.imgReverser;
if (typeof imgReverser !== 'function') {
throw new Error('MangaReader: page global imgReverser not available');
}
try {
return (await imgReverser(url)).toDataURL();
} catch (error) {
throw new Error('MangaReader: unscrambling ' + url + ' failed: ' + error.message);
}
}
return url;
});
const listImages = toAbsoluteImageListB(await Promise.all(sources));
const prevItem = chapter ? chapter.nextElementSibling : null;
const nextItem = chapter ? chapter.previousElementSibling : null;
return {
title: queryText('.hr-manga h2'),
series: queryAttribute('.hr-manga', 'href'),
pages: listImages.length,
prev: prevItem ? queryAttribute('a', 'href', prevItem) : '',
next: nextItem ? queryAttribute('a', 'href', nextItem) : '',
listImages: listImages
};
}
};
/** MangaStream: selector of the reader images only (no navigation containers). */
const mangastreamImageSelector = ':where(#readerarea, .check-box, #viewer-img) img:not(.asurascans):not([src*="loader"]):not([src*="chevron"])';
// MangaStream WordPress theme (generic): DOM query of reader images; prev/next by link text.
const siteMangastreamwp = {
name: [
'MangaStream WordPress Plugin',
'Realm Oasis',
'Voids-Scans',
'Luminous Scans',
'Shimada Scans',
'Night Scans',
'Manhwa-Freak',
'OzulScansEn',
'CypherScans',
'MangaGalaxy',
'LuaScans',
'Drake Scans',
'Rizzfables',
'NovatoScans',
'TresDaos',
'Lectormiau',
'NTRGod',
'Threedaos'
],
url: /https?:\/\/[^/]*(scans?|comic|realm|rizz|hivetoon|tresdaos|zonamiau|ntrgod|threedaos)[^/]*\/.+/,
homepage: [
'https://themesia.com/mangastream-wordpress-theme/',
'https://realmoasis.com/',
'https://void-scans.com/',
'https://luminous-scans.com/',
'https://shimadascans.com/',
'https://night-scans.com/',
'https://freakcomic.com/',
'https://ozulscansen.com/',
'https://cypherscans.xyz/',
'https://mangagalaxy.me/',
'https://luascans.com/',
'https://drake-scans.com/',
'https://rizzfables.com/',
'https://www.novatoscans.top/',
'https://tresdaos.com',
'https://zonamiau.com/',
'https://ntrgod.com/',
'https://threedaos.zdrz.xyz/'
],
language: [Language.ENGLISH, Language.SPANISH],
category: Category.MANGA,
waitEle: ':where(#readerarea, .check-box, #viewer-img) img:not(.asurascans):not([src*="loader"]):not([src*="chevron"]),:where(.nextprev, .inner_nPL)',
testUrls: {
match: ['https://luminous-scans.com/some-title-chapter-1/', 'https://rizzfables.com/chapter/some-title-1'],
noMatch: ['https://example.com/manga/a', 'https://luminous-scans.com/']
},
run: function () {
// FIX (vs NEW): NEW reused waitEle, whose second part matches navigation containers, so they
// were counted as pages with '' URLs (docs/03 5.8 MangaStream WP). Use the image part only.
const images = queryAll(mangastreamImageSelector);
const listImages = toAbsoluteImageListB(images.map(function (img) {
return readFirstAttribute(img, ['data-src', 'data-lazy-src', 'src']);
}));
const prevLinks = findByContentEq(':where(.nextprev, .inner_nPL) a', ['Prev', 'Anterior']);
const nextLinks = findByContentEq(':where(.nextprev, .inner_nPL) a', ['Next', 'Siguiente']);
const breadcrumbLinks = queryAll('[class*="breadcrumb"] a');
return {
title: queryText('title'),
series: queryAttribute(':where(.allc, .tac) a', 'href') || readAttribute(breadcrumbLinks[1] || null, 'href'),
pages: listImages.length,
prev: readAttribute(prevLinks[0] || null, 'href'),
next: readAttribute(nextLinks[0] || null, 'href'),
listImages: listImages
};
}
};
// MangaToons: DOM query of lazy images (data-src), cover excluded.
const siteMangatoon = {
name: 'MangaToons',
url: /https?:\/\/.*mangatoon\.mobi\/.+\/watch\/.+/,
homepage: 'https://mangatoon.mobi/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '.pictures img:not(.cover)',
testUrls: {
match: ['https://mangatoon.mobi/en/watch/123456/7890', 'https://www.mangatoon.mobi/es/watch/1/2'],
noMatch: ['https://mangatoon.mobi/', 'https://mangatoonXmobi/en/watch/1/2']
},
run: function () {
const images = queryAll('.pictures img:not(.cover)');
const listImages = toAbsoluteImageListB(images.map(function (img) {
return readAttribute(img, 'data-src');
}));
return {
title: queryText('title'),
series: queryAttribute('.top-left a', 'href'),
pages: listImages.length,
prev: queryAttribute('.page-icons-prev', 'href'),
next: queryAttribute('.page-icons-next', 'href'),
listImages: listImages
};
}
};
// MangaTown: listImages when the page shows all images, otherwise listPages from the page <select>.
const siteMangatown = {
name: 'MangaTown',
url: /https?:\/\/www\.mangatown\.com\/manga\/.+\/.+\//,
homepage: 'https://www.mangatown.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'div.page_select select option, .read_img .image',
testUrls: {
match: ['https://www.mangatown.com/manga/some_title/c001/', 'https://www.mangatown.com/manga/a/v01/c002/2.html'],
noMatch: ['https://www.mangatown.com/', 'https://mangatown.com/manga/a/c001/']
},
run: function () {
const options = queryAll('div.page_select select option');
const images = toAbsoluteImageListB(queryAll('.read_img .image').map(function (img) {
return readAttribute(img, 'src');
}));
const pageUrls = toAbsoluteImageListB(options.map(function (element) {
return element.value;
}).filter(function (url) {
return !url.endsWith('featured.html');
}));
const checkedChapter = query('.chapter_select option:checked');
const result = {
title: queryText('div.title h1'),
series: queryAttribute('div.title a', 'href'),
prev: checkedChapter ? readAttribute(checkedChapter.previousElementSibling, 'value') : '',
next: checkedChapter ? readAttribute(checkedChapter.nextElementSibling, 'value') : ''
};
// FIX (vs NEW): NEW returned total_pages || pages.length for both strategies; use the length
// of the list actually returned so no blank pages appear (docs/03 5.9 MangaTown).
if (images.length > 1) {
return Object.assign({}, result, { pages: images.length, listImages: images });
}
return Object.assign({}, result, { pages: pageUrls.length, listPages: pageUrls, img: '#image' });
}
};
// ---- Site modules, batch C (ported from NEW:13334-13770) ----
/**
* Reads a nested property path safely, e.g. siteCReadPath(data, ['chapter', 'images']).
* Returns undefined as soon as a step is null/undefined (replaces optional-chaining chains).
* @param {*} source - Starting value.
* @param {Array<string|number>} path - Property names / indexes.
* @returns {*}
*/
function siteCReadPath(source, path) {
let current = source;
for (let i = 0; i < path.length; i++) {
if (current === null || current === undefined) {
return undefined;
}
current = current[path[i]];
}
return current;
}
/**
* Returns the first value that is not null/undefined (replaces nullish-coalescing chains).
* @param {Array<*>} values - Candidates in priority order.
* @returns {*}
*/
function siteCFirstDefined(values) {
for (let i = 0; i < values.length; i++) {
if (values[i] !== null && values[i] !== undefined) {
return values[i];
}
}
return undefined;
}
// ManhwaWeb: JSON API on a separate backend host (chapter data + prev/next ids).
const siteManhwaweb = {
name: 'ManhwaWeb',
url: /https?:\/\/(www\.)?manhwaweb\.com\/leer\/.+/,
homepage: 'https://manhwaweb.com/',
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://manhwaweb.com/leer/some-series_123-1', 'https://www.manhwaweb.com/leer/abc'],
noMatch: ['https://manhwaweb.com/', 'https://manhwawebXcom/leer/abc']
},
run: async function () {
const slug = window.location.pathname.replace('/leer', '');
const api = await fetchJson('https://manhwawebbackend-production.up.railway.app/chapters/see' + slug);
const data = await fetchJson('https://manhwawebbackend-production.up.railway.app/chapters/seeprevpost' + slug);
const episodesLabel = queryAll('div').filter(function (element) {
return element.textContent === 'Episodios';
})[0];
const images = (siteCReadPath(api, ['chapter', 'img']) || []).filter(function (url) {
return typeof url === 'string' && url !== '';
});
// FIX (vs NEW): docs/03 5.8 - NEW called .replace on chapterAnterior/chapterSiguiente without a null check; missing links now become ''.
const prevId = data && typeof data.chapterAnterior === 'string' ? data.chapterAnterior : '';
const nextId = data && typeof data.chapterSiguiente === 'string' ? data.chapterSiguiente : '';
return {
title: api.name + ' ' + api.chapter.chapter,
series: episodesLabel ? readAttribute(episodesLabel.parentElement, 'href') : '',
pages: images.length,
prev: prevId ? prevId.replace(api._id, api.real_id) : '',
next: nextId ? nextId.replace(api._id, api.real_id) : '',
listImages: images
};
}
};
// MangaGeko: DOM query of reader images and nav links.
const siteMgeko = {
name: ['MangaGeko.com', 'MangaGeko.cc'],
url: /https?:\/\/(www\.)?mgeko\.(com|cc)?\/reader\/.*/,
homepage: ['https://www.mgeko.com/', 'https://www.mgeko.cc/'],
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://www.mgeko.com/reader/en/some-manga-chapter-1/', 'https://mgeko.cc/reader/en/x-chapter-2/'],
noMatch: ['https://www.mgeko.com/', 'https://www.mgekoXcom/reader/x']
},
run: function () {
const images = queryAll('img[id^="image-"]').map(function (img) {
return toAbsoluteUrl(readAttribute(img, 'src'));
}).filter(function (url) {
return url !== '';
});
return {
title: queryText('.titles'),
series: queryAttribute('.titles a', 'href'),
pages: images.length,
prev: queryAttribute('.chnav.prev:not(.isDisabled)', 'href'),
next: queryAttribute('.chnav.next:not(.isDisabled)', 'href'),
listImages: images
};
}
};
// NineAnime: listPages from the page <select>, one image per page.
const siteNineanime = {
name: 'NineAnime',
url: /https?:\/\/(www\.)?nineanime\.com\/chapter\/.+/,
homepage: 'https://www.nineanime.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://www.nineanime.com/chapter/some-manga-chapter-1/123/', 'https://nineanime.com/chapter/x'],
noMatch: ['https://www.nineanime.com/', 'https://www.nineanimeXcom/chapter/x']
},
run: function () {
const pageList = queryAll('.sl-page option').map(function (option) {
return toAbsoluteUrl(readAttribute(option, 'value'));
}).filter(function (url) {
return url !== '';
});
const chapter = query('.mangaread-pagenav select option[selected]');
return {
title: queryText('.title h1') + '/' + queryText('.title h2'),
// Note: ':has()' needs a recent browser (docs/03 5.10); kept as in NEW.
series: queryAttribute('.title a:has(h2)', 'href'),
pages: pageList.length,
prev: chapter ? readAttribute(chapter.nextElementSibling, 'value') : '',
next: chapter ? readAttribute(chapter.previousElementSibling, 'value') : '',
listPages: pageList,
img: '.manga_pic'
};
}
};
// Olympus: DOM query of reader images and named nav links.
const siteOlympus = {
name: 'Olympus',
url: /https?:\/\/(www\.)?olympusxyz\.com\/capitulo\/\d+\/.+/,
homepage: 'https://olympusxyz.com/',
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://olympusxyz.com/capitulo/12345/some-series', 'https://www.olympusxyz.com/capitulo/1/x'],
noMatch: ['https://olympusxyz.com/', 'https://olympusxyz.com/capitulo/abc/x']
},
run: function () {
const images = queryAll('section img.w-full.h-full').map(function (img) {
return toAbsoluteUrl(readAttribute(img, 'src'));
}).filter(function (url) {
return url !== '';
});
const heading = query('h1');
return {
title: queryText('title').replace(/\|.+/, '').trim(),
series: heading ? readAttribute(heading.closest('a'), 'href') : '',
pages: images.length,
prev: queryAttribute('a[name="capitulo anterior"]', 'href'),
next: queryAttribute('a[name="capitulo siguiente"]', 'href'),
listImages: images
};
}
};
// Qi Manga: DOM images + regex over the Angular state script (#ng-state) for prev/next slugs.
const siteQimanga = {
name: 'Qi Manga',
url: /https?:\/\/(www\.)?qimanga\.com\/series\/[^/]+\/chapter-.+/,
homepage: 'https://qimanga.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'img.r-page-img',
testUrls: {
match: ['https://qimanga.com/series/some-series/chapter-12', 'https://www.qimanga.com/series/x/chapter-1-5'],
noMatch: ['https://qimanga.com/', 'https://qimanga.com/series/some-series']
},
run: function () {
const images = queryAll('img.r-page-img').map(function (img) {
return toAbsoluteUrl(readAttribute(img, 'src'));
}).filter(function (url) {
return url !== '';
});
const state = queryText('#ng-state');
const nav = /"navigation":\{"prev":\{"slug":"([^"]+)"[\s\S]*?"next":\{"slug":"([^"]+)"/.exec(state);
const series = queryAttribute('.r-back', 'href');
/** Builds a chapter link from a slug, or '' without a series link. */
function chapterUrl(slug) {
return series ? series + '/' + slug : '';
}
return {
title: queryText('h1.sr-only'),
series: series,
// FIX (vs NEW): docs/03 5.9 - NEW used the '.r-page-counter' total, which can exceed the list and leave blank pages; now the list length.
pages: images.length,
prev: nav ? chapterUrl(nav[1]) : '',
next: nav ? chapterUrl(nav[2]) : '',
listImages: images
};
}
};
// QiManhwa: DOM images; prev/next via Lucide chevron icons, series via the "Home" button.
const siteQimanhwa = {
name: 'QiManhwa',
url: /https?:\/\/qimanhwa\.com\/series\/[^/]+\/chapter-.+/,
homepage: 'https://qimanhwa.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'img[alt*="Chapter"]',
testUrls: {
match: ['https://qimanhwa.com/series/some-series/chapter-3', 'http://qimanhwa.com/series/x/chapter-10'],
noMatch: ['https://qimanhwa.com/', 'https://qimanhwa.com/series/some-series']
},
run: function () {
const images = queryAll('img[alt*="Chapter"]').map(function (img) {
return toAbsoluteUrl(readAttribute(img, 'src'));
}).filter(function (url) {
return url !== '';
});
const prevIcon = query('.lucide-chevron-left');
const nextIcon = query('.lucide-chevron-right');
return {
title: queryText('h1'),
series: readAttribute(findClosestByContentEq('button', 'Home', 'a'), 'href'),
pages: images.length,
prev: prevIcon ? readAttribute(prevIcon.closest('a'), 'href') : '',
next: nextIcon ? readAttribute(nextIcon.closest('a'), 'href') : '',
listImages: images
};
}
};
// ReadComicsOnline: DOM images (data-src) + page globals for title and prev/next.
const siteReadcomicsonline = {
name: 'ReadComicsOnline',
url: /https?:\/\/(www\.)?readcomicsonline\.ru\/comic\/.*\/\d*/,
homepage: 'https://readcomicsonline.ru/',
language: [Language.ENGLISH],
category: Category.COMIC,
testUrls: {
match: ['https://readcomicsonline.ru/comic/some-comic-2020/1', 'https://www.readcomicsonline.ru/comic/x/12'],
noMatch: ['https://readcomicsonline.ru/', 'https://readcomicsonlineXru/comic/x/1']
},
run: function () {
// FIX (vs NEW): docs/03 5.8 - NEW read the page globals with no wait and threw a bare TypeError if they were late; now a named error.
if (typeof pageWindow.title !== 'string') {
throw new Error('ReadComicsOnline: page global "title" not found');
}
const images = queryAll('#all img').map(function (img) {
return toAbsoluteUrl(readAttribute(img, 'data-src'));
}).filter(function (url) {
return url !== '';
});
return {
title: pageWindow.title.replace(/ - Page \d+/, ''),
series: queryAttribute('div.pager-cnt a', 'href'),
// Spec rule: pages = list length (NEW used pageWindow.pages.length, which can differ from the DOM list).
pages: images.length,
prev: pageWindow.prev_chapter || '',
next: pageWindow.next_chapter || '',
listImages: images
};
}
};
// ReaperScans: DOM images (data-src || src); prev/next via Font Awesome icons.
const siteReaperscans = {
name: 'ReaperScans',
url: /https?:\/\/(www\.)?reaperscans\.com\/series\/.+\/chapter.+/,
homepage: 'https://reaperscans.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#content .container img:not(.rounded)',
testUrls: {
match: ['https://reaperscans.com/series/some-series/chapter-10', 'https://www.reaperscans.com/series/x/chapter-1'],
noMatch: ['https://reaperscans.com/', 'https://reaperscans.com/series/some-series']
},
run: function () {
const images = queryAll('#content .container img:not(.rounded)').map(function (img) {
return toAbsoluteUrl(readFirstAttribute(img, ['data-src', 'src']));
}).filter(function (url) {
return url !== '';
});
const homeIcon = query('button .fa-house');
const prevIcon = query('.fa-chevron-left');
const nextIcon = query('.fa-chevron-right');
return {
title: queryText('title'),
series: homeIcon ? readAttribute(homeIcon.closest('a'), 'href') : '',
pages: images.length,
prev: prevIcon ? readAttribute(prevIcon.closest('a'), 'href') : '',
next: nextIcon ? readAttribute(nextIcon.closest('a'), 'href') : '',
listImages: images
};
}
};
// ReiManga: embedded Next.js JSON (__NEXT_DATA__), else a DOM keyword heuristic; prev/next from the chapter <select>.
const siteReimanga = {
name: 'ReiManga',
url: /https?:\/\/(www\.)?reimanga\.com\/manga\/[^/]+-\d+\/\d+/,
homepage: 'https://reimanga.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'img',
testUrls: {
match: ['https://reimanga.com/manga/some-series-123/456', 'https://www.reimanga.com/manga/x-1/2'],
noMatch: ['https://reimanga.com/', 'https://reimanga.com/manga/some-series-123']
},
run: function () {
const nextData = document.getElementById('__NEXT_DATA__');
let data = null;
if (nextData) {
try {
const parsed = JSON.parse(nextData.textContent || '{}');
const pageProps = siteCReadPath(parsed, ['props', 'pageProps']);
data = pageProps === undefined || pageProps === null ? null : pageProps;
} catch (error) {
throw new Error('ReiManga: __NEXT_DATA__ JSON parse failed: ' + error.message);
}
}
const images = siteCFirstDefined([siteCReadPath(data, ['chapter', 'images']), siteCReadPath(data, ['chapter', 'img']), null]);
let listImages = [];
if (Array.isArray(images)) {
listImages = images.map(function (img) {
if (typeof img === 'string') {
return toAbsoluteUrl(img);
}
return toAbsoluteUrl(siteCFirstDefined([siteCReadPath(img, ['url']), siteCReadPath(img, ['src']), '']));
});
} else {
listImages = queryAll('img').filter(function (img) {
const src = readAttribute(img, 'src');
return /\/(manga|chapter|uploads?|files?|images?)\//i.test(src) || /(cdn|media|img|static)/i.test(src);
}).map(function (img) {
return readAttribute(img, 'src');
}).filter(function (src) {
return src.indexOf('http') === 0;
});
}
listImages = listImages.filter(function (url) {
return url !== '';
});
const pathParts = window.location.pathname.split('/').filter(Boolean);
const basePath = pathParts.length > 1 ? '/' + pathParts.slice(0, 2).join('/') : '';
const seriesSlug = siteCReadPath(data, ['series', 'slug']);
const series = seriesSlug !== undefined ? '/manga/' + seriesSlug : (queryAttribute('a[href*="/manga/"]', 'href') || basePath);
const select = query('select[aria-label="Jump to chapter"]');
const options = select ? Array.prototype.slice.call(select.options) : [];
const index = select ? select.selectedIndex : -1;
const prevId = index + 1 < options.length && options[index + 1] ? options[index + 1].value : '';
const nextId = index - 1 >= 0 && options[index - 1] ? options[index - 1].value : '';
/** Builds a chapter link from an id, or '' when id or series is missing. */
function chapterUrl(id) {
return id && series ? series + '/' + id : '';
}
let title = siteCReadPath(data, ['title']);
if (title === undefined || title === null) {
const seriesName = siteCFirstDefined([siteCReadPath(data, ['chapters', 0, 'series_name']), siteCReadPath(data, ['manga', 'name']), siteCReadPath(data, ['series', 'name']), '']);
const chapterName = siteCFirstDefined([siteCReadPath(data, ['chapters', 0, 'name']), siteCReadPath(data, ['chapter', 'name']), siteCReadPath(data, ['chapter', 'number']), '']);
title = (seriesName + ' ' + chapterName).trim() || document.title.replace(/ –.*| -.*| \|.*/, '').trim();
}
return {
title: title,
series: series,
pages: listImages.length,
prev: chapterUrl(prevId),
next: chapterUrl(nextId),
listImages: listImages
};
}
};
/**
* TuMangaOnline engine (zonatmo + tmohentai): shared run() body for the site modules that use
* this reader. Single-image pages are followed through the page selector (listPages + img);
* when all images are already in the DOM their URLs are read directly.
* @returns {Object} The manga object (see docs/03 section 2.2).
*/
function runTmoReader() {
const images = queryAll('.img-container img, .viewer-container img, .content-image, .viewer-image, .img-fluid, .reader-img-wrap img, .viewer-img, #viewer-container img, .viewer-page');
const pageOptions = queryAll('div.container:nth-child(4) select#viewer-pages-select option, #viewer-pages-select option, select#chapter-pages option, select#pages option');
const seriesLink = query('a[title="Volver"]') || query('.breadcrumb-item:nth-child(2) a') || query('.book-name a') || query('.breadcrumb-item a');
const prevLink = query('.chapter-prev a') || query('.prev_page') || query('a.prev-chapter') || query('.chapter-prev-btn');
const nextLink = query('.chapter-next a') || query('.next_page') || query('a.next-chapter') || query('.chapter-next-btn');
const result = {
title: queryText('title'),
series: readAttribute(seriesLink, 'href'),
prev: readAttribute(prevLink, 'href'),
next: readAttribute(nextLink, 'href'),
img: '#viewer-container img, .viewer-page, .img-container img, .content-image, .viewer-image, .reader-img-wrap img, .viewer-img',
before: function () {
if (window.location.pathname.indexOf('paginated') !== -1) {
window.location.pathname = window.location.pathname.replace(/paginated.*/, 'cascade');
}
if (window.location.pathname.indexOf('view_uploads') !== -1) {
const button = query('a.btn.btn-primary, .btn-primary a, a.btn-block');
if (button) {
button.click();
}
}
}
};
if (images.length <= 1 && pageOptions.length > 1) {
const baseUrl = window.location.href.replace(/\/\d+$/, '');
const listPages = [];
for (let i = 0; i < pageOptions.length; i++) {
listPages.push(baseUrl + '/' + (i + 1));
}
result.listPages = listPages;
result.pages = listPages.length;
} else {
const listImages = images.map(function (item) {
return toAbsoluteUrl(readFirstAttribute(item, ['data-src', 'data-original', 'src']));
}).filter(function (url) {
return url !== '';
});
result.listImages = listImages;
// FIX (vs NEW): docs/03 5.8 - NEW returned 'pages: num || 1', so it never declined and could win with one empty page; now the list length (0 declines).
result.pages = listImages.length;
}
return result;
}
// TuMangaOnline: DOM images (cascade view) or listPages built from the page <select> (paginated view); before() switches layout.
const siteTmofans = {
name: 'TuMangaOnline',
url: /https?:\/\/(www\.)?zonatmo\.(com|org|app)\/(viewer|news|view_uploads|reader|library)\/.+/,
homepage: 'https://zonatmo.org/',
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://zonatmo.org/viewer/abc123/cascade', 'https://zonatmo.com/view_uploads/123456', 'https://www.zonatmo.app/news/1/2'],
noMatch: ['https://zonatmo.org/', 'https://zonatmo.org/profile/x']
},
run: function () {
return runTmoReader();
}
};
// WebNovel: page global g_data (chapter info and page list).
const siteWebNovel = {
name: 'WebNovel',
url: /https?:\/\/(www\.)?webnovel\.com\/comic\/.+/,
homepage: 'https://www.webnovel.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'g_data',
testUrls: {
match: ['https://www.webnovel.com/comic/some-comic_123/chapter-1_456', 'https://webnovel.com/comic/x'],
noMatch: ['https://www.webnovel.com/', 'https://www.webnovelXcom/comic/x']
},
run: function () {
const chapterInfo = siteCReadPath(pageWindow.g_data, ['chapter', 'chapterInfo']);
if (!chapterInfo || !Array.isArray(chapterInfo.chapterPage)) {
throw new Error('WebNovel: g_data.chapter.chapterInfo.chapterPage not found');
}
const images = chapterInfo.chapterPage.map(function (img) {
return img ? img.url : '';
}).filter(function (url) {
return typeof url === 'string' && url !== '';
});
/** Builds '<name>_<id>' or '' when the id is missing. */
function chapterLink(name, id) {
if (id === undefined || id === null || id === '') {
return '';
}
return (name === undefined || name === null ? '' : name) + '_' + id;
}
return {
title: queryText('title'),
series: './',
pages: images.length,
// FIX (vs NEW): docs/03 5.6/5.8 - NEW built 'undefined_undefined' when a neighbor chapter is missing; now ''.
prev: chapterLink(chapterInfo.preChapterName, chapterInfo.preChapterId),
next: chapterLink(chapterInfo.nextChapterName, chapterInfo.nextChapterId),
listImages: images
};
}
};
// WebToons: DOM query of #_imageList images (data-url || data-src || src).
const siteWebToons = {
name: 'WebToons',
url: /https?:\/\/(www\.)?webtoons\.com\/.+viewer.+/,
homepage: 'https://www.webtoons.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://www.webtoons.com/en/fantasy/some-title/episode-1/viewer?title_no=1&episode_no=1'],
noMatch: ['https://www.webtoons.com/', 'https://www.webtoons.com/en/fantasy/some-title/list?title_no=1']
},
run: function () {
const images = queryAll('#_imageList img').map(function (img) {
return toAbsoluteUrl(readFirstAttribute(img, ['data-url', 'data-src', 'src']));
}).filter(function (url) {
return url !== '';
});
return {
title: queryText('.subj_info'),
series: queryAttribute('.subj_info a', 'href'),
pages: images.length,
prev: queryAttribute('._prevEpisode', 'href'),
next: queryAttribute('._nextEpisode', 'href'),
listImages: images
};
}
};
// WeebCentral: HTMX fragments (images endpoint and chapter-select endpoint) fetched with the HX-Request header.
const siteWeebCentral = {
name: 'WeebCentral',
url: /https?:\/\/(www\.)?(weebcentral)\.com\/chapters\/.+/,
homepage: 'https://weebcentral.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'section[hx-get*="/images"]',
testUrls: {
match: ['https://weebcentral.com/chapters/01J76XYZABCDEF', 'https://www.weebcentral.com/chapters/abc'],
noMatch: ['https://weebcentral.com/', 'https://weebcentral.com/series/01J76XYZ/some-title']
},
run: async function () {
// Re-run guard: the viewer marks <html> with the 'mvm' attribute after takeover; decline quietly.
if (document.documentElement.hasAttribute('mvm')) {
return {
pages: 0,
listImages: []
};
}
const imagesUrlBase = queryAttribute('section[hx-get*="/images"]', 'hx-get');
if (!imagesUrlBase) {
throw new Error('WeebCentral: images HTMX endpoint not found');
}
const htmxOptions = { headers: { 'HX-Request': 'true' } };
const imagesUrl = imagesUrlBase.replace(/&/g, '&') + '&reading_style=long_strip';
const imagesDocument = parseHtml(await fetchTextOrThrow(imagesUrl, htmxOptions));
const sources = queryAll('img', imagesDocument).map(function (img) {
return readFirstAttribute(img, ['src', 'data-src']);
}).filter(function (source) {
return source !== '' && source.indexOf('broken_image') === -1;
}).map(function (source) {
return source.indexOf('http') === 0 ? source : new URL(source, window.location.origin).href;
});
/** Page number taken from '-<n>.<ext>' at the end of the URL, 0 when absent. */
function pageNumber(url) {
const match = url.match(/-(\d+)\.[^.]+$/);
return match ? parseInt(match[1], 10) : 0;
}
const uniqueImages = uniqueValues(sources).sort(function (a, b) {
return pageNumber(a) - pageNumber(b);
});
const chapterSelectUrl = queryAttribute('button[hx-get*="chapter-select"]', 'hx-get');
let selectedChapter = null;
if (chapterSelectUrl) {
const chaptersDocument = parseHtml(await fetchTextOrThrow(chapterSelectUrl, htmxOptions));
selectedChapter = query('#selected_chapter', chaptersDocument);
}
/** Absolute URL for a path, '' when the path is empty. */
function absoluteUrl(path) {
if (!path) {
return '';
}
return path.indexOf('http') === 0 ? path : new URL(path, window.location.origin).href;
}
return {
title: document.title.split(' - ')[0].trim(),
series: absoluteUrl(queryAttribute('main section a.btn-ghost', 'href')),
pages: uniqueImages.length,
prev: absoluteUrl(selectedChapter ? readAttribute(selectedChapter.nextElementSibling, 'href') : ''),
next: absoluteUrl(selectedChapter ? readAttribute(selectedChapter.previousElementSibling, 'href') : ''),
listImages: uniqueImages,
// Kept from NEW for fidelity, but the rewrite displays images with a plain <img src> (no fetch),
// so these options are ignored for display. 'Referer' is also a forbidden fetch header, and
// 'HX-Request' would force a CORS preflight on the image host (docs/03 4.11).
fetchOptions: {
headers: {
'HX-Request': 'true',
Referer: window.location.href
}
}
};
}
};
// WeebDex: JSON API (api.weebdex.org) for the chapter and the manga aggregate (prev/next).
const siteWeebDex = {
name: 'WeebDex',
url: /https?:\/\/(www\.)?weebdex\.org\/.+/,
homepage: 'https://weebdex.org/',
// NEW passed a bare value (Language.ENGLISH); the spec requires an array.
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'a[href^="/title/"]',
testUrls: {
match: ['https://weebdex.org/chapter/abc123', 'https://www.weebdex.org/chapter/xyz/1'],
noMatch: ['https://weebdex.org/', 'https://weebdexXorg/chapter/abc']
},
run: async function () {
const chapterMatch = /\/chapter\/([^/]+)/.exec(window.location.pathname);
if (!chapterMatch) {
throw new Error('WeebDex: not a chapter URL (no /chapter/<id> in path)');
}
const chapterId = chapterMatch[1];
const chapterData = await fetchJson('https://api.weebdex.org/chapter/' + chapterId);
const mangaId = siteCReadPath(chapterData, ['relationships', 'manga', 'id']);
const aggregate = await fetchJson('https://api.weebdex.org/manga/' + mangaId + '/aggregate?lang=' + chapterData.language);
const chapters = aggregate.chapters || [];
const currentIndex = chapters.findIndex(function (item) {
return item.chapter === chapterData.chapter;
});
// lodash keys() in NEW -> safeKeys (docs/03 5.10: never throws on a missing 'entries').
const prevEntry = chapters[currentIndex + 1];
const nextEntry = chapters[currentIndex - 1];
const images = (chapterData.data || []).map(function (img) {
return chapterData.node + '/data/' + chapterId + '/' + img.name;
});
return {
title: queryText('title').replace(/Page \d+:/, ''),
series: queryAttribute('a[href^="/title/"]', 'href'),
pages: images.length,
prev: prevEntry ? '/chapter/' + safeKeys(prevEntry.entries)[0] : '',
next: nextEntry ? '/chapter/' + safeKeys(nextEntry.entries)[0] : '',
listImages: images
};
}
};
// Vortex Scans: Next.js app-router flight data (pageWindow.__next_f), image URLs extracted by regex.
const siteVortexscans = {
name: 'Vortex Scans',
url: /https?:\/\/(www\.)?(vortexscans)\.(org)\/.+/,
homepage: 'https://vortexscans.org/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: '__next_f',
waitFunc: function () {
// Self-contained: does not rely on waitVar having passed first, and does not use this.
const flight = pageWindow.__next_f;
if (!Array.isArray(flight)) {
return false;
}
const entry = flight.find(function (item) {
return Boolean(item) && /images/.test(item[1]);
});
return entry !== undefined && entry.length > 0;
},
testUrls: {
match: ['https://vortexscans.org/series/some-series/chapter-1', 'https://www.vortexscans.org/x'],
noMatch: ['https://vortexscans.org/', 'https://vortexscansXorg/series/x']
},
run: function () {
const flight = Array.isArray(pageWindow.__next_f) ? pageWindow.__next_f : [];
const entry = flight.find(function (item) {
return Boolean(item) && /images/.test(item[1]);
});
const data = entry ? entry[1] : undefined;
if (typeof data !== 'string') {
throw new Error('Vortex Scans: __next_f entry with "images" not found');
}
const images = data.slice(data.indexOf('images')).match(/http[^"]+\.(png|gif|jpg|jpeg|webp)/g) || [];
const time = query('time');
const timeDiv = time ? time.closest('div') : null;
return {
title: timeDiv ? readText(timeDiv.querySelector('div')) : '',
series: time ? readAttribute(time.closest('a'), 'href') : '',
pages: images.length,
prev: readAttribute(findClosestByContentEq('button', 'Prev', 'a'), 'href'),
next: readAttribute(findClosestByContentEq('button', 'Next', 'a'), 'href'),
listImages: images
};
}
};
// ZeroScans: page global __ZEROSCANS__ (Nuxt state) with the high-quality image list.
const siteZeroScans = {
name: 'ZeroScans',
url: /https?:\/\/(www\.)?zscans\.com\/comics\/.+/,
homepage: 'https://zscans.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: '__ZEROSCANS__',
testUrls: {
match: ['https://zscans.com/comics/some-series/12345', 'https://www.zscans.com/comics/x'],
noMatch: ['https://zscans.com/', 'https://zscansXcom/comics/x']
},
run: function () {
// at(0) in NEW -> index [0].
const images = siteCReadPath(pageWindow.__ZEROSCANS__, ['data', 0, 'current_chapter', 'high_quality']);
if (!Array.isArray(images)) {
throw new Error('ZeroScans: page global __ZEROSCANS__ has no data[0].current_chapter.high_quality');
}
const chapters = queryAll('.v-btn--router');
return {
title: queryText('title'),
series: queryAttribute('.v-breadcrumbs li:nth-child(2) + a', 'href'),
pages: images.length,
prev: readAttribute(chapters[0], 'href'),
next: readAttribute(chapters[1], 'href'),
listImages: images
};
}
};
// ---- Site modules, batch E (ported from a user-supplied reference script, 2026-10) ----
// Conversion notes (docs/07 Part B): unsafeWindow -> pageWindow, _.keys -> safeKeys, .at(i)
// -> [i], flatMap/replaceAll -> ES2017 equivalents, DOM reads -> section 06 helpers, relative
// URLs -> toAbsoluteUrl, waitVar names unchanged (MM resolves them on pageWindow with a timeout).
/**
* File extension from a one-letter server code ('p' = png...), as the HentaiFox and Imhentai
* modules receive it (page global g_th).
* @param {string} code - One-letter code.
* @returns {string}
*/
function extensionByTypeCode(code) {
switch (code) {
case 'p':
return 'png';
case 'b':
return 'bmp';
case 'g':
return 'gif';
case 'w':
return 'webp';
default:
return 'jpg';
}
}
/** nHentai.net API image-type codes: the 't' field of each page -> file extension. */
const GALLERY_API_IMAGE_EXT = { j: 'jpg', p: 'png', w: 'webp', g: 'gif' };
// 3Hentai/HentaiVox: page global readerPages (needs waitVar; MM rejects numeric/empty values).
const site3hentai = {
name: ['3Hentai', 'HentaiVox'],
url: /https?:\/\/(www|es|fr|it|pt|ru)?\.?(3hentai|hentaivox)\.(net|com)\/(d|view)\/.+\/.+/,
homepage: ['https://3hentai.net/', 'https://hentaivox.com/'],
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'readerPages',
testUrls: {
match: ['https://3hentai.net/d/some-title/1/', 'https://hentaivox.com/view/abc/def'],
noMatch: ['https://3hentai.net/', 'https://example.com/d/a/b']
},
run: function () {
const readerPages = pageWindow.readerPages;
if (!readerPages || !readerPages.pages) {
throw new Error('3Hentai: page global readerPages.pages not found');
}
const baseUri = String(readerPages.baseUriImg || '');
const listImages = sitesAImageList(safeKeys(readerPages.pages).map(function (key) {
const page = readerPages.pages[key] || {};
return baseUri.replace('%s', page.f || '');
}));
return {
title: String(readerPages.title || '').replace(/- Page.+/, '').trim(),
series: String(readerPages.baseUri || '').replace('%s', ''),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// 8Muses: page global link_images; falls back to clicking "next" through every page (bruteforceClick).
const siteEightMuses = {
name: ['8Muses.com', '8Muses.io'],
obs: 'Slow start, bruteforce may be required',
url: /https?:\/\/(comics\.)?8muses\.(com|io)\/(comics\/)?picture\/.+/,
homepage: ['https://comics.8muses.com/', 'https://8muses.io/'],
language: [Language.ENGLISH],
category: Category.COMIC,
testUrls: {
match: ['https://comics.8muses.com/comics/picture/12345/a', 'https://8muses.io/picture/abc'],
noMatch: ['https://comics.8muses.com/', 'https://example.com/picture/1']
},
run: async function () {
const linkImages = pageWindow.link_images;
const startImages = Array.isArray(linkImages) ? linkImages.slice(1) : [];
const lastHref = queryAttribute('link[rel="last"]', 'href');
const lastMatch = lastHref ? lastHref.match(/\d+$/) : null;
const num = startImages.length > 0 ? startImages.length : (lastMatch ? parseInt(lastMatch[0], 10) : 0);
if (!(num > 0)) {
throw new Error('8Muses: no link_images and no last-page link found');
}
const breadcrumb = queryAll('.top-menu-breadcrumb li:not(:last-child)').map(function (item) {
return readText(item).trim();
}).join('/');
const seriesLink = query('.top-menu-breadcrumb li:nth-last-child(2) a');
const manga = {
title: breadcrumb,
series: readAttribute(seriesLink, 'href'),
pages: num,
prev: '#',
next: '#',
lazy: false,
timer: 10,
// [''] placeholder when the page global is missing: an empty list would make the
// viewer reject the result before before() runs (validateMangaResult needs a strategy).
listImages: startImages.length > 0 ? startImages : [''],
before: async function () {
if (pageWindow.link_images && pageWindow.link_images.length > 0) {
return;
}
manga.listImages = await bruteforceClick(function () {
const prev = query('.page-prev');
let guard = 0;
while (queryText('.c-dropdown-toggle').match(/\d+/) !== null && queryText('.c-dropdown-toggle').match(/\d+/)[0] !== '1' && guard < 200) {
if (prev) {
prev.dispatchEvent(new Event('click'));
}
guard += 1;
}
}, num, '.page-next', '.p-picture', '.photo img', 'src');
}
};
return manga;
}
};
// 9Hentai: same-origin POST API /api/getBookByID.
const siteNineHentai = {
name: '9Hentai',
url: /https?:\/\/(www\.)?9hentai\.(so)\/g\/.+\/.+/,
homepage: 'https://9hentai.so',
language: [Language.ENGLISH],
category: Category.MANGA,
waitAttr: ['#jumpPageModal input', 'max'],
testUrls: {
match: ['https://9hentai.so/g/12345/some-title', 'https://www.9hentai.so/g/1/x'],
noMatch: ['https://9hentai.so/', 'https://9hentai.com/g/1/x']
},
run: async function () {
const idMatch = window.location.pathname.match(/\d+/);
const bookId = parseInt(idMatch ? idMatch[0] : '0', 10);
const api = await fetchJson('/api/getBookByID', {
method: 'POST',
body: JSON.stringify({ id: bookId }),
headers: { 'Content-Type': 'application/json' }
});
if (!api || !api.results) {
throw new Error('9Hentai: /api/getBookByID returned no results');
}
const total = api.results.total_page;
const server = String(api.results.image_server || '').replace('.com', '.so') + api.results.id;
const listImages = [];
for (let i = 0; i < total; i++) {
listImages.push(server + '/' + (i + 1) + '.jpg');
}
return {
title: api.results.title,
series: '/g/' + api.results.id + '/',
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// AkumaMoe: page globals img_lst / img_prt (wait until every option has its image).
const siteAkumamoe = {
name: 'AkumaMoe',
url: /https?:\/\/(www\.)?akuma\.moe\/g\/.+\/.+/,
homepage: 'https://akuma.moe',
language: [Language.RAW],
category: Category.MANGA,
waitFunc: function () {
const list = pageWindow.img_lst;
if (!Array.isArray(list) || list.length === 0) {
return false;
}
return list.length === queryAll('.reader-nav:first-child .nav-select option').length;
},
testUrls: {
match: ['https://akuma.moe/g/some-title/1/', 'https://www.akuma.moe/g/x/y'],
noMatch: ['https://akuma.moe/', 'https://akuma.moe/g/']
},
run: async function () {
const list = pageWindow.img_lst;
const prefix = pageWindow.img_prt;
if (!Array.isArray(list) || typeof prefix !== 'string') {
throw new Error('AkumaMoe: page globals img_lst/img_prt not found');
}
const slugMatch = window.location.pathname.match(/\/g\/([^/]+)\//);
const listImages = sitesAImageList(list.map(function (img) {
return prefix + '/' + img;
}));
return {
title: queryText('h1.sr-only').replace(/^Reading /, ''),
series: 'https://akuma.moe/g/' + (slugMatch ? slugMatch[1] : '') + '/',
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// BestPornComix: gallery links (<figure><a href>) are the full images.
const siteBestporncomix = {
name: 'BestPornComix',
url: /https?:\/\/(www\.)?bestporncomix\.com\/gallery\/.+/,
homepage: 'https://www.bestporncomix.com',
language: [Language.ENGLISH],
category: Category.COMIC,
waitTime: 5000,
testUrls: {
match: ['https://bestporncomix.com/gallery/some-comic/', 'https://www.bestporncomix.com/gallery/x'],
noMatch: ['https://bestporncomix.com/', 'https://bestporncomix.com/gallery']
},
run: function () {
const listImages = sitesAImageList(queryAll('figure a').map(function (link) {
return readAttribute(link, 'href');
}));
return {
title: queryText('h1.entry-title'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// DoujinMoeNM: .doujin elements carry the file in data-file.
const siteDoujinmoe = {
name: 'DoujinMoeNM',
url: /https?:\/\/(www\.)?doujins\.com\/.+/,
homepage: 'https://doujins.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '.doujin',
testUrls: {
match: ['https://doujins.com/abc123', 'https://www.doujins.com/x/y'],
noMatch: ['https://doujins.com/', 'https://example.com/abc']
},
run: function () {
const listImages = sitesAImageList(queryAll('.doujin').map(function (img) {
return readAttribute(img, 'data-file');
}));
const titles = queryAll('.folder-title a');
return {
title: readText(titles[titles.length - 1]).trim(),
series: readAttribute(titles[titles.length - 2], 'href'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// Dragon Translation: chapter images, minus the discord banner.
const siteDragontranslation = {
name: 'Dragon Translation',
url: /https?:\/\/(www\.)?dragontranslation\.net\/leer\/.+/,
homepage: 'https://dragontranslation.net/es',
language: [Language.SPANISH],
category: Category.MANGA,
waitEle: '#chapter_imgs img',
testUrls: {
match: ['https://dragontranslation.net/leer/some-title/1', 'https://www.dragontranslation.net/leer/x'],
noMatch: ['https://dragontranslation.net/', 'https://dragontranslation.net/leer']
},
run: function () {
const listImages = sitesAImageList(queryAll('#chapter_imgs img').map(function (img) {
return readAttribute(img, 'src');
}).filter(function (src) {
return src !== '' && src !== '/discord2.jpg';
}));
const prevIcon = query('.fa-chevron-circle-left');
const nextIcon = query('.fa-chevron-circle-right');
return {
title: queryText('h1'),
series: queryAttribute('h2 + div a', 'href'),
pages: listImages.length,
prev: prevIcon && prevIcon.parentElement ? readAttribute(prevIcon.parentElement, 'href') : '',
next: nextIcon && nextIcon.parentElement ? readAttribute(nextIcon.parentElement, 'href') : '',
listImages: listImages
};
}
};
// ExHentai/e-Hentai: gallery index pages give listPages; each page needs a fresh nl= token (reload).
const siteExhentai = {
name: ['ExHentai', 'e-Hentai'],
url: /https?:\/\/(g\.)?(exhentai|e-hentai)\.org\/s\/.+\/.+/,
homepage: ['https://exhentai.org/', 'https://e-hentai.org/'],
language: [Language.ENGLISH],
obs: 'May get your IP Banned, use with moderation',
category: Category.MANGA,
testUrls: {
match: ['https://e-hentai.org/s/abc123/456-7', 'https://g.exhentai.org/s/x/1-1'],
noMatch: ['https://e-hentai.org/', 'https://e-hentai.org/g/123/abc']
},
run: async function () {
const num = parseInt(queryText('.sn div span:nth-child(2)') || '0', 10);
if (!(num > 0)) {
throw new Error('ExHentai: page count not found');
}
const gallery = queryAttribute('.sb a', 'href').replace(/\?p=\d+/, '');
if (gallery === '') {
throw new Error('ExHentai: gallery link not found');
}
const galleryCount = Math.ceil(num / 20);
// FIX (vs the first port): the gallery index pages are fetched in parallel (as the reference
// script does with Promise.all). A sequential await loop made startup take ceil(num/20)
// round-trips added together before the reader could open.
const galleryDocs = await Promise.all(sequence(galleryCount).map(function (pageIndex) {
return fetchDocument(gallery + '?p=' + (pageIndex - 1));
}));
let listPages = [];
galleryDocs.forEach(function (doc) {
queryAll('#gdt a', doc).forEach(function (item) {
listPages.push(readAttribute(item, 'href'));
});
});
listPages = uniqueValues(listPages.filter(function (href) {
return href !== '';
}));
return {
title: queryText('#i1 h1'),
series: gallery,
pages: num,
begin: parseInt(queryText('div#i2 span') || '1', 10),
prev: '#',
next: '#',
listPages: listPages,
img: '#img',
lazy: true,
reload: async function (page) {
const oldUrl = String(listPages[page - 1] || '');
const firstHtml = await fetchTextOrThrow(oldUrl);
const slugMatch = firstHtml.match(/nl\('([\d-]+)'\)/);
if (!slugMatch) {
throw new Error('ExHentai: nl token not found on ' + oldUrl);
}
// FIX (vs the reference): it used indexOf('?') as a boolean (-1 is truthy), so it always
// joined with '&'; join with '?' when the URL has no query string yet.
const separator = oldUrl.indexOf('?') === -1 ? '?' : '&';
const refreshed = await fetchDocument(oldUrl + separator + 'nl=' + slugMatch[1]);
const image = refreshed.querySelector('#img');
const source = image ? readAttribute(image, 'src') : '';
if (source === '') {
throw new Error('ExHentai: #img not found after token refresh');
}
return source;
}
};
}
};
// FreeAdultComix: <figure> images.
const siteFreeadultcomix = {
name: 'FreeAdultComix',
url: /https?:\/\/(www\.)?freeadultcomix\.com\/.+/,
homepage: 'https://www.freeadultcomix.com',
language: [Language.ENGLISH],
category: Category.COMIC,
waitTime: 5000,
testUrls: {
match: ['https://www.freeadultcomix.com/some-comic/', 'https://freeadultcomix.com/x'],
noMatch: ['https://www.freeadultcomix.com/', 'https://example.com/x']
},
run: function () {
const listImages = sitesAImageList(queryAll('figure img').map(function (img) {
return readAttribute(img, 'src');
}));
return {
title: queryText('.post-conteudo h1'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// FSIComics: WordPress gallery (data-large-file, src as fallback).
const siteFsicomics = {
name: 'FSIComics',
url: /https?:\/\/(www\.)?fsicomics\.com\/.+/,
homepage: 'https://fsicomics.com/',
language: [Language.ENGLISH],
category: Category.COMIC,
testUrls: {
match: ['https://fsicomics.com/some-comic/', 'https://www.fsicomics.com/x/y'],
noMatch: ['https://fsicomics.com/', 'https://example.com/x']
},
run: function () {
// FIX (vs the reference): it read data-large-file only; src fallback keeps pages whose
// markup lacks the large-file attribute instead of failing them.
const listImages = sitesAImageList(queryAll('.wp-block-gallery img').map(function (img) {
return readFirstAttribute(img, ['data-large-file', 'src']);
}));
return {
title: queryText('.s-title'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// GNTAI.net: image list inside #main > script ("var pages = [...]").
const siteGntai = {
name: 'GNTAI.net',
url: /https?:\/\/(www\.)?gntai\.net\/(?!(category|tags|autores))[^/]+\/.+/,
homepage: 'https://www.gntai.net/',
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://www.gntai.net/some-gallery/1/', 'https://gntai.net/x/y'],
noMatch: ['https://www.gntai.net/category/x/', 'https://www.gntai.net/tags/y/', 'https://www.gntai.net/']
},
run: function () {
const scriptText = queryText('#main > script');
const pagesMatch = scriptText.match(/var pages = [^;]+/);
const found = pagesMatch ? pagesMatch[0].match(/https?[^"]+/g) : null;
const listImages = sitesAImageList(found || []);
return {
title: queryText('.entry-header h1'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// HDoujin: reader state in history.state.memo (same engine as SchaleNetwork).
const siteHdoujin = {
name: 'HDoujin',
url: /https?:\/\/(www\.)?hdoujin\.org\/.+/,
homepage: 'https://hdoujin.org/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'nav select option',
testUrls: {
match: ['https://hdoujin.org/g/12345/abc/', 'https://www.hdoujin.org/x'],
noMatch: ['https://hdoujin.org/', 'https://example.com/x']
},
run: function () {
const memo = history.state ? history.state.memo : null;
const gallery = memo ? memo.gallery : null;
const data = memo ? memo.data : null;
if (!gallery || !data || !Array.isArray(data.entries)) {
throw new Error('HDoujin: history.state.memo.gallery/data.entries not found');
}
const size = gallery.resolution;
const listImages = sitesAImageList(data.entries.map(function (image) {
return data.base + '/' + image.path + '?w=' + size;
}));
return {
title: gallery.title,
series: '/g/' + gallery.id + '/' + gallery.key + '/',
pages: listImages.length,
prev: '#',
next: '#',
fetchOptions: {
method: 'GET',
redirect: 'follow'
},
listImages: listImages
};
}
};
// Hentai2Read: page global gData (image paths are CDN-relative).
const siteHentai2read = {
name: 'Hentai2Read',
url: /https?:\/\/(www\.)?hentai2read\.com\/[^/]+\/\d+(.\d+)?\//,
homepage: 'https://hentai2read.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://hentai2read.com/some-manga/1/', 'https://www.hentai2read.com/x/2.5/'],
noMatch: ['https://hentai2read.com/', 'https://hentai2read.com/some-manga/']
},
run: function () {
const gData = pageWindow.gData;
if (!gData || !Array.isArray(gData.images)) {
throw new Error('Hentai2Read: page global gData.images not found');
}
const listImages = sitesAImageList(gData.images.map(function (path) {
return 'https://static.hentaicdn.com/hentai' + path;
}));
return {
title: queryText('.reader-left-text'),
series: String(gData.mainURL || ''),
pages: listImages.length,
prev: String(gData.previousURL || ''),
next: String(gData.nextURL || ''),
listImages: listImages
};
}
};
// HentaiEra: one page per URL (listPages + #gimg, lazy data-src).
const siteHentaiera = {
name: 'HentaiEra',
url: /https?:\/\/(www\.)?hentaiera\.com\/view\/.+\/\d+\/?/,
homepage: 'https://hentaiera.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://hentaiera.com/view/some-title/3/', 'https://www.hentaiera.com/view/x/1'],
noMatch: ['https://hentaiera.com/', 'https://hentaiera.com/view/x/']
},
run: function () {
const num = parseInt(queryText('.total_pages') || '0', 10);
if (!(num > 0)) {
throw new Error('HentaiEra: page count not found');
}
const base = window.location.href.replace(/\/\d*\/?$/, '');
const listPages = [];
for (let i = 0; i < num; i++) {
listPages.push(base + '/' + (i + 1));
}
return {
title: queryText('h1').replace(/ - Page .+$/, ''),
series: queryAttribute('.return_btn', 'href'),
pages: num,
prev: '#',
next: '#',
listPages: listPages,
img: '#gimg',
lazyAttr: 'data-src'
};
}
};
// HentaiForce: page global readerPages (baseUriImg has %c = folder and %s = file).
const siteHentaiforce = {
name: 'HentaiForce',
url: /https?:\/\/(www\.)?hentaiforce\.net\/view\/.+\/\d+/,
homepage: 'https://hentaiforce.net/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://hentaiforce.net/view/some-title/3', 'https://www.hentaiforce.net/view/x/1'],
noMatch: ['https://hentaiforce.net/', 'https://hentaiforce.net/view/x/']
},
run: function () {
const readerPages = pageWindow.readerPages;
if (!readerPages || typeof readerPages.lastPage !== 'number' || !readerPages.pages) {
throw new Error('HentaiForce: page global readerPages not found');
}
const template = String(readerPages.baseUriImg || '');
const listImages = [];
for (let i = 1; i <= readerPages.lastPage; i++) {
const page = readerPages.pages[i] || {};
listImages.push(template.replace('%c', page.l || '').replace('%s', page.f || ''));
}
const clean = sitesAImageList(listImages);
return {
title: queryText('h1').replace(/ - Page .+$/, ''),
series: queryAttribute('.reader-go-back', 'href'),
pages: clean.length,
prev: '#',
next: '#',
listImages: clean
};
}
};
// HentaiFox: page count from .total_pages, URLs built from #gimg + g_th type codes.
const siteHentaifox = {
name: 'HentaiFox',
url: /https?:\/\/(www\.)?hentaifox\.com\/g\/.+/,
homepage: 'https://www.hentaifox.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'g_th',
waitFunc: function () {
const image = query('#gimg');
return image !== null && image.classList.contains('loaded');
},
testUrls: {
match: ['https://hentaifox.com/g/12345-some-title/1/', 'https://www.hentaifox.com/g/x/'],
noMatch: ['https://hentaifox.com/', 'https://hentaifox.com/g/']
},
run: function () {
const num = parseInt(queryText('.total_pages') || '', 10);
if (!(num > 0)) {
throw new Error('HentaiFox: page count not found');
}
const table = pageWindow.g_th;
const first = queryAttribute('#gimg', 'src').replace(/\d+\.\w+$/, '');
const listImages = [];
for (let i = 1; i <= num; i++) {
const row = table ? table[i] : null;
const code = row ? row[0] : '';
listImages.push(first + i + '.' + extensionByTypeCode(code));
}
const cleanImages = sitesAImageList(listImages);
return {
title: queryText('title').replace(/ - Page .+/, '').trim(),
series: queryAttribute('.browse_buttons a', 'href'),
pages: cleanImages.length,
prev: '#',
next: '#',
listImages: cleanImages
};
}
};
// HentaiHand/nHentai.com: DOM images first, then gallery-page thumbnails, then URL pattern.
const siteHentaihand = {
name: ['HentaiHand', 'nHentai.com'],
url: /https?:\/\/(www\.)?(hentaihand|nhentai)\.com\/(?:[a-z]{2}\/)?(?:comic|reader)\/.+\/\d+/,
homepage: ['https://hentaihand.com/', 'https://nhentai.com/'],
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '.reader img, #reader img, .comic-page img, .page-image img, #image-container img, .pages-selector, .comic-title',
testUrls: {
match: ['https://hentaihand.com/comic/some-title/1/', 'https://nhentai.com/reader/abc/5/'],
noMatch: ['https://hentaihand.com/', 'https://nhentai.com/comic/x/']
},
run: async function () {
const seriesLink = query('.reader-header h5 a, .comic-title a, a.back-to-gallery, a.go-back, a[href*="/comic/"]');
const series = readAttribute(seriesLink, 'href') !== '' ? readAttribute(seriesLink, 'href') : window.location.href.replace(/\/\d+\/?$/, '');
const titleElement = query('.reader-header h5, .comic-title, h1.title, h1');
const title = titleElement ? readText(titleElement) : queryText('title').replace(/\s*-\s*Page.*$/i, '').trim();
const domImages = queryAll('.reader img, #reader img, .comic-page img, .page-image img, .reader-images img');
if (domImages.length > 1) {
const listImages = sitesAImageList(domImages.map(function (img) {
return readFirstAttribute(img, ['data-src', 'src']);
}));
return {
title: title,
series: series,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
try {
const fullSeriesUrl = series.indexOf('http') === 0 ? series : window.location.origin + series;
const galleryDocument = await fetchDocument(fullSeriesUrl);
const containers = queryAll('.comic-thumb, .gallery-thumb, .thumbnails > div, #thumbnail-container .thumb-container, .thumbs > div, .grid > div', galleryDocument);
if (containers.length > 0) {
const listImages = sitesAImageList(containers.map(function (container) {
const img = container.querySelector('img');
return (img ? readFirstAttribute(img, ['data-src', 'src']) : '').replace(/\/thumb\//, '/image/').replace(/t\.([a-zA-Z0-9]+)$/, '.$1');
}));
const galleryTitle = galleryDocument.querySelector('h1.title, h1, .comic-title');
return {
title: galleryTitle ? galleryTitle.textContent.trim() : title,
series: series,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
} catch (ignored) {
logDebug('HentaiHand: gallery thumbnail fetch failed, trying the URL pattern');
}
const optionCount = queryAll('select.pages-selector option, select.page-select option, .page-dropdown option').length;
const firstDom = domImages[0] ? readFirstAttribute(domImages[0], ['data-src', 'src']) : queryAttribute('#image-container img, .reader img', 'src');
const pattern = firstDom.match(/^(.*\/)(0*1)(\.[a-zA-Z0-9]+)(?:\?.*)?$/);
const total = optionCount > 0 ? optionCount : (domImages.length > 0 ? domImages.length : 1);
if (pattern && total > 1) {
const pad = pattern[2].length;
const listImages = [];
for (let i = 1; i <= total; i++) {
let padded = String(i);
while (padded.length < pad) {
padded = '0' + padded;
}
listImages.push(pattern[1] + padded + pattern[3]);
}
return {
title: title,
series: series,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
const fallback = sitesAImageList(domImages.map(function (img) {
return readFirstAttribute(img, ['data-src', 'src']);
}));
return {
title: title,
series: series,
pages: fallback.length,
prev: '#',
next: '#',
listImages: fallback
};
}
};
// HentaIHere: page globals rff_imageList / rff_pageTitle (needs waitVar).
const siteHentaihere = {
name: 'HentaIHere',
url: /https?:\/\/(www\.)?hentaihere\.com\/.+\/.+\/.+/,
homepage: 'https://www.hentaihere.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'rff_imageList',
testUrls: {
match: ['https://www.hentaihere.com/m/some-title/1/', 'https://hentaihere.com/a/b/c'],
noMatch: ['https://www.hentaihere.com/', 'https://www.hentaihere.com/m/x/']
},
run: function () {
const imageList = pageWindow.rff_imageList;
if (!Array.isArray(imageList) || imageList.length === 0) {
throw new Error('HentaIHere: page global rff_imageList not found');
}
const prefix = queryAttribute('#arf-reader-img', 'src').replace(/\d.+/, '');
const listImages = sitesAImageList(imageList.map(function (img) {
return prefix + img;
}));
return {
title: String(pageWindow.rff_pageTitle || '').replace(/.+\|/, '').trim(),
series: String(pageWindow.rff_thisManga || ''),
pages: listImages.length,
prev: String(pageWindow.rff_previousChapter || ''),
next: String(pageWindow.rff_nextChapter || ''),
listImages: listImages
};
}
};
// HentaiNexus: page global pageData (avif preferred, fallback chain per page).
const siteHentainexus = {
name: 'HentaiNexus',
url: /https?:\/\/(www\.)?hentainexus\.com\/read\/.+/,
homepage: 'https://hentainexus.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'pageData',
testUrls: {
match: ['https://hentainexus.com/read/12345', 'https://www.hentainexus.com/read/x'],
noMatch: ['https://hentainexus.com/', 'https://example.com/read/1']
},
run: function () {
const pageData = pageWindow.pageData;
if (!Array.isArray(pageData) || pageData.length === 0) {
throw new Error('HentaiNexus: page global pageData not found');
}
const listImages = sitesAImageList(pageData.map(function (item) {
if (item) {
if (item.image_avif !== null && item.image_avif !== undefined) {
return item.image_avif;
}
if (item.image_fallback !== null && item.image_fallback !== undefined) {
return item.image_fallback;
}
return item.image;
}
return '';
}));
return {
title: queryText('title').replace(/^\[[\d/]+\]/, '').trim(),
series: queryAttribute('#returnGalleryFooter a', 'href'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// HenTalk: SvelteKit __data.json payload (fragile indexes, guarded with a clear error).
const siteHentalk = {
name: 'HenTalk',
// FIX (vs the reference): it left the dot in '(www.)' unescaped, matching e.g. 'wwwXhentalk'.
url: /https?:\/\/(www\.)?hentalk\.pw/,
homepage: 'https://hentalk.pw/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://hentalk.pw/read/abc', 'https://www.hentalk.pw/x'],
noMatch: ['https://example.com/', 'https://hentalkXpw/']
},
run: async function () {
const payload = await fetchJson(window.location.pathname + '/__data.json?x-sveltekit-trailing-slash=1&x-sveltekit-invalidated=001');
if (!payload || !Array.isArray(payload.nodes) || !payload.nodes[2] || !payload.nodes[2].data) {
throw new Error('HenTalk: __data.json has no nodes[2].data');
}
const api = payload.nodes[2].data;
const galleryRef = api.find(function (entry) {
return entry && entry.gallery;
});
const gallery = galleryRef ? api[galleryRef.gallery] : null;
if (!gallery || !gallery.images) {
throw new Error('HenTalk: gallery reference not found in __data.json');
}
const hashRef = api.find(function (entry) {
return entry && entry.hash && entry.id;
});
const slug = api[gallery.hash] || (hashRef ? api[hashRef.hash] : null);
const imageRefs = api[gallery.images];
if (!slug || !Array.isArray(imageRefs)) {
throw new Error('HenTalk: image list reference not found in __data.json');
}
const listImages = sitesAImageList(imageRefs.map(function (ref) {
const image = api[ref];
const file = image ? api[image.filename] : null;
const fileName = (file && file.filename) || file;
return typeof fileName === 'string' ? 'https://hentalk.pw/image/' + slug + '/' + fileName : '';
}));
return {
title: api[gallery.title],
series: window.location.href.replace(/read\/.+/, ''),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// Hitomi: page globals galleryinfo + url_from_url_from_hash.
const siteHitomi = {
name: 'Hitomi',
url: /https?:\/\/hitomi\.la\/reader\/.+/,
homepage: 'https://hitomi.la/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitAttr: ['#comicImages img', 'src'],
waitVar: 'galleryinfo',
testUrls: {
match: ['https://hitomi.la/reader/12345.html', 'https://hitomi.la/reader/abc.html#1'],
noMatch: ['https://hitomi.la/', 'https://hitomi.la/galleries/1.html']
},
run: function () {
const gallery = pageWindow.galleryinfo;
const hashToUrl = pageWindow.url_from_url_from_hash;
if (!gallery || !Array.isArray(gallery.files) || typeof hashToUrl !== 'function') {
throw new Error('Hitomi: page globals galleryinfo/url_from_url_from_hash not found');
}
const listImages = sitesAImageList(gallery.files.map(function (file) {
return hashToUrl(gallery, file, 'webp');
}));
return {
title: queryText('title').replace('| Hitomi.la', '').trim(),
series: queryAttribute('.brand', 'href'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// Imhentai: hidden inputs (gallery, dir, count) + g_th type codes + random_server host.
const siteImhentai = {
name: 'Imhentai',
url: /https?:\/\/(www\.)?imhentai\.xxx\/view\/.+\/.+\//,
homepage: 'https://imhentai.xxx/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitVar: 'g_th',
testUrls: {
match: ['https://imhentai.xxx/view/some-title/1/', 'https://www.imhentai.xxx/view/x/y/'],
noMatch: ['https://imhentai.xxx/', 'https://imhentai.xxx/view/x/']
},
run: async function () {
const galleryId = queryAttribute('#gallery_id', 'value');
const imageDir = queryAttribute('#image_dir', 'value');
const num = parseInt(queryAttribute('#pages', 'value') || '', 10);
const server = pageWindow.random_server;
if (galleryId === '' || imageDir === '' || !(num > 0) || typeof server !== 'string' || server === '') {
throw new Error('Imhentai: gallery inputs or page global random_server not found');
}
const table = pageWindow.g_th;
const listImages = [];
for (let i = 1; i <= num; i++) {
const row = table ? table[i] : null;
listImages.push('//' + server + '/' + imageDir + '/' + galleryId + '/' + i + '.' + extensionByTypeCode(row ? row[0] : ''));
}
// sitesAImageList turns the protocol-relative '//host/...' entries into absolute URLs (the
// ZIP download requests them through the userscript manager, which needs a full URL).
const cleanImages = sitesAImageList(listImages);
return {
title: queryText('title'),
series: queryAttribute('.return_btn', 'href'),
pages: cleanImages.length,
prev: '#',
next: '#',
listImages: cleanImages
};
}
};
// KingComix/Chochox/Comics18: WordPress article images (lazy attributes first).
const siteKingcomix = {
name: ['KingComix', 'Chochox', 'Comics18'],
url: /https?:\/\/(www\.)?(kingcomix|chochox|comics18)\.(com|org)\/.+/,
homepage: ['https://kingcomix.com/', 'https://chochox.com/porno/', 'https://comics18.org/'],
language: [Language.ENGLISH, Language.SPANISH],
category: Category.COMIC,
testUrls: {
match: ['https://kingcomix.com/some-comic/', 'https://chochox.com/porno/x/', 'https://comics18.org/y/'],
noMatch: ['https://kingcomix.com/', 'https://example.com/x/']
},
run: function () {
const listImages = sitesAImageList(queryAll('figure img, .entry-content img:not(a img), .wp-content img').map(function (img) {
return readFirstAttribute(img, ['data-src', 'data-full-url', 'data-lazy-src', 'src']);
}));
return {
title: queryText('h1.singleTitle-h1'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// Luscious: album GraphQL API on another host (manager request, not page fetch, for CORS).
const siteLuscious = {
name: 'Luscious',
url: /https?:\/\/(www\.)?luscious\.net\/.+\/read\/.+/,
homepage: 'https://luscious.net/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '.album-info div',
testUrls: {
match: ['https://luscious.net/albums/x_123/read/1', 'https://www.luscious.net/c/y_1/read/2'],
noMatch: ['https://luscious.net/', 'https://luscious.net/albums/x_123/']
},
run: async function () {
const countText = queryText('input[name="page_number"] + span');
const countMatch = countText ? countText.match(/\d+/) : null;
const num = parseInt(countMatch ? countMatch.pop() : '0', 10);
if (!(num > 0)) {
throw new Error('Luscious: page count not found');
}
const albumHref = queryAttribute('.album-heading a', 'href');
const idMatch = albumHref ? albumHref.match(/\d+\//) : null;
const albumId = parseInt(idMatch ? idMatch.toString() : '0', 10);
if (!(albumId > 0)) {
throw new Error('Luscious: album id not found');
}
const graphqlQuery = '&query=%20query%20PictureListInsideAlbum(%24input%3A%20PictureListInput!)%20%7B%20picture%20%7B%20list(input%3A%20%24input)%20%7B%20info%20%7B%20...FacetCollectionInfo%20%7D%20items%20%7B%20__typename%20id%20title%20description%20created%20like_status%20number_of_comments%20number_of_favorites%20moderation_status%20width%20height%20resolution%20aspect_ratio%20url_to_original%20url_to_video%20is_animated%20position%20permissions%20url%20tags%20%7B%20category%20text%20url%20%7D%20thumbnails%20%7B%20width%20height%20size%20url%20%7D%20%7D%20%7D%20%7D%20%7D%20fragment%20FacetCollectionInfo%20on%20FacetCollectionInfo%20%7B%20page%20has_next_page%20has_previous_page%20total_items%20total_pages%20items_per_page%20url_complete%20%7D%20';
const blockCount = Math.ceil(num / 50);
// Same fix as ExHentai: the album blocks are independent, so they are fetched in parallel
// (as the reference script does with Promise.all), not one per round-trip.
const blockUrls = [];
for (let block = 0; block < blockCount; block++) {
blockUrls.push('https://apicdn.luscious.net/graphql/nobatch/?operationName=PictureListInsideAlbum&variables={"input":{"filters":[{"name":"album_id","value":"' + albumId + '"}],"display":"position","items_per_page":50,"page":' + (block + 1) + '}}' + graphqlQuery);
}
const blocks = await Promise.all(blockUrls.map(function (apiUrl) {
return fetchJsonViaManager(apiUrl, { method: 'GET' });
}));
let listImages = [];
blocks.forEach(function (answer) {
const items = siteCReadPath(answer, ['data', 'picture', 'list', 'items']);
if (Array.isArray(items)) {
listImages = listImages.concat(items.map(function (img) {
return img ? img.url_to_original : '';
}));
}
});
listImages = sitesAImageList(listImages);
if (listImages.length === 0) {
throw new Error('Luscious: GraphQL answers contain no images');
}
return {
title: queryText('.album-heading a'),
series: queryAttribute('.album-heading a', 'href'),
pages: num,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// MultPorn: player config URL in <head>, image list as XML.
const siteMultporn = {
name: 'MultPorn',
url: /https?:\/\/(www\.)?multporn\.net\/(comics|hentai_manga)\/.+/,
homepage: 'https://multporn.net/',
language: [Language.ENGLISH],
category: Category.COMIC,
testUrls: {
match: ['https://multporn.net/comics/some-title', 'https://www.multporn.net/hentai_manga/x'],
noMatch: ['https://multporn.net/', 'https://multporn.net/tags/x']
},
run: async function () {
const headText = queryText('head');
const configMatch = headText.match(/"configUrl":"(.+?)",/);
const configUrl = configMatch ? configMatch[1].replace(/\\/g, '') : '';
if (configUrl === '') {
throw new Error('MultPorn: configUrl not found in <head>');
}
const xmlText = await fetchTextOrThrow(configUrl);
const xml = parseHtml(xmlText, 'text/xml');
const listImages = sitesAImageList(queryAll('image', xml).map(function (img) {
return readAttribute(img, 'imageURL');
}));
return {
title: queryText('#page-title'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// MyHentaiGallery: one page per URL (listPages + .gallery-slide img).
const siteMyhentaigallery = {
name: 'MyHentaiGallery',
url: /https?:\/\/(www\.)?myhentaigallery\.com\/g\/.+\/\d+/,
homepage: 'https://www.myhentaigallery.com',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://www.myhentaigallery.com/g/some-title/1', 'https://myhentaigallery.com/g/x/2'],
noMatch: ['https://www.myhentaigallery.com/', 'https://www.myhentaigallery.com/g/x/']
},
run: function () {
const nextItem = query('#js__pagination__next');
const lastLink = nextItem && nextItem.parentElement ? nextItem.parentElement.previousElementSibling : null;
const lastAnchor = lastLink ? lastLink.querySelector('a') : null;
const num = parseInt(lastAnchor ? readText(lastAnchor) : '', 10);
if (!(num > 0)) {
throw new Error('MyHentaiGallery: page count not found');
}
const base = window.location.href.replace(/\/\d+$/, '');
const listPages = [];
for (let i = 1; i <= num; i++) {
listPages.push(base + '/' + i);
}
return {
title: queryText('title'),
series: queryAttribute('.back-to-gallery a', 'href'),
pages: num,
prev: '#',
next: '#',
listPages: listPages,
img: '.gallery-slide img'
};
}
};
// nHentai.net: API first (media_id + type codes), then v2 API, gallery thumbnails, URL pattern.
const siteNhentaiNet = {
name: 'nHentai.net',
url: /https?:\/\/(www\.)?nhentai\.net\/g\/.+/,
homepage: 'https://nhentai.net/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#image-container img',
testUrls: {
match: ['https://nhentai.net/g/12345/1/', 'https://www.nhentai.net/g/abc/'],
noMatch: ['https://nhentai.net/', 'https://nhentai.net/search/?q=x']
},
run: async function () {
const parts = window.location.pathname.split('/');
const galleryId = parts[2] || '';
const backLink = query('.go-back, .back_btn');
const series = readAttribute(backLink, 'href') !== '' ? readAttribute(backLink, 'href') : '/g/' + galleryId + '/';
const title = queryText('title').split('- Page')[0].trim();
try {
const api = await fetchJson('https://nhentai.net/api/gallery/' + galleryId);
if (api && api.media_id && api.images && Array.isArray(api.images.pages)) {
const listImages = sitesAImageList(api.images.pages.map(function (page, i) {
return 'https://i.nhentai.net/galleries/' + api.media_id + '/' + (i + 1) + '.' + (GALLERY_API_IMAGE_EXT[page.t] || 'jpg');
}));
const pretty = api.title ? (api.title.pretty || api.title.english) : '';
return {
title: pretty || title,
series: series,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
} catch (ignored) {
logDebug('nHentai.net: v1 API failed, trying v2');
}
try {
const cdn = await fetchJson('https://nhentai.net/api/v2/config');
const gallery = await fetchJson('https://nhentai.net/api/v2/galleries/' + galleryId);
if (gallery && Array.isArray(gallery.pages) && cdn && Array.isArray(cdn.image_servers) && cdn.image_servers.length > 0) {
const listImages = sitesAImageList(gallery.pages.map(function (img) {
const host = cdn.image_servers[Math.floor(Math.random() * cdn.image_servers.length)];
return host + '/' + img.path;
}));
return {
title: title,
series: series,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
} catch (ignored2) {
logDebug('nHentai.net: v2 API failed, trying gallery thumbnails');
}
try {
const galleryDocument = await fetchDocument('https://nhentai.net/g/' + galleryId + '/');
const containers = queryAll('#thumbnail-container .thumb-container', galleryDocument);
if (containers.length > 0) {
const listImages = sitesAImageList(containers.map(function (container) {
const img = container.querySelector('img');
return (img ? readFirstAttribute(img, ['data-src', 'src']) : '').replace(/\/\/t(\d*)\.nhentai\.net/, '//i$1.nhentai.net').replace(/t\.([a-zA-Z0-9]+)$/, '.$1');
}));
const infoTitle = galleryDocument.querySelector('#info h1');
return {
title: infoTitle ? infoTitle.textContent.trim() : title,
series: series,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
} catch (ignored3) {
logDebug('nHentai.net: gallery fetch failed, trying the URL pattern');
}
const firstSrc = queryAttribute('#image-container img', 'src');
const pattern = firstSrc.match(/^(https?:\/\/.*\/galleries\/[^/]+\/)\d+\.([a-zA-Z0-9]+)$/);
const countText = queryText('.num-pages');
const total = parseInt(countText || '0', 10) || 1;
const listImages = pattern ? [] : [firstSrc];
if (pattern) {
for (let i = 1; i <= total; i++) {
listImages.push(pattern[1] + i + '.' + pattern[2]);
}
}
return {
title: title,
series: series,
pages: pattern ? total : listImages.length,
prev: '#',
next: '#',
listImages: sitesAImageList(listImages)
};
}
};
// nHentai.to: gallery thumbnails first, then the URL pattern.
const siteNhentaiTo = {
name: 'nHentai.to',
url: /https?:\/\/(www\.)?nhentai\.to\/g\/\d+\/\d+/,
homepage: 'https://nhentai.to/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#image-container img',
testUrls: {
match: ['https://nhentai.to/g/12345/1/', 'https://www.nhentai.to/g/1/2/'],
noMatch: ['https://nhentai.to/', 'https://nhentai.to/g/12345/']
},
run: async function () {
const backLink = query('.back_btn, .go-back');
const backHref = readAttribute(backLink, 'href');
const seriesUrl = backHref !== '' ? backHref : window.location.pathname.replace(/\/\d+\/?$/, '/');
const firstSrc = queryAttribute('#image-container img', 'src');
const countText = queryText('.num-pages');
const total = parseInt(countText || '0', 10);
try {
const fullSeriesUrl = seriesUrl.indexOf('http') === 0 ? seriesUrl : window.location.origin + seriesUrl;
const galleryDocument = await fetchDocument(fullSeriesUrl);
const containers = queryAll('#thumbnail-container .thumb-container', galleryDocument);
if (containers.length > 0) {
const listImages = sitesAImageList(containers.map(function (container) {
const img = container.querySelector('img');
return (img ? readFirstAttribute(img, ['data-src', 'src']) : '').replace(/t\.([a-zA-Z0-9]+)$/, '.$1');
}));
const infoTitle = galleryDocument.querySelector('#info h1');
const pageTitle = queryText('title').split('> Page')[0].trim();
return {
title: infoTitle ? infoTitle.textContent.trim() : pageTitle,
series: seriesUrl,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
} catch (ignored) {
logDebug('nHentai.to: gallery fetch failed, trying the URL pattern');
}
const pattern = firstSrc.match(/^(https?:\/\/.*\/galleries\/[^/]+\/)\d+\.([a-zA-Z0-9]+)$/);
const count = total > 0 ? total : 1;
const listImages = pattern ? [] : [firstSrc];
if (pattern) {
for (let i = 1; i <= count; i++) {
listImages.push(pattern[1] + i + '.' + pattern[2]);
}
}
return {
title: queryText('title').split('> Page')[0].trim(),
series: seriesUrl,
pages: pattern ? count : listImages.length,
prev: '#',
next: '#',
listImages: sitesAImageList(listImages)
};
}
};
// nHentai.xxx: hidden inputs (server, dir, id, count) + first-image extension.
const siteNhentaiXxx = {
name: 'nHentai.xxx',
url: /https?:\/\/(www\.)?nhentai\.xxx\/g\/\d+\/\d+/,
homepage: 'https://nhentai.xxx/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#pages, #fimg, .reader_wrap',
testUrls: {
match: ['https://nhentai.xxx/g/12345/1/', 'https://www.nhentai.xxx/g/1/2/'],
noMatch: ['https://nhentai.xxx/', 'https://nhentai.xxx/g/12345/']
},
run: function () {
const serverInput = query('#server_id') || query('#load_server');
const dirInput = query('#image_dir') || query('#load_dir');
const idInput = query('#gallery_id') || query('#load_id');
const pagesInput = query('#pages') || query('#load_pages');
const server = serverInput ? serverInput.value : '';
const dir = dirInput ? dirInput.value : '';
const galleryId = idInput ? idInput.value : '';
const pagesValue = pagesInput ? pagesInput.value : queryText('.pages_btn .tp');
const num = parseInt(pagesValue || '0', 10);
const firstImg = query('#fimg');
const firstSrc = firstImg ? readFirstAttribute(firstImg, ['data-src', 'src']) : '';
const extMatch = firstSrc.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/);
const ext = extMatch ? extMatch[1] : 'webp';
const base = firstSrc !== '' ? firstSrc.replace(/\/\d+\.[a-zA-Z0-9]+(?:\?.*)?$/, '') : (server !== '' && dir !== '' && galleryId !== '' ? 'https://i' + server + '.nhentaimg.com/' + dir + '/' + galleryId : '');
if (!(num > 0) || base === '') {
throw new Error('nHentai.xxx: hidden inputs and first image not found');
}
const listImages = [];
for (let i = 1; i <= num; i++) {
listImages.push(base + '/' + i + '.' + ext);
}
const backLink = query('.back_btn, .go-back');
return {
title: queryText('title').split('- Page')[0].trim(),
series: readAttribute(backLink, 'href') !== '' ? readAttribute(backLink, 'href') : window.location.pathname.replace(/\/\d+\/?$/, '/'),
pages: num,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// PornComicsHD: Angular reader images (plain DOM query, no page globals).
const sitePorncomicshd = {
name: 'PornComicsHD',
url: /https?:\/\/(www\.)?porncomicshd\.com\/es.*/,
homepage: 'https://porncomicshd.com/es',
language: [Language.SPANISH],
category: Category.COMIC,
waitEle: 'app-comic-reader img',
testUrls: {
match: ['https://porncomicshd.com/es/comic/x', 'https://www.porncomicshd.com/es/y'],
noMatch: ['https://porncomicshd.com/', 'https://porncomicshd.com/en/x']
},
run: async function () {
const listImages = sitesAImageList(queryAll('app-comic-reader img').map(function (img) {
return readAttribute(img, 'src');
}));
return {
title: queryText('h1'),
pages: listImages.length,
prev: '#',
next: '#',
lazy: false,
listImages: listImages
};
}
};
// Pururin: first-image URL with the page number swapped per option.
const sitePururin = {
name: 'Pururin',
url: /https?:\/\/(www\.)?pururin\.me\/(view|read)\/.+\/.+\/.+/,
homepage: 'https://pururin.me/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitAttr: ['.img-viewer img', 'src'],
testUrls: {
match: ['https://pururin.me/view/12345/1/some-title/', 'https://www.pururin.me/read/x/y/z'],
noMatch: ['https://pururin.me/', 'https://pururin.me/view/x/']
},
run: function () {
const src = queryAttribute('.img-viewer img', 'src');
const options = queryAll('.img-select option');
if (src === '' || options.length === 0) {
throw new Error('Pururin: viewer image or page options not found');
}
const listImages = [];
for (let i = 0; i < options.length; i++) {
listImages.push(src.replace(/\/\d+\./, '/' + (i + 1) + '.'));
}
return {
title: queryText('.title'),
series: queryAttribute('.breadcrumb-item:nth-child(4) a', 'href'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// SchaleNetwork: reader state in history.state.memo (same engine as HDoujin).
const siteSchalenetwork = {
name: 'SchaleNetwork',
url: /https?:\/\/(www\.)?(niyaniya|shupogaki|hoshino)\.(moe|one)/,
homepage: 'https://schale.network/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: 'nav select option',
testUrls: {
match: ['https://niyaniya.moe/g/abc/', 'https://hoshino.one/x', 'https://shupogaki.moe/y'],
noMatch: ['https://example.com/', 'https://niyaniyaXmoe/x']
},
run: async function () {
const memo = history.state ? history.state.memo : null;
const gallery = memo ? memo.gallery : null;
const data = memo ? memo.data : null;
if (!gallery || !data || !Array.isArray(data.entries)) {
throw new Error('SchaleNetwork: history.state.memo.gallery/data.entries not found');
}
const size = gallery.resolution;
const listImages = sitesAImageList(data.entries.map(function (image) {
return data.base + '/' + image.path + '?w=' + size;
}));
return {
title: gallery.title,
series: '/g/' + gallery.id + '/' + gallery.key + '/',
pages: listImages.length,
prev: '#',
next: '#',
fetchOptions: {
method: 'GET',
redirect: 'follow'
},
listImages: listImages
};
}
};
// Simply-Hentai: Next.js payload (#__NEXT_DATA__) holds the full-size URLs.
const siteSimplyhentai = {
name: 'Simply-Hentai',
url: /https?:\/\/(www\.)?simply-hentai\.com\/.+\/page\/.+/,
homepage: 'https://simply-hentai.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
waitEle: '#__NEXT_DATA__',
testUrls: {
match: ['https://simply-hentai.com/x/page/1/', 'https://www.simply-hentai.com/a/b/page/2'],
noMatch: ['https://simply-hentai.com/', 'https://simply-hentai.com/x/']
},
run: async function () {
let payload = null;
try {
payload = JSON.parse(queryText('#__NEXT_DATA__') || '');
} catch (error) {
throw new Error('Simply-Hentai: #__NEXT_DATA__ JSON parse failed: ' + error.message);
}
const pages = siteCReadPath(payload, ['props', 'pageProps', 'data', 'pages']);
if (!Array.isArray(pages)) {
throw new Error('Simply-Hentai: props.pageProps.data.pages not found');
}
const listImages = sitesAImageList(pages.map(function (img) {
return img ? img.sizes.full : '';
}));
const headline = query('.content-headline a');
return {
title: readText(headline),
series: readAttribute(headline, 'href'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// TMOHentai: same reader engine as TuMangaOnline, shared run() body.
const siteTmohentai = {
name: 'TMOHentai',
url: /https?:\/\/(www\.)?tmohentai\.(com|app)\/(reader|library|view_uploads)\/.+/,
homepage: 'https://tmohentai.app/',
language: [Language.SPANISH],
category: Category.MANGA,
testUrls: {
match: ['https://tmohentai.app/reader/abc/cascade', 'https://www.tmohentai.com/library/123'],
noMatch: ['https://tmohentai.app/', 'https://tmohentai.app/profile/x']
},
run: function () {
return runTmoReader();
}
};
// Tsumino: reader API (data-opt token) + CDN template with [PAGE].
const siteTsumino = {
name: 'Tsumino',
url: /https?:\/\/(www\.)?tsumino\.com\/Read\/Index\/\d+(\?page=.+)?/,
homepage: 'https://tsumino.com/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://www.tsumino.com/Read/Index/12345', 'https://tsumino.com/Read/Index/1?page=2'],
noMatch: ['https://www.tsumino.com/', 'https://www.tsumino.com/Read/']
},
run: async function () {
const dataOpt = queryAttribute('#image-container', 'data-opt');
const dataCdn = queryAttribute('#image-container', 'data-cdn');
if (dataOpt === '' || dataCdn === '') {
throw new Error('Tsumino: #image-container data-opt/data-cdn not found');
}
const api = await fetchJson('https://www.tsumino.com/Read/Load?q=' + dataOpt);
if (!api || typeof api.reader_page_total !== 'number') {
throw new Error('Tsumino: reader API returned no page count');
}
const listImages = [];
for (let i = 1; i <= api.reader_page_total; i++) {
listImages.push(dataCdn.replace('[PAGE]', String(i)));
}
return {
title: queryText('title').replace(/.+Read/, '').trim(),
series: api.reader_start_url,
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// vermangasporno/vercomicsporno: .pp-comic-content images (lazy attributes first).
const siteVermanga = {
name: ['vermangasporno', 'vercomicsporno'],
url: /https?:\/\/(www\.)?(vermangasporno|vercomicsporno)\.com\/.+/,
homepage: ['https://vermangasporno.com/', 'https://vercomicsporno.com/'],
language: [Language.SPANISH],
category: Category.MANGA,
waitEle: '.pp-comic-content img',
testUrls: {
match: ['https://vermangasporno.com/some-comic/', 'https://vercomicsporno.com/x/y'],
noMatch: ['https://vermangasporno.com/', 'https://example.com/x']
},
run: function () {
const listImages = sitesAImageList(queryAll('.pp-comic-content img').map(function (img) {
return readFirstAttribute(img, ['data-lazy-src', 'data-src', 'src']);
}));
const titleElement = query('h1.titl') || query('title');
return {
title: readText(titleElement),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// wnacg: page selector gives listPages, the reader box (#picarea) gives the image.
const siteWnacg = {
name: 'wnacg',
url: /https?:\/\/(www\.)?wnacg\.com\/photos-view-id-.+/,
homepage: 'https://wnacg.com/',
language: [Language.ENGLISH, Language.RAW, Language.CHINESE],
category: Category.MANGA,
testUrls: {
match: ['https://wnacg.com/photos-view-id-12345.html', 'https://www.wnacg.com/photos-view-id-x'],
noMatch: ['https://wnacg.com/', 'https://wnacg.com/albums-x.html']
},
run: function () {
const options = queryAll('.pageselect option');
if (options.length === 0) {
throw new Error('wnacg: page selector not found');
}
const listPages = options.map(function (option) {
return toAbsoluteUrl(window.location.pathname.replace(/\d+/, option.value));
});
return {
title: queryText('.bread a:last-of-type'),
pages: listPages.length,
prev: '#',
next: '#',
listPages: listPages,
img: '#picarea'
};
}
};
// XlecxOne: article images (data-src, linked full image, or src), deduplicated.
const siteXlecxone = {
name: 'XlecxOne',
url: /https?:\/\/(www\.)?xlecx\.one\/.+/,
homepage: 'https://xlecx.one/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://xlecx.one/some-post/', 'https://www.xlecx.one/x/y'],
noMatch: ['https://xlecx.one/', 'https://example.com/x']
},
run: function () {
const listImages = uniqueValues(sitesAImageList(queryAll('article .page__text img, article #content-2 img').map(function (img) {
const direct = readFirstAttribute(img, ['data-src', 'data-srce', 'src']);
if (direct !== '') {
return direct;
}
const link = img.closest ? img.closest('a') : null;
return readAttribute(link, 'href');
})));
return {
title: queryText('title'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// xyzcomics: gallery links (.jig-link href) are the full images.
const siteXyzcomics = {
name: 'xyzcomics',
url: /https?:\/\/(www\.)?xyzcomics\.com\/.+/,
homepage: 'https://xyzcomics.com/',
language: [Language.ENGLISH],
category: Category.COMIC,
testUrls: {
match: ['https://xyzcomics.com/some-comic/', 'https://www.xyzcomics.com/x'],
noMatch: ['https://xyzcomics.com/', 'https://example.com/x']
},
run: function () {
const listImages = sitesAImageList(queryAll('.jig-link').map(function (link) {
return readAttribute(link, 'href');
}));
return {
title: queryText('.entry-title'),
pages: listImages.length,
prev: '#',
next: '#',
listImages: listImages
};
}
};
// Yabai: page count from the nav selector, images by clicking "Next" (bruteforceClick).
const siteYabai = {
name: 'Yabai',
url: /https?:\/\/(www\.)?yabai\.si\/g\/.+\/read/,
homepage: 'https://yabai.si/',
language: [Language.ENGLISH],
category: Category.MANGA,
testUrls: {
match: ['https://yabai.si/g/some-title/read/1', 'https://www.yabai.si/g/x/read'],
noMatch: ['https://yabai.si/', 'https://yabai.si/g/x']
},
run: async function () {
const num = queryAll('nav select option').length;
if (!(num > 0)) {
throw new Error('Yabai: page count not found');
}
const manga = {
title: queryText('title'),
series: '../',
pages: num,
prev: '#',
next: '#',
listImages: [''],
before: async function () {
manga.listImages = await bruteforceClick(function () {
const first = query('select option');
if (first) {
first.selected = true;
}
const select = query('select');
if (select) {
select.dispatchEvent(new Event('change'));
}
}, num, 'button[title="Next"]', 'h1 + div', 'img.mx-auto', 'src');
}
};
return manga;
}
};
// Madara mirrors with /porncomic/ or /read-scan/ paths (any host) and the /title/ path of
// Manytoon and Manga District only: same findMadaraImages engine. The /title/ path is limited
// to those two hosts because Comix, DemonicScans, Batoto, MangaPark and MangaDex also use
// /title/x/y addresses; a generic /title/ rule would run this module there too.
const siteMadarawph = {
name: ['Madara WordPress Plugin', 'AllPornComic', 'Manytoon', 'Manga District'],
url: /https?:\/\/(.+\/(porncomic|read-scan)|(www\.)?(manytoon|mangadistrict)\.com\/title)\/.+\/.+/,
homepage: ['#', 'https://allporncomic.com/', 'https://manytoon.com/', 'https://mangadistrict.com/'],
language: [Language.ENGLISH],
obs: 'Any site that uses the Madara WordPress Plugin with these URL paths',
category: Category.MANGA,
testUrls: {
match: ['https://manytoon.com/title/some-title/chapter-1/', 'https://example.com/porncomic/x/y'],
noMatch: ['https://manytoon.com/', 'https://manytoon.com/title/x', 'https://comix.to/title/some-manga/12345-chapter-1', 'https://demonicscans.org/title/Some-Manga/chapter/12/1', 'https://mangapark.net/title/12345-en-some-title/678-chapter-1', 'https://www.mangatoto.com/title/12345-some-title/678910-ch_1', 'https://mangadex.org/title/abc/some-title']
},
waitFunc: function () {
const images = findMadaraImages();
return images.length > 0 && images.every(function (source) {
return source !== '' && madaraImageRegex.test(source);
});
},
run: function () {
const listImages = toAbsoluteImageListB(findMadaraImages());
const seriesLink = query('.breadcrumb li:nth-child(3) a') || query('.breadcrumb li:nth-child(2) a');
return {
title: queryText('#chapter-heading'),
series: readAttribute(seriesLink, 'href'),
pages: listImages.length,
prev: queryAttribute('.prev_page', 'href'),
next: queryAttribute('.next_page', 'href'),
listImages: listImages
};
}
};
/**
* Every site module, in matching priority order. Generic CMS modules (MangaStream, FoOlSlide,
* Madara) must stay LAST: their URL patterns are broad, so a specific module should win a tie.
*/
const SITES = [
siteAsura,
siteBatoto,
siteBilibilicomics,
siteComick,
siteComix,
siteDemonicscans,
siteDynastyscans,
siteFlamecomics,
siteIkigai,
siteKagane,
siteKumanga,
siteLeercapitulo,
siteLectorxd,
siteLhtranslation,
siteLocalhost,
siteM440,
siteMangabuddy,
siteMangadex,
siteMangafox,
siteMangago,
siteMangahub,
siteMangakakalot,
siteMangaball,
siteMangaoni,
siteMangapark,
siteMangareader,
siteMangatoon,
siteMangatown,
siteManhwaweb,
siteMgeko,
siteNineanime,
siteOlympus,
siteQimanga,
siteQimanhwa,
siteReadcomicsonline,
siteReaperscans,
siteReimanga,
siteTmofans,
siteWebNovel,
siteWebToons,
siteWeebCentral,
siteWeebDex,
siteVortexscans,
siteZeroScans,
site3hentai,
siteEightMuses,
siteNineHentai,
siteAkumamoe,
siteBestporncomix,
siteDoujinmoe,
siteDragontranslation,
siteExhentai,
siteFreeadultcomix,
siteFsicomics,
siteGntai,
siteHdoujin,
siteHentai2read,
siteHentaiera,
siteHentaiforce,
siteHentaifox,
siteHentaihand,
siteHentaihere,
siteHentainexus,
siteHentalk,
siteHitomi,
siteImhentai,
siteKingcomix,
siteLuscious,
siteMultporn,
siteMyhentaigallery,
siteNhentaiNet,
siteNhentaiTo,
siteNhentaiXxx,
sitePorncomicshd,
sitePururin,
siteSchalenetwork,
siteSimplyhentai,
siteTmohentai,
siteTsumino,
siteVermanga,
siteWnacg,
siteXlecxone,
siteXyzcomics,
siteYabai,
siteMadarawph,
siteMangastreamwp,
siteFoolslide,
siteMadarawp
];
// ==========================================================================================
// ==== 24. ENTRY ============================================================================
// Console helpers (type them in the browser console on a chapter page):
// MVMDiagnose() - test every matching site module step by step, without opening the reader
// MVMDebug(true) - turn verbose logging on (stored; reload the page). MVMDebug(false) = off
// MVMSettings() - print the current settings
// MVMErrors() - print the last errors with their context and details
// MVM(begin, end) - open the reader now (optionally for a page range) after a site was found
// MVMOpenFiles() - show the local files panel (images, a folder or a ZIP/CBZ archive)
// TEST MODE: when the page defines window.__MVM_TEST_MODE__ = true BEFORE this script runs,
// the script does not start; it exposes its internals as window.__MVM_INTERNALS__ for
// tests/logic-tests.html.
// ==========================================================================================
exposeToConsole('MVMDiagnose', function () {
return diagnose(SITES);
});
exposeToConsole('MVMDebug', function (enabled) {
const value = enabled !== false;
writeStoredValue(CONFIG.verboseStorageKey, value);
setVerbose(value);
log('Verbose logging ' + (value ? 'ON' : 'OFF') + ' (stored). Reload the page to see the startup steps.');
return value;
});
exposeToConsole('MVMSettings', function () {
const report = {
effective: deepClone(SETTINGS_STATE.effective),
global: deepClone(SETTINGS_STATE.global),
local: deepClone(SETTINGS_STATE.local),
localScopeEnabled: isLocalScopeEnabled()
};
console.log(CONFIG.logPrefix + ' settings', report);
return report;
});
exposeToConsole('MVMSiteData', function (asJson) {
SITE_DATA_CAPTURES.forEach(logSiteDataReport);
if (SITE_DATA_CAPTURES.length === 0) {
log('MVMSiteData: no site data captured on this page (only some site modules record data).');
}
if (asJson === true) {
return toShortJson(SITE_DATA_CAPTURES, 5000000);
}
return SITE_DATA_CAPTURES.slice();
});
exposeToConsole('MVMErrors', function () {
if (typeof console.table === 'function') {
console.table(ERROR_HISTORY);
}
return ERROR_HISTORY.slice();
});
exposeToConsole('MVMOpenFiles', function () {
showLocalFilesPanel(openLocalImages);
return 'Choose images, a folder or a ZIP/CBZ archive in the panel';
});
if (pageWindow.__MVM_TEST_MODE__ === true || window.__MVM_TEST_MODE__ === true) {
const internals = {
CONFIG: CONFIG,
SITES: SITES,
LOCALES: LOCALES,
DEFAULT_SETTINGS: DEFAULT_SETTINGS,
isNothing: isNothing,
isPlainObject: isPlainObject,
deepClone: deepClone,
deepEqual: deepEqual,
fillDefaults: fillDefaults,
diffObjects: diffObjects,
sequence: sequence,
clamp: clamp,
escapeHtml: escapeHtml,
normalizeUrl: normalizeUrl,
isValidUrl: isValidUrl,
addCacheBuster: addCacheBuster,
toAbsoluteUrl: toAbsoluteUrl,
lastItem: lastItem,
safeKeys: safeKeys,
uniqueValues: uniqueValues,
waitUntil: waitUntil,
withTimeout: withTimeout,
validateMangaResult: validateMangaResult,
getLoadingStrategy: getLoadingStrategy,
findMatchingSites: findMatchingSites,
firstSuccessfulSite: firstSuccessfulSite,
getLocaleString: getLocaleString,
buildDefaultSettings: buildDefaultSettings,
OLD_SETTING_RENAMES: OLD_SETTING_RENAMES,
migrateOldSettingKeys: migrateOldSettingKeys,
migrateStoredSettings: migrateStoredSettings,
loadSettingsObject: loadSettingsObject,
placeholder: placeholder,
findUrlLikeValues: findUrlLikeValues,
bruteforceClick: bruteforceClick,
describeDataShape: describeDataShape,
recordSiteData: recordSiteData,
SITE_DATA_CAPTURES: SITE_DATA_CAPTURES,
start: start,
diagnose: diagnose,
countImages: countImages,
getSetting: getSetting,
setSessionSetting: setSessionSetting,
getStoredSetting: getStoredSetting,
restoreStoredSetting: restoreStoredSetting,
COLOR_PALETTES: COLOR_PALETTES,
PALETTE_SHADES: PALETTE_SHADES,
LEGACY_THEME_COLORS: LEGACY_THEME_COLORS,
RECOMMENDED_THEME_COLORS: RECOMMENDED_THEME_COLORS,
parseHexColor: parseHexColor,
rgbToHex: rgbToHex,
rgbToHsl: rgbToHsl,
hslToRgb: hslToRgb,
lightenColor: lightenColor,
darkenColor: darkenColor,
rgbToOkLch: rgbToOkLch,
okLchToHex: okLchToHex,
parseCssColor: parseCssColor,
resolveThemeColor: resolveThemeColor,
getLightnessStar: getLightnessStar,
getReadableTextColor: getReadableTextColor,
generateColorGradient: generateColorGradient,
buildThemeVariables: buildThemeVariables,
buildThemeCss: buildThemeCss,
ICONS: ICONS,
buildIconSvg: buildIconSvg,
READER_LAYOUT_CSS: READER_LAYOUT_CSS,
buildButtonHtml: buildButtonHtml,
buildButtonGroupHtml: buildButtonGroupHtml,
buildAppHtml: buildAppHtml,
VIEW_MODE_BUTTONS: VIEW_MODE_BUTTONS,
PAGE_BUTTONS: PAGE_BUTTONS,
computeGlobalPageSize: computeGlobalPageSize,
computeSteppedPageWidth: computeSteppedPageWidth,
computeBookPageSides: computeBookPageSides,
computeHeadroomPosition: computeHeadroomPosition,
isHeaderShown: isHeaderShown,
pickCurrentPage: pickCurrentPage,
getPaginationState: getPaginationState,
SETTING_APPLIERS: SETTING_APPLIERS,
ACTIONS: ACTIONS,
KEYBIND_ACTIONS: KEYBIND_ACTIONS,
runAction: runAction,
resolveDeviceMode: resolveDeviceMode,
PANELS_CSS: PANELS_CSS,
SETTING_CHOICES: SETTING_CHOICES,
SETTING_SLIDERS: SETTING_SLIDERS,
SETTINGS_PANEL_LAYOUT: SETTINGS_PANEL_LAYOUT,
getAutoScrollMaximum: getAutoScrollMaximum,
buildSwitchHtml: buildSwitchHtml,
buildSegmentedHtml: buildSegmentedHtml,
buildSelectHtml: buildSelectHtml,
buildSliderParts: buildSliderParts,
buildSwatchHtml: buildSwatchHtml,
buildThemePickerHtml: buildThemePickerHtml,
buildSettingsPanelHtml: buildSettingsPanelHtml,
buildKeybindingsPanelHtml: buildKeybindingsPanelHtml,
isRowConditionMet: isRowConditionMet,
computePageStepTarget: computePageStepTarget,
isBookRowStart: isBookRowStart,
computeBookRowTarget: computeBookRowTarget,
computeAutoScrollSpeed: computeAutoScrollSpeed,
convertSettingValue: convertSettingValue,
isAllowedSettingValue: isAllowedSettingValue,
KEYBIND_IDS: KEYBIND_IDS,
parseKeyBinding: parseKeyBinding,
getEventKeyNames: getEventKeyNames,
getEventCombos: getEventCombos,
buildKeybindTable: buildKeybindTable,
findKeybindForEvent: findKeybindForEvent,
formatKeyListForEditor: formatKeyListForEditor,
parseKeyListText: parseKeyListText,
validateKeybindEdits: validateKeybindEdits,
buildKeybindListHtml: buildKeybindListHtml,
buildKeybindEditorHtml: buildKeybindEditorHtml,
isEditableTarget: isEditableTarget,
// M3
FEATURES_CSS: FEATURES_CSS,
onSettingSaved: onSettingSaved,
saveSetting: saveSetting,
saveBookmark: saveBookmark,
removeBookmark: removeBookmark,
getBookmarkedPage: getBookmarkedPage,
padPageNumber: padPageNumber,
normalizePageRange: normalizePageRange,
computeRangeFillPercent: computeRangeFillPercent,
buildPageRangeHtml: buildPageRangeHtml,
showToast: showToast,
updateToast: updateToast,
closeToast: closeToast,
buildBookmarkListHtml: buildBookmarkListHtml,
getSafeLinkHref: getSafeLinkHref,
computeEagerPages: computeEagerPages,
isRectNearViewport: isRectNearViewport,
selectPagesNearViewport: selectPagesNearViewport,
selectLookAheadPages: selectLookAheadPages,
LOADER: LOADER,
computeCrc32: computeCrc32,
encodeUtf8: encodeUtf8,
decodeUtf8: decodeUtf8,
decodeZipFileName: decodeZipFileName,
buildZipParts: buildZipParts,
concatenateBytes: concatenateBytes,
inflateRaw: inflateRaw,
readZipDirectory: readZipDirectory,
extractZipEntry: extractZipEntry,
detectImageExtension: detectImageExtension,
getExtensionFromName: getExtensionFromName,
getImageExtension: getImageExtension,
buildPageFileName: buildPageFileName,
sanitizeFileName: sanitizeFileName,
buildZipFileName: buildZipFileName,
decodeDataUrl: decodeDataUrl,
readResponseHeader: readResponseHeader,
DOWNLOAD: DOWNLOAD,
isChapterSettled: isChapterSettled,
getDownloadReadiness: getDownloadReadiness,
isPagePending: isPagePending,
announcePartialDownload: announcePartialDownload,
updateDownloadAvailability: updateDownloadAvailability,
runDownload: runDownload,
getSettingLabel: getSettingLabel,
formatSettingValue: formatSettingValue,
shouldRemoveResumeBookmark: shouldRemoveResumeBookmark,
computeAutoScrollStep: computeAutoScrollStep,
computeScrollRemaining: computeScrollRemaining,
AUTO_SCROLL: AUTO_SCROLL,
isElementNode: isElementNode,
compareNaturally: compareNaturally,
detectArchiveFormat: detectArchiveFormat,
isLocalImageFile: isLocalImageFile,
getImageMimeTypeFromName: getImageMimeTypeFromName,
loadImagesFromArchive: loadImagesFromArchive,
loadImagesFromFiles: loadImagesFromFiles,
buildLocalFilesPanelHtml: buildLocalFilesPanelHtml,
APP_STATE: APP_STATE,
ERROR_HISTORY: ERROR_HISTORY,
resolveChapterHref: resolveChapterHref,
buildHeaderHtml: buildHeaderHtml,
onReaderClick: onReaderClick,
validateMangaResult: validateMangaResult
};
pageWindow.__MVM_INTERNALS__ = internals;
window.__MVM_INTERNALS__ = internals;
log('TEST MODE: not starting; internals exposed as window.__MVM_INTERNALS__');
} else {
start(SITES).catch(function (error) {
logError('start', error);
});
}
})();