Ultimate gallery-page overlay reader for HentaiNexus with strict full-res image resolving from reader pages, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, standalone close button, cleaned compact theme picker, and low-memory loading.
// ==UserScript==
// @name HentaiNexus Infinite Reader
// @name:ja HentaiNexus 無限スクロールリーダー
// @name:zh-CN HentaiNexus 无限滚动阅读器
// @namespace https://hentainexus.com/
// @version 2.5.2
// @author L1Z4RD
// @license MIT
// @match https://hentainexus.com/view/*
// @run-at document-idle
// @grant none
// @description Ultimate gallery-page overlay reader for HentaiNexus with strict full-res image resolving from reader pages, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, standalone close button, cleaned compact theme picker, 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'
};
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 wait = setInterval(() => {
refreshGalleryImagePages();
if (!totalPages) return;
clearInterval(wait);
ensureStyle();
mountLauncher();
}, 100);
setTimeout(() => {
if (!totalPages) {
clearInterval(wait);
}
}, 15000);
function refreshGalleryImagePages() {
imagePages = extractGalleryImagePages(document);
totalPages = imagePages.length;
}
function extractGalleryImagePages(root) {
const pagesByKey = new Map();
const anchors = Array.from(root.querySelectorAll('a[href*="/read/"]'));
anchors.forEach((anchor, index) => {
const parsed = parseReadPageLink(anchor.getAttribute('href'));
if (!parsed) return;
const img = anchor.querySelector('img');
const thumb = getBestImageSource(img, location.href);
const key = `${parsed.galleryId}:${parsed.pageLabel}`;
if (pagesByKey.has(key)) return;
pagesByKey.set(key, {
image: '',
thumb,
readUrl: parsed.href,
url_label: parsed.pageLabel,
pageNo: parsed.pageNo,
originalIndex: index,
fullImageResolved: false,
fullImageResolvePromise: null
});
});
return Array.from(pagesByKey.values())
.sort((a, b) => {
if (a.pageNo !== b.pageNo) return a.pageNo - b.pageNo;
return a.originalIndex - b.originalIndex;
})
.map((page, originalIndex) => ({ ...page, originalIndex }));
}
function parseReadPageLink(href) {
if (!href) return null;
let url;
try {
url = new URL(href, location.origin);
} catch {
return null;
}
const match = url.pathname.match(/^\/read\/(\d+)\/([^/?#]+)\/?$/i);
if (!match) return null;
const galleryId = match[1];
const pageLabel = decodeURIComponent(match[2]);
const pageNo = parsePageNoFromText(pageLabel);
if (!pageNo) return null;
return {
href: url.href,
galleryId,
pageLabel,
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(Boolean) || '';
try {
return picked ? new URL(picked, baseUrl).href : '';
} catch {
return picked;
}
}
function toAbsoluteUrl(value, base = location.href) {
if (!value) return '';
try {
return new URL(value, base).href;
} catch {
return String(value || '').trim();
}
}
function stripUrlNoise(value, base = location.href) {
const absolute = toAbsoluteUrl(value, base);
if (!absolute) return '';
try {
const url = new URL(absolute);
url.hash = '';
url.search = '';
return url.href;
} catch {
return absolute.replace(/[?#].*$/, '');
}
}
function isThumbnailUrl(src) {
return /\.thumb\.(?:jpg|jpeg|png|webp)(?:[?#].*)?$/i.test(String(src || '')) || /\.thumb\./i.test(String(src || ''));
}
function deriveFullImageUrlFromThumb(src) {
if (!src) return '';
const clean = toAbsoluteUrl(src, location.href).trim();
if (!clean || !isThumbnailUrl(clean)) return '';
return clean.replace(/\.thumb\.(?:jpg|jpeg|png|webp)(?=([?#]|$))/i, '');
}
function getUrlFilename(src, base = location.href) {
const clean = stripUrlNoise(src, base);
if (!clean) return '';
try {
const url = new URL(clean, base);
return decodeURIComponent((url.pathname.split('/').pop() || '').trim());
} catch {
const fallback = String(clean).split('/').pop() || '';
return decodeURIComponent(fallback.replace(/[?#].*$/, '').trim());
}
}
function imageFilenameMatchesPage(src, page, base = location.href) {
const filename = getUrlFilename(src, base).replace(/\.thumb\.(?:jpg|jpeg|png|webp)$/i, '');
if (!filename) return false;
const stem = filename.replace(/\.(?:png|jpe?g|webp|gif)$/i, '');
const labels = pageLabelCandidates(page)
.filter(label => /^\d+$/.test(label))
.sort((a, b) => b.length - a.length);
return labels.some(label => {
const pattern = new RegExp(`(^|\\D)${escapeRegExp(label)}(\\D|$)`);
return pattern.test(stem);
});
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function selectorEscape(value) {
// Tiny CSS attribute-selector escape for numeric/page labels.
return String(value || '').replace(/[\\"\]]/g, '\\$&');
}
function pageLabelCandidates(page) {
const labels = new Set();
const rawLabel = String(page?.url_label || '').trim();
const numeric = Number.parseInt(page?.pageNo || rawLabel, 10);
if (rawLabel) labels.add(rawLabel);
if (Number.isFinite(numeric) && numeric >= 1) {
labels.add(String(numeric));
for (const length of [2, 3, 4, 5]) {
labels.add(String(numeric).padStart(length, '0'));
}
}
return Array.from(labels).filter(Boolean);
}
function pageFilePattern(page) {
const labels = pageLabelCandidates(page).map(escapeRegExp);
if (!labels.length) {
return /\.(?:png|jpe?g|webp|gif)(?:[?#]|$)/i;
}
return new RegExp(`/(?:${labels.join('|')})\\.(?:png|jpe?g|webp|gif)(?:[?#]|$)`, 'i');
}
function isImageUrl(src) {
return /\.(?:png|jpe?g|webp|gif)(?:[?#]|$)/i.test(String(src || ''));
}
function isReaderImageCdnUrl(src) {
return /images\.hentainexus\.com\/v2\//i.test(String(src || ''));
}
function isUsableFullImageUrl(src, page, options = {}) {
if (!src) return false;
const clean = stripUrlNoise(src, page?.readUrl || location.href);
const thumb = stripUrlNoise(page?.thumb || '', location.href);
if (!clean) return false;
if (isThumbnailUrl(clean)) return false;
if (thumb && clean === thumb) return false;
if (!isImageUrl(clean)) return false;
if (pageFilePattern(page).test(clean) || imageFilenameMatchesPage(clean, page)) {
return true;
}
// The native reader's main #reader_image is authoritative. Some galleries use padded
// file names differently from their /read/{gallery}/{page} URL, so allow a non-thumb
// image CDN URL from that exact reader slot even when numeric label formatting differs.
return Boolean(options.fromReaderSlot && isReaderImageCdnUrl(clean));
}
function pickBestFullImageCandidate(candidates, page, baseUrl) {
const seen = new Set();
const usable = [];
candidates.forEach(candidate => {
const absolute = toAbsoluteUrl(candidate, baseUrl || page?.readUrl || location.href);
const clean = stripUrlNoise(absolute, baseUrl || page?.readUrl || location.href);
if (!clean || seen.has(clean)) return;
seen.add(clean);
if (isUsableFullImageUrl(absolute, page)) {
usable.push(absolute);
}
});
if (!usable.length) return '';
return (
usable.find(src => /images\.hentainexus\.com\/v2\//i.test(src) && pageFilePattern(page).test(src)) ||
usable.find(src => /images\.hentainexus\.com\/v2\//i.test(src) && imageFilenameMatchesPage(src, page, baseUrl)) ||
usable.find(src => pageFilePattern(page).test(src)) ||
usable.find(src => imageFilenameMatchesPage(src, page, baseUrl)) ||
usable[0]
);
}
function extractFullImageSrcFromReaderDocument(doc, baseUrl, page) {
if (!doc) return '';
const readerSlotCandidates = [];
doc.querySelectorAll('#reader_image img, figure#reader_image img, #nextLink #reader_image img, #nextLink img').forEach(img => {
const picked = getBestImageSource(img, baseUrl);
if (picked) readerSlotCandidates.push(picked);
});
const directReaderSlot = readerSlotCandidates
.map(src => toAbsoluteUrl(src, baseUrl))
.find(src => isUsableFullImageUrl(src, page, { fromReaderSlot: true }));
if (directReaderSlot) return directReaderSlot;
const candidates = [...readerSlotCandidates];
const selectors = [
...pageLabelCandidates(page).map(label => `img[src*="/${selectorEscape(label)}."]`),
'img[src*="images.hentainexus.com/v2/"]'
];
selectors.forEach(selector => {
doc.querySelectorAll(selector).forEach(img => {
const picked = getBestImageSource(img, baseUrl);
if (picked) candidates.push(picked);
});
});
return pickBestFullImageCandidate(candidates, page, baseUrl);
}
function extractFullImageSrcFromReaderHtml(html, baseUrl, page) {
if (!html) return '';
const candidates = [];
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const fromDoc = extractFullImageSrcFromReaderDocument(doc, baseUrl, page);
if (fromDoc) candidates.push(fromDoc);
} catch (error) {
console.warn('[HNIR] DOMParser failed while resolving reader image:', error);
}
const imgSrcRegex = /<img\b[^>]*\bsrc\s*=\s*(["'])(.*?)\1/gi;
let match;
while ((match = imgSrcRegex.exec(html))) {
candidates.push(match[2]);
}
const escapedCdnRegex = /https?:\\?\/\\?\/images\.hentainexus\.com\\?\/v2\\?\/[^"'<>\s]+/gi;
while ((match = escapedCdnRegex.exec(html))) {
candidates.push(match[0].replace(/\\\//g, '/'));
}
return pickBestFullImageCandidate(candidates, page, baseUrl);
}
async function resolveFullImageByFetch(readUrl, page) {
const response = await fetch(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, readUrl, page);
}
function resolveFullImageByIframe(readUrl, page) {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
let finished = false;
let observer = null;
const cleanup = () => {
if (observer) {
observer.disconnect();
observer = null;
}
iframe.onload = null;
iframe.onerror = null;
if (iframe.isConnected) {
iframe.remove();
}
};
const finish = value => {
if (finished) return;
finished = true;
cleanup();
resolve(value || '');
};
const fail = error => {
if (finished) return;
finished = true;
cleanup();
reject(error);
};
const tryExtract = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
const src = extractFullImageSrcFromReaderDocument(doc, readUrl, page);
if (src) {
finish(src);
return true;
}
} catch (error) {
fail(error);
return true;
}
return false;
};
iframe.style.cssText = [
'position:fixed',
'left:-10000px',
'top:-10000px',
'width:1px',
'height:1px',
'opacity:0',
'pointer-events:none',
'border:0'
].join(';');
iframe.className = 'hnir-resolver-frame';
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.onload = () => {
if (tryExtract()) return;
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
observer = new MutationObserver(() => {
tryExtract();
});
observer.observe(doc.documentElement || doc.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'data-src', 'data-original', 'data-lazy-src']
});
setTimeout(tryExtract, 250);
setTimeout(tryExtract, 700);
setTimeout(tryExtract, 1400);
} catch (error) {
fail(error);
}
};
iframe.onerror = () => fail(new Error('Reader iframe failed to load'));
setTimeout(() => {
if (!finished) {
fail(new Error('Reader iframe full-res resolve timed out'));
}
}, 7000);
document.body.appendChild(iframe);
iframe.src = readUrl;
});
}
async function resolveFullImageSrcForPage(pageNo, force = false) {
const page = imagePages[clampPage(pageNo) - 1];
if (!page) return '';
if (!force && page.fullImageResolved && isUsableFullImageUrl(page.image, page)) {
return page.image;
}
if (!force && page.fullImageResolvePromise) {
return page.fullImageResolvePromise;
}
const readUrl = page.readUrl;
if (!readUrl) {
console.warn('[HNIR] Missing reader URL for page:', pageNo, page);
return '';
}
page.fullImageResolvePromise = (async () => {
const errors = [];
const resolvers = [
['fetch', () => resolveFullImageByFetch(readUrl, page)],
['iframe', () => resolveFullImageByIframe(readUrl, page)],
['thumb-derived', () => Promise.resolve(deriveFullImageUrlFromThumb(page.thumb))]
];
for (const [name, resolver] of resolvers) {
try {
const src = await resolver();
if (isUsableFullImageUrl(src, page)) {
page.image = toAbsoluteUrl(src, readUrl);
page.fullImageResolved = true;
return page.image;
}
errors.push(`${name}: no usable full-res src`);
} catch (error) {
errors.push(`${name}: ${error?.message || error}`);
}
}
page.fullImageResolved = false;
console.warn('[HNIR] Could not resolve full-res image. Thumbnail-derived full URL also failed.', {
pageNo,
readUrl,
thumb: page.thumb,
errors
});
return '';
})();
try {
return await page.fullImageResolvePromise;
} finally {
page.fullImageResolvePromise = null;
}
}
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) {
return exact;
}
const match = text.match(/(?:^|\/|#)(\d{1,5})(?:\.|[/?#]|$)/);
if (!match) return null;
const num = parseInt(match[1], 10);
if (Number.isFinite(num) && num >= 1) {
return num;
}
return 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,
wheelLocked: false,
wheelUnlockTimer: 0,
lastOverlayScrollTop: 0,
uiAutoHidden: false,
oldBodyOverflow: '',
oldHtmlOverflow: '',
oldWindowScrollX: 0,
oldWindowScrollY: 0,
hiddenNativeNodes: [],
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('HNIR: Could not find gallery page thumbnails on this view page.');
return;
}
openOverlay(1);
}
function openOverlay(startPageNo) {
if (state.overlay?.isConnected) return;
state.currentPageNo = clampPage(startPageNo || 1);
state.lastOverlayScrollTop = 0;
state.uiAutoHidden = false;
state.oldWindowScrollX = window.scrollX || 0;
state.oldWindowScrollY = window.scrollY || 0;
state.oldBodyOverflow = document.body.style.overflow;
state.oldHtmlOverflow = document.documentElement.style.overflow;
state.hiddenNativeNodes = hideNativePageForDocumentReader();
document.body.classList.add('hnir-document-reader-open');
document.documentElement.classList.add('hnir-document-reader-open');
document.body.style.overflowX = 'hidden';
document.documentElement.style.overflowX = '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();
scrollReaderTo(0, 'auto');
stabilizedJumpToPage(state.currentPageNo, 'auto');
}
function closeOverlay() {
if (!state.overlay) return;
clearMenuCollapse();
clearTimeout(state.wheelUnlockTimer);
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
removeOverlayEvents();
if (state.overlay.isConnected) {
state.overlay.remove();
}
restoreNativePageAfterDocumentReader();
state.overlay = null;
state.reader = null;
state.ui = null;
state.menuOpen = false;
state.openPanelName = null;
state.pointerInsideUI = false;
state.wheelLocked = false;
state.uiAutoHidden = false;
state.lastOverlayScrollTop = 0;
document.body.classList.remove('hnir-document-reader-open');
document.documentElement.classList.remove('hnir-document-reader-open');
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({
left: state.oldWindowScrollX || 0,
top: state.oldWindowScrollY || 0,
behavior: 'auto'
});
}
function hideNativePageForDocumentReader() {
const hidden = [];
Array.from(document.body.children).forEach(node => {
if (!node || node.id === IDS.overlay || node.id === IDS.launcher) return;
if (node.classList?.contains('hnir-resolver-frame')) return;
hidden.push({ node, display: node.style.display });
node.style.display = 'none';
});
return hidden;
}
function restoreNativePageAfterDocumentReader() {
const hidden = Array.isArray(state.hiddenNativeNodes) ? state.hiddenNativeNodes : [];
hidden.forEach(entry => {
if (entry?.node?.style) {
entry.node.style.display = entry.display || '';
}
});
state.hiddenNativeNodes = [];
}
function getReaderScrollTop() {
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
}
function getReaderViewportHeight() {
return window.innerHeight || document.documentElement.clientHeight || 0;
}
function getReaderScrollHeight() {
if (state.overlay) {
return Math.max(state.overlay.scrollHeight, document.documentElement.scrollHeight, document.body.scrollHeight);
}
return Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
}
function getReaderMaxScrollTop() {
return Math.max(0, getReaderScrollHeight() - getReaderViewportHeight());
}
function scrollReaderTo(top, behavior = 'auto') {
window.scrollTo({
top: Math.max(0, Math.min(getReaderMaxScrollTop(), Number(top) || 0)),
behavior
});
}
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.readUrl = page.readUrl || '';
img.dataset.retryCount = '0';
img.dataset.pageNo = pageNo;
img.alt = `Page ${pageNo}`;
img.decoding = 'async';
img.loading = 'lazy';
img.crossOrigin = 'anonymous';
return img;
}
function createErrorBox(img) {
const errorBox = document.createElement('div');
errorBox.className = 'hnir-error';
errorBox.innerHTML = `
<div>Full-res 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-page-no]').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() {
if (!state.overlay) return;
window.__HNIR_KEY_HANDLER__ = handleKeydown;
window.__HNIR_RESIZE_HANDLER__ = handleResize;
window.__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 });
window.addEventListener('scroll', window.__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 (window.__HNIR_SCROLL_HANDLER__) {
window.removeEventListener('scroll', window.__HNIR_SCROLL_HANDLER__);
window.__HNIR_SCROLL_HANDLER__ = null;
}
if (state.overlay?.__HNIR_WHEEL_HANDLER__) {
state.overlay.removeEventListener('wheel', state.overlay.__HNIR_WHEEL_HANDLER__);
}
}
function handleOverlayScroll() {
if (!state.overlay) return;
const scrollTop = getReaderScrollTop();
const delta = scrollTop - (state.lastOverlayScrollTop || 0);
state.lastOverlayScrollTop = scrollTop;
updateAutoHiddenUiFromScroll(delta);
if (scrollTicking) return;
scrollTicking = true;
requestAnimationFrame(() => {
updateCurrentPage();
updateProgressBar();
scrollTicking = false;
});
}
function updateAutoHiddenUiFromScroll(delta) {
if (!state.overlay || !state.ui) return;
if (!isMobileViewport()) return;
if (state.menuOpen || state.pointerInsideUI) {
setOverlayUiAutoHidden(false);
return;
}
if (Math.abs(delta) < 6) return;
// Reading forward on mobile should get the controls out of the way.
// Scrolling back brings them back, similar to mobile browser chrome.
setOverlayUiAutoHidden(delta > 0);
}
function setOverlayUiAutoHidden(hidden) {
if (!state.overlay) return;
const shouldHide = Boolean(hidden);
if (state.uiAutoHidden === shouldHide) return;
state.uiAutoHidden = shouldHide;
state.overlay.classList.toggle('hnir-ui-auto-hidden', shouldHide);
}
function isMobileViewport() {
return window.matchMedia?.('(max-width: 700px)').matches || window.innerWidth <= 700;
}
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();
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 targetRect = target.getBoundingClientRect();
const targetTop = getReaderScrollTop() + targetRect.top;
scrollReaderTo(targetTop, behavior);
maintainMemory(state.currentPageNo);
updatePageIndicator();
updatePageInput();
}
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();
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 || (!force && (img.src || img.dataset.loading === '1'))) return;
const pageNo = parseInt(img.dataset.pageNo || '0', 10);
const errorBox = img.parentElement?.querySelector('.hnir-error');
if (force) {
img.classList.remove('loaded');
img.closest('.hnir-page, .hnir-manga-slot')?.classList.remove('hnir-page-loaded');
img.removeAttribute('src');
}
errorBox?.classList.remove('visible');
img.dataset.loading = '1';
const src = await resolveFullImageSrcForPage(pageNo, force);
if (!src) {
img.dataset.loading = '0';
img.dataset.resolveError = 'full-res-src-not-resolved';
errorBox?.classList.add('visible');
return;
}
delete img.dataset.resolveError;
if (!force && img.src === src) {
img.dataset.loading = '0';
return;
}
img.onload = () => {
img.classList.add('loaded');
img.closest('.hnir-page, .hnir-manga-slot')?.classList.add('hnir-page-loaded');
img.dataset.retryCount = '0';
img.dataset.loading = '0';
if (img.naturalWidth) {
img.style.setProperty('--hnir-natural-width', `${img.naturalWidth}px`);
}
errorBox?.classList.remove('visible');
};
img.onerror = () => {
img.classList.remove('loaded');
img.closest('.hnir-page, .hnir-manga-slot')?.classList.remove('hnir-page-loaded');
img.dataset.loading = '0';
let retryCount = parseInt(img.dataset.retryCount || '0', 10);
retryCount += 1;
img.dataset.retryCount = String(retryCount);
img.removeAttribute('src');
if (retryCount <= CONFIG.maxRetries) {
setTimeout(() => {
loadImage(img, true);
}, 1000 * retryCount);
} else {
errorBox?.classList.add('visible');
}
};
img.src = src;
}
function unloadImage(img) {
if (!img || !img.src) return;
img.classList.remove('loaded');
img.closest('.hnir-page, .hnir-manga-slot')?.classList.remove('hnir-page-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');
actionStack.append(
modeItem.item,
fitItem.item,
gapItem.item,
pageItem.item,
themeItem.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);
const closeButtonWrap = document.createElement('div');
closeButtonWrap.className = 'hnir-close-button';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'hnir-btn hnir-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 = 'hnir-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('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();
}
});
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 = 'hnir-color-picker';
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(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('--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;
scrollReaderTo(getReaderMaxScrollTop(), 'smooth');
navPressTimer = 0;
}, CONFIG.longPressMs);
});
const releaseNav = event => {
event.preventDefault();
event.stopPropagation();
if (navPressTimer) {
clearNavTimer();
if (!navLongPressFired) {
scrollReaderTo(0, '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 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;
setOverlayUiAutoHidden(false);
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;
setOverlayUiAutoHidden(false);
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 = getReaderMaxScrollTop();
const progress = maxScroll <= 0 ? 0 : (getReaderScrollTop() / 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;
}
html.hnir-document-reader-open,
body.hnir-document-reader-open {
min-height: 100%;
overflow-x: hidden !important;
background: var(--hnir-theme-bg);
}
#${IDS.overlay} {
position: relative;
width: 100%;
min-height: 100vh;
z-index: 2147483647;
overflow: visible;
background: var(--hnir-theme-bg);
color: white;
transition: background-color 180ms ease;
overscroll-behavior: contain;
}
.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);
}
#${IDS.overlay} .hnir-mode-webtoon .hnir-page.hnir-page-loaded,
#${IDS.overlay}.hnir-fit-mode .hnir-mode-webtoon .hnir-page.hnir-page-loaded {
min-height: 0;
align-items: flex-start;
scroll-margin-top: 0;
scroll-margin-bottom: 0;
}
.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;
opacity: 1;
transform: translateY(0);
transition: opacity 180ms ease, transform 180ms ease;
}
.hnir-top-button,
.hnir-menu-button,
.hnir-close-button {
pointer-events: auto;
}
.hnir-bottom-controls {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 10px;
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: 440px;
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; }
.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);
}
.hnir-close-btn:hover {
transform: scale(1.06);
}
#${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-stack-item[data-item="theme"].is-open .hnir-panel.hnir-panel-vertical {
min-width: 244px;
padding: 10px;
border-radius: 22px;
}
.hnir-color-picker {
width: 244px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hnir-color-square {
--hnir-picker-hue: 0;
position: relative;
height: 138px;
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;
opacity: 1;
transform: translateY(0);
transition: opacity 180ms ease, transform 180ms ease;
}
@media (max-width: 700px) {
#${IDS.launcher},
#${IDS.uiRoot} {
right: 12px;
bottom: 12px;
}
#${IDS.pageIndicator} {
left: 12px;
bottom: 12px;
}
#${IDS.overlay}.hnir-ui-auto-hidden #${IDS.uiRoot}:not(.is-menu-open),
#${IDS.overlay}.hnir-ui-auto-hidden #${IDS.pageIndicator} {
opacity: 0;
transform: translateY(20px);
pointer-events: none;
}
.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;
}
#${IDS.overlay} .hnir-mode-webtoon .hnir-image.loaded {
max-width: 100vw;
max-height: none;
border-radius: 0;
box-shadow: none;
}
.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 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[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
}
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)}`;
}
})();