Lightweight reader with a compact collapsible toolbar, width slider, in-page navigation and native WebP display. English / Chinese interface.
// ==UserScript==
// @name Better Ehentai Interface
// @name:en Better Ehentai Interface
// @name:zh-CN 更好的Ehentai界面
// @namespace local.comic-fit-screen
// @version 3.5.0
// @description Lightweight reader with a compact collapsible toolbar, width slider, in-page navigation and native WebP display. English / Chinese interface.
// @description:zh-CN 轻量阅读器:可收起的紧凑工具栏、宽度滑杆、页内翻页、预加载及原生 WebP 显示;支持中文 / English 界面。
// @license MIT
// @match *://*.e-hentai.org/s/*
// @match *://*.e-hentai.org/mpv/*
// @match *://*.exhentai.org/s/*
// @match *://*.exhentai.org/mpv/*
// @match *://*.e-hentai.com/s/*
// @match *://*.e-hentai.com/mpv/*
// @match *://*.exhentai.com/s/*
// @match *://*.exhentai.com/mpv/*
// @exclude *://nhentai.net/*
// @exclude *://*.nhentai.net/*
// @run-at document-end
// @noframes
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant unsafeWindow
// ==/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';
// 阅读器只作用于 EH/EX 的阅读页。
if (adapter === 'generic' || !/^https?:$/.test(location.protocol)) return;
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: '适合宽度', custom: '手动宽度', zoom: '显示宽度', file: '文件信息', focus: '黑底阅读',
enlarge: '小图放大', margin: '留边(px)', inline: '页内翻页', preload: '预加载(页)',
cache: '预加载进度', leftClick: '左侧点击返回', pick: '选图', disable: '关闭贴屏', enable: '开启贴屏',
previous: '上一页', next: '下一页', page: '页码', jump: '跳转', details: '详情',
first: '第一页', last: '最后一页', collapse: '收起工具栏', restore: '展开工具栏',
menu: '阅读菜单', position: '工具栏位置', bottomRight: '右下角', bottomCenter: '底部居中',
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', custom: 'Manual width', zoom: 'Display width', file: 'File information', 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',
first: 'First page', last: 'Last page', collapse: 'Collapse toolbar', restore: 'Expand toolbar',
menu: 'Reader menu', position: 'Toolbar position', bottomRight: 'Bottom right', bottomCenter: 'Bottom center',
fullscreen: 'Fullscreen', exitFullscreen: 'Exit fullscreen', failed: 'Failed',
menuToggle: 'Fit: on / off (Alt+Shift+M)', menuSettings: 'Reader: settings',
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, zoom: 65,
toolbarCollapsed: false, toolbarPosition: 'right'
};
const stored = GM_getValue(key, {});
// 只使用阅读器设置;保存时仍保留存储中的旧动图配置。
const config = Object.fromEntries(Object.entries(defaults).map(([name, fallback]) => [name, stored && typeof stored === 'object' && name in stored ? stored[name] : fallback]));
config.mode = ['width', 'custom'].includes(config.mode) ? config.mode : 'page';
config.zoom = Number.isFinite(Number(config.zoom)) ? Math.min(100, Math.max(10, Number(config.zoom))) : 65;
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;
config.toolbarCollapsed = config.toolbarCollapsed === true;
config.toolbarPosition = config.toolbarPosition === 'center' ? 'center' : 'right';
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, saveTimer = 0, transitionCanvas = null, transitionCleanup = null;
const isWebP = source => /\.webp(?:$|[?#;])|[-/]wbp(?:[/?;]|$)/i.test(source || '');
// 翻页与预加载仅作用于当前画廊;通用模式继续使用原网站的加载机制。
const css = document.createElement('style');
css.textContent = `
html.${classPrefix}single body { margin:0 !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(#i3) { 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;
position:relative !important; align-items:safe center !important; justify-content:safe 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:safe center !important; }
html.${classPrefix}single #i1 { background:inherit !important; }
html.${classPrefix}single #i3 { background:transparent !important; }
html.${classPrefix}single.${classPrefix}black body,
html.${classPrefix}single.${classPrefix}black #i1,
html.${classPrefix}single.${classPrefix}black #i3 { background:#000 !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);
const page = Number(match?.[3]);
return url.origin === location.origin && match && Number.isSafeInteger(page) && page > 0
? { url: url.href, gid: match[2], page } : 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 fileInformation(root) {
const node = root.querySelector('#i2');
if (!node) return '';
const copy = node.cloneNode(true);
for (const controls of copy.querySelectorAll('.sn,script,style')) controls.remove();
return copy.textContent.replace(/\s+/g, ' ').trim();
}
function pageTotal(root, current) {
// 保留节点间的分隔,避免页数与相邻脚本或控件里的数字被拼成一个大数。
const text = node => node.nodeType === 3 ? node.textContent
: node.nodeType !== 1 || node.matches('a,script,style,input,select') ? ''
: [...node.childNodes].map(text).join(' ');
let total = 0;
for (const node of root.querySelectorAll('#i2 .sn, #i4 .sn')) {
const counter = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)/.exec(text(node));
const page = Number(counter?.[1].replace(/,/g, ''));
const count = Number(counter?.[2].replace(/,/g, ''));
if (page === current && Number.isSafeInteger(count) && count >= current) total = Math.max(total, count);
}
return total;
}
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);
state.total = Math.max(state.total, info.page, ...pages, pageTotal(root, info.page));
const sections = {};
for (const id of ['i2', 'i4', 'i5', 'i6', 'i7']) {
const node = root.querySelector('#' + id);
if (node) sections[id] = node.innerHTML;
}
const fileInfo = fileInformation(root);
const size = /(\d[\d,]*)\s*[x×]\s*(\d[\d,]*)/i.exec(fileInfo);
return { page: info.page, url, imageURL, sections, fileInfo, title: root.querySelector('title')?.textContent || document.title,
width: Number(image.getAttribute('width')) || Number(size?.[1].replace(/,/g, '')) || 0,
height: Number(image.getAttribute('height')) || Number(size?.[2].replace(/,/g, '')) || 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, onImage) {
return new Promise((resolve, reject) => {
const img = new Image();
img.decoding = 'async'; img.referrerPolicy = data.referrerPolicy;
if (data.width && data.height) { img.setAttribute('width', data.width); img.setAttribute('height', data.height); }
onImage?.(img);
let settled = false;
const cleanup = () => { img.onload = null; img.onerror = null; signal.removeEventListener('abort', cancel); };
const fail = error => { if (settled) return; settled = true; cleanup(); if (!img.isConnected) 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; }
// 原生动图不等待 decode(),避免把完整动画的解码放在翻页之前。
if (!isWebP(data.imageURL)) { 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;
if (!isWebP(img.currentSrc || img.src)) { 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 foreground ? record.displayPromise || record.promise : record.promise;
}
record = { page, status: 'loading', foreground, controller: new AbortController(), image: null, data: null };
cache.set(page, record);
record.timeout = setTimeout(() => { record.timedOut = true; record.controller.abort(); }, 30000);
record.displayPromise = (async () => {
if (adapter === 'mpv') { record.image = await loadMpv(page, record.controller.signal); return record; }
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.imagePromise = warmImage(record.data, record.controller.signal, image => { record.image = image; });
// WebP 元数据可用后切换正文,原生首帧到达前保留上一页的画面。
if (!isWebP(record.data.imageURL)) await record.imagePromise;
return record;
})();
record.promise = record.displayPromise.then(async entry => {
await entry.imagePromise;
entry.status = 'ready';
return entry;
}).catch(error => {
if (cache.get(page) === record) cache.delete(page);
if (state.current === page && record.image?.isConnected && error.name !== 'AbortError') notify('imageFailed');
throw record.timedOut ? readerError('timeout') : error;
}).finally(() => { clearTimeout(record.timeout); countCache(); changed(); });
// 前台与完整缓存分别等待,拒绝也有接收者,避免未处理错误。
record.promise.catch(() => {});
return foreground ? record.displayPromise : 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 cap = isWebP(cache.get(state.current)?.data?.imageURL || mpvImage(state.current)?.src) ? 1 : 2;
const nextSignature = `${state.current}:${config.preload}:${state.total}:${cap}`;
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 < cap && 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 previous = pane.querySelector('#img');
const preview = record.image.complete && record.image.naturalWidth ? null : captureTransition(previous);
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;
if (preview) {
preview.style.cssText += ';position:absolute;inset:0;margin:auto;max-width:100%;max-height:100%;pointer-events:none;z-index:0';
holder.style.position = 'relative'; holder.style.zIndex = '1';
pane.prepend(preview); transitionCanvas = preview;
const ready = () => requestAnimationFrame(() => requestAnimationFrame(() => {
if (document.querySelector('#i3 #img') === image && transitionCanvas === preview) clearTransition();
}));
image.addEventListener('load', ready, { once: true });
transitionCleanup = () => image.removeEventListener('load', ready);
if (image.complete && image.naturalWidth) ready();
} else clearTransition();
if (isWebP(record.data.imageURL)) clearTimeout(record.timeout);
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 = record.displayPromise = 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 === 'single' && initialized) {
const image = document.querySelector('#i3 #img'), entry = cache.get(state.current);
// 原站复用正文节点换图或从页面缓存恢复时,重建当前页的元数据。
if (image && (!entry || entry.image !== image || entry.data?.imageURL !== image.src)) {
try {
const data = parsePage(document, location.href);
const current = { status:'ready', page:data.page, image, data, foreground:false };
current.promise = current.displayPromise = Promise.resolve(current);
cache.set(data.page, current); state.current = data.page;
} catch {}
}
}
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(); schedule(); } });
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,
currentRecord: () => cache.get(state.current),
currentImage: () => adapter === 'mpv' ? mpvImage(state.current) : document.querySelector('#i3 #img') };
}
// 翻页只临时保留一张可见画面的画布,不导出图片,也不循环解码。
function clearTransition() {
transitionCleanup?.(); transitionCleanup = null;
if (transitionCanvas) { transitionCanvas.remove(); transitionCanvas.width = transitionCanvas.height = 1; transitionCanvas = null; }
}
function captureTransition(image) {
transitionCleanup?.(); transitionCleanup = null;
if (!image?.naturalWidth) return transitionCanvas;
const box = image.getBoundingClientRect();
const width = Math.max(1, Math.min(innerWidth, Math.round(box.width)));
const height = Math.max(1, Math.min(innerHeight, Math.round(box.height)));
const preview = document.createElement('canvas');
preview.dataset.readerPreview = 'true'; preview.width = width; preview.height = height;
const pane = document.querySelector('#i3');
const offset = Math.max(0, (pane.getBoundingClientRect().top - box.top) * image.naturalHeight / Math.max(1, box.height));
const cropHeight = Math.min(image.naturalHeight - offset, height * image.naturalWidth / width);
try { preview.getContext('2d').drawImage(image, 0, offset, image.naturalWidth, cropHeight, 0, 0, width, height); }
catch { return transitionCanvas; }
clearTransition();
return preview;
}
function persist() { GM_setValue(key, { ...(stored && typeof stored === 'object' ? stored : {}), ...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 []; }
}
const imageDimensions = new WeakMap();
function intrinsicSize(img) {
const source = `${img.getAttribute('src') || ''}\n${img.getAttribute('srcset') || ''}`;
const size = (width, height) => Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
? { width, height } : null;
const metadata = [];
const add = (name, value) => { if (value) metadata.push({ name, ...value }); };
if (adapter === 'single' && img.closest('#i3, .sni')) {
// 文件栏由原站当前页提供;下载尚未完成时也能取得正文图的实际尺寸。
const dimensions = /(?:^|[^\d])(\d[\d,]*)\s*[x×]\s*(\d[\d,]*)(?:[^\d]|$)/i
.exec(document.querySelector('#i2')?.textContent || '');
if (dimensions) add('file', size(Number(dimensions[1].replace(/,/g, '')), Number(dimensions[2].replace(/,/g, ''))));
}
if (adapter !== 'generic') {
add('html', size(Number(img.getAttribute('width')), Number(img.getAttribute('height'))));
const original = owned.get(img);
const originalLength = property => {
const current = img.style.getPropertyValue(property), entry = original?.props.get(property);
const value = entry && current === entry.last && img.style.getPropertyPriority(property) === entry.lastPriority
? entry.before : current;
return /^\d+(?:\.\d+)?px$/i.test(value) ? Number.parseFloat(value) : 0;
};
// 不把本脚本写入的贴合宽高重新当成原始图像宽高。
add('style', size(originalLength('width'), originalLength('height')));
}
const signatures = metadata.map(value => `${value.name}:${value.width}:${value.height}`);
let tracked = imageDimensions.get(img);
if (!tracked) tracked = { source, pending: false, signatures, size: null };
else if (tracked.source !== source) {
// 同一 img 改源时 currentSrc 与 naturalWidth 可能仍属于旧请求,不能沿用旧图比例。
tracked = { source, pending: true, before: new Set(tracked.signatures), signatures, size: null };
}
imageDimensions.set(img, tracked);
let known = null;
const record = adapter !== 'generic' ? reader?.currentRecord?.() : null;
if (record?.image === img && [record.data?.imageURL, record.asset?.sourceURL, record.asset?.posterURL].includes(img.src)) {
known = size(Number(record.asset?.width || record.data?.width), Number(record.asset?.height || record.data?.height));
}
if (!known && (!tracked.pending || img.complete || !img.getAttribute('srcset') && img.currentSrc === img.src)) {
known = size(img.naturalWidth, img.naturalHeight);
}
if (!known) {
const fresh = metadata.find(value => !tracked.pending || !tracked.before.has(`${value.name}:${value.width}:${value.height}`));
if (fresh) known = size(fresh.width, fresh.height);
}
tracked.signatures = signatures;
if (known) { tracked.size = known; tracked.pending = false; }
return known || (tracked.pending ? null : tracked.size);
}
function imageCandidates() {
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, #pane_images_inner > div > .comic-fit-screen-motion-wrap > img'
: config.selector;
if (!selector) return [];
return safeQuery(selector).filter(img => img instanceof HTMLImageElement && img.isConnected);
}
function writeOwned(img, values) {
let record = owned.get(img);
const sizeKey = `${values.width}:${values.height}`;
// 固定样式与尺寸均未变化时,省去逐项读取和比较十余个 CSS 属性。
// 播放层会更新样式记录,仍须核实原站加载回调没有覆盖正文尺寸。
const currentWidth = img.style.getPropertyValue('width'), currentHeight = img.style.getPropertyValue('height');
if (record?.sizeKey === sizeKey && img.getAttribute('style') === record.lastStyle
&& currentWidth.endsWith('px') && currentHeight.endsWith('px')
&& Number.parseFloat(currentWidth) === Number.parseFloat(values.width)
&& Number.parseFloat(currentHeight) === Number.parseFloat(values.height)
&& img.style.getPropertyPriority('width') === 'important'
&& img.style.getPropertyPriority('height') === 'important') 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' && 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 candidates = config.enabled ? imageCandidates() : [];
const dimensions = new Map(candidates.map(img => [img, intrinsicSize(img)]));
const images = candidates.filter(img => {
const size = dimensions.get(img);
return size && (adapter !== 'generic' || img.complete) && size.width >= 160 && size.height >= 160
&& Math.max(size.width, size.height) >= 280;
});
setRootClass('single', config.enabled && adapter === 'single' && !!document.querySelector('#i1 > #i3'));
setRootClass('mpv', config.enabled && adapter === 'mpv');
setRootClass('black', config.enabled && config.focus);
setRootClass('width', config.enabled && config.mode !== 'page');
const current = new Set(images), present = new Set(candidates);
for (const [img, record] of owned) {
// 更换预览或正文源会短暂失去解码尺寸;节点仍在正文时保留已有贴合,避免闪回大图。
if (!current.has(img) && (!present.has(img) || dimensions.get(img))) restoreImage(img, record);
}
// 未加载图片的占位高度留给原阅读器,避免破坏按滚动位置加载的逻辑。
const layoutImages = candidates.filter(img => current.has(img) || owned.has(img) && !dimensions.get(img));
const wrappers = new Set(adapter === 'mpv' ? layoutImages.map(img => img.closest('.mimg')
|| img.closest('#pane_images_inner > div')).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'
? 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];
const size = dimensions.get(img);
let scale = config.mode === 'custom' ? box.width * config.zoom / 100 / size.width
: config.mode === 'width' ? box.width / size.width
: Math.min(box.width / size.width, box.height / size.height);
if (!config.enlarge) scale = Math.min(1, scale);
writeOwned(img, {
width: `${(size.width * scale).toFixed(3)}px`,
height: `${(size.height * 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, 16);
}
function setEnabled(value) {
config.enabled = value;
persist();
if (value) attachObservers();
else {
clearTransition();
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 cancelPicker() {}
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:#ddd!important;color-scheme:dark!important;font:13px/1.45 system-ui,"Microsoft YaHei",sans-serif!important;position:fixed!important;bottom:12px!important;z-index:2147483646!important;';
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { color-scheme:dark; font:13px/1.45 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] { height:30px; border:1px solid #414141; border-radius:4px; background:#242424; padding:4px 7px; }
button { cursor:pointer; white-space:nowrap; } button:hover { background:#343434; } button:disabled { opacity:.3; cursor:default; }
button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid #aaa; outline-offset:2px; }
#toolbar { display:flex; align-items:center; gap:2px; padding:4px; background:#171717f2; border:1px solid #353535; border-radius:6px; }
.icon { display:grid; place-items:center; width:28px; height:28px; padding:0; border-color:transparent; background:transparent; }
svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
#collapse { margin-right:3px; } #launcher { margin-left:3px; }
#counter { display:flex; align-items:center; justify-content:center; gap:4px; min-width:72px; height:28px; padding:0 5px; border:0; background:transparent; font-variant-numeric:tabular-nums; }
#counter[aria-busy=true] { color:#d9b06c; } #total { color:#999; }
#restore { width:32px; height:32px; background:#171717f2; border:1px solid #353535; border-radius:6px; }
#panel { position:absolute; right:0; bottom:calc(100% + 8px); width:280px; max-width:calc(100vw - 24px); max-height:calc(100dvh - 72px); overflow:auto; padding:12px; background:#171717; border:1px solid #353535; border-radius:6px; display:flex; flex-direction:column; gap:12px; }
:host([data-position=center]) #panel { right:50%; transform:translateX(50%); }
.row { display:flex; align-items:center; gap:10px; justify-content:space-between; }
.row > label { flex:1; } #page { width:62px; } #go { flex:0 0 auto; }
#file { display:flex; flex-direction:column; gap:3px; padding-bottom:10px; border-bottom:1px solid #333; color:#999; font-size:12px; overflow-wrap:anywhere; }
#file-name { color:#ddd; } .actions { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
#mode { width:100%; } .zoom { display:flex; flex-direction:column; gap:5px; } input[type=range] { width:100%; accent-color:#b3b3b3; margin:2px 0; }
#panel input[type=number] { width:62px; } #panel select:not(#mode) { width:118px; }
#cache { color:#999; font-size:12px; } #status { font-size:12px; color:#d9b06c; }
@media(max-width:350px) { .icon { width:24px; } #counter { min-width:62px; font-size:12px; } }
</style>
<section id="toolbar" data-i18n-aria="controls">
<button id="collapse" class="icon" data-i18n-title="collapse" data-i18n-aria="collapse"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 12h8"/></svg></button>
<button id="first" class="icon" data-i18n-title="first" data-i18n-aria="first"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5v14m13-14-7 7 7 7"/></svg></button>
<button id="previous" class="icon" data-i18n-title="previous" data-i18n-aria="previous"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg></button>
<button id="counter" data-i18n-title="jump" data-i18n-aria="page" aria-controls="panel"><span id="current"></span><span id="total"></span></button>
<button id="next" class="icon" data-i18n-title="next" data-i18n-aria="next"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 5 7 7-7 7"/></svg></button>
<button id="last" class="icon" data-i18n-title="last" data-i18n-aria="last"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 5v14M6 5l7 7-7 7"/></svg></button>
<button id="launcher" class="icon" data-i18n-title="menu" data-i18n-aria="menu" aria-controls="panel" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6h14M5 12h14M5 18h14"/></svg></button>
</section>
<button id="restore" class="icon" data-i18n-title="restore" data-i18n-aria="restore" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16m5-10 3 2-3 2"/></svg></button>
<section id="panel" data-i18n-aria="readingSettings" hidden>
<div id="file" data-i18n-aria="file"><span id="file-name"></span><span id="file-dimensions"></span><span id="file-size"></span></div>
<div class="row"><label for="page" data-i18n="page"></label><input id="page" type="number" min="1" step="1"><button id="go" data-i18n="jump"></button></div>
<select id="mode" data-i18n-aria="display"><option value="page" data-i18n="fitPage"></option><option value="width" data-i18n="fitWidth"></option><option value="custom" data-i18n="custom"></option></select>
<div class="zoom"><div class="row"><label for="zoom" data-i18n="zoom"></label><output id="zoom-value"></output></div><input id="zoom" type="range" min="10" max="100" step="1"></div>
<div class="actions"><button id="details" data-i18n="details"></button><button id="fullscreen" data-i18n="fullscreen"></button></div>
<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="position" data-i18n="position"></label><select id="position"><option value="right" data-i18n="bottomRight"></option><option value="center" data-i18n="bottomCenter"></option></select></div>
<div class="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"><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"><label for="preload" data-i18n="preload"></label><input id="preload" type="number" min="0" max="20" step="1"></div>
<div class="row"><label data-i18n="cache"></label><output id="cache" data-i18n-aria="cache"></output></div>
<button id="disable" data-i18n="disable"></button>
<output id="status" aria-live="polite" hidden></output>
</section>`;
const element = id => shadow.getElementById(id);
ui = { host, shadow, panel:element('panel'), toolbar:element('toolbar'), collapse:element('collapse'), restore:element('restore'), counter:element('counter'), current:element('current'), first:element('first'), last:element('last'), position:element('position'), launcher:element('launcher'), language:element('language'), mode:element('mode'), zoom:element('zoom'), zoomValue:element('zoom-value'), focus:element('focus'), enlarge:element('enlarge'), margin:element('margin'), inline:element('inline'), leftClick:element('leftClick'), preload:element('preload'), cache:element('cache'), page:element('page'), total:element('total'), go:element('go'), previous:element('previous'), next:element('next'), details:element('details'), fullscreen:element('fullscreen'), disable:element('disable'), status:element('status'), fileName:element('file-name'), fileDimensions:element('file-dimensions'), fileSize:element('file-size') };
document.documentElement.appendChild(host);
ui.language.addEventListener('change', () => { language = ui.language.value === 'zh-CN' ? 'zh-CN' : 'en'; GM_setValue(languageKey, language); syncUI(); registerMenus(); });
ui.launcher.addEventListener('click', () => { ui.panel.hidden = !ui.panel.hidden; syncUI(); });
ui.counter.addEventListener('click', () => { ui.panel.hidden = false; syncUI(); ui.page.focus(); ui.page.select(); });
ui.collapse.addEventListener('click', () => { config.toolbarCollapsed = true; ui.panel.hidden = true; persist(); syncUI(); ui.restore.focus(); });
ui.restore.addEventListener('click', () => { config.toolbarCollapsed = false; persist(); syncUI(); ui.collapse.focus(); });
ui.position.addEventListener('change', () => { config.toolbarPosition = ui.position.value === 'center' ? 'center' : 'right'; persist(); syncUI(); });
// 收起菜单不拦截正文点击,让原有的左右翻页继续执行。
document.addEventListener('pointerdown', event => { if (!ui.panel.hidden && !event.composedPath().includes(host)) { ui.panel.hidden = true; syncUI(); } }, true);
shadow.addEventListener('keydown', event => { if (event.key === 'Escape' && !ui.panel.hidden) { event.preventDefault(); ui.panel.hidden = true; syncUI(); ui.launcher.focus(); } });
for (const name of ['mode','focus','enlarge','margin','inline','preload','leftClick']) {
const update = () => {
if (name === 'mode') config.mode = ui.mode.value;
else if (name === 'margin' || name === 'preload') config[name] = Math.min(name === 'margin' ? 80 : 20, Math.max(0, Math.trunc(Number(ui[name].value) || 0)));
else config[name] = ui[name].checked;
persist(); schedule(); syncUI();
};
ui[name].addEventListener('change', update);
}
ui.zoom.addEventListener('input', () => {
config.mode = 'custom'; config.zoom = Math.min(100, Math.max(10, Number(ui.zoom.value) || 65));
clearTimeout(saveTimer); saveTimer = setTimeout(persist, 160);
schedule(); syncUI();
});
ui.zoom.addEventListener('change', () => { clearTimeout(saveTimer); persist(); });
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.first.addEventListener('click', () => reader?.go(1)); ui.last.addEventListener('click', () => reader?.go(reader.state.total));
ui.details.addEventListener('click', () => reader?.back()); ui.fullscreen.addEventListener('click', toggleFullscreen);
ui.disable.addEventListener('click', () => 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.zoom.value = String(config.zoom);
ui.zoomValue.textContent = config.mode === 'custom' ? config.zoom + '%' : '—';
ui.focus.checked = config.focus; 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);
ui.preload.disabled = !config.inline || !config.enabled; ui.zoom.disabled = !config.enabled;
ui.fullscreen.textContent = t(document.fullscreenElement ? 'exitFullscreen' : 'fullscreen');
ui.disable.textContent = t(config.enabled ? 'disable' : 'enable');
ui.position.value = config.toolbarPosition;
if (ui.host.dataset.position !== config.toolbarPosition) {
ui.host.style.setProperty('right', config.toolbarPosition === 'center' ? 'auto' : '12px', 'important');
ui.host.style.setProperty('left', config.toolbarPosition === 'center' ? '50%' : 'auto', 'important');
ui.host.style.setProperty('transform', config.toolbarPosition === 'center' ? 'translateX(-50%)' : 'none', 'important');
ui.host.dataset.position = config.toolbarPosition;
}
ui.toolbar.hidden = config.toolbarCollapsed; ui.restore.hidden = !config.toolbarCollapsed;
if (config.toolbarCollapsed) ui.panel.hidden = true;
ui.host.dataset.uiState = config.toolbarCollapsed ? 'icon' : ui.panel.hidden ? 'toolbar' : 'menu';
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.current.textContent = String(current); ui.counter.setAttribute('aria-busy', String(!!state?.loading));
ui.page.disabled = ui.go.disabled = ui.counter.disabled = !config.enabled || !total;
ui.first.disabled = ui.previous.disabled = !config.enabled || current <= 1;
ui.last.disabled = ui.next.disabled = !config.enabled || !total || 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) : '';
const record = reader?.currentRecord?.(), image = reader?.currentImage?.();
const info = record?.data?.fileInfo || '';
const parts = info.split(/\s*::\s*/);
ui.fileName.textContent = parts[0] || (image?.src ? new URL(image.src, location.href).pathname.split('/').pop() : '');
const dimensions = parts.find(part => /\d+\s*[x×]\s*\d+/i.test(part));
const size = image ? intrinsicSize(image) : null;
ui.fileDimensions.textContent = dimensions || (size ? size.width + ' × ' + size.height : '');
ui.fileSize.textContent = parts.find(part => /\d+(?:\.\d+)?\s*(?:[KMGT]i?B|bytes)/i.test(part)) || '';
ui.fileSize.hidden = !ui.fileSize.textContent;
ui.host.dataset.version = '3.5.0'; ui.host.dataset.mode = config.mode; ui.host.dataset.zoom = String(config.zoom);
ui.host.dataset.nativeWebp = String(isWebP(record?.data?.imageURL || image?.src));
}
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'), () => {
showUI(); config.toolbarCollapsed = false; persist(); ui.panel.hidden = false; syncUI();
}),
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);
}
}, 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();
});
window.addEventListener('pagehide', () => { clearTransition(); clearTimeout(saveTimer); persist(); });
reader = createReader();
if (config.enabled) attachObservers();
refresh();
})();