Adds a continuous vertical reader with progressive loading, zoom controls, page counter and automatic retries.
// ==UserScript==
// @name nHentai - Vertical Cascade Reader
// @namespace jack.nhentai.vertical.reader
// @version 1.3.0
// @description Adds a continuous vertical reader with progressive loading, zoom controls, page counter and automatic retries.
// @match https://nhentai.net/g/*
// @match https://www.nhentai.net/g/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const APP_ID = 'jack-nh-reader';
const BUTTON_ID = 'jack-nh-open-reader';
const STYLE_ID = 'jack-nh-reader-style';
let readerWidth = 900;
let oldBodyOverflow = '';
let fallbackQueue = Promise.resolve();
let lastRouteSignature = '';
let autoOpenTimer = null;
// ============================================================
// LEER SIEMPRE LA URL ACTUAL
// ============================================================
function getRoute() {
const match =
location.pathname.match(
/^\/g\/(\d+)(?:\/(\d+))?\/?$/i
);
if (!match) {
return null;
}
return {
galleryId:
match[1],
currentPage:
Math.max(
1,
Number(match[2] || 1)
),
isReaderPage:
Boolean(match[2]),
signature:
`${match[1]}:${match[2] || 'cover'}`
};
}
// ============================================================
// ESTILOS
// ============================================================
function addStyles() {
if (
document.getElementById(
STYLE_ID
)
) {
return;
}
const style =
document.createElement(
'style'
);
style.id =
STYLE_ID;
style.textContent = `
#${BUTTON_ID} {
position:
fixed !important;
right:
22px !important;
bottom:
22px !important;
z-index:
2147483647 !important;
padding:
12px 17px !important;
border:
1px solid
rgba(255,255,255,.18)
!important;
border-radius:
10px !important;
background:
rgba(25,25,25,.94)
!important;
color:
white !important;
font-family:
Arial,
sans-serif !important;
font-size:
14px !important;
font-weight:
600 !important;
cursor:
pointer !important;
box-shadow:
0 5px 20px
rgba(0,0,0,.4)
!important;
}
#${BUTTON_ID}:hover {
background:
rgba(50,50,50,.98)
!important;
}
#${APP_ID} {
--jack-width:
${readerWidth}px;
position:
fixed !important;
inset:
0 !important;
z-index:
2147483646 !important;
overflow-y:
auto !important;
overflow-x:
hidden !important;
background:
#111 !important;
overscroll-behavior:
contain !important;
}
#${APP_ID} .jack-pages {
width:
100% !important;
display:
flex !important;
flex-direction:
column !important;
align-items:
center !important;
margin:
0 !important;
padding:
0 !important;
}
#${APP_ID} .jack-page {
width:
100% !important;
display:
flex !important;
justify-content:
center !important;
align-items:
flex-start !important;
margin:
0 !important;
padding:
0 !important;
min-height:
700px !important;
background:
#111 !important;
position:
relative !important;
}
#${APP_ID} .jack-page.jack-loaded {
min-height:
0 !important;
}
#${APP_ID} .jack-page img {
display:
block !important;
width:
min(
var(--jack-width),
100vw
)
!important;
max-width:
100vw !important;
height:
auto !important;
margin:
0 auto !important;
padding:
0 !important;
border:
0 !important;
object-fit:
contain !important;
background:
#111 !important;
}
#${APP_ID} .jack-loader {
position:
absolute !important;
left:
50% !important;
top:
50% !important;
transform:
translate(-50%, -50%)
!important;
color:
#aaa !important;
font:
14px Arial,
sans-serif !important;
text-align:
center !important;
line-height:
1.5 !important;
}
#${APP_ID} .jack-retry {
margin-top:
10px !important;
padding:
8px 14px !important;
border:
1px solid #555
!important;
border-radius:
7px !important;
background:
#222 !important;
color:
white !important;
cursor:
pointer !important;
font:
13px Arial,
sans-serif !important;
}
#${APP_ID} .jack-retry:hover {
background:
#333 !important;
}
#${APP_ID} .jack-toolbar {
position:
fixed !important;
right:
18px !important;
top:
50% !important;
transform:
translateY(-50%)
!important;
width:
145px !important;
overflow:
hidden !important;
background:
rgba(20,20,20,.72)
!important;
backdrop-filter:
blur(10px)
!important;
-webkit-backdrop-filter:
blur(10px)
!important;
border:
1px solid
rgba(255,255,255,.14)
!important;
border-radius:
12px !important;
box-shadow:
0 6px 24px
rgba(0,0,0,.4)
!important;
z-index:
2147483647 !important;
font-family:
Arial,
sans-serif !important;
opacity:
.72 !important;
transition:
opacity .15s ease,
background .15s ease
!important;
}
#${APP_ID} .jack-toolbar:hover {
opacity:
1 !important;
background:
rgba(20,20,20,.96)
!important;
}
#${APP_ID} .jack-toolbar button,
#${APP_ID} .jack-toolbar a,
#${APP_ID} .jack-counter {
width:
100% !important;
height:
46px !important;
box-sizing:
border-box !important;
border:
0 !important;
border-bottom:
1px solid
rgba(255,255,255,.08)
!important;
background:
transparent !important;
color:
white !important;
font:
500 13px Arial,
sans-serif !important;
}
#${APP_ID} .jack-toolbar button,
#${APP_ID} .jack-toolbar a {
display:
flex !important;
align-items:
center !important;
justify-content:
center !important;
text-decoration:
none !important;
cursor:
pointer !important;
}
#${APP_ID} .jack-toolbar button:hover,
#${APP_ID} .jack-toolbar a:hover {
background:
rgba(255,255,255,.13)
!important;
}
#${APP_ID} .jack-counter {
display:
flex !important;
align-items:
center !important;
justify-content:
center !important;
}
#${APP_ID} .jack-start-message {
width:
100% !important;
height:
100vh !important;
display:
flex !important;
justify-content:
center !important;
align-items:
center !important;
color:
white !important;
font:
15px Arial,
sans-serif !important;
text-align:
center !important;
}
`;
document.head
.appendChild(
style
);
}
// ============================================================
// OBTENER URL DE IMAGEN
// ============================================================
function getImageSource(img) {
if (!img) {
return null;
}
const candidates = [
img.getAttribute(
'data-src'
),
img.getAttribute(
'data-original'
),
img.getAttribute(
'data-lazy-src'
),
img.getAttribute(
'src'
)
];
for (
const value
of
candidates
) {
if (
!value ||
value.startsWith(
'data:'
)
) {
continue;
}
try {
return new URL(
value,
location.origin
).href;
} catch (_) {}
}
const srcset =
img.getAttribute(
'srcset'
)
||
img.getAttribute(
'data-srcset'
);
if (srcset) {
const first =
srcset
.split(',')[0]
?.trim()
.split(/\s+/)[0];
if (first) {
try {
return new URL(
first,
location.origin
).href;
} catch (_) {}
}
}
return null;
}
// ============================================================
// MINIATURA → IMAGEN COMPLETA
// ============================================================
function thumbnailToFullImages(
src
) {
if (!src) {
return [];
}
try {
const url =
new URL(
src,
location.origin
);
url.hostname =
url.hostname.replace(
/^t(\d*)\./i,
'i$1.'
);
const pageMatch =
url.pathname.match(
/^(.*\/)(\d+)t\.([a-z0-9]+)$/i
);
if (!pageMatch) {
return [
url.href.replace(
/t(\.[a-z0-9]+)(?:\?.*)?$/i,
'$1'
)
];
}
const folder =
pageMatch[1];
const pageNumber =
pageMatch[2];
const originalExtension =
pageMatch[3]
.toLowerCase();
const extensions =
[
...new Set(
[
originalExtension,
'jpg',
'jpeg',
'png',
'webp',
'gif',
'avif'
]
)
];
return extensions.map(
extension => {
const candidate =
new URL(
url.href
);
candidate.pathname =
`${folder}${pageNumber}.${extension}`;
candidate.search =
'';
return candidate.href;
}
);
} catch (_) {
return [];
}
}
// ============================================================
// VARIANTES DE EXTENSIÓN
// ============================================================
function createImageVariants(
src
) {
if (!src) {
return [];
}
try {
const url =
new URL(
src,
location.origin
);
const match =
url.pathname.match(
/^(.*\/)(\d+)\.([a-z0-9]+)$/i
);
if (!match) {
return [
url.href
];
}
const folder =
match[1];
const number =
match[2];
const originalExtension =
match[3]
.toLowerCase();
const extensions =
[
...new Set(
[
originalExtension,
'jpg',
'jpeg',
'png',
'webp',
'gif',
'avif'
]
)
];
return extensions.map(
extension => {
const candidate =
new URL(
url.href
);
candidate.pathname =
`${folder}${number}.${extension}`;
candidate.search =
'';
return candidate.href;
}
);
} catch (_) {
return [
src
];
}
}
// ============================================================
// EXTRAER PÁGINAS DE UNA GALERÍA
// ============================================================
function extractPagesFromDocument(
doc,
galleryId
) {
const result =
new Map();
for (
const link
of
doc.querySelectorAll(
'a[href]'
)
) {
let url;
try {
url =
new URL(
link.getAttribute(
'href'
),
location.origin
);
} catch (_) {
continue;
}
const match =
url.pathname.match(
new RegExp(
`^/g/${galleryId}/(\\d+)/?$`,
'i'
)
);
if (!match) {
continue;
}
const pageNumber =
Number(
match[1]
);
if (
!Number.isFinite(
pageNumber
)
||
pageNumber < 1
) {
continue;
}
const thumb =
link.querySelector(
'img'
);
const thumbSrc =
getImageSource(
thumb
);
const possibleImages =
thumbnailToFullImages(
thumbSrc
);
result.set(
pageNumber,
{
number:
pageNumber,
galleryId:
galleryId,
pageUrl:
`${location.origin}/g/${galleryId}/${pageNumber}/`,
thumbUrl:
thumbSrc,
imageUrl:
possibleImages[0] ||
null,
autoRetries:
0
}
);
}
return result;
}
// ============================================================
// DETECTAR TOTAL DE PÁGINAS
// ============================================================
function findTotalPages(
doc
) {
const text =
doc.body?.innerText ||
'';
const patterns = [
/Pages?\s*:?\s*(\d+)/i,
/(\d+)\s+pages?/i,
/(\d+)\s+páginas?/i
];
for (
const regex
of
patterns
) {
const found =
text.match(
regex
);
if (!found) {
continue;
}
const number =
Number(
found[1]
);
if (
Number.isFinite(
number
)
&&
number > 0
&&
number < 10000
) {
return number;
}
}
return 0;
}
// ============================================================
// CARGAR SIEMPRE LA GALERÍA ACTUAL
// ============================================================
async function getGalleryPages(
galleryId
) {
/*
* IMPORTANTE:
*
* Se fuerza una petición NUEVA usando
* exclusivamente la ID actual.
*
* No se reutiliza la lista anterior.
*/
const response =
await fetch(
`/g/${galleryId}/?_cascade=${Date.now()}`,
{
credentials:
'include',
cache:
'no-store'
}
);
if (!response.ok) {
throw new Error(
`No pude abrir la galería ${galleryId}. HTTP ${response.status}`
);
}
const html =
await response.text();
const doc =
new DOMParser()
.parseFromString(
html,
'text/html'
);
const pageMap =
extractPagesFromDocument(
doc,
galleryId
);
const total =
findTotalPages(
doc
);
let highestDetected =
0;
for (
const number
of
pageMap.keys()
) {
highestDetected =
Math.max(
highestDetected,
number
);
}
/*
* Si la ficha dice 51 páginas,
* pero solo encontramos miniaturas
* de algunas, igualmente generamos
* las 51 posiciones.
*/
const finalTotal =
Math.max(
total,
highestDetected,
pageMap.size
);
if (!finalTotal) {
throw new Error(
`No pude detectar las páginas de la galería ${galleryId}.`
);
}
const pages =
[];
for (
let i = 1;
i <= finalTotal;
i++
) {
const detected =
pageMap.get(
i
);
pages.push(
detected
||
{
number:
i,
galleryId:
galleryId,
pageUrl:
`${location.origin}/g/${galleryId}/${i}/`,
thumbUrl:
null,
imageUrl:
null,
autoRetries:
0
}
);
}
return pages;
}
// ============================================================
// OBTENER IMAGEN DESDE PÁGINA INDIVIDUAL
// ============================================================
async function getImageFromReaderPage(
page
) {
const response =
await fetch(
`${page.pageUrl}?_cascade=${Date.now()}`,
{
credentials:
'include',
cache:
'no-store'
}
);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`
);
}
const html =
await response.text();
const doc =
new DOMParser()
.parseFromString(
html,
'text/html'
);
const selectors = [
'#image-container img',
'section#image-container img',
'.image-container img',
'img[src*="/galleries/"]',
'img[data-src*="/galleries/"]'
];
for (
const selector
of
selectors
) {
for (
const img
of
doc.querySelectorAll(
selector
)
) {
const src =
getImageSource(
img
);
if (!src) {
continue;
}
try {
const parsed =
new URL(
src
);
if (
/^i\d*\./i
.test(
parsed.hostname
)
) {
return src;
}
} catch (_) {}
const converted =
thumbnailToFullImages(
src
);
if (
converted.length
) {
return converted[0];
}
}
}
const matches =
html.match(
/https?:\\?\/\\?\/i\d*\.nhentai\.net\\?\/galleries\\?\/[^"'<> ]+\.(?:jpg|jpeg|png|gif|webp|avif)/ig
);
if (
matches?.length
) {
return matches[0]
.replace(
/\\u002F/gi,
'/'
)
.replace(
/\\\//g,
'/'
);
}
throw new Error(
`No encontré la imagen de la página ${page.number}`
);
}
// ============================================================
// COLA DE FALLBACK
// ============================================================
function getImageFromReaderPageQueued(
page
) {
const job =
fallbackQueue.then(
async () => {
await new Promise(
resolve =>
setTimeout(
resolve,
220
)
);
return getImageFromReaderPage(
page
);
}
);
fallbackQueue =
job.catch(
() => {}
);
return job;
}
// ============================================================
// PROBAR IMAGEN
// ============================================================
function testImage(
url,
page,
element
) {
return new Promise(
(
resolve,
reject
) => {
const img =
document.createElement(
'img'
);
img.alt =
`Página ${page.number}`;
img.decoding =
'async';
img.dataset.page =
String(
page.number
);
img.addEventListener(
'load',
() => {
/*
* Evita aceptar una imagen
* diminuta o placeholder.
*/
if (
img.naturalWidth < 50
||
img.naturalHeight < 50
) {
img.remove();
reject(
new Error(
`Imagen inválida: ${url}`
)
);
return;
}
resolve(
img
);
},
{
once:
true
}
);
img.addEventListener(
'error',
() => {
img.remove();
reject(
new Error(
`No cargó ${url}`
)
);
},
{
once:
true
}
);
element.appendChild(
img
);
img.src =
url;
}
);
}
// ============================================================
// CARGAR PÁGINA
// ============================================================
async function loadPage(
page,
element,
manualRetry = false
) {
const reader =
document.getElementById(
APP_ID
);
/*
* SEGURIDAD CONTRA GALERÍA ANTERIOR.
*/
if (
!reader
||
reader.dataset.galleryId !==
String(
page.galleryId
)
) {
return;
}
if (
element.dataset.loaded ===
'1'
||
element.dataset.loading ===
'1'
) {
return;
}
element.dataset.loading =
'1';
element.dataset.error =
'0';
let loader =
element.querySelector(
'.jack-loader'
);
if (!loader) {
loader =
document.createElement(
'div'
);
loader.className =
'jack-loader';
element.appendChild(
loader
);
}
loader.textContent =
`Cargando página ${page.number}…`;
element
.querySelectorAll(
'img'
)
.forEach(
img =>
img.remove()
);
let candidates =
[];
if (
page.imageUrl
) {
candidates.push(
...createImageVariants(
page.imageUrl
)
);
}
if (
page.thumbUrl
) {
candidates.push(
...thumbnailToFullImages(
page.thumbUrl
)
);
}
candidates =
[
...new Set(
candidates.filter(
Boolean
)
)
];
// --------------------------------------------------------
// PRIMER INTENTO
// --------------------------------------------------------
for (
const candidate
of
candidates
) {
try {
await testImage(
candidate,
page,
element
);
page.imageUrl =
candidate;
page.autoRetries =
0;
element.dataset.loaded =
'1';
element.dataset.loading =
'0';
element.classList.add(
'jack-loaded'
);
loader.remove();
return;
} catch (_) {}
}
// --------------------------------------------------------
// BUSCAR LA URL REAL
// --------------------------------------------------------
try {
loader.textContent =
`Buscando imagen real de página ${page.number}…`;
const realUrl =
await getImageFromReaderPageQueued(
page
);
for (
const candidate
of
createImageVariants(
realUrl
)
) {
try {
await testImage(
candidate,
page,
element
);
page.imageUrl =
candidate;
page.autoRetries =
0;
element.dataset.loaded =
'1';
element.dataset.loading =
'0';
element.classList.add(
'jack-loaded'
);
loader.remove();
return;
} catch (_) {}
}
} catch (
error
) {
console.warn(
`[Cascada] Galería ${page.galleryId}, página ${page.number}:`,
error
);
}
// --------------------------------------------------------
// REINTENTO AUTOMÁTICO
// --------------------------------------------------------
if (
page.autoRetries < 2
&&
!manualRetry
) {
page.autoRetries++;
element.dataset.loading =
'0';
loader.textContent =
`Reintentando página ${page.number}…`;
setTimeout(
() => {
loadPage(
page,
element,
false
);
},
1200 *
page.autoRetries
);
return;
}
// --------------------------------------------------------
// ERROR MANUAL
// --------------------------------------------------------
element.dataset.loading =
'0';
element.dataset.error =
'1';
loader.innerHTML =
'';
const errorText =
document.createElement(
'div'
);
errorText.textContent =
`No cargó la página ${page.number}`;
const retryButton =
document.createElement(
'button'
);
retryButton.className =
'jack-retry';
retryButton.textContent =
'↻ Reintentar';
retryButton.addEventListener(
'click',
event => {
event.stopPropagation();
page.autoRetries =
0;
loadPage(
page,
element,
true
);
}
);
loader.append(
errorText,
retryButton
);
}
// ============================================================
// TOOLBAR
// ============================================================
function createToolbar(
reader,
galleryId,
total,
startPage
) {
const toolbar =
document.createElement(
'div'
);
toolbar.className =
'jack-toolbar';
// --------------------------------------------------------
// CONTADOR
// --------------------------------------------------------
const counter =
document.createElement(
'div'
);
counter.className =
'jack-counter';
counter.textContent =
`${startPage} / ${total}`;
// --------------------------------------------------------
// PORTADA
// --------------------------------------------------------
const cover =
document.createElement(
'a'
);
cover.textContent =
'⌂ Portada';
cover.href =
`${location.origin}/g/${galleryId}/`;
cover.target =
'_blank';
cover.rel =
'noopener noreferrer';
cover.title =
'Abrir portada en una pestaña nueva';
// --------------------------------------------------------
// ZOOM +
// --------------------------------------------------------
const plus =
document.createElement(
'button'
);
plus.textContent =
'+ Zoom';
plus.addEventListener(
'click',
() => {
readerWidth =
Math.min(
readerWidth + 100,
1800
);
reader.style.setProperty(
'--jack-width',
`${readerWidth}px`
);
}
);
// --------------------------------------------------------
// ZOOM -
// --------------------------------------------------------
const minus =
document.createElement(
'button'
);
minus.textContent =
'− Zoom';
minus.addEventListener(
'click',
() => {
readerWidth =
Math.max(
readerWidth - 100,
350
);
reader.style.setProperty(
'--jack-width',
`${readerWidth}px`
);
}
);
// --------------------------------------------------------
// AJUSTAR
// --------------------------------------------------------
const fit =
document.createElement(
'button'
);
fit.textContent =
'↔ Ajustar';
fit.addEventListener(
'click',
() => {
readerWidth =
Math.max(
350,
window.innerWidth - 50
);
reader.style.setProperty(
'--jack-width',
`${readerWidth}px`
);
}
);
// --------------------------------------------------------
// CERRAR
// --------------------------------------------------------
const close =
document.createElement(
'button'
);
close.textContent =
'✕ Cerrar';
close.addEventListener(
'click',
closeReader
);
toolbar.append(
counter,
cover,
plus,
minus,
fit,
close
);
reader.appendChild(
toolbar
);
return counter;
}
// ============================================================
// CERRAR LECTOR
// ============================================================
function closeReader() {
document
.getElementById(
APP_ID
)
?.remove();
document.body.style.overflow =
oldBodyOverflow;
addOpenButton();
}
// ============================================================
// ABRIR LECTOR
// ============================================================
async function openReader() {
/*
* MUY IMPORTANTE:
*
* La ID se obtiene AHORA.
*
* No se utiliza una variable creada
* cuando se cargó una galería anterior.
*/
const route =
getRoute();
if (!route) {
return;
}
const existing =
document.getElementById(
APP_ID
);
// --------------------------------------------------------
// SI HAY UN LECTOR DE OTRA GALERÍA, BORRARLO
// --------------------------------------------------------
if (existing) {
if (
existing.dataset.galleryId ===
route.galleryId
) {
return;
}
existing.remove();
document.body.style.overflow =
oldBodyOverflow;
}
addStyles();
document
.getElementById(
BUTTON_ID
)
?.remove();
oldBodyOverflow =
document.body.style.overflow;
document.body.style.overflow =
'hidden';
/*
* También reiniciamos la cola.
*/
fallbackQueue =
Promise.resolve();
const reader =
document.createElement(
'div'
);
reader.id =
APP_ID;
/*
* Guardamos qué galería pertenece
* a ESTE lector.
*/
reader.dataset.galleryId =
route.galleryId;
reader.dataset.routeSignature =
route.signature;
reader.style.setProperty(
'--jack-width',
`${readerWidth}px`
);
const start =
document.createElement(
'div'
);
start.className =
'jack-start-message';
start.textContent =
`Cargando galería ${route.galleryId}…`;
reader.appendChild(
start
);
document.body.appendChild(
reader
);
try {
/*
* Solicitar específicamente
* la galería de la URL actual.
*/
const galleryPages =
await getGalleryPages(
route.galleryId
);
/*
* COMPROBACIÓN CRÍTICA:
*
* Si el usuario cambió de galería
* mientras se estaba cargando,
* cancelar todo.
*/
const liveRoute =
getRoute();
if (
!liveRoute
||
liveRoute.galleryId !==
route.galleryId
) {
reader.remove();
document.body.style.overflow =
oldBodyOverflow;
addOpenButton();
return;
}
start.remove();
// ====================================================
// CREAR PÁGINAS
// ====================================================
const pagesContainer =
document.createElement(
'div'
);
pagesContainer.className =
'jack-pages';
const elementMap =
new Map();
for (
const page
of
galleryPages
) {
const item =
document.createElement(
'div'
);
item.className =
'jack-page';
item.dataset.page =
String(
page.number
);
/*
* Incluimos la ID de galería
* en el ID HTML.
*/
item.id =
`jack-page-${route.galleryId}-${page.number}`;
const loader =
document.createElement(
'div'
);
loader.className =
'jack-loader';
loader.textContent =
`Página ${page.number}`;
item.appendChild(
loader
);
pagesContainer.appendChild(
item
);
elementMap.set(
page.number,
item
);
}
reader.appendChild(
pagesContainer
);
const startPage =
Math.min(
route.currentPage,
galleryPages.length
);
const counter =
createToolbar(
reader,
route.galleryId,
galleryPages.length,
startPage
);
// ====================================================
// CARGA PROGRESIVA
// ====================================================
const loadObserver =
new IntersectionObserver(
entries => {
for (
const entry
of
entries
) {
if (
!entry.isIntersecting
) {
continue;
}
const number =
Number(
entry.target
.dataset.page
);
const page =
galleryPages[
number - 1
];
if (page) {
loadPage(
page,
entry.target
);
}
}
},
{
root:
reader,
rootMargin:
'1800px 0px 1800px 0px',
threshold:
0
}
);
elementMap.forEach(
element => {
loadObserver.observe(
element
);
}
);
// ====================================================
// CONTADOR
// ====================================================
const pageObserver =
new IntersectionObserver(
entries => {
const visible =
entries
.filter(
entry =>
entry.isIntersecting
)
.sort(
(
a,
b
) =>
b.intersectionRatio -
a.intersectionRatio
)[0];
if (visible) {
counter.textContent =
`${Number(visible.target.dataset.page)} / ${galleryPages.length}`;
}
},
{
root:
reader,
threshold:
[
.15,
.3,
.5,
.7
]
}
);
elementMap.forEach(
element => {
pageObserver.observe(
element
);
}
);
// ====================================================
// CARGAR PÁGINA INICIAL
// ====================================================
const actual =
galleryPages[
startPage - 1
];
const actualElement =
elementMap.get(
startPage
);
if (
actual &&
actualElement
) {
await loadPage(
actual,
actualElement
);
}
// ====================================================
// PRECARGAR ANTERIOR Y SIGUIENTE
// ====================================================
for (
const number
of
[
startPage - 1,
startPage + 1
]
) {
const page =
galleryPages[
number - 1
];
const element =
elementMap.get(
number
);
if (
page &&
element
) {
loadPage(
page,
element
);
}
}
// ====================================================
// IR A LA PÁGINA CORRESPONDIENTE
// ====================================================
setTimeout(
() => {
const target =
elementMap.get(
startPage
);
if (target) {
target.scrollIntoView(
{
block:
'start'
}
);
}
},
100
);
console.log(
`[Cascada] Galería ${route.galleryId}: ${galleryPages.length} páginas.`
);
} catch (
error
) {
console.error(
'[Cascada]',
error
);
start.innerHTML =
`No pude crear el lector vertical.` +
`<br><br>` +
`${error.message}`;
const close =
document.createElement(
'button'
);
close.textContent =
'Cerrar';
close.style.cssText =
'position:fixed;' +
'right:20px;' +
'top:20px;' +
'z-index:2147483647;' +
'padding:10px 15px;' +
'cursor:pointer;';
close.addEventListener(
'click',
closeReader
);
reader.appendChild(
close
);
}
}
// ============================================================
// BOTÓN LEER EN CASCADA
// ============================================================
function addOpenButton() {
const route =
getRoute();
if (!route) {
document
.getElementById(
BUTTON_ID
)
?.remove();
return;
}
if (
document.getElementById(
APP_ID
)
) {
return;
}
addStyles();
let button =
document.getElementById(
BUTTON_ID
);
/*
* Si el botón pertenece a otra galería,
* eliminarlo.
*/
if (
button
&&
button.dataset.galleryId !==
route.galleryId
) {
button.remove();
button =
null;
}
if (button) {
return;
}
button =
document.createElement(
'button'
);
button.id =
BUTTON_ID;
button.dataset.galleryId =
route.galleryId;
button.textContent =
'⇣ Leer en cascada';
/*
* No se guarda galleryId dentro
* del listener.
*
* openReader() vuelve a consultar
* la URL actual.
*/
button.addEventListener(
'click',
() => {
openReader();
}
);
document.body.appendChild(
button
);
}
// ============================================================
// SINCRONIZAR AL CAMBIAR DE GALERÍA
// ============================================================
function syncToCurrentRoute(
{
allowAutoOpen = true
} = {}
) {
const route =
getRoute();
if (!route) {
document
.getElementById(
APP_ID
)
?.remove();
document
.getElementById(
BUTTON_ID
)
?.remove();
document.body.style.overflow =
oldBodyOverflow;
return;
}
const existing =
document.getElementById(
APP_ID
);
/*
* Si navegamos a otra galería,
* cerrar automáticamente el lector viejo.
*/
if (
existing
&&
existing.dataset.galleryId !==
route.galleryId
) {
existing.remove();
document.body.style.overflow =
oldBodyOverflow;
}
addOpenButton();
if (
autoOpenTimer
) {
clearTimeout(
autoOpenTimer
);
autoOpenTimer =
null;
}
/*
* Si abrimos directamente:
*
* /g/123456/5/
*
* abrir cascada desde esa página.
*/
if (
allowAutoOpen
&&
route.isReaderPage
&&
!document.getElementById(
APP_ID
)
) {
const expected =
route.signature;
autoOpenTimer =
setTimeout(
() => {
const now =
getRoute();
if (
now?.signature ===
expected
) {
openReader();
}
},
350
);
}
}
// ============================================================
// DETECTAR CAMBIOS DE URL
// ============================================================
function installNavigationWatch() {
lastRouteSignature =
getRoute()?.signature ||
'';
// --------------------------------------------------------
// ATRÁS / ADELANTE
// --------------------------------------------------------
window.addEventListener(
'popstate',
() => {
setTimeout(
() => {
syncToCurrentRoute();
},
0
);
}
);
// --------------------------------------------------------
// BFCache / RESTAURAR PESTAÑA
// --------------------------------------------------------
window.addEventListener(
'pageshow',
() => {
setTimeout(
() => {
syncToCurrentRoute();
},
0
);
}
);
// --------------------------------------------------------
// PUSHSTATE
// --------------------------------------------------------
const originalPushState =
history.pushState;
history.pushState =
function (
...args
) {
const result =
originalPushState.apply(
this,
args
);
setTimeout(
() => {
syncToCurrentRoute();
},
0
);
return result;
};
// --------------------------------------------------------
// REPLACESTATE
// --------------------------------------------------------
const originalReplaceState =
history.replaceState;
history.replaceState =
function (
...args
) {
const result =
originalReplaceState.apply(
this,
args
);
setTimeout(
() => {
syncToCurrentRoute();
},
0
);
return result;
};
// --------------------------------------------------------
// COMPROBACIÓN EXTRA
// --------------------------------------------------------
setInterval(
() => {
const signature =
getRoute()?.signature ||
'';
if (
signature !==
lastRouteSignature
) {
lastRouteSignature =
signature;
syncToCurrentRoute();
}
},
700
);
}
// ============================================================
// TECLADO
// ============================================================
document.addEventListener(
'keydown',
event => {
const tag =
document.activeElement
?.tagName
?.toLowerCase();
if (
tag ===
'input'
||
tag ===
'textarea'
) {
return;
}
// ----------------------------------------------------
// ESC = CERRAR
// ----------------------------------------------------
if (
event.key ===
'Escape'
&&
document.getElementById(
APP_ID
)
) {
closeReader();
return;
}
// ----------------------------------------------------
// V = ABRIR / CERRAR
// ----------------------------------------------------
if (
(
event.key ===
'v'
||
event.key ===
'V'
)
&&
!event.ctrlKey
&&
!event.altKey
&&
!event.metaKey
) {
if (
document.getElementById(
APP_ID
)
) {
closeReader();
} else {
openReader();
}
}
}
);
// ============================================================
// INICIO
// ============================================================
installNavigationWatch();
syncToCurrentRoute();
})();