Ultimate reading experience for HentaiNexus with overlay reading, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, native page sync, and low-memory loading.
// ==UserScript==
// @name HentaiNexus Infinite Reader
// @name:ja HentaiNexus 無限スクロールリーダー
// @name:zh-CN HentaiNexus 无限滚动阅读器
// @namespace https://hentainexus.com/
// @version 2.0
// @author L1Z4RD + upgraded
// @license MIT
// @match https://hentainexus.com/read/*
// @run-at document-idle
// @grant none
// @description Ultimate reading experience for HentaiNexus with overlay reading, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, native page sync, and low-memory loading.
// @description:ja HentaiNexus向けの究極の読書体験。オーバーレイリーダー、縦スクロール・漫画表示、ズーム調整、ページ間隔、テーマカラー、ページ移動、ネイティブページ同期、低メモリ読み込みに対応。
// @description:zh-CN 为 HentaiNexus 打造的终极阅读体验,支持覆盖式阅读器、条漫/漫画模式、缩放控制、页面间距、主题颜色选择、跳转页面、原生页面同步和低内存加载。
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
fullMaxWidth: 1800,
loadBuffer: 6,
unloadBuffer: 10,
rootMargin: '1200px',
maxRetries: 3,
menuCollapseMs: 5000,
longPressMs: 450,
wheelLockMs: 360,
defaultThemeColor: '#353a45',
defaultReadingMode: 'webtoon',
defaultGap: 5,
defaultZoom: 0
};
const IDS = {
style: 'hnir-overlay-style',
launcher: 'hnir-open-book-launcher',
overlay: 'hnir-overlay',
reader: 'hnir-overlay-reader',
uiRoot: 'hnir-overlay-ui-root',
progressBar: 'hnir-progress-bar',
pageIndicator: 'hnir-page-indicator'
};
const STORAGE = {
settings: 'hnir-overlay-settings-v25',
progress: 'hnir-overlay-progress:' + location.pathname
};
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 pageData = [];
let imagePages = [];
let totalPages = 0;
let scrollTicking = false;
let state = loadSettings();
const wait = setInterval(() => {
if (window.pageData && window.pageData.length) {
clearInterval(wait);
pageData = window.pageData;
imagePages = extractImagePages(pageData);
totalPages = imagePages.length;
if (!totalPages) return;
ensureStyle();
installNativePageTracker();
mountLauncher();
const detected = detectNativePageNo();
if (detected) {
rememberNativePage(detected, 'initial-detect');
}
}
}, 100);
function extractImagePages(data) {
return data
.map((page, originalIndex) => ({ ...page, originalIndex }))
.filter(page => page.type === 'image');
}
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,
currentHash: null,
suppressPageTrackingUntil: 0,
lastNativePageNo: null,
lastNativePageAt: 0,
lastNativePageReason: 'fallback',
menuOpen: false,
openPanelName: null,
pointerInsideUI: false,
menuCollapseTimer: 0,
wheelLocked: false,
wheelUnlockTimer: 0,
oldBodyOverflow: '',
oldHtmlOverflow: '',
picker: null,
nativeObserver: 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();
openOverlayFromNative();
});
document.body.appendChild(button);
}
function openOverlayFromNative() {
const current = detectNativePageNo() || getPageNoFromPossibleGlobals() || getBestNativePageNo() || 1;
const safe = clampPage(current);
console.log('[HNIR] Opening overlay from native page:', safe);
rememberNativePage(safe, 'launcher-click');
openOverlay(safe);
}
function openOverlay(startPageNo) {
if (state.overlay?.isConnected) return;
state.currentPageNo = clampPage(startPageNo || getBestNativePageNo());
state.oldBodyOverflow = document.body.style.overflow;
state.oldHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
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 = 'hnir-reader';
overlay.appendChild(reader);
document.body.appendChild(overlay);
state.overlay = overlay;
state.reader = reader;
createProgressBar(overlay);
createPageIndicator(overlay);
mountOverlayUI(overlay);
applyAllSettings();
renderReader(state.currentPageNo);
setupOverlayEvents();
stabilizedJumpToPage(state.currentPageNo, 'auto');
}
function closeOverlay(syncNative = true) {
if (!state.overlay) return;
if (syncNative) {
syncNativeReaderToPage(state.currentPageNo);
}
clearMenuCollapse();
clearTimeout(state.wheelUnlockTimer);
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;
state.wheelLocked = false;
document.body.style.overflow = state.oldBodyOverflow || '';
document.documentElement.style.overflow = state.oldHtmlOverflow || '';
const launcher = document.getElementById(IDS.launcher);
if (launcher) launcher.style.display = '';
}
function installNativePageTracker() {
document.addEventListener('click', event => {
const pageNo = inferPageNoFromClickedElement(event.target);
if (pageNo) {
rememberNativePage(pageNo, 'native-click');
}
setTimeout(() => {
const detected = detectNativePageNo();
if (detected) rememberNativePage(detected, 'native-click-after-80ms');
}, 80);
setTimeout(() => {
const detected = detectNativePageNo();
if (detected) rememberNativePage(detected, 'native-click-after-260ms');
}, 260);
}, true);
const nativeRoot = document.querySelector('#pageChangeSnap, #reader_container, #reader_image, #nextLink') || document.body;
if (nativeRoot && !state.nativeObserver) {
state.nativeObserver = new MutationObserver(() => {
const detected = detectNativePageNo();
if (detected) rememberNativePage(detected, 'native-mutation');
});
state.nativeObserver.observe(nativeRoot, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'href', 'class', 'style']
});
}
wrapNativeNavFunctions();
}
function wrapNativeNavFunctions() {
const names = [
'nextPage',
'prevPage',
'previousPage',
'goPage',
'goToPage',
'setPage',
'changePage',
'loadPage',
'openPage'
];
names.forEach(name => {
if (typeof window[name] !== 'function') return;
if (window[name].__hnirWrapped) return;
const original = window[name];
window[name] = function (...args) {
const before = getBestNativePageNo();
if (/next/i.test(name)) {
rememberNativePage(before + 1, 'wrapped-nextPage-before');
} else if (/prev|previous/i.test(name)) {
rememberNativePage(before - 1, 'wrapped-prevPage-before');
} else if (args.length) {
const possible = parsePageNoFromText(args[0]);
if (possible) rememberNativePage(possible, `wrapped-${name}-arg`);
}
const result = original.apply(this, args);
setTimeout(() => {
const detected = detectNativePageNo();
if (detected) rememberNativePage(detected, `wrapped-${name}-after`);
}, 120);
return result;
};
window[name].__hnirWrapped = true;
});
}
function inferPageNoFromClickedElement(target) {
const el = target?.closest?.('a, button, [onclick], [data-page], [data-index], [data-id], img, figure, li');
if (!el) return null;
const img = el.matches('img') ? el : el.querySelector?.('img');
if (img) {
const fromImg = findPageNoByImageSrc(img.currentSrc || img.src || img.getAttribute('src') || img.getAttribute('data-src'));
if (fromImg) return fromImg;
}
for (const attr of ['data-page', 'data-page-no', 'data-index', 'data-number', 'data-id']) {
const value = el.getAttribute?.(attr);
const pageNo = parsePageNoFromText(value);
if (pageNo) return pageNo;
}
const href = el.getAttribute?.('href');
const fromHref = parsePageNoFromHrefOrHash(href);
if (fromHref) return fromHref;
const onclick = el.getAttribute?.('onclick');
const fromOnclick = parsePageNoFromText(onclick);
if (fromOnclick) return fromOnclick;
if (/nextPage\s*\(/i.test(onclick || '') || /next/i.test(el.textContent || '')) {
return clampPage((state.lastNativePageNo || detectNativePageNo() || 1) + 1);
}
if (/prevPage\s*\(/i.test(onclick || '') || /prev|previous/i.test(el.textContent || '')) {
return clampPage((state.lastNativePageNo || detectNativePageNo() || 1) - 1);
}
const text = el.textContent?.trim();
const fromText = parsePageNoFromText(text);
if (fromText) return fromText;
return null;
}
function getBestNativePageNo() {
const detected = detectNativePageNo();
if (detected) {
rememberNativePage(detected, 'best-native-detected');
return detected;
}
if (state.lastNativePageNo && state.lastNativePageNo >= 1 && state.lastNativePageNo <= totalPages) {
return clampPage(state.lastNativePageNo);
}
return 1;
}
function rememberNativePage(pageNo, reason) {
const safe = clampPage(pageNo);
if (!safe) return;
state.lastNativePageNo = safe;
state.lastNativePageAt = Date.now();
state.lastNativePageReason = reason || 'unknown';
}
function detectNativePageNo() {
return (
getPageNoFromPossibleGlobals() ||
getPageNoFromHash() ||
getPageNoFromNativeImage() ||
getPageNoFromNativePagination() ||
getPageNoFromNativeLinks() ||
null
);
}
function getPageNoFromPossibleGlobals() {
if (typeof window.currentPage === 'number' || typeof window.currentPage === 'string') {
const num = parsePageNoFromText(window.currentPage);
if (num) return num;
}
const possibleNames = [
'currentPage',
'current_page',
'readerPage',
'reader_page',
'page',
'pageNum',
'pageNumber',
'currentImage',
'currentIndex',
'pageIndex'
];
for (const name of possibleNames) {
const value = window[name];
if (typeof value === 'number' || typeof value === 'string') {
const num = parsePageNoFromText(value);
if (num) return num;
}
}
return null;
}
function getPageNoFromHash() {
const hash = location.hash.replace('#', '').trim();
if (!hash) return null;
if (hash === 'end') return totalPages;
const labelIndex = imagePages.findIndex(page => page.url_label === hash);
if (labelIndex >= 0) return labelIndex + 1;
return parsePageNoFromText(hash);
}
function getPageNoFromNativeImage() {
const selectors = [
'#reader_image img',
'#nextLink img',
'figure#reader_image img',
'#pageChangeSnap img',
'.reader-image img'
];
for (const selector of selectors) {
const img = document.querySelector(selector);
if (!img) continue;
const candidates = [
img.currentSrc,
img.src,
img.getAttribute('src'),
img.getAttribute('data-src')
].filter(Boolean);
for (const src of candidates) {
const matched = findPageNoByImageSrc(src);
if (matched) return matched;
}
}
return null;
}
function getPageNoFromNativePagination() {
const selectors = [
'.reader-pagination-current',
'.pagination-link.is-current',
'.pagination-link.is-current.is-hidden-tablet',
'#pageChangeSnap .is-current',
'#pageChangeSnap .pagination-link.is-current',
'nav.reader-pagination .is-current',
'.reader-pagination-list .is-current'
];
for (const selector of selectors) {
const el = document.querySelector(selector);
const num = parsePageNoFromText(el?.textContent);
if (num) return num;
}
return null;
}
function getPageNoFromNativeLinks() {
const nextLink = document.querySelector('#nextLink, .reader-pagination-next, .pagination-next');
const prevLink = document.querySelector('.reader-pagination-prev, .pagination-previous');
const nextPage = parsePageNoFromHrefOrHash(nextLink?.getAttribute?.('href'));
if (nextPage && nextPage > 1) {
return clampPage(nextPage - 1);
}
const prevPage = parsePageNoFromHrefOrHash(prevLink?.getAttribute?.('href'));
if (prevPage) {
return clampPage(prevPage + 1);
}
return null;
}
function parsePageNoFromHrefOrHash(value) {
if (!value) return null;
const raw = String(value).trim();
const hash = raw.includes('#') ? raw.split('#').pop() : raw;
if (hash) {
const labelIndex = imagePages.findIndex(page => page.url_label === hash);
if (labelIndex >= 0) {
return labelIndex + 1;
}
}
return parsePageNoFromText(raw);
}
function parsePageNoFromText(value) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text) return null;
const exact = parseInt(text, 10);
if (Number.isFinite(exact) && exact >= 1 && exact <= totalPages) {
return exact;
}
const patterns = [
/(?:page|p|index|goPage|goToPage|setPage|loadPage|openPage)[^\d]{0,10}(\d{1,5})/i,
/\/(\d{1,5})(?:[/?#]|$)/,
/#(\d{1,5})(?:$|[^\d])/,
/(\d{1,5})\.(?:jpg|jpeg|png|webp|gif)(?:\?|#|$)/i
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
const num = parseInt(match[1], 10);
if (Number.isFinite(num) && num >= 1 && num <= totalPages) {
return num;
}
}
}
return null;
}
function findPageNoByImageSrc(src) {
if (!src) return null;
const normalizedSrc = normalizeUrlish(src);
const filename = getFilename(src);
for (let i = 0; i < imagePages.length; i++) {
const page = imagePages[i];
const pageImage = page.image || '';
const normalizedPage = normalizeUrlish(pageImage);
const pageFilename = getFilename(pageImage);
if (
normalizedSrc === normalizedPage ||
normalizedSrc.endsWith(normalizedPage) ||
normalizedPage.endsWith(normalizedSrc) ||
filename && pageFilename && filename === pageFilename ||
filename && normalizedPage.includes(filename)
) {
return i + 1;
}
}
const numeric = parsePageNoFromText(src);
if (numeric) return numeric;
return null;
}
function syncNativeReaderToPage(pageNo) {
const safePageNo = clampPage(pageNo);
const page = imagePages[safePageNo - 1];
if (!page) return;
state.currentPageNo = safePageNo;
rememberNativePage(safePageNo, 'overlay-close-sync');
if (page.url_label) {
localStorage.setItem(STORAGE.progress, page.url_label);
const url = new URL(location.href);
url.hash = page.url_label;
history.replaceState(null, '', url.toString());
setTimeout(() => {
location.reload();
}, 80);
return;
}
if (typeof window.setPage === 'function') {
window.setPage(safePageNo);
}
setTimeout(() => {
location.reload();
}, 80);
}
function renderReader(targetPageNo = state.currentPageNo) {
if (!state.reader) return;
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
state.reader.innerHTML = '';
state.reader.className = `hnir-reader hnir-mode-${state.readingMode}`;
if (state.readingMode === 'manga') {
renderMangaReader();
} else {
renderWebtoonReader();
}
applyGap();
applyZoom();
setupObserver();
stabilizedJumpToPage(targetPageNo, 'auto');
}
function renderWebtoonReader() {
imagePages.forEach((page, index) => {
const pageNo = index + 1;
const wrapper = document.createElement('div');
wrapper.className = 'hnir-page';
wrapper.dataset.pageNo = pageNo;
wrapper.dataset.index = page.originalIndex;
wrapper.dataset.observePage = pageNo;
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 = 'hnir-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 = 'hnir-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.append(img, errorBox);
return slot;
}
function createImage(page, pageNo) {
const img = document.createElement('img');
img.className = 'hnir-image';
img.dataset.src = page.image;
img.dataset.retryCount = '0';
img.dataset.pageNo = pageNo;
img.alt = `Page ${pageNo}`;
img.decoding = 'async';
img.loading = 'lazy';
return img;
}
function createErrorBox(img) {
const errorBox = document.createElement('div');
errorBox.className = 'hnir-error';
errorBox.innerHTML = `
<div>Image failed to load.</div>
<button type="button">Retry</button>
`;
errorBox.querySelector('button').addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
img.dataset.retryCount = '0';
errorBox.classList.remove('visible');
loadImage(img, true);
});
return errorBox;
}
function setupObserver() {
if (!state.overlay || !state.reader) return;
state.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const target = entry.target;
target.querySelectorAll('img[data-src]').forEach(img => {
loadImage(img);
});
const pageNo = parseInt(
target.dataset.pageNo ||
target.dataset.spreadFirst ||
target.dataset.observePage ||
state.currentPageNo,
10
);
maintainMemory(pageNo);
});
}, {
root: state.overlay,
rootMargin: CONFIG.rootMargin
});
state.reader.querySelectorAll('[data-observe-page]').forEach(el => {
state.observer.observe(el);
});
}
function setupOverlayEvents() {
if (!state.overlay) return;
window.__HNIR_KEY_HANDLER__ = handleKeydown;
window.__HNIR_RESIZE_HANDLER__ = handleResize;
state.overlay.__HNIR_SCROLL_HANDLER__ = handleOverlayScroll;
state.overlay.__HNIR_WHEEL_HANDLER__ = handleOverlayWheel;
window.addEventListener('keydown', window.__HNIR_KEY_HANDLER__);
window.addEventListener('resize', window.__HNIR_RESIZE_HANDLER__, { passive: true });
state.overlay.addEventListener('scroll', state.overlay.__HNIR_SCROLL_HANDLER__, { passive: true });
state.overlay.addEventListener('wheel', state.overlay.__HNIR_WHEEL_HANDLER__, { passive: false });
}
function removeOverlayEvents() {
if (window.__HNIR_KEY_HANDLER__) {
window.removeEventListener('keydown', window.__HNIR_KEY_HANDLER__);
window.__HNIR_KEY_HANDLER__ = null;
}
if (window.__HNIR_RESIZE_HANDLER__) {
window.removeEventListener('resize', window.__HNIR_RESIZE_HANDLER__);
window.__HNIR_RESIZE_HANDLER__ = null;
}
if (state.overlay?.__HNIR_SCROLL_HANDLER__) {
state.overlay.removeEventListener('scroll', state.overlay.__HNIR_SCROLL_HANDLER__);
}
if (state.overlay?.__HNIR_WHEEL_HANDLER__) {
state.overlay.removeEventListener('wheel', state.overlay.__HNIR_WHEEL_HANDLER__);
}
}
function handleOverlayScroll() {
if (scrollTicking) return;
scrollTicking = true;
requestAnimationFrame(() => {
updateCurrentPage();
updateProgressBar();
scrollTicking = false;
});
}
function handleOverlayWheel(event) {
if (!state.overlay) return;
if (state.zoom !== 0) return;
event.preventDefault();
if (state.wheelLocked) return;
state.wheelLocked = true;
clearTimeout(state.wheelUnlockTimer);
state.wheelUnlockTimer = setTimeout(() => {
state.wheelLocked = false;
}, CONFIG.wheelLockMs);
if (event.deltaY > 0) {
goNextUnit();
} else if (event.deltaY < 0) {
goPreviousUnit();
}
}
function handleKeydown(event) {
if (!state.overlay) return;
if (event.key === 'Escape') {
event.preventDefault();
closeOverlay(true);
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.zoom !== 0) 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.key === ' ') {
event.preventDefault();
goNextUnit();
}
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
event.preventDefault();
goPreviousUnit();
}
}
function handleResize() {
applyZoom();
updateProgressBar();
}
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 preloadImagesAroundPage(pageNo) {
if (!state.reader) return;
const safePageNo = clampPage(pageNo);
const start = Math.max(1, safePageNo - 2);
const end = Math.min(totalPages, safePageNo + 2);
state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
const imgPageNo = parseInt(img.dataset.pageNo, 10);
if (imgPageNo >= start && imgPageNo <= end) {
loadImage(img);
}
});
}
function stabilizedJumpToPage(pageNo, behavior = 'auto') {
const safePageNo = clampPage(pageNo);
state.suppressPageTrackingUntil = Date.now() + 2600;
preloadImagesAroundPage(safePageNo);
maintainMemory(safePageNo);
requestAnimationFrame(() => {
forceJumpToPage(safePageNo, behavior);
requestAnimationFrame(() => {
forceJumpToPage(safePageNo, 'auto');
});
});
setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 120);
setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 350);
setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 800);
setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 1400);
setTimeout(() => {
updatePageIndicator();
updatePageInput();
updateProgressBar();
maintainMemory(safePageNo);
}, 1700);
}
function forceJumpToPage(pageNo, behavior = 'auto') {
if (!state.reader || !state.overlay) return;
const safePageNo = clampPage(pageNo);
let target;
if (state.readingMode === 'manga') {
const spreadFirst = getSpreadFirstFromPage(safePageNo);
target = state.reader.querySelector(`.hnir-spread[data-spread-first="${spreadFirst}"]`);
state.currentPageNo = Math.min(totalPages, spreadFirst + 1);
} else {
target = state.reader.querySelector(`.hnir-page[data-page-no="${safePageNo}"]`);
state.currentPageNo = safePageNo;
}
if (!target) return;
state.suppressPageTrackingUntil = Math.max(
state.suppressPageTrackingUntil || 0,
Date.now() + 900
);
const overlayRect = state.overlay.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const targetTop = state.overlay.scrollTop + targetRect.top - overlayRect.top;
state.overlay.scrollTo({
top: Math.max(0, targetTop),
behavior
});
maintainMemory(state.currentPageNo);
updatePageIndicator();
updatePageInput();
saveProgress();
}
function jumpToPage(pageNo, behavior = 'smooth') {
forceJumpToPage(pageNo, behavior);
}
function updateCurrentPage() {
if (!state.overlay || !state.reader) return;
if (state.suppressPageTrackingUntil && Date.now() < state.suppressPageTrackingUntil) {
return;
}
const selector = state.readingMode === 'manga' ? '.hnir-spread' : '.hnir-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();
const center = rect.top + rect.height / 2;
const distance = Math.abs(viewportCenter - center);
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();
saveProgress();
maintainMemory(state.currentPageNo);
}
function saveProgress() {
const page = imagePages[clampPage(state.currentPageNo) - 1];
if (page?.url_label) {
state.currentHash = page.url_label;
localStorage.setItem(STORAGE.progress, page.url_label);
}
}
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);
}
});
}
function loadImage(img, force = false) {
if (!img || (!force && img.src)) return;
const src = img.dataset.src;
if (!src) return;
const errorBox = img.parentElement?.querySelector('.hnir-error');
if (force) {
img.classList.remove('loaded');
img.removeAttribute('src');
}
errorBox?.classList.remove('visible');
img.onload = () => {
img.classList.add('loaded');
img.dataset.retryCount = '0';
if (img.naturalWidth) {
img.style.setProperty('--hnir-natural-width', `${img.naturalWidth}px`);
}
errorBox?.classList.remove('visible');
};
img.onerror = () => {
img.classList.remove('loaded');
let retryCount = parseInt(img.dataset.retryCount || '0', 10);
retryCount += 1;
img.dataset.retryCount = String(retryCount);
img.removeAttribute('src');
if (retryCount <= CONFIG.maxRetries) {
setTimeout(() => {
img.src = src;
}, 1000 * retryCount);
} else {
errorBox?.classList.add('visible');
}
};
img.src = src;
}
function unloadImage(img) {
if (!img || !img.src) return;
img.classList.remove('loaded');
setTimeout(() => {
img.removeAttribute('src');
}, 160);
}
function mountOverlayUI(overlay) {
const root = document.createElement('div');
root.id = IDS.uiRoot;
const topButtonWrap = document.createElement('div');
topButtonWrap.className = 'hnir-top-button';
const topButton = document.createElement('button');
topButton.type = 'button';
topButton.className = 'hnir-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 = 'hnir-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');
const closeItem = createDirectStackItem('close', ICONS.closedBook, 'Close reader');
actionStack.append(
modeItem.item,
fitItem.item,
gapItem.item,
pageItem.item,
themeItem.item,
closeItem.item
);
const menuButtonWrap = document.createElement('div');
menuButtonWrap.className = 'hnir-menu-button';
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'hnir-btn';
menuButton.innerHTML = ICONS.menu;
menuButton.title = 'Menu';
menuButton.setAttribute('aria-label', 'Menu');
menuButtonWrap.appendChild(menuButton);
root.append(topButtonWrap, actionStack, menuButtonWrap);
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('hnir-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 = 'hnir-slider-wrap';
const zoomTop = document.createElement('div');
zoomTop.className = 'hnir-slider-top';
const zoomLabel = document.createElement('span');
zoomLabel.textContent = 'Zoom';
const zoomValue = document.createElement('span');
zoomValue.className = 'hnir-slider-value';
zoomValue.textContent = `${state.zoom}%`;
zoomTop.append(zoomLabel, zoomValue);
const zoomRow = document.createElement('div');
zoomRow.className = 'hnir-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([
{
label: '0px',
active: state.gap === 0,
onSelect() {
updateGap(0);
armMenuCollapse();
}
},
{
label: '5px',
active: state.gap === 5,
onSelect() {
updateGap(5);
armMenuCollapse();
}
},
{
label: '10px',
active: state.gap === 10,
onSelect() {
updateGap(10);
armMenuCollapse();
}
},
{
label: '15px',
active: state.gap === 15,
onSelect() {
updateGap(15);
armMenuCollapse();
}
}
]);
gapItem.panel.appendChild(gapToggles.root);
const pagePanel = document.createElement('div');
pagePanel.className = 'hnir-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('hnir-panel-vertical');
const colorPicker = createCustomColorPicker();
themeItem.panel.appendChild(colorPicker.root);
root.addEventListener('pointerenter', () => {
state.pointerInsideUI = true;
clearMenuCollapse();
});
root.addEventListener('pointerleave', () => {
state.pointerInsideUI = false;
armMenuCollapse();
});
menuButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (state.menuOpen) {
closeMenu();
} else {
openMenu();
}
});
closeItem.button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
closeOverlay(true);
});
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,
close: closeItem
},
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 = 'hnir-color-picker';
const top = document.createElement('div');
top.className = 'hnir-color-picker-top';
const preview = document.createElement('div');
preview.className = 'hnir-color-preview';
const hexInput = document.createElement('input');
hexInput.className = 'hnir-color-hex';
hexInput.type = 'text';
hexInput.value = state.themeColor;
top.append(preview, hexInput);
const square = document.createElement('div');
square.className = 'hnir-color-square';
const knob = document.createElement('div');
knob.className = 'hnir-color-knob';
square.appendChild(knob);
const hueRow = document.createElement('div');
hueRow.className = 'hnir-hue-row';
const hueSlider = document.createElement('input');
hueSlider.className = 'hnir-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(top, 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(false);
};
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(false);
});
hueSlider.addEventListener('change', () => {
saveSettings();
armMenuCollapse();
});
hexInput.addEventListener('change', () => {
const fixed = normalizeHex(hexInput.value);
if (!fixed) {
hexInput.value = state.themeColor;
return;
}
state.themeColor = fixed;
state.picker = hexToHsv(fixed);
applyThemeColor();
updateColorPickerUI();
saveSettings();
});
resetButton.addEventListener('click', () => {
state.themeColor = CONFIG.defaultThemeColor;
state.picker = hexToHsv(state.themeColor);
applyThemeColor();
updateColorPickerUI();
saveSettings();
armMenuCollapse();
});
return {
root,
preview,
hexInput,
square,
knob,
hueSlider
};
}
function updateThemeColorFromPicker(shouldSave) {
state.themeColor = hsvToHex(state.picker.h, state.picker.s, state.picker.v);
applyThemeColor();
updateColorPickerUI();
if (shouldSave) {
saveSettings();
}
}
function updateColorPickerUI() {
if (!state.ui?.color || !state.picker) return;
const { preview, hexInput, square, knob, hueSlider } = state.ui.color;
preview.style.backgroundColor = state.themeColor;
hexInput.value = state.themeColor;
hueSlider.value = String(Math.round(state.picker.h));
square.style.setProperty('--hnir-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;
state.overlay.scrollTo({
top: state.overlay.scrollHeight,
behavior: 'smooth'
});
navPressTimer = 0;
}, CONFIG.longPressMs);
});
const releaseNav = event => {
event.preventDefault();
event.stopPropagation();
if (navPressTimer) {
clearNavTimer();
if (!navLongPressFired) {
state.overlay.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 = 'hnir-stack-item';
item.dataset.item = name;
const panel = document.createElement('div');
panel.className = 'hnir-panel';
const button = document.createElement('button');
button.type = 'button';
button.className = 'hnir-btn';
button.innerHTML = icon;
button.setAttribute('aria-label', label);
button.title = label;
item.append(panel, button);
return { item, panel, button };
}
function createDirectStackItem(name, icon, label) {
const item = document.createElement('div');
item.className = 'hnir-stack-item';
item.dataset.item = name;
const button = document.createElement('button');
button.type = 'button';
button.className = 'hnir-btn';
button.innerHTML = icon;
button.setAttribute('aria-label', label);
button.title = label;
item.appendChild(button);
return { item, button };
}
function createToggleGroup(items) {
const root = document.createElement('div');
root.className = 'hnir-segmented';
const buttons = items.map(item => {
const button = document.createElement('button');
button.type = 'button';
button.className = `hnir-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');
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');
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 current = state.currentPageNo;
state.readingMode = mode;
saveSettings();
renderReader(current);
syncUI();
}
function updateGap(gap) {
state.gap = gap;
saveSettings();
applyGap();
syncUI();
}
function updateZoom(zoom, shouldSave) {
state.zoom = Math.max(0, Math.min(100, zoom));
applyZoom();
syncUI();
if (shouldSave) {
saveSettings();
}
}
function applyAllSettings() {
applyThemeColor();
applyGap();
applyZoom();
syncUI();
}
function applyThemeColor() {
document.documentElement.style.setProperty('--hnir-theme-bg', state.themeColor);
}
function applyGap() {
if (!state.reader) return;
state.reader.style.gap = `${state.gap}px`;
document.documentElement.style.setProperty('--hnir-reader-gap', `${state.gap}px`);
}
function applyZoom() {
const viewportBase = Math.max(240, window.innerWidth - 32);
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 - 24) / 2));
document.documentElement.style.setProperty('--hnir-zoom-width', `${zoomWidth}px`);
document.documentElement.style.setProperty('--hnir-manga-page-width', `${mangaPageWidth}px`);
if (state.overlay) {
state.overlay.classList.toggle('hnir-zoom-over', state.zoom > 0);
state.overlay.classList.toggle('hnir-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[0].classList.toggle('is-active', state.gap === 0);
state.ui.gapButtons[1].classList.toggle('is-active', state.gap === 5);
state.ui.gapButtons[2].classList.toggle('is-active', state.gap === 10);
state.ui.gapButtons[3].classList.toggle('is-active', state.gap === 15);
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 || !state.overlay) return;
const maxScroll = state.overlay.scrollHeight - state.overlay.clientHeight;
const progress = maxScroll <= 0 ? 0 : (state.overlay.scrollTop / 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 {
--hnir-theme-bg: ${state.themeColor};
--hnir-ui-surface: rgba(245, 241, 232, 0.96);
--hnir-ui-stroke: rgba(0, 0, 0, 0.08);
--hnir-ui-text: #2d3137;
--hnir-ui-track: rgba(0, 0, 0, 0.12);
--hnir-ui-fill: #303843;
--hnir-reader-gap: 5px;
--hnir-zoom-width: calc(100vw - 32px);
--hnir-manga-page-width: calc((100vw - 48px) / 2);
}
#${IDS.launcher} {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2147483646;
width: 50px;
height: 50px;
border: 1px solid var(--hnir-ui-stroke);
border-radius: 50%;
background: var(--hnir-ui-surface);
color: var(--hnir-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,
.hnir-btn svg {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
flex: none;
}
#${IDS.overlay} {
position: fixed;
inset: 0;
z-index: 2147483647;
overflow-y: auto;
overflow-x: hidden;
background: var(--hnir-theme-bg);
color: white;
transition: background-color 180ms ease;
overscroll-behavior: contain;
scroll-behavior: smooth;
}
.hnir-reader {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--hnir-reader-gap);
padding: 14px 0 120px;
box-sizing: border-box;
}
.hnir-page {
width: min(100%, ${CONFIG.fullMaxWidth}px);
min-height: 300px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
scroll-margin-top: 10px;
scroll-margin-bottom: 10px;
}
#${IDS.overlay}.hnir-fit-mode .hnir-mode-webtoon .hnir-page {
min-height: calc(100vh - 32px);
}
.hnir-image {
display: block;
width: auto;
height: auto;
max-width: min(calc(100vw - 32px), ${CONFIG.fullMaxWidth}px);
max-height: calc(100vh - 32px);
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;
}
.hnir-image.loaded {
opacity: 1;
}
#${IDS.overlay}.hnir-zoom-over .hnir-mode-webtoon .hnir-image {
width: min(var(--hnir-zoom-width), ${CONFIG.fullMaxWidth}px);
max-width: none;
max-height: none;
object-fit: initial;
}
.hnir-mode-manga {
justify-content: flex-start;
}
.hnir-spread {
min-height: calc(100vh - 32px);
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: var(--hnir-reader-gap);
position: relative;
scroll-margin-top: 12px;
scroll-margin-bottom: 12px;
box-sizing: border-box;
}
.hnir-manga-slot {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-width: 0;
}
.hnir-manga-slot.is-empty {
width: min(calc((100vw - 48px) / 2), 820px);
min-height: 60vh;
}
.hnir-mode-manga .hnir-image {
max-width: min(calc((100vw - 48px) / 2), 900px);
max-height: calc(100vh - 48px);
}
#${IDS.overlay}.hnir-zoom-over .hnir-mode-manga .hnir-image {
width: var(--hnir-manga-page-width);
max-width: none;
max-height: none;
object-fit: initial;
}
.hnir-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;
}
.hnir-error.visible {
display: flex;
}
.hnir-error button,
.hnir-slider-row button,
.hnir-jump button,
.hnir-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;
}
.hnir-top-button,
.hnir-menu-button {
pointer-events: auto;
}
.hnir-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 .hnir-action-stack {
max-height: 520px;
opacity: 1;
pointer-events: auto;
}
.hnir-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 .hnir-stack-item {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
pointer-events: auto;
}
#${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(1) { transition-delay: 20ms; }
#${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(2) { transition-delay: 45ms; }
#${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(3) { transition-delay: 70ms; }
#${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(4) { transition-delay: 95ms; }
#${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(5) { transition-delay: 120ms; }
#${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(6) { transition-delay: 145ms; }
.hnir-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(--hnir-ui-stroke);
border-radius: 999px;
background: var(--hnir-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;
}
.hnir-panel.hnir-panel-vertical {
height: auto;
min-height: 46px;
flex-direction: column;
align-items: stretch;
justify-content: center;
border-radius: 24px;
white-space: normal;
}
.hnir-stack-item.is-open .hnir-panel {
max-width: 440px;
padding: 0 12px 0 16px;
opacity: 1;
transform: translate3d(0, -50%, 0);
pointer-events: auto;
}
.hnir-stack-item.is-open .hnir-panel.hnir-panel-vertical {
padding: 12px 14px;
min-width: 270px;
}
.hnir-btn {
width: 46px;
height: 46px;
border: 1px solid var(--hnir-ui-stroke);
border-radius: 50%;
background: var(--hnir-ui-surface);
color: var(--hnir-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;
}
.hnir-btn:hover {
transform: scale(1.04);
}
#${IDS.uiRoot}.is-menu-open .hnir-menu-button .hnir-btn {
transform: rotate(90deg);
}
#${IDS.uiRoot}.is-menu-open .hnir-menu-button .hnir-btn:hover {
transform: rotate(90deg) scale(1.04);
}
.hnir-segmented {
display: flex;
align-items: center;
gap: 8px;
}
.hnir-chip {
min-width: 74px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(45, 49, 55, 0.72);
cursor: pointer;
font-size: 13px;
font-weight: 700;
transition: background-color 150ms ease, color 150ms ease;
}
.hnir-chip.is-active {
background: #303843;
color: #f7f8fa;
}
.hnir-jump {
display: flex;
align-items: center;
gap: 8px;
}
.hnir-jump input {
width: 82px;
height: 32px;
padding: 0 10px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #2d3137;
font-size: 13px;
font-weight: 700;
outline: none;
box-sizing: border-box;
}
.hnir-slider-wrap {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 250px;
}
.hnir-slider-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--hnir-ui-text);
font-size: 12px;
font-weight: 700;
}
.hnir-slider-value {
min-width: 48px;
text-align: right;
color: rgba(45, 49, 55, 0.76);
}
.hnir-slider-row {
display: flex;
align-items: center;
gap: 8px;
}
.hnir-slider-row input[type="range"] {
flex: 1;
accent-color: #303843;
cursor: pointer;
}
.hnir-color-picker {
width: 260px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hnir-color-picker-top {
display: flex;
align-items: center;
gap: 10px;
}
.hnir-color-preview {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.16);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35);
flex: none;
}
.hnir-color-hex {
flex: 1;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #2d3137;
font-size: 13px;
font-weight: 700;
outline: none;
box-sizing: border-box;
}
.hnir-color-square {
--hnir-picker-hue: 0;
position: relative;
height: 142px;
border-radius: 18px;
overflow: hidden;
background: hsl(var(--hnir-picker-hue), 100%, 50%);
cursor: pointer;
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.12),
0 8px 20px rgba(0, 0, 0, 0.12);
}
.hnir-color-square::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}
.hnir-color-square::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), #000);
}
.hnir-color-knob {
position: absolute;
z-index: 2;
width: 14px;
height: 14px;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.45);
transform: translate(-50%, -50%);
pointer-events: none;
}
.hnir-hue-row {
display: flex;
align-items: center;
gap: 8px;
}
.hnir-hue-slider {
flex: 1;
height: 12px;
border-radius: 999px;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background: linear-gradient(
to right,
#ff0000 0%,
#ffff00 16.6%,
#00ff00 33.3%,
#00ffff 50%,
#0000ff 66.6%,
#ff00ff 83.3%,
#ff0000 100%
);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.16);
}
.hnir-hue-slider::-webkit-slider-runnable-track {
height: 12px;
border-radius: 999px;
background: linear-gradient(
to right,
#ff0000 0%,
#ffff00 16.6%,
#00ff00 33.3%,
#00ffff 50%,
#0000ff 66.6%,
#ff00ff 83.3%,
#ff0000 100%
);
}
.hnir-hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
margin-top: -3px;
border-radius: 50%;
border: 2px solid white;
background: #303843;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.35);
}
.hnir-hue-slider::-moz-range-track {
height: 12px;
border-radius: 999px;
background: linear-gradient(
to right,
#ff0000 0%,
#ffff00 16.6%,
#00ff00 33.3%,
#00ffff 50%,
#0000ff 66.6%,
#ff00ff 83.3%,
#ff0000 100%
);
}
.hnir-hue-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid white;
background: #303843;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.35);
}
#${IDS.progressBar} {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 0%;
z-index: 2147483647;
background: var(--hnir-ui-fill);
transition: width 80ms linear;
}
#${IDS.pageIndicator} {
position: fixed;
left: 20px;
bottom: 20px;
z-index: 2147483647;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--hnir-ui-stroke);
background: var(--hnir-ui-surface);
color: var(--hnir-ui-text);
font-family: "Segoe UI", sans-serif;
font-size: 13px;
font-weight: 700;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
pointer-events: none;
user-select: none;
}
@media (max-width: 700px) {
#${IDS.launcher},
#${IDS.uiRoot} {
right: 12px;
bottom: 12px;
}
#${IDS.pageIndicator} {
left: 12px;
bottom: 12px;
}
.hnir-stack-item.is-open .hnir-panel {
max-width: calc(100vw - 88px);
}
.hnir-stack-item.is-open .hnir-panel.hnir-panel-vertical {
min-width: min(270px, calc(100vw - 102px));
}
.hnir-chip {
min-width: 58px;
}
.hnir-mode-manga .hnir-image {
max-width: calc((100vw - 36px) / 2);
}
}
`;
document.head.appendChild(style);
}
function clampPage(pageNo) {
const parsed = parseInt(pageNo, 10);
if (!Number.isFinite(parsed)) return 1;
return Math.max(1, Math.min(totalPages, parsed));
}
function getFilename(url) {
try {
return String(url).split('/').pop().split('?')[0].split('#')[0];
} catch {
return '';
}
}
function normalizeUrlish(url) {
return String(url || '')
.trim()
.replace(/^https?:\/\//i, '')
.replace(/^\/\//, '')
.split('?')[0]
.split('#')[0];
}
function normalizeHex(value) {
let hex = String(value || '').trim();
if (!hex) return null;
if (!hex.startsWith('#')) {
hex = '#' + hex;
}
if (/^#[0-9a-f]{3}$/i.test(hex)) {
hex = '#' + hex.slice(1).split('').map(ch => ch + ch).join('');
}
if (!/^#[0-9a-f]{6}$/i.test(hex)) {
return null;
}
return hex.toLowerCase();
}
function hexToHsv(hex) {
const fixed = normalizeHex(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 c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r1 = 0;
let g1 = 0;
let b1 = 0;
if (h >= 0 && h < 60) {
r1 = c; g1 = x; b1 = 0;
} else if (h >= 60 && h < 120) {
r1 = x; g1 = c; b1 = 0;
} else if (h >= 120 && h < 180) {
r1 = 0; g1 = c; b1 = x;
} else if (h >= 180 && h < 240) {
r1 = 0; g1 = x; b1 = c;
} else if (h >= 240 && h < 300) {
r1 = x; g1 = 0; b1 = c;
} else {
r1 = c; g1 = 0; b1 = x;
}
const toHex = value => {
const num = Math.round((value + m) * 255);
return num.toString(16).padStart(2, '0');
};
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
}
})();