Full-gallery overlay reader for nHentai with full image loading, webtoon and manga modes, fit/zoom, page gap control, jump-to-page, theme picker, document scrolling, mobile browser toolbar compatibility, and low-memory lazy loading.
// ==UserScript==
// @name nHentai Infinite Reader
// @name:ja nHentai 無限スクロールリーダー
// @name:zh-CN nHentai 无限滚动阅读器
// @namespace https://nhentai.net/
// @version 1.0.0
// @author L1Z4RD
// @license MIT
// @match https://nhentai.net/g/*
// @run-at document-idle
// @grant none
// @description Full-gallery overlay reader for nHentai with full image loading, webtoon and manga modes, fit/zoom, page gap control, jump-to-page, theme picker, document scrolling, mobile browser toolbar compatibility, and low-memory lazy loading.
// @description:ja nHentai向けのギャラリー用オーバーレイリーダー。フル画像読み込み、縦スクロール・漫画表示、フィット/ズーム、ページ間隔、ページジャンプ、テーマカラー、ドキュメントスクロール、低メモリ読み込みに対応。
// @description:zh-CN nHentai 画廊覆盖式阅读器,支持完整图片加载、条漫/漫画模式、适应/缩放、页面间距、跳页、主题颜色、文档滚动、移动浏览器工具栏兼容和低内存懒加载。
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
fullMaxWidth: 1800,
loadBuffer: 5,
unloadBuffer: 9,
rootMargin: '1200px',
maxRetries: 3,
menuCollapseMs: 5000,
longPressMs: 450,
scrollHideThreshold: 8,
defaultThemeColor: '#353a45',
defaultReadingMode: 'webtoon',
defaultGap: 5,
defaultZoom: 0
};
const IDS = {
style: 'nhir-overlay-style',
launcher: 'nhir-open-book-launcher',
overlay: 'nhir-overlay',
reader: 'nhir-overlay-reader',
uiRoot: 'nhir-overlay-ui-root',
progressBar: 'nhir-progress-bar',
pageIndicator: 'nhir-page-indicator'
};
const STORAGE = {
settings: 'nhir-overlay-settings-v1'
};
const ICONS = {
openBook: '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 5.5c2.8 0 5 .7 8 2.5v12c-3-1.8-5.2-2.5-8-2.5z"></path><path d="M20 5.5c-2.8 0-5 .7-8 2.5v12c3-1.8 5.2-2.5 8-2.5z"></path><path d="M12 8v12"></path></svg>',
closedBook: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6.5 4.5h10A2.5 2.5 0 0 1 19 7v13H8a3 3 0 0 1-3-3V6a1.5 1.5 0 0 1 1.5-1.5z"></path><path d="M8 4.5V20"></path><path d="M8 17h11"></path></svg>',
menu: '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true"><path d="M5 7h14"></path><path d="M5 12h14"></path><path d="M5 17h14"></path></svg>',
up: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5"></path><path d="M5 12l7-7 7 7"></path></svg>',
mode: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M4 5h16"></path><path d="M4 12h16"></path><path d="M4 19h16"></path><path d="M8 3v18"></path><path d="M16 3v18"></path></svg>',
fit: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 4H4v5"></path><path d="M15 4h5v5"></path><path d="M9 20H4v-5"></path><path d="M15 20h5v-5"></path></svg>',
gap: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6 5h12"></path><path d="M6 12h12"></path><path d="M6 19h12"></path><path d="M12 7v3"></path><path d="M12 14v3"></path></svg>',
reader: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M7 4.5h6.75A3.25 3.25 0 0 1 17 7.75V19a2.5 2.5 0 0 0-2.5-2.5H7z"></path></svg>',
theme: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="4.5"></circle><path d="M12 2.5v2.5"></path><path d="M12 19v2.5"></path><path d="M4.9 4.9l1.8 1.8"></path><path d="M17.3 17.3l1.8 1.8"></path><path d="M2.5 12H5"></path><path d="M19 12h2.5"></path><path d="M4.9 19.1l1.8-1.8"></path><path d="M17.3 6.7l1.8-1.8"></path></svg>'
};
let imagePages = [];
let totalPages = 0;
let scrollTicking = false;
let state = loadSettings();
const startupWait = setInterval(() => {
refreshGalleryImagePages();
if (!totalPages) return;
clearInterval(startupWait);
ensureStyle();
mountLauncher();
}, 100);
setTimeout(() => {
if (!totalPages) clearInterval(startupWait);
}, 15000);
function getCurrentGalleryId() {
const match = location.pathname.match(/^\/g\/(\d+)(?:\/|$)/i);
return match ? match[1] : '';
}
function refreshGalleryImagePages() {
imagePages = extractGalleryImagePages(document);
totalPages = imagePages.length;
}
function extractGalleryImagePages(root) {
const galleryId = getCurrentGalleryId();
if (!galleryId) return [];
const pagesByNo = new Map();
const anchors = Array.from(root.querySelectorAll(`a.gallerythumb[href*="/g/${galleryId}/"]`));
anchors.forEach((anchor, index) => {
const parsed = parseNhentaiPageLink(anchor.getAttribute('href'), galleryId);
if (!parsed) return;
const img = anchor.querySelector('img');
const thumb = getBestImageSource(img, location.href);
if (!thumb) return;
const ratio = getImageRatio(img);
const directFull = deriveFullImageFromThumbnail(thumb);
if (pagesByNo.has(parsed.pageNo)) return;
pagesByNo.set(parsed.pageNo, {
pageNo: parsed.pageNo,
pageLabel: String(parsed.pageNo),
readUrl: parsed.href,
thumb,
fullImage: directFull,
fullImageResolvedBy: directFull ? 'thumbnail-map' : '',
directFullFailed: false,
nativeTried: false,
resolvePromise: null,
originalIndex: index,
ratio
});
});
return Array.from(pagesByNo.values()).sort((a, b) => a.pageNo - b.pageNo);
}
function parseNhentaiPageLink(href, expectedGalleryId) {
if (!href) return null;
let url;
try {
url = new URL(href, location.origin);
} catch {
return null;
}
const match = url.pathname.match(/^\/g\/(\d+)\/(\d+)\/?$/i);
if (!match) return null;
const galleryId = match[1];
const pageNo = parseInt(match[2], 10);
if (expectedGalleryId && galleryId !== expectedGalleryId) return null;
if (!Number.isFinite(pageNo) || pageNo < 1) return null;
return {
href: url.href,
galleryId,
pageNo
};
}
function getBestImageSource(img, baseUrl = location.href) {
if (!img) return '';
const candidates = [
img.getAttribute('data-src'),
img.getAttribute('data-original'),
img.getAttribute('data-lazy-src'),
img.currentSrc,
img.getAttribute('src')
].filter(Boolean);
const srcset = img.getAttribute('srcset') || img.getAttribute('data-srcset') || '';
if (srcset) {
srcset.split(',').forEach(part => {
const candidate = part.trim().split(/\s+/)[0];
if (candidate) candidates.push(candidate);
});
}
const picked = candidates.find(src => src && !/^data:/i.test(src)) || '';
try {
return picked ? new URL(picked, baseUrl).href : '';
} catch {
return picked;
}
}
function getImageRatio(img) {
if (!img) return 1.414;
const width = parseFloat(img.getAttribute('width') || img.naturalWidth || '0');
const height = parseFloat(img.getAttribute('height') || img.naturalHeight || '0');
if (width > 0 && height > 0) {
return Math.max(0.3, Math.min(3.5, height / width));
}
return 1.414;
}
function toAbsoluteUrl(value, base = location.href) {
if (!value) return '';
try {
return new URL(value, base).href;
} catch {
return String(value || '').trim();
}
}
function deriveFullImageFromThumbnail(thumbUrl) {
if (!thumbUrl) return '';
try {
const url = new URL(thumbUrl, location.href);
url.hostname = url.hostname.replace(/^t(\d*)\./i, (_, shard) => `i${shard || ''}.`);
url.pathname = url.pathname.replace(/\/(\d+)t\.(jpe?g|png|webp|gif)$/i, '/$1.$2');
const result = url.href;
return result !== thumbUrl ? result : '';
} catch {
return String(thumbUrl)
.replace(/^https?:\/\/t(\d*)\./i, (_, shard) => `https://i${shard || ''}.`)
.replace(/\/(\d+)t\.(jpe?g|png|webp|gif)$/i, '/$1.$2');
}
}
function isThumbnailUrl(src) {
return /\/\d+t\.(?:jpe?g|png|webp|gif)(?:[?#].*)?$/i.test(String(src || '')) || /\/t\d*\.nhentai\.net\//i.test(String(src || ''));
}
function isUsableFullImageUrl(src) {
if (!src) return false;
const clean = String(src).split('#')[0].split('?')[0];
if (isThumbnailUrl(clean)) return false;
return /\/galleries\/\d+\/\d+\.(?:jpe?g|png|webp|gif)$/i.test(clean) && /\/\/i\d*\.nhentai\.net\//i.test(clean);
}
function extractFullImageSrcFromReaderDocument(doc, baseUrl) {
if (!doc) return '';
const candidates = [];
const selectors = [
'a[aria-label="Click to navigate"] img',
'#image-container img',
'section#image-container img',
'img[alt^="Page "]',
'img[src*="/galleries/"]'
];
selectors.forEach(selector => {
doc.querySelectorAll(selector).forEach(img => {
const picked = getBestImageSource(img, baseUrl);
if (picked) candidates.push(picked);
});
});
return candidates.map(src => toAbsoluteUrl(src, baseUrl)).find(isUsableFullImageUrl) || '';
}
function extractFullImageSrcFromReaderHtml(html, baseUrl) {
if (!html) return '';
const candidates = [];
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const fromDoc = extractFullImageSrcFromReaderDocument(doc, baseUrl);
if (fromDoc) candidates.push(fromDoc);
} catch (error) {
console.warn('[NHIR] DOMParser failed while resolving native reader image:', error);
}
const regexes = [
/https?:\/\/i\d*\.nhentai\.net\/galleries\/\d+\/\d+\.(?:jpe?g|png|webp|gif)/gi,
/https?:\\?\/\\?\/i\d*\.nhentai\.net\\?\/galleries\\?\/\d+\\?\/\d+\.(?:jpe?g|png|webp|gif)/gi
];
regexes.forEach(regex => {
let match;
while ((match = regex.exec(html))) {
candidates.push(match[0].replace(/\\\//g, '/'));
}
});
return candidates.map(src => toAbsoluteUrl(src, baseUrl)).find(isUsableFullImageUrl) || '';
}
async function resolveFullImageByReaderPage(page) {
page.nativeTried = true;
const response = await fetch(page.readUrl, {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
return extractFullImageSrcFromReaderHtml(html, page.readUrl);
}
async function resolveFullImageSrcForPage(pageNo, forceNative = false) {
const page = imagePages[clampPage(pageNo) - 1];
if (!page) return '';
if (!forceNative && page.fullImage && isUsableFullImageUrl(page.fullImage) && !page.directFullFailed) {
return page.fullImage;
}
if (page.resolvePromise) return page.resolvePromise;
page.resolvePromise = (async () => {
if (!forceNative && !page.directFullFailed) {
const direct = page.fullImage || deriveFullImageFromThumbnail(page.thumb);
if (isUsableFullImageUrl(direct)) {
page.fullImage = direct;
page.fullImageResolvedBy = 'thumbnail-map';
return direct;
}
}
try {
const native = await resolveFullImageByReaderPage(page);
if (isUsableFullImageUrl(native)) {
page.fullImage = native;
page.fullImageResolvedBy = 'native-reader';
page.directFullFailed = false;
return native;
}
} catch (error) {
page.lastResolveError = error?.message || String(error);
}
return '';
})();
try {
return await page.resolvePromise;
} finally {
page.resolvePromise = null;
}
}
function loadSettings() {
const fallback = {
themeColor: CONFIG.defaultThemeColor,
readingMode: CONFIG.defaultReadingMode,
gap: CONFIG.defaultGap,
zoom: CONFIG.defaultZoom,
overlay: null,
reader: null,
ui: null,
observer: null,
currentPageNo: 1,
suppressPageTrackingUntil: 0,
menuOpen: false,
openPanelName: null,
pointerInsideUI: false,
menuCollapseTimer: 0,
oldScrollY: 0,
oldBodyOverflow: '',
oldHtmlOverflow: '',
hiddenNativeNodes: [],
lastScrollY: window.scrollY || 0,
picker: null
};
try {
return Object.assign(fallback, JSON.parse(localStorage.getItem(STORAGE.settings) || '{}'));
} catch {
return fallback;
}
}
function saveSettings() {
localStorage.setItem(STORAGE.settings, JSON.stringify({
themeColor: state.themeColor,
readingMode: state.readingMode,
gap: state.gap,
zoom: state.zoom
}));
}
function mountLauncher() {
if (document.getElementById(IDS.launcher)) return;
const button = document.createElement('button');
button.id = IDS.launcher;
button.type = 'button';
button.innerHTML = ICONS.openBook;
button.title = 'Open overlay reader';
button.setAttribute('aria-label', 'Open overlay reader');
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
openOverlayFromGallery();
});
document.body.appendChild(button);
}
function openOverlayFromGallery() {
refreshGalleryImagePages();
if (!totalPages) {
alert('NHIR: Could not find nHentai gallery thumbnails on this page. Open the gallery overview page, not a reader page.');
return;
}
openOverlay(1);
}
function openOverlay(startPageNo) {
if (state.overlay?.isConnected) return;
state.currentPageNo = clampPage(startPageNo || 1);
state.oldScrollY = window.scrollY || window.pageYOffset || 0;
state.oldBodyOverflow = document.body.style.overflow;
state.oldHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
const launcher = document.getElementById(IDS.launcher);
if (launcher) launcher.style.display = 'none';
const overlay = document.createElement('div');
overlay.id = IDS.overlay;
const reader = document.createElement('div');
reader.id = IDS.reader;
reader.className = 'nhir-reader';
overlay.appendChild(reader);
document.body.appendChild(overlay);
state.overlay = overlay;
state.reader = reader;
hideNativePageExceptOverlay();
document.body.classList.add('nhir-body-active');
createProgressBar(overlay);
createPageIndicator(overlay);
mountOverlayUI(overlay);
applyAllSettings();
window.scrollTo({ top: 0, behavior: 'auto' });
renderReader(state.currentPageNo);
setupOverlayEvents();
}
function hideNativePageExceptOverlay() {
state.hiddenNativeNodes = [];
Array.from(document.body.children).forEach(node => {
if (node.id === IDS.overlay || node.id === IDS.launcher) return;
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
state.hiddenNativeNodes.push({
node,
display: node.style.display,
visibility: node.style.visibility
});
node.style.display = 'none';
});
}
function restoreNativePage() {
state.hiddenNativeNodes.forEach(entry => {
if (!entry.node?.isConnected) return;
entry.node.style.display = entry.display || '';
entry.node.style.visibility = entry.visibility || '';
});
state.hiddenNativeNodes = [];
document.body.classList.remove('nhir-body-active');
}
function closeOverlay() {
if (!state.overlay) return;
clearMenuCollapse();
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
removeOverlayEvents();
if (state.overlay.isConnected) {
state.overlay.remove();
}
state.overlay = null;
state.reader = null;
state.ui = null;
state.menuOpen = false;
state.openPanelName = null;
state.pointerInsideUI = false;
restoreNativePage();
document.body.style.overflow = state.oldBodyOverflow || '';
document.documentElement.style.overflow = state.oldHtmlOverflow || '';
const launcher = document.getElementById(IDS.launcher);
if (launcher) launcher.style.display = '';
window.scrollTo({ top: state.oldScrollY || 0, behavior: 'auto' });
}
function renderReader(targetPageNo = state.currentPageNo) {
if (!state.reader) return;
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
state.reader.innerHTML = '';
state.reader.className = `nhir-reader nhir-mode-${state.readingMode}`;
if (state.readingMode === 'manga') {
renderMangaReader();
} else {
renderWebtoonReader();
}
updatePlaceholders();
applyGap();
applyZoom();
setupObserver();
stabilizedJumpToPage(targetPageNo, 'auto');
}
function renderWebtoonReader() {
imagePages.forEach((page, index) => {
const pageNo = index + 1;
const wrapper = document.createElement('div');
wrapper.className = 'nhir-page';
wrapper.dataset.pageNo = pageNo;
wrapper.dataset.observePage = pageNo;
wrapper.style.setProperty('--nhir-placeholder-height', `${getPlaceholderHeight(page)}px`);
const img = createImage(page, pageNo);
const errorBox = createErrorBox(img);
wrapper.append(img, errorBox);
state.reader.appendChild(wrapper);
});
}
function renderMangaReader() {
for (let i = 0; i < imagePages.length; i += 2) {
const firstPageNo = i + 1;
const secondPageNo = i + 2;
const spread = document.createElement('div');
spread.className = 'nhir-spread';
spread.dataset.spreadFirst = firstPageNo;
spread.dataset.observePage = firstPageNo;
const leftSlot = createMangaSlot(secondPageNo);
const rightSlot = createMangaSlot(firstPageNo);
spread.append(leftSlot, rightSlot);
state.reader.appendChild(spread);
}
}
function createMangaSlot(pageNo) {
const slot = document.createElement('div');
slot.className = 'nhir-manga-slot';
if (pageNo > totalPages) {
slot.classList.add('is-empty');
return slot;
}
const page = imagePages[pageNo - 1];
const img = createImage(page, pageNo);
const errorBox = createErrorBox(img);
slot.dataset.pageNo = pageNo;
slot.style.setProperty('--nhir-placeholder-height', `${getPlaceholderHeight(page) * 0.72}px`);
slot.append(img, errorBox);
return slot;
}
function createImage(page, pageNo) {
const img = document.createElement('img');
img.className = 'nhir-image';
img.dataset.pageNo = pageNo;
img.dataset.thumb = page.thumb || '';
img.dataset.readUrl = page.readUrl || '';
img.dataset.retryCount = '0';
img.dataset.loading = '0';
img.alt = `Page ${pageNo}`;
img.decoding = 'async';
img.loading = 'lazy';
return img;
}
function createErrorBox(img) {
const errorBox = document.createElement('div');
errorBox.className = 'nhir-error';
errorBox.innerHTML = `
<div>Image failed to load.</div>
<button type="button">Retry</button>
`;
errorBox.querySelector('button').addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const page = imagePages[clampPage(parseInt(img.dataset.pageNo || '1', 10)) - 1];
if (page) {
page.directFullFailed = true;
page.fullImage = '';
page.fullImageResolvedBy = '';
}
img.dataset.retryCount = '0';
img.removeAttribute('data-resolve-error');
errorBox.classList.remove('visible');
loadImage(img, true);
});
return errorBox;
}
function setupObserver() {
if (!state.reader) return;
state.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const target = entry.target;
target.querySelectorAll('img.nhir-image').forEach(img => {
loadImage(img);
});
const pageNo = parseInt(
target.dataset.pageNo ||
target.dataset.spreadFirst ||
target.dataset.observePage ||
state.currentPageNo,
10
);
maintainMemory(pageNo);
});
}, {
root: null,
rootMargin: CONFIG.rootMargin
});
state.reader.querySelectorAll('[data-observe-page]').forEach(el => {
state.observer.observe(el);
});
}
function setupOverlayEvents() {
window.__NHIR_KEY_HANDLER__ = handleKeydown;
window.__NHIR_RESIZE_HANDLER__ = handleResize;
window.__NHIR_SCROLL_HANDLER__ = handleWindowScroll;
window.addEventListener('keydown', window.__NHIR_KEY_HANDLER__);
window.addEventListener('resize', window.__NHIR_RESIZE_HANDLER__, { passive: true });
window.addEventListener('scroll', window.__NHIR_SCROLL_HANDLER__, { passive: true });
}
function removeOverlayEvents() {
if (window.__NHIR_KEY_HANDLER__) {
window.removeEventListener('keydown', window.__NHIR_KEY_HANDLER__);
window.__NHIR_KEY_HANDLER__ = null;
}
if (window.__NHIR_RESIZE_HANDLER__) {
window.removeEventListener('resize', window.__NHIR_RESIZE_HANDLER__);
window.__NHIR_RESIZE_HANDLER__ = null;
}
if (window.__NHIR_SCROLL_HANDLER__) {
window.removeEventListener('scroll', window.__NHIR_SCROLL_HANDLER__);
window.__NHIR_SCROLL_HANDLER__ = null;
}
}
function handleWindowScroll() {
if (scrollTicking) return;
scrollTicking = true;
requestAnimationFrame(() => {
updateCurrentPage();
updateProgressBar();
updateAutoHideControls();
scrollTicking = false;
});
}
function updateAutoHideControls() {
if (!state.ui?.root) return;
const currentY = window.scrollY || window.pageYOffset || 0;
const delta = currentY - (state.lastScrollY || 0);
state.lastScrollY = currentY;
if (Math.abs(delta) < CONFIG.scrollHideThreshold) return;
if (state.pointerInsideUI || state.menuOpen) return;
state.ui.root.classList.toggle('is-ui-hidden', delta > 0);
}
function handleKeydown(event) {
if (!state.overlay) return;
if (event.key === 'Escape') {
event.preventDefault();
closeOverlay();
return;
}
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'select' || tag === 'textarea') {
return;
}
if (event.key.toLowerCase() === 't') {
event.preventDefault();
openPanel('theme');
return;
}
if (event.key.toLowerCase() === 'f') {
event.preventDefault();
updateZoom(state.zoom === 0 ? 100 : 0, true);
return;
}
if (event.key === '+' || event.key === '=') {
event.preventDefault();
updateZoom(state.zoom + 5, true);
return;
}
if (event.key === '-' || event.key === '_') {
event.preventDefault();
updateZoom(state.zoom - 5, true);
return;
}
if (event.key === '0') {
event.preventDefault();
updateZoom(0, true);
return;
}
if (state.readingMode === 'manga') {
if (event.key === 'ArrowLeft' || event.key === 'PageDown' || event.key === ' ') {
event.preventDefault();
goNextUnit();
}
if (event.key === 'ArrowRight' || event.key === 'PageUp') {
event.preventDefault();
goPreviousUnit();
}
return;
}
if (event.key === 'ArrowDown' || event.key === 'PageDown') {
event.preventDefault();
goNextUnit();
}
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
event.preventDefault();
goPreviousUnit();
}
}
function handleResize() {
const anchor = captureViewportAnchor();
updatePlaceholders();
applyZoom();
requestAnimationFrame(() => {
restoreViewportAnchor(anchor, 'auto');
updateProgressBar();
updateCurrentPage(true);
});
}
function goNextUnit() {
if (state.readingMode === 'manga') {
const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
jumpToPage(Math.min(totalPages, spreadFirst + 2), 'smooth');
} else {
jumpToPage(Math.min(totalPages, state.currentPageNo + 1), 'smooth');
}
}
function goPreviousUnit() {
if (state.readingMode === 'manga') {
const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
jumpToPage(Math.max(1, spreadFirst - 2), 'smooth');
} else {
jumpToPage(Math.max(1, state.currentPageNo - 1), 'smooth');
}
}
function getSpreadFirstFromPage(pageNo) {
const safe = clampPage(pageNo);
return safe % 2 === 0 ? safe - 1 : safe;
}
function getTargetElementForPage(pageNo) {
const safePageNo = clampPage(pageNo);
if (!state.reader) return null;
if (state.readingMode === 'manga') {
const spreadFirst = getSpreadFirstFromPage(safePageNo);
return state.reader.querySelector(`.nhir-spread[data-spread-first="${spreadFirst}"]`);
}
return state.reader.querySelector(`.nhir-page[data-page-no="${safePageNo}"]`);
}
function preloadImagesAroundPage(pageNo) {
if (!state.reader) return [];
const safePageNo = clampPage(pageNo);
const start = Math.max(1, safePageNo - 2);
const end = Math.min(totalPages, safePageNo + 2);
const promises = [];
state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
const imgPageNo = parseInt(img.dataset.pageNo, 10);
if (imgPageNo >= start && imgPageNo <= end) {
promises.push(loadImage(img));
}
});
return promises;
}
async function ensurePageReady(pageNo, timeoutMs = 1400) {
const target = getTargetElementForPage(pageNo);
if (!target) return;
const imgs = Array.from(target.querySelectorAll('img.nhir-image'));
if (!imgs.length) return;
const loads = imgs.map(img => loadImage(img));
await Promise.race([
Promise.allSettled(loads),
wait(timeoutMs)
]);
}
async function stabilizedJumpToPage(pageNo, behavior = 'auto') {
const safePageNo = clampPage(pageNo);
state.suppressPageTrackingUntil = Date.now() + 800;
preloadImagesAroundPage(safePageNo);
maintainMemory(safePageNo);
await nextFrame();
await ensurePageReady(safePageNo, 1400);
await nextFrame();
forceJumpToPage(safePageNo, behavior);
await nextFrame();
forceJumpToPage(safePageNo, 'auto');
setTimeout(() => {
updateCurrentPage(true);
updatePageIndicator();
updatePageInput();
updateProgressBar();
maintainMemory(safePageNo);
}, 220);
}
function forceJumpToPage(pageNo, behavior = 'auto') {
const target = getTargetElementForPage(pageNo);
if (!target) return;
const safePageNo = clampPage(pageNo);
const rect = target.getBoundingClientRect();
const targetTop = window.scrollY + rect.top;
const centerOffset = Math.max(0, (window.innerHeight - rect.height) / 2);
const top = Math.max(0, targetTop - centerOffset);
state.suppressPageTrackingUntil = Math.max(state.suppressPageTrackingUntil || 0, Date.now() + 350);
window.scrollTo({ top, behavior });
state.currentPageNo = state.readingMode === 'manga'
? Math.min(totalPages, getSpreadFirstFromPage(safePageNo) + 1)
: safePageNo;
updatePageIndicator();
updatePageInput();
maintainMemory(state.currentPageNo);
}
function jumpToPage(pageNo, behavior = 'smooth') {
stabilizedJumpToPage(pageNo, behavior);
}
function updateCurrentPage(force = false) {
if (!state.reader) return;
if (!force && state.suppressPageTrackingUntil && Date.now() < state.suppressPageTrackingUntil) {
return;
}
const selector = state.readingMode === 'manga' ? '.nhir-spread' : '.nhir-page';
const elements = state.reader.querySelectorAll(selector);
const viewportCenter = window.innerHeight / 2;
let closest = null;
let closestDistance = Infinity;
elements.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
const center = rect.top + rect.height / 2;
const distance = Math.abs(viewportCenter - center);
if (distance < closestDistance) {
closestDistance = distance;
closest = el;
}
});
if (!closest) {
elements.forEach(el => {
const rect = el.getBoundingClientRect();
const distance = Math.abs(rect.top);
if (distance < closestDistance) {
closestDistance = distance;
closest = el;
}
});
}
if (!closest) return;
if (state.readingMode === 'manga') {
const spreadFirst = parseInt(closest.dataset.spreadFirst, 10);
state.currentPageNo = Math.min(totalPages, spreadFirst + 1);
} else {
state.currentPageNo = parseInt(closest.dataset.pageNo, 10);
}
updatePageIndicator();
updatePageInput();
maintainMemory(state.currentPageNo);
}
function captureViewportAnchor() {
updateCurrentPage(true);
const pageNo = clampPage(state.currentPageNo || 1);
const target = getTargetElementForPage(pageNo);
if (!target) {
return { pageNo, ratio: 0.5 };
}
const rect = target.getBoundingClientRect();
const ratio = rect.height > 0
? clamp((window.innerHeight / 2 - rect.top) / rect.height, 0, 1)
: 0.5;
return { pageNo, ratio };
}
function restoreViewportAnchor(anchor, behavior = 'auto') {
if (!anchor) return;
const target = getTargetElementForPage(anchor.pageNo);
if (!target) return;
const rect = target.getBoundingClientRect();
const targetTop = window.scrollY + rect.top;
const top = Math.max(0, targetTop + rect.height * anchor.ratio - window.innerHeight / 2);
window.scrollTo({ top, behavior });
state.currentPageNo = clampPage(anchor.pageNo);
updatePageIndicator();
updatePageInput();
maintainMemory(state.currentPageNo);
}
function maintainMemory(currentPage) {
if (!state.reader) return;
const safeCurrent = clampPage(currentPage);
state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
const pageNo = parseInt(img.dataset.pageNo, 10);
const distance = Math.abs(pageNo - safeCurrent);
if (distance <= CONFIG.loadBuffer) {
loadImage(img);
}
if (distance > CONFIG.unloadBuffer) {
unloadImage(img);
}
});
}
async function loadImage(img, force = false) {
if (!img) return '';
if (!force && img.src) return img.src;
if (!force && img.dataset.loading === '1' && img.__nhirLoadPromise) return img.__nhirLoadPromise;
const pageNo = parseInt(img.dataset.pageNo || '0', 10);
const page = imagePages[clampPage(pageNo) - 1];
const errorBox = img.parentElement?.querySelector('.nhir-error');
if (!page) return '';
if (force) {
img.classList.remove('loaded');
img.removeAttribute('src');
img.removeAttribute('data-resolve-error');
markPageLoaded(img, false);
}
errorBox?.classList.remove('visible');
img.dataset.loading = '1';
const src = await resolveFullImageSrcForPage(pageNo, force || page.directFullFailed);
if (!src) {
img.dataset.loading = '0';
img.dataset.resolveError = page.lastResolveError || 'full-image-src-not-resolved';
errorBox?.classList.add('visible');
return '';
}
img.dataset.fullSrc = src;
img.__nhirLoadPromise = new Promise(resolve => {
img.onload = () => {
img.classList.add('loaded');
img.dataset.retryCount = '0';
img.dataset.loading = '0';
img.removeAttribute('data-resolve-error');
if (img.naturalWidth && img.naturalHeight) {
page.ratio = img.naturalHeight / img.naturalWidth;
img.style.setProperty('--nhir-natural-width', `${img.naturalWidth}px`);
updatePagePlaceholder(img, page);
}
markPageLoaded(img, true);
errorBox?.classList.remove('visible');
resolve(src);
};
img.onerror = () => {
img.classList.remove('loaded');
img.dataset.loading = '0';
markPageLoaded(img, false);
img.removeAttribute('src');
if (page.fullImageResolvedBy === 'thumbnail-map' && !page.nativeTried) {
page.directFullFailed = true;
page.fullImage = '';
page.fullImageResolvedBy = '';
setTimeout(() => resolve(loadImage(img, true)), 350);
return;
}
let retryCount = parseInt(img.dataset.retryCount || '0', 10);
retryCount += 1;
img.dataset.retryCount = String(retryCount);
if (retryCount <= CONFIG.maxRetries) {
setTimeout(() => resolve(loadImage(img, true)), 850 * retryCount);
} else {
img.dataset.resolveError = 'image-load-failed';
errorBox?.classList.add('visible');
resolve('');
}
};
img.src = src;
});
return img.__nhirLoadPromise;
}
function unloadImage(img) {
if (!img || !img.src || img.dataset.loading === '1') return;
img.classList.remove('loaded');
markPageLoaded(img, false);
setTimeout(() => {
if (img.dataset.loading !== '1') {
img.removeAttribute('src');
}
}, 160);
}
function markPageLoaded(img, isLoaded) {
const pageWrap = img.closest('.nhir-page, .nhir-manga-slot');
pageWrap?.classList.toggle('is-loaded', Boolean(isLoaded));
}
function updatePlaceholders() {
if (!state.reader) return;
state.reader.querySelectorAll('.nhir-page[data-page-no]').forEach(wrapper => {
const page = imagePages[parseInt(wrapper.dataset.pageNo, 10) - 1];
if (page) wrapper.style.setProperty('--nhir-placeholder-height', `${getPlaceholderHeight(page)}px`);
});
state.reader.querySelectorAll('.nhir-manga-slot[data-page-no]').forEach(slot => {
const page = imagePages[parseInt(slot.dataset.pageNo, 10) - 1];
if (page) slot.style.setProperty('--nhir-placeholder-height', `${Math.round(getPlaceholderHeight(page) * 0.72)}px`);
});
}
function updatePagePlaceholder(img, page) {
const wrapper = img.closest('.nhir-page, .nhir-manga-slot');
if (!wrapper || !page) return;
const multiplier = wrapper.classList.contains('nhir-manga-slot') ? 0.72 : 1;
wrapper.style.setProperty('--nhir-placeholder-height', `${Math.round(getPlaceholderHeight(page) * multiplier)}px`);
}
function getPlaceholderHeight(page) {
const availableWidth = Math.max(260, Math.min(window.innerWidth - 16, CONFIG.fullMaxWidth));
const rawHeight = availableWidth * (page?.ratio || 1.414);
return Math.round(clamp(rawHeight, 300, Math.max(620, window.innerHeight * 1.4)));
}
function mountOverlayUI(overlay) {
const root = document.createElement('div');
root.id = IDS.uiRoot;
const topButtonWrap = document.createElement('div');
topButtonWrap.className = 'nhir-top-button';
const topButton = document.createElement('button');
topButton.type = 'button';
topButton.className = 'nhir-btn';
topButton.innerHTML = ICONS.up;
topButton.title = 'Click: top. Hold: bottom.';
topButton.setAttribute('aria-label', 'Click to scroll top. Hold to scroll bottom.');
topButtonWrap.appendChild(topButton);
const actionStack = document.createElement('div');
actionStack.className = 'nhir-action-stack';
const modeItem = createStackItem('mode', ICONS.mode, 'Reading mode');
const fitItem = createStackItem('fit', ICONS.fit, 'Fit and zoom');
const gapItem = createStackItem('gap', ICONS.gap, 'Page gap');
const pageItem = createStackItem('page', ICONS.reader, 'Jump to page');
const themeItem = createStackItem('theme', ICONS.theme, 'Theme color');
actionStack.append(
modeItem.item,
fitItem.item,
gapItem.item,
pageItem.item,
themeItem.item
);
const menuButtonWrap = document.createElement('div');
menuButtonWrap.className = 'nhir-menu-button';
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'nhir-btn';
menuButton.innerHTML = ICONS.menu;
menuButton.title = 'Menu';
menuButton.setAttribute('aria-label', 'Menu');
menuButtonWrap.appendChild(menuButton);
const closeButtonWrap = document.createElement('div');
closeButtonWrap.className = 'nhir-close-button';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'nhir-btn nhir-close-btn';
closeButton.innerHTML = ICONS.closedBook;
closeButton.title = 'Close reader';
closeButton.setAttribute('aria-label', 'Close reader');
closeButtonWrap.appendChild(closeButton);
const bottomControls = document.createElement('div');
bottomControls.className = 'nhir-bottom-controls';
bottomControls.append(closeButtonWrap, menuButtonWrap);
root.append(topButtonWrap, actionStack, bottomControls);
overlay.appendChild(root);
const modeToggles = createToggleGroup([
{
label: 'Webtoon',
active: state.readingMode === 'webtoon',
onSelect() {
updateReadingMode('webtoon');
armMenuCollapse();
}
},
{
label: 'Manga',
active: state.readingMode === 'manga',
onSelect() {
updateReadingMode('manga');
armMenuCollapse();
}
}
]);
modeItem.panel.appendChild(modeToggles.root);
fitItem.panel.classList.add('nhir-panel-vertical');
const fitToggles = createToggleGroup([
{
label: 'Fit',
active: state.zoom === 0,
onSelect() {
updateZoom(0, true);
armMenuCollapse();
}
},
{
label: 'Fullscreen',
active: state.zoom === 100,
onSelect() {
updateZoom(100, true);
armMenuCollapse();
}
}
]);
const zoomWrap = document.createElement('div');
zoomWrap.className = 'nhir-slider-wrap';
const zoomTop = document.createElement('div');
zoomTop.className = 'nhir-slider-top';
const zoomLabel = document.createElement('span');
zoomLabel.textContent = 'Zoom';
const zoomValue = document.createElement('span');
zoomValue.className = 'nhir-slider-value';
zoomValue.textContent = `${state.zoom}%`;
zoomTop.append(zoomLabel, zoomValue);
const zoomRow = document.createElement('div');
zoomRow.className = 'nhir-slider-row';
const zoomSlider = document.createElement('input');
zoomSlider.type = 'range';
zoomSlider.min = '0';
zoomSlider.max = '100';
zoomSlider.step = '1';
zoomSlider.value = String(state.zoom);
const zoomReset = document.createElement('button');
zoomReset.type = 'button';
zoomReset.textContent = 'Fit';
zoomRow.append(zoomSlider, zoomReset);
zoomWrap.append(zoomTop, zoomRow);
fitItem.panel.append(fitToggles.root, zoomWrap);
const gapToggles = createToggleGroup([0, 5, 10, 15].map(gap => ({
label: `${gap}px`,
active: state.gap === gap,
onSelect() {
updateGap(gap);
armMenuCollapse();
}
})));
gapItem.panel.appendChild(gapToggles.root);
const pagePanel = document.createElement('div');
pagePanel.className = 'nhir-jump';
const pageInput = document.createElement('input');
pageInput.type = 'number';
pageInput.min = '1';
pageInput.max = String(totalPages);
pageInput.placeholder = `1-${totalPages}`;
const pageButton = document.createElement('button');
pageButton.type = 'button';
pageButton.textContent = 'Go';
pagePanel.append(pageInput, pageButton);
pageItem.panel.appendChild(pagePanel);
themeItem.panel.classList.add('nhir-panel-vertical');
const colorPicker = createCustomColorPicker();
themeItem.panel.appendChild(colorPicker.root);
root.addEventListener('pointerenter', () => {
state.pointerInsideUI = true;
root.classList.remove('is-ui-hidden');
clearMenuCollapse();
});
root.addEventListener('pointerleave', () => {
state.pointerInsideUI = false;
armMenuCollapse();
});
menuButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (state.menuOpen) {
closeMenu();
} else {
openMenu();
}
});
closeButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
closeOverlay();
});
wirePanelToggle(modeItem, 'mode');
wirePanelToggle(fitItem, 'fit');
wirePanelToggle(gapItem, 'gap');
wirePanelToggle(pageItem, 'page', { noAutoCollapse: true });
wirePanelToggle(themeItem, 'theme');
pageButton.addEventListener('click', () => {
jumpToPage(pageInput.value, 'smooth');
});
pageInput.addEventListener('keydown', event => {
if (event.key === 'Enter') {
jumpToPage(pageInput.value, 'smooth');
}
});
zoomSlider.addEventListener('input', () => {
updateZoom(parseInt(zoomSlider.value, 10), false);
});
zoomSlider.addEventListener('change', () => {
saveSettings();
armMenuCollapse();
});
zoomReset.addEventListener('click', () => {
updateZoom(0, true);
armMenuCollapse();
});
setupTopButton(topButton);
state.ui = {
root,
menuButton,
topButton,
items: {
mode: modeItem,
fit: fitItem,
gap: gapItem,
page: pageItem,
theme: themeItem
},
closeButton,
modeButtons: modeToggles.buttons,
fitButtons: fitToggles.buttons,
gapButtons: gapToggles.buttons,
pageInput,
zoomSlider,
zoomValue,
color: colorPicker
};
updatePageInput();
syncUI();
}
function createCustomColorPicker() {
const root = document.createElement('div');
root.className = 'nhir-color-picker';
const square = document.createElement('div');
square.className = 'nhir-color-square';
const knob = document.createElement('div');
knob.className = 'nhir-color-knob';
square.appendChild(knob);
const hueRow = document.createElement('div');
hueRow.className = 'nhir-hue-row';
const hueSlider = document.createElement('input');
hueSlider.className = 'nhir-hue-slider';
hueSlider.type = 'range';
hueSlider.min = '0';
hueSlider.max = '360';
hueSlider.step = '1';
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.textContent = 'Reset';
hueRow.append(hueSlider, resetButton);
root.append(square, hueRow);
state.picker = hexToHsv(state.themeColor);
const updateSquareFromPointer = event => {
const rect = square.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
const y = Math.max(0, Math.min(rect.height, event.clientY - rect.top));
state.picker.s = x / rect.width;
state.picker.v = 1 - (y / rect.height);
updateThemeColorFromPicker();
};
square.addEventListener('pointerdown', event => {
event.preventDefault();
square.setPointerCapture(event.pointerId);
updateSquareFromPointer(event);
});
square.addEventListener('pointermove', event => {
if (event.buttons !== 1) return;
updateSquareFromPointer(event);
});
square.addEventListener('pointerup', () => {
saveSettings();
});
hueSlider.addEventListener('input', () => {
state.picker.h = parseInt(hueSlider.value, 10);
updateThemeColorFromPicker();
});
hueSlider.addEventListener('change', () => {
saveSettings();
armMenuCollapse();
});
resetButton.addEventListener('click', () => {
state.themeColor = CONFIG.defaultThemeColor;
state.picker = hexToHsv(state.themeColor);
applyThemeColor();
updateColorPickerUI();
saveSettings();
armMenuCollapse();
});
return {
root,
square,
knob,
hueSlider
};
}
function updateThemeColorFromPicker() {
state.themeColor = hsvToHex(state.picker.h, state.picker.s, state.picker.v);
applyThemeColor();
updateColorPickerUI();
}
function updateColorPickerUI() {
if (!state.ui?.color || !state.picker) return;
const { square, knob, hueSlider } = state.ui.color;
hueSlider.value = String(Math.round(state.picker.h));
square.style.setProperty('--nhir-picker-hue', String(Math.round(state.picker.h)));
knob.style.left = `${state.picker.s * 100}%`;
knob.style.top = `${(1 - state.picker.v) * 100}%`;
}
function setupTopButton(topButton) {
let navPressTimer = 0;
let navLongPressFired = false;
const clearNavTimer = () => {
if (navPressTimer) {
clearTimeout(navPressTimer);
navPressTimer = 0;
}
};
topButton.addEventListener('pointerdown', event => {
event.preventDefault();
event.stopPropagation();
navLongPressFired = false;
clearNavTimer();
navPressTimer = window.setTimeout(() => {
navLongPressFired = true;
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: 'smooth'
});
navPressTimer = 0;
}, CONFIG.longPressMs);
});
const releaseNav = event => {
event.preventDefault();
event.stopPropagation();
if (navPressTimer) {
clearNavTimer();
if (!navLongPressFired) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
};
topButton.addEventListener('pointerup', releaseNav);
topButton.addEventListener('pointerleave', clearNavTimer);
topButton.addEventListener('pointercancel', clearNavTimer);
}
function createStackItem(name, icon, label) {
const item = document.createElement('div');
item.className = 'nhir-stack-item';
item.dataset.item = name;
const panel = document.createElement('div');
panel.className = 'nhir-panel';
const button = document.createElement('button');
button.type = 'button';
button.className = 'nhir-btn';
button.innerHTML = icon;
button.setAttribute('aria-label', label);
button.title = label;
item.append(panel, button);
return { item, panel, button };
}
function createToggleGroup(items) {
const root = document.createElement('div');
root.className = 'nhir-segmented';
const buttons = items.map(item => {
const button = document.createElement('button');
button.type = 'button';
button.className = `nhir-chip${item.active ? ' is-active' : ''}`;
button.textContent = item.label;
button.addEventListener('click', item.onSelect);
root.appendChild(button);
return button;
});
return { root, buttons };
}
function wirePanelToggle(entry, name, options = {}) {
entry.button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const isOpen = entry.item.classList.contains('is-open');
if (isOpen) {
closePanels();
if (!options.noAutoCollapse) {
armMenuCollapse();
}
return;
}
openPanel(name, options);
});
}
function openMenu() {
if (!state.ui) return;
state.menuOpen = true;
state.ui.root.classList.add('is-menu-open');
state.ui.root.classList.remove('is-ui-hidden');
armMenuCollapse();
}
function closeMenu() {
if (!state.ui) return;
clearMenuCollapse();
state.menuOpen = false;
state.openPanelName = null;
state.ui.root.classList.remove('is-menu-open');
closePanels();
}
function openPanel(name, options = {}) {
if (!state.ui) return;
state.menuOpen = true;
state.openPanelName = name;
state.ui.root.classList.add('is-menu-open');
state.ui.root.classList.remove('is-ui-hidden');
Object.entries(state.ui.items).forEach(([key, item]) => {
item.item.classList.toggle('is-open', key === name);
});
if (name === 'theme') {
updateColorPickerUI();
}
if (options.noAutoCollapse) {
clearMenuCollapse();
return;
}
armMenuCollapse();
}
function closePanels() {
if (!state.ui) return;
Object.values(state.ui.items).forEach(item => {
item.item.classList.remove('is-open');
});
state.openPanelName = null;
}
function clearMenuCollapse() {
clearTimeout(state.menuCollapseTimer);
state.menuCollapseTimer = 0;
}
function armMenuCollapse() {
clearMenuCollapse();
if (!state.menuOpen) return;
if (state.pointerInsideUI) return;
if (state.openPanelName === 'page') return;
state.menuCollapseTimer = window.setTimeout(() => {
closeMenu();
}, CONFIG.menuCollapseMs);
}
function updateReadingMode(mode) {
if (state.readingMode === mode) return;
const anchor = captureViewportAnchor();
state.readingMode = mode;
saveSettings();
renderReader(anchor.pageNo);
syncUI();
}
function updateGap(gap) {
const anchor = captureViewportAnchor();
state.gap = gap;
saveSettings();
applyGap();
syncUI();
requestAnimationFrame(() => restoreViewportAnchor(anchor, 'auto'));
}
function updateZoom(zoom, shouldSave) {
const anchor = captureViewportAnchor();
state.zoom = Math.max(0, Math.min(100, zoom));
applyZoom();
syncUI();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
restoreViewportAnchor(anchor, 'auto');
updateCurrentPage(true);
});
});
if (shouldSave) {
saveSettings();
}
}
function applyAllSettings() {
applyThemeColor();
applyGap();
applyZoom();
syncUI();
}
function applyThemeColor() {
document.documentElement.style.setProperty('--nhir-theme-bg', state.themeColor);
if (state.overlay) {
state.overlay.style.backgroundColor = state.themeColor;
}
}
function applyGap() {
if (!state.reader) return;
state.reader.style.gap = `${state.gap}px`;
document.documentElement.style.setProperty('--nhir-reader-gap', `${state.gap}px`);
}
function applyZoom() {
const viewportBase = Math.max(240, window.innerWidth - 16);
const minFactor = 0.55;
const factor = minFactor + (state.zoom / 100) * (1 - minFactor);
const zoomWidth = Math.round(viewportBase * factor);
const mangaPageWidth = Math.max(160, Math.floor((zoomWidth - state.gap - 16) / 2));
document.documentElement.style.setProperty('--nhir-zoom-width', `${zoomWidth}px`);
document.documentElement.style.setProperty('--nhir-manga-page-width', `${mangaPageWidth}px`);
if (state.overlay) {
state.overlay.classList.toggle('nhir-zoom-over', state.zoom > 0);
state.overlay.classList.toggle('nhir-fit-mode', state.zoom === 0);
}
}
function syncUI() {
if (!state.ui) return;
state.ui.modeButtons[0].classList.toggle('is-active', state.readingMode === 'webtoon');
state.ui.modeButtons[1].classList.toggle('is-active', state.readingMode === 'manga');
state.ui.fitButtons[0].classList.toggle('is-active', state.zoom === 0);
state.ui.fitButtons[1].classList.toggle('is-active', state.zoom === 100);
state.ui.gapButtons.forEach((button, index) => {
button.classList.toggle('is-active', state.gap === [0, 5, 10, 15][index]);
});
state.ui.zoomSlider.value = String(state.zoom);
state.ui.zoomValue.textContent = `${state.zoom}%`;
updateColorPickerUI();
updatePageInput();
}
function createProgressBar(parent) {
const bar = document.createElement('div');
bar.id = IDS.progressBar;
parent.appendChild(bar);
}
function createPageIndicator(parent) {
const indicator = document.createElement('div');
indicator.id = IDS.pageIndicator;
indicator.textContent = `Page 1 / ${totalPages}`;
parent.appendChild(indicator);
}
function updateProgressBar() {
const bar = document.getElementById(IDS.progressBar);
if (!bar) return;
const doc = document.documentElement;
const maxScroll = doc.scrollHeight - window.innerHeight;
const progress = maxScroll <= 0 ? 0 : (window.scrollY / maxScroll) * 100;
bar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
}
function updatePageIndicator() {
const indicator = document.getElementById(IDS.pageIndicator);
if (!indicator) return;
if (state.readingMode === 'manga') {
const first = getSpreadFirstFromPage(state.currentPageNo);
const second = Math.min(totalPages, first + 1);
indicator.textContent = `Pages ${first}${second !== first ? '–' + second : ''} / ${totalPages}`;
return;
}
indicator.textContent = `Page ${state.currentPageNo} / ${totalPages}`;
}
function updatePageInput() {
if (!state.ui?.pageInput) return;
if (document.activeElement !== state.ui.pageInput) {
state.ui.pageInput.value = String(state.currentPageNo);
}
}
function ensureStyle() {
if (document.getElementById(IDS.style)) return;
const style = document.createElement('style');
style.id = IDS.style;
style.textContent = `
:root {
--nhir-theme-bg: ${state.themeColor};
--nhir-ui-surface: rgba(245, 241, 232, 0.96);
--nhir-ui-stroke: rgba(0, 0, 0, 0.08);
--nhir-ui-text: #2d3137;
--nhir-ui-track: rgba(0, 0, 0, 0.12);
--nhir-ui-fill: #303843;
--nhir-reader-gap: 5px;
--nhir-zoom-width: calc(100vw - 16px);
--nhir-manga-page-width: calc((100vw - 32px) / 2);
}
body.nhir-body-active {
margin: 0 !important;
background: var(--nhir-theme-bg) !important;
overscroll-behavior-y: contain;
}
#${IDS.launcher} {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2147483646;
width: 50px;
height: 50px;
border: 1px solid var(--nhir-ui-stroke);
border-radius: 50%;
background: var(--nhir-ui-surface);
color: var(--nhir-ui-text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
transition: transform 150ms ease, background-color 150ms ease, color 150ms ease;
}
#${IDS.launcher}:hover {
transform: scale(1.05);
}
#${IDS.launcher} svg,
.nhir-btn svg {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
flex: none;
}
#${IDS.overlay} {
position: relative;
z-index: 2147483645;
width: 100%;
min-height: 100dvh;
background: var(--nhir-theme-bg);
color: white;
transition: background-color 180ms ease;
overscroll-behavior: contain;
}
.nhir-reader {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--nhir-reader-gap);
padding: 8px 0 120px;
box-sizing: border-box;
}
.nhir-page {
width: min(100%, ${CONFIG.fullMaxWidth}px);
min-height: var(--nhir-placeholder-height, 520px);
display: flex;
justify-content: center;
align-items: center;
position: relative;
scroll-margin-top: 8px;
scroll-margin-bottom: 8px;
box-sizing: border-box;
}
.nhir-mode-webtoon .nhir-page.is-loaded {
min-height: 0;
}
.nhir-image {
display: block;
width: auto;
height: auto;
max-width: min(calc(100vw - 16px), ${CONFIG.fullMaxWidth}px);
max-height: calc(100dvh - 16px);
border-radius: 10px;
background: rgba(0, 0, 0, 0.07);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
object-fit: contain;
image-rendering: auto;
opacity: 0;
transition:
opacity 220ms ease,
width 180ms ease,
max-width 180ms ease,
max-height 180ms ease,
border-radius 180ms ease,
box-shadow 180ms ease;
}
.nhir-image.loaded {
opacity: 1;
}
#${IDS.overlay}.nhir-zoom-over .nhir-mode-webtoon .nhir-image {
width: min(var(--nhir-zoom-width), ${CONFIG.fullMaxWidth}px);
max-width: none;
max-height: none;
object-fit: initial;
}
.nhir-mode-manga {
justify-content: flex-start;
}
.nhir-spread {
min-height: calc(100dvh - 16px);
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: var(--nhir-reader-gap);
position: relative;
scroll-margin-top: 8px;
scroll-margin-bottom: 8px;
box-sizing: border-box;
}
.nhir-manga-slot {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-width: 0;
min-height: var(--nhir-placeholder-height, 360px);
}
.nhir-manga-slot.is-loaded {
min-height: 0;
}
.nhir-manga-slot.is-empty {
width: min(calc((100vw - 32px) / 2), 820px);
min-height: 40dvh;
}
.nhir-mode-manga .nhir-image {
max-width: min(calc((100vw - 32px) / 2), 900px);
max-height: calc(100dvh - 32px);
}
#${IDS.overlay}.nhir-zoom-over .nhir-mode-manga .nhir-image {
width: var(--nhir-manga-page-width);
max-width: none;
max-height: none;
object-fit: initial;
}
.nhir-error {
display: none;
position: absolute;
inset: 0;
min-height: 220px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
background: rgba(30, 30, 30, 0.82);
color: white;
font-family: "Segoe UI", Arial, sans-serif;
font-size: 14px;
text-align: center;
border-radius: 10px;
}
.nhir-error.visible {
display: flex;
}
.nhir-error button,
.nhir-slider-row button,
.nhir-jump button,
.nhir-color-picker button {
min-width: 58px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #303843;
color: #f7f8fa;
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
#${IDS.uiRoot} {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2147483647;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
pointer-events: none;
font-family: "Segoe UI", sans-serif;
user-select: none;
transition: opacity 180ms ease, transform 180ms ease;
}
#${IDS.uiRoot}.is-ui-hidden:not(.is-menu-open) {
opacity: 0;
transform: translate3d(0, 70px, 0);
pointer-events: none;
}
.nhir-top-button,
.nhir-menu-button,
.nhir-close-button {
pointer-events: auto;
}
.nhir-bottom-controls {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 10px;
pointer-events: auto;
}
.nhir-action-stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
max-height: 0;
opacity: 0;
overflow: visible;
pointer-events: none;
transition:
max-height 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 180ms ease;
}
#${IDS.uiRoot}.is-menu-open .nhir-action-stack {
max-height: 440px;
opacity: 1;
pointer-events: auto;
}
.nhir-stack-item {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 48px;
pointer-events: none;
opacity: 0;
transform: translate3d(0, 14px, 0) scale(0.96);
transition:
opacity 180ms ease,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#${IDS.uiRoot}.is-menu-open .nhir-stack-item {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
pointer-events: auto;
}
#${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(1) { transition-delay: 20ms; }
#${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(2) { transition-delay: 45ms; }
#${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(3) { transition-delay: 70ms; }
#${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(4) { transition-delay: 95ms; }
#${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(5) { transition-delay: 120ms; }
.nhir-panel {
position: absolute;
right: 56px;
top: 50%;
transform: translate3d(12px, -50%, 0);
display: flex;
align-items: center;
gap: 10px;
height: 46px;
padding: 0;
max-width: 0;
overflow: hidden;
white-space: nowrap;
opacity: 0;
pointer-events: none;
border: 1px solid var(--nhir-ui-stroke);
border-radius: 999px;
background: var(--nhir-ui-surface);
backdrop-filter: blur(14px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
transition:
max-width 220ms ease,
opacity 180ms ease,
transform 220ms ease,
padding 180ms ease;
}
.nhir-panel.nhir-panel-vertical {
height: auto;
min-height: 46px;
flex-direction: column;
align-items: stretch;
justify-content: center;
border-radius: 24px;
white-space: normal;
}
.nhir-stack-item.is-open .nhir-panel {
max-width: 440px;
padding: 0 12px 0 16px;
opacity: 1;
transform: translate3d(0, -50%, 0);
pointer-events: auto;
}
.nhir-stack-item.is-open .nhir-panel.nhir-panel-vertical {
padding: 12px 14px;
min-width: 270px;
}
.nhir-btn {
width: 46px;
height: 46px;
border: 1px solid var(--nhir-ui-stroke);
border-radius: 50%;
background: var(--nhir-ui-surface);
color: var(--nhir-ui-text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
transition:
transform 150ms ease,
background-color 150ms ease,
color 150ms ease;
}
.nhir-btn:hover {
transform: scale(1.04);
}
.nhir-close-btn:hover {
transform: scale(1.06);
}
#${IDS.uiRoot}.is-menu-open .nhir-menu-button .nhir-btn {
transform: rotate(90deg);
}
#${IDS.uiRoot}.is-menu-open .nhir-menu-button .nhir-btn:hover {
transform: rotate(90deg) scale(1.04);
}
.nhir-segmented {
display: flex;
align-items: center;
gap: 8px;
}
.nhir-chip {
min-width: 74px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
color: var(--nhir-ui-text);
cursor: pointer;
font-weight: 700;
font-size: 13px;
transition: background-color 150ms ease, color 150ms ease;
}
.nhir-chip.is-active {
background: #303843;
color: #f7f8fa;
}
.nhir-slider-wrap {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 240px;
color: var(--nhir-ui-text);
font-size: 13px;
font-weight: 700;
}
.nhir-slider-top,
.nhir-slider-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.nhir-slider-value {
min-width: 40px;
text-align: right;
}
.nhir-slider-row input[type="range"] {
width: 160px;
accent-color: #303843;
}
.nhir-jump {
display: flex;
align-items: center;
gap: 8px;
}
.nhir-jump input {
width: 88px;
height: 32px;
border: 0;
border-radius: 999px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.82);
color: #111 !important;
-webkit-text-fill-color: #111;
caret-color: #111;
outline: none;
font-size: 13px;
font-weight: 800;
}
.nhir-color-picker {
width: 250px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nhir-color-square {
position: relative;
width: 100%;
height: 145px;
border-radius: 18px 18px 8px 8px;
overflow: hidden;
cursor: pointer;
background:
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, hsla(var(--nhir-picker-hue, 220), 100%, 50%, 1));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.10);
}
.nhir-color-knob {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
transform: translate(-50%, -50%);
pointer-events: none;
}
.nhir-hue-row {
display: flex;
align-items: center;
gap: 10px;
}
.nhir-hue-slider {
flex: 1;
accent-color: #303843;
cursor: pointer;
}
#${IDS.progressBar} {
position: fixed;
top: 0;
left: 0;
height: 3px;
width: 0;
z-index: 2147483647;
background: rgba(255, 255, 255, 0.75);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.55);
pointer-events: none;
}
#${IDS.pageIndicator} {
position: fixed;
left: 20px;
bottom: 20px;
transform: none;
z-index: 2147483647;
padding: 8px 14px;
border-radius: 999px;
background: rgba(245, 241, 232, 0.92);
color: #2d3137;
font-family: "Segoe UI", sans-serif;
font-size: 13px;
font-weight: 800;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
pointer-events: none;
}
@media (max-width: 760px) {
#${IDS.launcher},
#${IDS.uiRoot} {
right: 12px;
bottom: 12px;
}
#${IDS.pageIndicator} {
left: 12px;
bottom: 14px;
font-size: 12px;
padding: 7px 11px;
}
.nhir-reader {
padding-top: 4px;
}
.nhir-image {
max-width: 100vw;
border-radius: 0;
box-shadow: none;
}
#${IDS.overlay}.nhir-zoom-over .nhir-mode-webtoon .nhir-image {
width: var(--nhir-zoom-width);
}
.nhir-panel {
right: 54px;
}
.nhir-stack-item.is-open .nhir-panel.nhir-panel-vertical {
min-width: min(270px, calc(100vw - 92px));
}
.nhir-color-picker {
width: min(248px, calc(100vw - 114px));
}
}
`;
document.head.appendChild(style);
}
function hexToHsv(hex) {
const fixed = normalizeHexColor(hex) || CONFIG.defaultThemeColor;
const r = parseInt(fixed.slice(1, 3), 16) / 255;
const g = parseInt(fixed.slice(3, 5), 16) / 255;
const b = parseInt(fixed.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
if (delta !== 0) {
if (max === r) {
h = 60 * (((g - b) / delta) % 6);
} else if (max === g) {
h = 60 * ((b - r) / delta + 2);
} else {
h = 60 * ((r - g) / delta + 4);
}
}
if (h < 0) h += 360;
const s = max === 0 ? 0 : delta / max;
const v = max;
return { h, s, v };
}
function hsvToHex(h, s, v) {
const safeS = clamp(s, 0.04, 0.94);
const safeV = clamp(v, 0.10, 0.94);
const c = safeV * safeS;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = safeV - c;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const toHex = value => Math.round((value + m) * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function normalizeHexColor(value) {
const text = String(value || '').trim();
if (/^#[0-9a-f]{6}$/i.test(text)) return text;
if (/^#[0-9a-f]{3}$/i.test(text)) {
return `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`;
}
return null;
}
function clampPage(pageNo) {
const parsed = parseInt(pageNo, 10);
if (!Number.isFinite(parsed)) return 1;
return Math.max(1, Math.min(totalPages || 1, parsed));
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function nextFrame() {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
})();