Screen fitting, in-page navigation, next-page preloading, page jumps and left-click back. English / Chinese interface.
// ==UserScript==
// @name Better Ehentai Interface
// @name:en Better Ehentai Interface
// @name:zh-CN 更好的Ehentai界面
// @namespace local.comic-fit-screen
// @version 2.1.0
// @description Screen fitting, in-page navigation, next-page preloading, page jumps and left-click back. English / Chinese interface.
// @description:zh-CN 整页贴屏、页内翻页、后续页预加载、页码跳转和左侧点击返回;支持中文 / English 界面。
// @match http://*/*
// @match https://*/*
// @exclude *://nhentai.net/*
// @exclude *://*.nhentai.net/*
// @run-at document-idle
// @noframes
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant unsafeWindow
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const isEH = /(^|\.)(?:e-hentai|exhentai)\.(?:org|com)$/i.test(location.hostname);
const adapter = isEH && /^\/s\//.test(location.pathname) ? 'single'
: isEH && /^\/mpv\//.test(location.pathname) ? 'mpv' : 'generic';
const key = `comic-fit-screen:v1:${location.origin}:${adapter}`;
const languageKey = 'comic-fit-screen:language';
const savedLanguage = GM_getValue(languageKey, '');
let language = savedLanguage === 'en' || savedLanguage === 'zh-CN' ? savedLanguage
: /^zh\b/i.test(navigator.language || '') ? 'zh-CN' : 'en';
const translations = {
'zh-CN': {
language: '语言', settings: '设置', readingSettings: '阅读设置', controls: '阅读控制',
display: '显示方式', fitPage: '整页贴合', fitWidth: '适合宽度', focus: '黑底阅读',
enlarge: '小图放大', margin: '留边(px)', inline: '页内翻页', preload: '预加载(页)',
cache: '预加载进度', leftClick: '左侧点击返回', pick: '选图', disable: '关闭贴屏', enable: '开启贴屏',
previous: '上一页', next: '下一页', page: '页码', jump: '跳转', details: '详情',
fullscreen: '全屏', exitFullscreen: '退出全屏', failed: '失败',
menuToggle: '贴屏:开启 / 关闭(Alt+Shift+M)', menuSettings: '贴屏:设置 / 选择漫画图',
menuPick: '贴屏:重选漫画区域(其他网站)', menuClear: '贴屏:清除本站设置',
cancelled: '已取消', readerUnavailable: '阅读页不可用', noImageURL: '没有图片地址',
imageURLUnavailable: '图片地址不可用', requestFailed: '请求失败({status})',
noGallery: '没有详情页入口', pageListUnavailable: '页码列表不可用', galleryTimeout: '详情页加载超时',
pageNotFound: '未找到指定页码', imageFailed: '图片加载失败', imageUnavailable: '图片不可用',
mpvUnavailable: 'MPV 加载接口不可用', mpvFailed: 'MPV 加载失败', pageMismatch: '页码不匹配',
timeout: '加载超时', areaUnavailable: '阅读区域不存在', pageUnavailable: '页码不可用',
loadFailed: '加载失败', imageNotLoaded: '图片尚未加载', picker: '点击正文图片 · Esc 取消',
fullscreenUnavailable: '全屏不可用,可按 F11', pageOutOfRange: '页码超出范围', cleared: '设置已清除'
},
en: {
language: 'Language', settings: 'Settings', readingSettings: 'Reader settings', controls: 'Reader controls',
display: 'Display', fitPage: 'Fit page', fitWidth: 'Fit width', focus: 'Black background',
enlarge: 'Upscale small images', margin: 'Margin (px)', inline: 'In-page navigation', preload: 'Preload (pages)',
cache: 'Preload progress', leftClick: 'Click left for previous', pick: 'Pick image', disable: 'Disable fit', enable: 'Enable fit',
previous: 'Previous page', next: 'Next page', page: 'Page number', jump: 'Jump', details: 'Gallery',
fullscreen: 'Fullscreen', exitFullscreen: 'Exit fullscreen', failed: 'Failed',
menuToggle: 'Fit: on / off (Alt+Shift+M)', menuSettings: 'Fit: settings / pick image',
menuPick: 'Fit: select another image area', menuClear: 'Fit: clear site settings',
cancelled: 'Cancelled', readerUnavailable: 'Reader page unavailable', noImageURL: 'Image URL missing',
imageURLUnavailable: 'Image URL unavailable', requestFailed: 'Request failed ({status})',
noGallery: 'Gallery link missing', pageListUnavailable: 'Page list unavailable', galleryTimeout: 'Gallery request timed out',
pageNotFound: 'Page not found', imageFailed: 'Image failed to load', imageUnavailable: 'Image unavailable',
mpvUnavailable: 'MPV loading API unavailable', mpvFailed: 'MPV loading failed', pageMismatch: 'Page number mismatch',
timeout: 'Loading timed out', areaUnavailable: 'Reader area missing', pageUnavailable: 'Page unavailable',
loadFailed: 'Loading failed', imageNotLoaded: 'Image has not loaded', picker: 'Click a page image · Esc to cancel',
fullscreenUnavailable: 'Fullscreen unavailable. Try F11', pageOutOfRange: 'Page number out of range', cleared: 'Settings cleared'
}
};
const t = (name, values = {}) => (translations[language][name] || translations.en[name] || name)
.replace(/\{(\w+)\}/g, (_, token) => String(values[token] ?? ''));
const readerError = (name, values = {}) => Object.assign(new Error(t(name, values)), { translationKey: name, values });
const defaults = {
enabled: adapter !== 'generic', mode: 'page', focus: true,
enlarge: true, margin: 8, selector: '', inline: true, preload: 3, leftClick: true
};
const stored = GM_getValue(key, {});
const config = { ...defaults, ...(stored && typeof stored === 'object' ? stored : {}) };
config.mode = config.mode === 'width' ? 'width' : 'page';
config.margin = Number.isFinite(Number(config.margin)) ? Math.min(80, Math.max(0, Number(config.margin))) : 8;
config.selector = typeof config.selector === 'string' ? config.selector : '';
config.preload = Number.isFinite(Number(config.preload)) ? Math.min(20, Math.max(0, Math.trunc(Number(config.preload)))) : 3;
const owned = new Map();
const ownedWrappers = new Set();
const classPrefix = 'comic-fit-screen-';
const addedClasses = new Set();
let ui, observer, resizeObserver, timer = 0, picking = false, hovering = null;
let hoveredOutline = null, ownsFullscreen = false, lastImages = [];
let message = '', messageValues = {}, messageUntil = 0, uiLanguage = '', menuIds = [];
let reader;
// 翻页与预加载仅作用于当前画廊;通用模式继续使用原网站的加载机制。
const css = document.createElement('style');
css.textContent = `
html.${classPrefix}single body { margin:0 !important; background:#000 !important; overflow:hidden !important; }
html.${classPrefix}single #i1 {
position:fixed !important; inset:0 !important; z-index:2147483000 !important;
box-sizing:border-box !important; width:100% !important; max-width:none !important;
height:100% !important; min-height:0 !important; margin:0 !important;
padding:0 !important; border:0 !important; background:#000 !important;
display:flex !important; flex-direction:column !important;
}
html.${classPrefix}single #i1 > h1,
html.${classPrefix}single #i1 > :not(#i2):not(#i3):not(#i4) { display:none !important; }
html.${classPrefix}single #i2, html.${classPrefix}single #i4 {
box-sizing:border-box !important; flex:0 0 auto !important; width:100% !important;
min-width:0 !important; margin:0 !important; padding:3px 0 !important;
color:#ddd !important; background:#111 !important;
}
html.${classPrefix}single #i3 {
box-sizing:border-box !important; display:flex !important; flex:1 1 0 !important;
min-height:0 !important; min-width:0 !important; width:100% !important; height:auto !important;
align-items:center !important; justify-content:center !important;
overflow:auto !important; padding:0 !important; margin:0 !important; background:#000 !important;
}
html.${classPrefix}single #i3 > a, html.${classPrefix}single #i3 > picture {
display:block !important; flex:0 0 auto !important; margin:0 !important;
padding:0 !important; line-height:0 !important;
}
html.${classPrefix}single.${classPrefix}width #i3 { align-items:flex-start !important; }
html.${classPrefix}mpv #pane_images { overflow-x:hidden !important; }
html.${classPrefix}mpv #pane_images_inner { width:100% !important; max-width:none !important; }
html.${classPrefix}mpv #pane_images .mimg.${classPrefix}fitted,
html.${classPrefix}mpv #pane_images_inner > .${classPrefix}fitted {
display:block !important; width:auto !important; height:auto !important;
min-width:0 !important; min-height:0 !important; max-width:none !important;
max-height:none !important; margin:0 auto 8px !important; text-align:center !important;
}
html.${classPrefix}black #pane_images { background:#000 !important; }
`;
function createReader() {
const bridge = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
const urls = new Map(), cache = new Map(), galleryJobs = new Map();
const state = { current: 1, total: 0, requested: 0, loading: false, readyAhead: 0, preloadError: false };
let initialized = false, galleryURL = '', bookId = '', thumbnailCount = 0;
let wave = 0, signature = '', sequence = 0, stopped = false, historyReady = false, scrollFrame = 0;
let hostCache = null;
const galleryControllers = new Set();
const abortError = () => new DOMException(t('cancelled'), 'AbortError');
const changed = () => syncUI();
function pageURL(value, base = location.href) {
try {
const url = new URL(value, base);
const match = /^\/s\/([a-z0-9]+)\/(\d+)-(\d+)\/?$/i.exec(url.pathname);
return url.origin === location.origin && match ? { url: url.href, gid: match[2], page: Number(match[3]) } : null;
} catch { return null; }
}
function collectURLs(root, base) {
const found = [];
for (const link of root.querySelectorAll('a[href]')) {
const info = pageURL(link.getAttribute('href'), base);
if (info && info.gid === bookId) { urls.set(info.page, info.url); found.push(info.page); }
if (!galleryURL) {
try {
const url = new URL(link.getAttribute('href'), base);
const match = /^\/g\/(\d+)\/([a-z0-9]+)\/?$/i.exec(url.pathname);
if (url.origin === location.origin && match?.[1] === bookId) galleryURL = url.href;
} catch {}
}
}
return [...new Set(found)].sort((a, b) => a - b);
}
function parsePage(root, url) {
const info = pageURL(url);
const image = root.querySelector('#i3 #img, #i3 a > img');
if (!info || info.gid !== bookId || !image) throw readerError('readerUnavailable');
const source = image.getAttribute('src');
if (!source) throw readerError('noImageURL');
const imageURL = new URL(source, url).href;
if (!/^(?:https?:|data:image\/)/i.test(imageURL)) throw readerError('imageURLUnavailable');
const pages = collectURLs(root, url);
const text = root.querySelector('#i2 .sn, #i2')?.textContent.replace(/,/g, '') || '';
const counter = /(\d+)\s*\/\s*(\d+)/.exec(text);
state.total = Math.max(state.total, info.page, ...pages, Number(counter?.[2] || 0));
const sections = {};
for (const id of ['i2', 'i4', 'i5', 'i6', 'i7']) {
const node = root.querySelector('#' + id);
if (node) sections[id] = node.innerHTML;
}
return { page: info.page, url, imageURL, sections, title: root.querySelector('title')?.textContent || document.title,
width: Number(image.getAttribute('width')) || 0, height: Number(image.getAttribute('height')) || 0,
referrerPolicy: image.getAttribute('referrerpolicy') || '' };
}
function inertHTML(html) {
// 模板里的图片和脚本保持惰性,不因解析预加载页面而下载广告或执行脚本。
const template = document.createElement('template');
template.innerHTML = html;
return template.content;
}
async function fetchHTML(url, signal) {
const response = await fetch(url, { credentials: 'same-origin', redirect: 'error', signal,
headers: { Accept: 'text/html' } });
if (!response.ok) throw readerError('requestFailed', { status: response.status });
return inertHTML(await response.text());
}
async function galleryPage(index) {
if (!galleryURL) throw readerError('noGallery');
if (galleryJobs.has(index)) return galleryJobs.get(index);
const url = new URL(galleryURL); url.searchParams.set('p', String(index));
const controller = new AbortController(); galleryControllers.add(controller);
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 25000);
const job = fetchHTML(url.href, controller.signal).then(root => {
const pages = collectURLs(root, url.href);
if (!pages.length) throw readerError('pageListUnavailable');
if (index === 0) thumbnailCount = Math.max(...pages);
return pages;
}).catch(error => { galleryJobs.delete(index); throw timedOut ? readerError('galleryTimeout') : error; }).finally(() => {
clearTimeout(timeout); galleryControllers.delete(controller);
});
galleryJobs.set(index, job);
return job;
}
async function resolveURL(page, signal) {
if (urls.has(page)) return urls.get(page);
await galleryPage(0);
if (signal.aborted) throw abortError();
if (!urls.has(page) && thumbnailCount) await galleryPage(Math.floor((page - 1) / thumbnailCount));
if (signal.aborted) throw abortError();
if (!urls.has(page)) throw readerError('pageNotFound');
return urls.get(page);
}
function warmImage(data, signal) {
return new Promise((resolve, reject) => {
const img = new Image();
img.decoding = 'async'; img.referrerPolicy = data.referrerPolicy;
let settled = false;
const cleanup = () => { img.onload = null; img.onerror = null; signal.removeEventListener('abort', cancel); };
const fail = error => { if (settled) return; settled = true; cleanup(); img.removeAttribute('src'); reject(error); };
const cancel = () => fail(abortError());
img.onerror = () => fail(readerError('imageFailed'));
img.onload = async () => {
if (img.naturalWidth < 160 || img.naturalHeight < 160) { fail(readerError('imageUnavailable')); return; }
try { await img.decode?.(); } catch {}
if (signal.aborted) { cancel(); return; }
if (!settled) { settled = true; cleanup(); resolve(img); }
};
signal.addEventListener('abort', cancel, { once: true });
if (signal.aborted) cancel(); else img.src = data.imageURL;
});
}
function mpvHosts() {
if (hostCache) return hostCache;
const pane = document.querySelector('#pane_images');
if (!pane) return [];
const hosts = [...pane.querySelectorAll('.mimg')];
return hostCache = hosts.length ? hosts : [...pane.querySelectorAll('#pane_images_inner > div')];
}
function mpvImage(page) {
return document.getElementById(`imgsrc_${page}`) || mpvHosts()[page - 1]?.querySelector('img');
}
function loaded(img) { return img?.complete && img.naturalWidth >= 160 && img.naturalHeight >= 160; }
function loadMpv(page, signal) {
return new Promise((resolve, reject) => {
let settled = false, checking = false;
const pane = document.querySelector('#pane_images'), host = mpvHosts()[page - 1];
if (!host || !pane) { reject(readerError('pageUnavailable')); return; }
const watcher = new MutationObserver(records => {
if (records.some(item => item.type === 'childList')) hostCache = null;
inspect();
});
const onLoad = event => { if (event.target === mpvImage(page)) inspect(); };
const failed = event => { if (event.target === mpvImage(page)) finish(readerError('imageFailed')); };
const cleanup = () => {
watcher.disconnect(); signal.removeEventListener('abort', cancel);
pane.removeEventListener('load', onLoad, true); pane.removeEventListener('error', failed, true);
};
const finish = (error, image) => { if (settled) return; settled = true; cleanup(); error ? reject(error) : resolve(image); };
const cancel = () => finish(abortError());
const inspect = async () => {
if (settled || checking) return;
const img = mpvImage(page);
if (loaded(img)) {
checking = true;
try { await img.decode?.(); } catch {}
checking = false;
if (signal.aborted) cancel();
else if (img === mpvImage(page) && loaded(img)) finish(null, img);
else inspect();
}
};
signal.addEventListener('abort', cancel, { once: true });
if (signal.aborted) { cancel(); return; }
// 图片替换和加载完成由事件通知,不再每 100 毫秒扫描、重复解码。
pane.addEventListener('load', onLoad, true); pane.addEventListener('error', failed, true);
watcher.observe(pane, { subtree: true, childList: true, attributes: true, attributeFilter: ['src', 'srcset'] });
if (!loaded(mpvImage(page))) {
try {
// 使用 MPV 自带的单页加载接口,复用它的地址和缓存,不另开一套图片请求。
if (typeof bridge.load_image === 'function') bridge.load_image(page);
else {
const img = mpvImage(page), src = img?.dataset.src || img?.dataset.original;
if (!img || !src) { finish(readerError('mpvUnavailable')); return; }
img.src = new URL(src, location.href).href;
}
} catch { finish(readerError('mpvFailed')); return; }
}
inspect();
});
}
function ensure(page, foreground = false) {
let record = cache.get(page);
if (record?.controller?.signal.aborted) { cache.delete(page); record = null; }
if (record) { record.foreground ||= foreground; return record.promise; }
record = { page, status: 'loading', foreground, controller: new AbortController(), image: null, data: null };
cache.set(page, record);
const timeout = setTimeout(() => { record.timedOut = true; record.controller.abort(); }, 30000);
record.promise = (async () => {
if (adapter === 'mpv') record.image = await loadMpv(page, record.controller.signal);
else {
const url = await resolveURL(page, record.controller.signal);
if (record.controller.signal.aborted) throw abortError();
record.data = parsePage(await fetchHTML(url, record.controller.signal), url);
if (record.data.page !== page) throw readerError('pageMismatch');
record.image = await warmImage(record.data, record.controller.signal);
}
record.status = 'ready';
return record;
})().catch(error => {
if (cache.get(page) === record) cache.delete(page);
throw record.timedOut ? readerError('timeout') : error;
}).finally(() => { clearTimeout(timeout); countCache(); changed(); });
return record.promise;
}
function countCache() {
let ready = 0;
for (let page = state.current + 1; page <= Math.min(state.total, state.current + config.preload); page++) {
if (cache.get(page)?.status === 'ready' || adapter === 'mpv' && loaded(mpvImage(page))) ready++;
}
state.readyAhead = ready;
}
function trimCache() {
for (const [page, record] of cache) {
if (page >= state.current - 2 && page <= state.current + config.preload || page === state.requested || record.foreground) continue;
record.controller?.abort(); cache.delete(page);
}
}
function pool() {
if (!initialized || adapter === 'generic' || !config.enabled || !config.inline || stopped) return;
const nextSignature = `${state.current}:${config.preload}:${state.total}`;
if (signature === nextSignature) { countCache(); return; }
signature = nextSignature;
const generation = ++wave;
const wanted = new Set();
for (let page = state.current + 1; page <= Math.min(state.total, state.current + config.preload); page++) wanted.add(page);
for (const [page, record] of cache) {
if (record.status === 'loading' && !record.foreground && !wanted.has(page)) record.controller.abort();
}
const queue = [...wanted].filter(page => cache.get(page)?.status !== 'ready' && !(adapter === 'mpv' && loaded(mpvImage(page))));
let running = 0;
state.preloadError = false;
function pump() {
if (generation !== wave || stopped || !config.enabled || !config.inline) return;
while (running < 2 && queue.length) {
const page = queue.shift(); running++;
ensure(page).catch(error => {
if (generation === wave && error.name !== 'AbortError') { queue.length = 0; state.preloadError = true; }
}).finally(() => { running--; if (generation === wave) { countCache(); changed(); pump(); } });
}
}
trimCache(); countCache(); pump();
}
function replaceReaderURL(url) {
const previous = history.state && typeof history.state === 'object' ? history.state : {};
history.replaceState({ ...previous, comicFitReader: { kind: 'reader', gallery: galleryURL } }, '', url);
}
function prepareHistory() {
if (historyReady || !galleryURL || !config.inline) return;
historyReady = true;
const currentURL = location.href;
let fromGallery = false;
try {
const previous = new URL(document.referrer);
const gallery = new URL(galleryURL);
fromGallery = previous.origin === gallery.origin && previous.pathname === gallery.pathname;
if (fromGallery) galleryURL = previous.href;
} catch {}
if (fromGallery || history.state?.comicFitReader?.kind === 'reader') replaceReaderURL(currentURL);
else {
// 直接打开阅读页时补一个详情页入口;后续翻页一律替换当前阅读记录。
history.replaceState({ comicFitReader: { kind: 'gallery', gallery: galleryURL } }, '', galleryURL);
history.pushState({ comicFitReader: { kind: 'reader', gallery: galleryURL } }, '', currentURL);
}
}
function sanitized(html, base) {
const fragment = inertHTML(html);
for (const node of fragment.querySelectorAll('script,iframe,object,embed,link,style,base,meta')) node.remove();
for (const node of fragment.querySelectorAll('*')) {
for (const attr of [...node.attributes]) {
if (/^on/i.test(attr.name) || attr.name === 'srcset') node.removeAttribute(attr.name);
else if (attr.name === 'href' || attr.name === 'src') {
try {
const url = new URL(attr.value, base);
if (/^(?:https?:|data:image\/)/i.test(url.href)) node.setAttribute(attr.name, url.href);
else node.removeAttribute(attr.name);
} catch { node.removeAttribute(attr.name); }
}
}
}
return fragment;
}
function renderSingle(record) {
const data = record.data, pane = document.querySelector('#i3');
if (!pane) throw readerError('areaUnavailable');
const image = record.image;
image.id = 'img'; image.alt = String(data.page); image.removeAttribute('loading');
if (!owned.has(image)) {
image.style.width = `${data.width || image.naturalWidth}px`;
image.style.height = `${data.height || image.naturalHeight}px`;
}
const target = urls.get(data.page + 1);
const holder = document.createElement(target ? 'a' : 'div');
if (target) holder.href = target;
holder.style.lineHeight = '0'; holder.appendChild(image);
pane.replaceChildren(holder); pane.scrollTop = 0;
for (const [id, html] of Object.entries(data.sections)) {
document.getElementById(id)?.replaceChildren(sanitized(html, data.url));
}
document.title = data.title;
replaceReaderURL(data.url);
attachObservers(); refresh();
if (!config.focus) image.scrollIntoView({ block: 'center', inline: 'nearest' });
}
async function go(value) {
const page = Math.trunc(Number(value));
if (!Number.isFinite(page) || page < 1 || page > state.total || !config.enabled) return false;
if (adapter === 'generic') { lastImages[page - 1]?.scrollIntoView({ block: 'center' }); state.current = page; changed(); return true; }
if (!config.inline) {
if (adapter === 'mpv') mpvHosts()[page - 1]?.scrollIntoView({ block: 'start' });
else { try { location.assign(await resolveURL(page, new AbortController().signal)); } catch { notify('pageUnavailable'); } }
return true;
}
const ticket = ++sequence;
if (state.requested && state.requested !== page) {
const old = cache.get(state.requested);
if (old?.status === 'loading') old.controller.abort();
if (old) old.foreground = false;
}
state.requested = page; state.loading = true; changed();
let record;
try {
record = await ensure(page, true);
if (ticket !== sequence || stopped || !config.enabled) return false;
state.current = page;
if (adapter === 'single') renderSingle(record);
else {
const pane = document.querySelector('#pane_images'), host = mpvHosts()[page - 1];
if (!pane || !host) throw readerError('pageUnavailable');
refresh();
pane.scrollTop += host.getBoundingClientRect().top - pane.getBoundingClientRect().top;
replaceReaderURL(`${location.pathname}${location.search}#page${page}`);
}
signature = ''; pool();
return true;
} catch (error) {
if (ticket === sequence && error.name !== 'AbortError') notify(error.translationKey || 'loadFailed', error.values);
return false;
} finally {
if (record) record.foreground = false;
if (ticket === sequence) { state.loading = false; state.requested = 0; countCache(); trimCache(); changed(); }
}
}
function visiblePage() {
const nodes = adapter === 'mpv' ? mpvHosts() : lastImages;
const pane = document.querySelector('#pane_images');
const bounds = pane?.getBoundingClientRect();
const center = bounds ? bounds.top + pane.clientHeight / 2 : innerHeight / 2;
const midpoint = index => {
const box = nodes[index].getBoundingClientRect();
return box.top + Math.min(box.height, bounds?.height || innerHeight) / 2;
};
if (adapter === 'mpv' && nodes.length) {
// MPV 各页按纵向顺序排列,只测量中间节点和相邻候选,不逐页读布局。
let low = 0, high = nodes.length - 1;
while (low <= high) {
const middle = (low + high) >> 1;
if (midpoint(middle) < center) low = middle + 1;
else high = middle - 1;
}
const before = Math.max(0, high), after = Math.min(nodes.length - 1, low);
return (Math.abs(midpoint(before) - center) <= Math.abs(midpoint(after) - center) ? before : after) + 1;
}
let best = 1, distance = Infinity;
nodes.forEach((node, index) => {
const delta = Math.abs(midpoint(index) - center);
if (delta < distance) { best = index + 1; distance = delta; }
});
return best;
}
function tick() {
stopped = false;
if (adapter === 'generic') { state.total = lastImages.length; state.current = visiblePage(); changed(); return; }
if (!initialized) {
if (adapter === 'single') {
const info = pageURL(location.href), image = document.querySelector('#i3 #img');
if (!info || !image) return;
bookId = info.gid;
try {
const data = parsePage(document, location.href);
state.current = info.page; urls.set(info.page, location.href);
const record = { status: 'ready', page: info.page, image, data, foreground: false };
record.promise = Promise.resolve(record); cache.set(info.page, record);
} catch { return; }
} else {
const match = /^\/mpv\/(\d+)\/([a-z0-9]+)/i.exec(location.pathname);
if (!mpvHosts().length) return;
bookId = match?.[1] || '';
if (match) galleryURL = `${location.origin}/g/${match[1]}/${match[2]}/`;
state.current = Number(/^#page(\d+)$/.exec(location.hash)?.[1]) || visiblePage();
state.total = mpvHosts().length;
}
initialized = true;
}
if (adapter === 'mpv') state.total = mpvHosts().length;
if (!config.enabled || !config.inline) { pause(); return; }
prepareHistory();
if (adapter === 'mpv' && !state.loading) state.current = visiblePage();
if (adapter === 'mpv' || loaded(document.querySelector('#i3 #img'))) pool();
countCache(); changed();
}
function pause() {
stopped = true; signature = ''; wave++; sequence++;
cancelAnimationFrame(scrollFrame); scrollFrame = 0;
state.loading = false; state.requested = 0;
for (const record of cache.values()) { if (record.status === 'loading') record.controller.abort(); }
for (const controller of galleryControllers) controller.abort();
}
window.addEventListener('click', event => {
if (!config.enabled || picking || event.button !== 0 || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
const image = event.target instanceof Element ? event.target.closest('img') : null;
if (config.leftClick && image && owned.has(image)) {
const box = image.getBoundingClientRect();
if (event.clientX >= box.left && event.clientX < box.left + box.width / 2) {
const page = adapter === 'mpv' ? mpvHosts().indexOf(image.closest('.mimg') || image.parentElement.closest('#pane_images_inner > div')) + 1
: adapter === 'generic' ? lastImages.indexOf(image) + 1 : state.requested || state.current;
// 首页左侧点击也要截住,避免落到图片原有的“下一页”链接上。
event.preventDefault(); event.stopImmediatePropagation();
if (page > 1) go(page - 1);
return;
}
}
if (!config.inline || !initialized || adapter === 'generic') return;
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (!link || link.target && link.target !== '_self') return;
const info = pageURL(link.href);
let page = info?.gid === bookId ? info.page : 0;
if (adapter === 'mpv') {
const url = new URL(link.href);
if (url.origin === location.origin && url.pathname === location.pathname) page = Number(/^#page(\d+)$/.exec(url.hash)?.[1]) || 0;
}
if (!page) return;
event.preventDefault(); event.stopImmediatePropagation(); go(page);
}, true);
window.addEventListener('keydown', event => {
if (!config.enabled || !config.inline || adapter === 'generic' || !initialized || picking
|| event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
const target = event.composedPath()[0];
if (target instanceof Element && (target.matches('input,textarea,select,button') || target.isContentEditable)) return;
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (!step) return;
event.preventDefault(); event.stopImmediatePropagation(); go((state.requested || state.current) + step);
}, true);
window.addEventListener('scroll', event => {
if (adapter === 'generic' && config.enabled) { schedule(); return; }
if (!config.enabled || !config.inline || adapter !== 'mpv' || event.target !== document.querySelector('#pane_images')) return;
// 保留浏览器滚动,接管原站可能新增历史记录的翻页与加载回调。
event.stopImmediatePropagation();
if (scrollFrame) return;
scrollFrame = requestAnimationFrame(() => {
scrollFrame = 0;
if (state.loading || stopped || !config.enabled || !config.inline) return;
const page = visiblePage();
if (page === state.current) return;
state.current = page;
replaceReaderURL(`${location.pathname}${location.search}#page${page}`);
ensure(page).catch(() => {});
pool(); changed();
});
}, true);
window.addEventListener('popstate', event => {
const mark = event.state?.comicFitReader;
if (mark?.kind === 'gallery') { pause(); location.replace(mark.gallery); }
});
window.addEventListener('pagehide', pause);
window.addEventListener('pageshow', () => { if (config.enabled) tick(); });
return { state, tick, pause, go, invalidate: () => { hostCache = null; },
next: step => go((state.requested || state.current) + step),
back: () => { if (historyReady) history.back(); else if (galleryURL) location.assign(galleryURL); },
hasGallery: () => !!galleryURL };
}
function persist() { GM_setValue(key, config); }
function setRootClass(suffix, wanted) {
const name = classPrefix + suffix;
if (wanted && !document.documentElement.classList.contains(name)) {
document.documentElement.classList.add(name);
addedClasses.add(name);
} else if (!wanted && addedClasses.has(name)) {
document.documentElement.classList.remove(name);
addedClasses.delete(name);
}
}
function notify(name, values = {}) {
message = name; messageValues = values;
messageUntil = Date.now() + 6000;
showUI();
syncUI();
window.setTimeout(syncUI, 6100);
}
function safeQuery(selector) {
try { return [...document.querySelectorAll(selector)]; } catch { return []; }
}
function findImages() {
const selector = adapter === 'single' ? '#i3 img#img, #i3 a > img, .sni > a > img'
: adapter === 'mpv' ? '#pane_images .mimg img, #pane_images img[id^="imgsrc_"], #pane_images_inner > div > img'
: config.selector;
if (!selector) return [];
return safeQuery(selector).filter(img => img instanceof HTMLImageElement && img.isConnected
&& img.complete && img.naturalWidth >= 160 && img.naturalHeight >= 160
&& Math.max(img.naturalWidth, img.naturalHeight) >= 280);
}
function writeOwned(img, values) {
let record = owned.get(img);
const sizeKey = `${values.width}:${values.height}`;
// 固定样式与尺寸均未变化时,省去逐项读取和比较十余个 CSS 属性。
if (record?.sizeKey === sizeKey && img.getAttribute('style') === record.lastStyle) return;
if (!record) {
record = { originalStyle: img.getAttribute('style'), props: new Map(), external: false, lastStyle: null };
owned.set(img, record);
}
if (record.lastStyle !== null && img.getAttribute('style') !== record.lastStyle) record.external = true;
for (const [prop, value] of Object.entries(values)) {
const current = img.style.getPropertyValue(prop);
const priority = img.style.getPropertyPriority(prop);
let state = record.props.get(prop);
if (!state) {
state = { before: current, priority, last: null, lastPriority: '' };
record.props.set(prop, state);
} else if (state.last !== null && (current !== state.last || priority !== state.lastPriority)) {
// 网站翻页时重新写入的尺寸,成为关闭功能后应恢复的最新样式。
state.before = current;
state.priority = priority;
record.external = true;
}
if (current !== value || priority !== 'important') img.style.setProperty(prop, value, 'important');
state.last = img.style.getPropertyValue(prop);
state.lastPriority = img.style.getPropertyPriority(prop);
}
record.lastStyle = img.getAttribute('style');
record.sizeKey = sizeKey;
}
function restoreImage(img, record) {
if (!record.external && img.getAttribute('style') === record.lastStyle) {
if (record.originalStyle === null) img.removeAttribute('style');
else img.setAttribute('style', record.originalStyle);
} else {
// 只恢复仍由本脚本占用的属性,保留其他脚本后续添加的样式。
for (const [prop, state] of record.props) {
if (img.style.getPropertyValue(prop) !== state.last || img.style.getPropertyPriority(prop) !== state.lastPriority) continue;
if (state.before) img.style.setProperty(prop, state.before, state.priority);
else img.style.removeProperty(prop);
}
if (record.originalStyle === null && !img.style.cssText) img.removeAttribute('style');
}
owned.delete(img);
}
function availableSize(img) {
const viewport = window.visualViewport;
let width = Math.min(document.documentElement.clientWidth || innerWidth, viewport?.width || innerWidth);
let height = viewport?.height || innerHeight;
const pane = adapter === 'single' && config.focus && document.querySelector('#i1 > #i3')
|| adapter === 'mpv' && document.querySelector('#pane_images');
if (pane) {
width = Math.min(width, pane.clientWidth || width);
height = Math.min(height, pane.clientHeight || height);
} else {
// 通用模式尊重原页面的内容栏;先选择漫画区域,再限制每张图片的尺寸。
const container = img.closest('a, picture')?.parentElement || img.parentElement;
if (container) {
const styles = getComputedStyle(container);
const contentWidth = container.clientWidth - parseFloat(styles.paddingLeft || 0) - parseFloat(styles.paddingRight || 0);
if (contentWidth > 0) width = Math.min(width, contentWidth);
}
if (adapter === 'single') {
for (const node of safeQuery('#i1 > h1, #i2, #i4')) height -= node.getBoundingClientRect().height + 8;
}
}
return { width: Math.max(1, width - 2 * config.margin), height: Math.max(1, height - 2 * config.margin) };
}
function refresh() {
clearTimeout(timer);
timer = 0;
const images = config.enabled ? findImages() : [];
setRootClass('single', config.enabled && config.focus && adapter === 'single' && !!document.querySelector('#i1 > #i3'));
setRootClass('mpv', config.enabled && adapter === 'mpv');
setRootClass('black', config.enabled && config.focus && adapter === 'mpv');
setRootClass('width', config.enabled && config.mode === 'width');
const current = new Set(images);
for (const [img, record] of owned) if (!current.has(img)) restoreImage(img, record);
// 未加载图片的占位高度留给原阅读器,避免破坏按滚动位置加载的逻辑。
const wrappers = new Set(adapter === 'mpv' ? images.map(img => img.closest('.mimg')
|| (img.parentElement.matches('#pane_images_inner > div') ? img.parentElement : null)).filter(Boolean) : []);
for (const node of ownedWrappers) {
if (!wrappers.has(node)) {
node.classList.remove(classPrefix + 'fitted');
ownedWrappers.delete(node);
}
}
for (const node of wrappers) {
if (!node.classList.contains(classPrefix + 'fitted')) {
node.classList.add(classPrefix + 'fitted');
ownedWrappers.add(node);
}
}
const sharedBox = adapter === 'mpv' || adapter === 'single' && config.focus
? images.length && availableSize(images[0]) : null;
// 先完成尺寸读取,再统一写入,避免多页阅读时在每张图之间反复触发布局。
const boxes = sharedBox ? null : images.map(availableSize);
for (const [index, img] of images.entries()) {
const box = sharedBox || boxes[index];
let scale = config.mode === 'width' ? box.width / img.naturalWidth
: Math.min(box.width / img.naturalWidth, box.height / img.naturalHeight);
if (!config.enlarge) scale = Math.min(1, scale);
writeOwned(img, {
width: `${(img.naturalWidth * scale).toFixed(3)}px`,
height: `${(img.naturalHeight * scale).toFixed(3)}px`,
'min-width': '0px', 'min-height': '0px', 'max-width': 'none', 'max-height': 'none',
'object-fit': 'contain', 'box-sizing': 'content-box', display: 'block',
'margin-left': 'auto', 'margin-right': 'auto', padding: '0px', border: '0px',
'flex-shrink': '0', transform: 'none'
});
}
lastImages = images;
if (images.length) showUI();
reader?.tick();
syncUI();
}
function schedule() {
if (!timer) timer = window.setTimeout(refresh, 60);
}
function setEnabled(value) {
if (value && adapter === 'generic' && !config.selector) { startPicker(); return; }
config.enabled = value;
persist();
if (value) attachObservers();
else {
reader?.pause();
observer?.disconnect();
resizeObserver?.disconnect();
if (ownsFullscreen && document.fullscreenElement) document.exitFullscreen().catch(() => {});
}
refresh();
}
function attachObservers() {
if (!css.isConnected) document.documentElement.appendChild(css);
// 暂停期间原站也可能增删页面,重新开启时重建节点索引。
if (adapter === 'mpv') reader?.invalidate();
observer ??= new MutationObserver(records => {
if (!config.enabled) return;
if (adapter === 'mpv' && records.some(item => item.type === 'childList'
|| item.attributeName === 'class' || item.attributeName === 'id')) reader?.invalidate();
const changed = records.some(item => {
if (item.target === ui?.host || ui?.host.contains(item.target)) return false;
if (item.type === 'attributes' && item.attributeName === 'class' && item.target === document.documentElement) return false;
if (item.type === 'attributes' && item.attributeName === 'style' && owned.has(item.target)) {
return item.target.getAttribute('style') !== owned.get(item.target).lastStyle;
}
return true;
});
if (changed) schedule();
});
observer.disconnect();
// EH / EX 只监听阅读区域,无关广告、菜单等变化不再反复触发整页扫描。
const root = document.querySelector(adapter === 'single' ? '#i1' : adapter === 'mpv' ? '#pane_images' : 'body') || document.body;
observer.observe(root, { subtree: true, childList: true, attributes: true,
attributeFilter: ['src', 'srcset', 'style', 'class', 'id', 'width', 'height'] });
if (typeof ResizeObserver === 'function') {
resizeObserver ??= new ResizeObserver(schedule);
resizeObserver.disconnect();
resizeObserver.observe(document.documentElement);
const pane = document.querySelector(adapter === 'single' ? '#i3' : '#pane_images');
if (pane) resizeObserver.observe(pane);
}
}
function selectorFor(img) {
const readerWords = /(?:reader|reading|comic|manga|chapter|viewer|image-container|read-content|pages)/i;
for (let node = img.parentElement; node && node !== document.body; node = node.parentElement) {
if (node.id && readerWords.test(node.id)) return `#${CSS.escape(node.id)} img`;
const name = [...node.classList].find(value => readerWords.test(value));
if (name) return `${node.tagName.toLowerCase()}.${CSS.escape(name)} img`;
}
if (img.id) return `img#${CSS.escape(img.id)}`;
const imageClass = [...img.classList].find(value => readerWords.test(value));
if (imageClass) return `img.${CSS.escape(imageClass)}`;
for (let node = img.parentElement; node && node !== document.body; node = node.parentElement) {
if (node.id) return `#${CSS.escape(node.id)} img`;
}
const path = [];
for (let node = img; node && node !== document.body; node = node.parentElement) {
const index = [...node.parentElement.children].indexOf(node) + 1;
path.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
}
return `body > ${path.join(' > ')}`;
}
function clearHover() {
if (hovering) {
if (hoveredOutline.value) hovering.style.setProperty('outline', hoveredOutline.value, hoveredOutline.priority);
else hovering.style.removeProperty('outline');
if (!hoveredOutline.hadStyle && !hovering.style.cssText) hovering.removeAttribute('style');
}
hovering = null;
hoveredOutline = null;
}
function cancelPicker() {
picking = false;
clearHover();
document.removeEventListener('pointermove', pickerMove, true);
document.removeEventListener('click', pickerClick, true);
syncUI();
}
function pickerMove(event) {
const img = event.target instanceof Element ? event.target.closest('img') : null;
if (img === hovering) return;
clearHover();
if (img) {
hovering = img;
hoveredOutline = { value: img.style.getPropertyValue('outline'), priority: img.style.getPropertyPriority('outline'), hadStyle: img.hasAttribute('style') };
img.style.setProperty('outline', '3px solid #6ddbc6', 'important');
}
}
function pickerClick(event) {
const img = event.target instanceof Element ? event.target.closest('img') : null;
if (!img) return;
event.preventDefault();
event.stopImmediatePropagation();
cancelPicker();
if (!img.complete || img.naturalWidth < 160 || img.naturalHeight < 160) {
notify('imageNotLoaded');
return;
}
config.selector = selectorFor(img);
setEnabled(true);
img.scrollIntoView({ block: 'center', inline: 'nearest' });
}
function startPicker() {
if (adapter !== 'generic') { setEnabled(true); showUI(); ui.panel.hidden = false; return; }
cancelPicker();
picking = true;
showUI();
ui.panel.hidden = false;
notify('picker');
document.addEventListener('pointermove', pickerMove, true);
document.addEventListener('click', pickerClick, true);
}
async function toggleFullscreen() {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
if (!document.documentElement.requestFullscreen) throw new Error('unsupported');
await document.documentElement.requestFullscreen();
ownsFullscreen = true;
}
schedule();
} catch {
notify('fullscreenUnavailable');
}
}
function showUI() {
if (ui) return;
const host = document.createElement('div');
host.id = 'comic-fit-screen-controls';
host.style.cssText = 'all:initial!important;color:#eee!important;font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif!important;color-scheme:dark!important;position:fixed!important;right:12px!important;bottom:12px!important;z-index:2147483646!important;';
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { color-scheme:dark; font:13px/1.4 system-ui,"Microsoft YaHei",sans-serif; color:#ddd; }
* { box-sizing:border-box; } [hidden] { display:none !important; }
button,select,input { font:inherit; color:inherit; }
button,select,input[type=number] { border:1px solid #494949; border-radius:3px; background:#252525; padding:4px 7px; height:29px; }
button { cursor:pointer; white-space:nowrap; } button:hover { background:#383838; } button:disabled { opacity:.35; cursor:default; }
button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid #aaa; outline-offset:1px; }
#toolbar { display:flex; align-items:center; justify-content:flex-end; flex-wrap:wrap; gap:5px; max-width:calc(100vw - 24px); padding:6px; border:1px solid #444; border-radius:4px; background:#161616ed; }
#panel { width:282px; max-width:calc(100vw - 24px); margin:0 0 6px auto; padding:8px 12px; border:1px solid #444; border-radius:4px; background:#161616f5; }
.row { display:flex; align-items:center; gap:8px; min-height:34px; } .row label:first-child { flex:1; }
input[type=number] { width:58px; } input[type=checkbox] { accent-color:#aaa; } #page { width:58px; } #page-total { color:#aaa; }
#cache { color:#aaa; font-size:12px; min-width:24px; text-align:right; } #status { max-width:282px; margin:0 0 6px auto; padding:5px 9px; background:#222; border:1px solid #555; font-size:12px; }
</style>
<section id="panel" hidden data-i18n-aria="readingSettings">
<div class="row"><label for="language" data-i18n="language"></label><select id="language"><option value="zh-CN">中文</option><option value="en">English</option></select></div>
<div class="row"><label for="mode" data-i18n="display"></label><select id="mode"><option value="page" data-i18n="fitPage"></option><option value="width" data-i18n="fitWidth"></option></select></div>
<div class="row" id="focus-row"><label for="focus" data-i18n="focus"></label><input id="focus" type="checkbox"></div>
<div class="row"><label for="enlarge" data-i18n="enlarge"></label><input id="enlarge" type="checkbox"></div>
<div class="row"><label for="margin" data-i18n="margin"></label><input id="margin" type="number" min="0" max="80" step="1"></div>
<div class="row" id="inline-row"><label for="inline" data-i18n="inline"></label><input id="inline" type="checkbox"></div>
<div class="row"><label for="leftClick" data-i18n="leftClick"></label><input id="leftClick" type="checkbox"></div>
<div class="row" id="preload-row"><label for="preload" data-i18n="preload"></label><input id="preload" type="number" min="0" max="20" step="1"><output id="cache" data-i18n-aria="cache"></output></div>
<div class="row"><button id="pick" data-i18n="pick"></button><button id="disable"></button></div>
</section>
<div id="status" role="status" hidden></div>
<nav id="toolbar" data-i18n-aria="controls">
<button id="previous" data-i18n-aria="previous" data-i18n-title="previous">◀</button>
<input id="page" type="number" min="1" data-i18n-aria="page"><span id="page-total"></span><button id="go" data-i18n="jump"></button>
<button id="next" data-i18n-aria="next" data-i18n-title="next">▶</button>
<button id="details" data-i18n="details"></button><button id="fullscreen"></button><button id="launcher" aria-expanded="false" data-i18n="settings"></button>
</nav>`;
const element = id => shadow.getElementById(id);
ui = { host, shadow, panel: element('panel'), status: element('status'), language: element('language'), mode: element('mode'),
focus: element('focus'), enlarge: element('enlarge'), margin: element('margin'), inline: element('inline'), preload: element('preload'), leftClick: element('leftClick'),
fullscreen: element('fullscreen'), pick: element('pick'), disable: element('disable'), launcher: element('launcher'),
page: element('page'), total: element('page-total'), previous: element('previous'), next: element('next'),
go: element('go'), details: element('details'), cache: element('cache') };
document.documentElement.appendChild(host);
ui.language.addEventListener('change', () => {
language = ui.language.value === 'en' ? 'en' : 'zh-CN';
GM_setValue(languageKey, language);
syncUI(); registerMenus();
});
ui.launcher.addEventListener('click', () => { ui.panel.hidden = !ui.panel.hidden; ui.launcher.setAttribute('aria-expanded', String(!ui.panel.hidden)); syncUI(); });
for (const name of ['mode', 'focus', 'enlarge', 'margin', 'inline', 'preload', 'leftClick']) {
const update = () => {
config[name] = name === 'mode' ? ui.mode.value : name === 'margin' || name === 'preload'
? Math.min(name === 'margin' ? 80 : 20, Math.max(0, Math.trunc(Number(ui[name].value) || 0))) : ui[name].checked;
persist();
refresh();
};
ui[name].addEventListener('change', update);
// 数值输入立即保存,避免窗口刷新时覆盖尚未离焦的输入。
if (name === 'margin' || name === 'preload') ui[name].addEventListener('input', update);
}
const jump = () => {
const number = Number(ui.page.value);
if (!Number.isInteger(number) || number < 1 || number > (reader?.state.total || 0)) { notify('pageOutOfRange'); return; }
reader?.go(number); ui.page.blur();
};
ui.go.addEventListener('click', jump);
ui.page.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); jump(); } });
ui.previous.addEventListener('click', () => reader?.next(-1));
ui.next.addEventListener('click', () => reader?.next(1));
ui.details.addEventListener('click', () => reader?.back());
ui.fullscreen.addEventListener('click', toggleFullscreen);
ui.pick.addEventListener('click', startPicker);
ui.disable.addEventListener('click', () => { cancelPicker(); setEnabled(!config.enabled); });
syncUI();
}
function syncUI() {
if (!ui) return;
if (uiLanguage !== language) {
ui.host.lang = language; ui.language.value = language;
for (const node of ui.shadow.querySelectorAll('[data-i18n]')) node.textContent = t(node.dataset.i18n);
for (const node of ui.shadow.querySelectorAll('[data-i18n-aria]')) node.setAttribute('aria-label', t(node.dataset.i18nAria));
for (const node of ui.shadow.querySelectorAll('[data-i18n-title]')) node.title = t(node.dataset.i18nTitle);
uiLanguage = language;
}
ui.mode.value = config.mode;
ui.focus.checked = config.focus;
ui.shadow.getElementById('focus-row').hidden = adapter === 'generic';
ui.enlarge.checked = config.enlarge;
if (ui.margin.value !== String(config.margin)) ui.margin.value = String(config.margin);
ui.inline.checked = config.inline;
ui.leftClick.checked = config.leftClick;
if (ui.preload.value !== String(config.preload)) ui.preload.value = String(config.preload);
for (const id of ['inline-row', 'preload-row']) ui.shadow.getElementById(id).hidden = adapter === 'generic';
ui.preload.disabled = !config.inline || !config.enabled;
ui.fullscreen.textContent = t(document.fullscreenElement ? 'exitFullscreen' : 'fullscreen');
ui.pick.hidden = adapter !== 'generic';
ui.disable.textContent = t(config.enabled ? 'disable' : 'enable');
ui.launcher.setAttribute('aria-expanded', String(!ui.panel.hidden));
const state = reader?.state, current = state?.requested || state?.current || 1, total = state?.total || 0;
if (ui.shadow.activeElement !== ui.page) ui.page.value = String(current);
ui.page.max = String(total); ui.total.textContent = `/ ${total}`;
ui.page.setAttribute('aria-busy', String(!!state?.loading));
ui.page.disabled = ui.go.disabled = !config.enabled || !total;
ui.previous.disabled = !config.enabled || current <= 1;
ui.next.disabled = !config.enabled || current >= total;
ui.details.hidden = !reader?.hasGallery();
ui.cache.textContent = state?.preloadError ? t('failed') : `${state?.readyAhead || 0}/${Math.min(config.preload, Math.max(0, total - (state?.current || 1)))}`;
ui.status.hidden = Date.now() >= messageUntil;
ui.status.textContent = message ? t(message, messageValues) : '';
}
function registerMenus() {
if (menuIds.length && typeof GM_unregisterMenuCommand !== 'function') return;
for (const id of menuIds) if (id !== undefined) GM_unregisterMenuCommand(id);
menuIds = [
GM_registerMenuCommand(t('menuToggle'), () => setEnabled(!config.enabled)),
GM_registerMenuCommand(t('menuSettings'), () => {
if (adapter === 'generic' && !config.selector) startPicker();
else { showUI(); ui.panel.hidden = false; syncUI(); }
}),
GM_registerMenuCommand(t('menuPick'), startPicker),
GM_registerMenuCommand(t('menuClear'), () => {
cancelPicker(); setEnabled(false); GM_deleteValue(key);
Object.assign(config, defaults, { enabled: false }); notify('cleared');
})
];
}
registerMenus();
document.addEventListener('keydown', event => {
const target = event.composedPath()[0];
if (target instanceof Element && (target.matches('input,textarea,select') || target.isContentEditable)) return;
if (event.altKey && event.shiftKey && event.code === 'KeyM' && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setEnabled(!config.enabled);
} else if (event.key === 'Escape' && picking) {
event.preventDefault();
cancelPicker();
}
}, true);
const onImageEvent = event => {
if (!config.enabled || !(event.target instanceof HTMLImageElement)) return;
const img = event.target;
if (adapter === 'single' ? img.closest('#i3, .sni') : adapter === 'mpv' ? img.closest('#pane_images') : true) schedule();
};
document.addEventListener('load', onImageEvent, true);
document.addEventListener('error', onImageEvent, true);
window.addEventListener('resize', () => { if (config.enabled) schedule(); });
window.visualViewport?.addEventListener('resize', () => { if (config.enabled) schedule(); });
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) ownsFullscreen = false;
if (config.enabled) schedule();
syncUI();
});
reader = createReader();
if (config.enabled) attachObservers();
refresh();
})();