With the goal of being light, discreet, and non-invasive, a QoL script for Exhentai and E-hentai. It adds features like horizontal and vertical reading, infinite scrolling, pre-fetching images, and more to make browsing extremely smooth.
// ==UserScript==
// @name Ex Fast Reader
// @namespace https://tampermonkey.net/
// @version 4.6.6
// @description With the goal of being light, discreet, and non-invasive, a QoL script for Exhentai and E-hentai. It adds features like horizontal and vertical reading, infinite scrolling, pre-fetching images, and more to make browsing extremely smooth.
// @author ethyr
// @match *://e-hentai.org/*
// @match *://exhentai.org/*
// @match *://*.e-hentai.org/*
// @match *://*.exhentai.org/*
// @icon https://e-hentai.org/favicon.ico
// @exclude *://forums.e-hentai.org/*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// =========================================================================
// 0. CORE & SEAMLESS STRIP STYLES
// =========================================================================
const CORE_CSS = `
/* Failure Toast */
.eh-toast-fail {
position: fixed;
bottom: 24px;
right: 24px;
background: rgba(26, 18, 18, 0.96);
color: #fca5a5;
border: 1px solid #ef4444;
padding: 9px 16px;
border-radius: 6px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
box-shadow: 0 4px 14px rgba(0,0,0,0.5);
z-index: 999999;
cursor: pointer;
}
/* Seamless Native-Themed Controls */
.eh-strip-controls {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 3px 8px;
vertical-align: middle;
}
.eh-strip-controls.single-mode-bottom {
display: flex !important;
justify-content: center !important;
align-items: center !important;
margin: 1px auto 2px auto !important;
padding: 0 !important;
text-align: center !important;
clear: both !important;
width: 100% !important;
}
/* In single-page mode, strictly hide every control except the strip mode toggle button */
.eh-strip-controls.single-mode-bottom > :not(#eh-strip-mode-toggle) {
display: none !important;
}
.eh-strip-btn {
display: inline-flex;
align-items: center !important;
justify-content: center !important;
font-family: arial, helvetica, sans-serif !important;
font-size: 9pt !important;
font-weight: bold !important;
min-height: 26px !important;
line-height: 20px !important;
padding: 1px 12px 2px !important;
margin: 0 2px !important;
border-radius: 3px !important;
cursor: pointer !important;
text-decoration: none !important;
user-select: none !important;
white-space: nowrap !important;
box-sizing: border-box !important;
-webkit-text-stroke: none !important;
text-shadow: none !important;
outline: none !important;
vertical-align: middle !important;
transition: background-color 0.1s ease, border-color 0.1s ease !important;
border: 2px solid #B5A4A4 !important;
background-color: #EDEADA !important;
color: #5C0D12 !important;
}
.eh-strip-btn b,
.eh-strip-btn span {
font-weight: normal;
-webkit-text-stroke: none !important;
text-shadow: none !important;
}
.eh-strip-btn.active b {
font-weight: bold;
}
.eh-strip-page-counter {
font-family: arial, helvetica, sans-serif;
font-size: 9pt;
font-weight: normal;
padding: 2px 6px;
user-select: none;
white-space: nowrap;
line-height: normal;
}
/* E-Hentai Theme (Light) - Exact Match to Native Site Buttons */
.eh-theme-light .eh-strip-btn,
.eh-strip-btn {
background-color: #EDEADA !important;
color: #5C0D12 !important;
border: 2px solid #B5A4A4 !important;
border-radius: 3px !important;
-webkit-text-stroke: none !important;
text-shadow: none !important;
}
.eh-theme-light .eh-strip-btn:hover {
background-color: #F3F0E0 !important;
border-color: #977273 !important;
color: #5C0D12 !important;
}
.eh-theme-light .eh-strip-btn.active {
background-color: #D7D3C2 !important;
border-color: #5C0D12 !important;
color: #5C0D12 !important;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.25) !important;
}
.eh-theme-light .eh-strip-page-counter,
.eh-theme-light #eh-topbar-info {
color: #5C0D12 !important;
}
/* ExHentai Theme (Dark) - Exact Match to Native Site Buttons */
.eh-theme-dark .eh-strip-btn {
background-color: #34353b !important;
color: #dddddd !important;
border: 2px solid #8d8d8d !important;
border-radius: 3px !important;
-webkit-text-stroke: none !important;
text-shadow: none !important;
}
.eh-theme-dark .eh-strip-btn:hover {
background-color: #43464e !important;
border-color: #aeaeae !important;
color: #ffffff !important;
}
.eh-theme-dark .eh-strip-btn.active {
background-color: #1a1a1a !important;
border-color: #aeaeae !important;
color: #ffffff !important;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4) !important;
}
.eh-theme-dark .eh-strip-page-counter,
.eh-theme-dark #eh-topbar-info {
color: #dddddd !important;
}
/* Hide title bar (h1, #nb, #gn), original switchers, bottom bars, footers, and redundant links cleanly via CSS */
html.eh-strip-mode-active h1,
html.eh-strip-mode-active #nb,
html.eh-strip-mode-active #gn,
html.eh-strip-mode-active #gj,
html.eh-strip-mode-active .sb,
html.eh-strip-mode-active .sn,
html.eh-strip-mode-active #i3,
html.eh-strip-mode-active #i4,
html.eh-strip-mode-active #i5,
html.eh-strip-mode-active #i6,
html.eh-strip-mode-active #i7,
html.eh-strip-mode-active .c,
html.eh-strip-mode-active .c1,
html.eh-strip-mode-active .c2,
html.eh-strip-mode-active .c3,
html.eh-strip-mode-active #footer,
html.eh-strip-mode-active .dp,
html.eh-strip-mode-active #dp,
html.eh-strip-mode-active .ip,
html.eh-strip-mode-active #ip,
html.eh-strip-mode-active .ft,
html.eh-strip-mode-active #ft,
html.eh-strip-mode-active .gpc,
html.eh-strip-mode-active #gpc,
html.eh-strip-mode-active div.d,
html.eh-strip-mode-active #i2 > div:not(.sn):not(#eh-strip-controls):not(#eh-topbar-info),
html.eh-strip-mode-active #i2 > p,
html.eh-strip-mode-active #i2 > span:not(.sn),
html.eh-strip-mode-active #i2 > a:not(#eh-strip-controls a),
html.eh-strip-mode-active .eh-strip-hidden-bottom,
body.eh-strip-mode-active h1,
body.eh-strip-mode-active #nb,
body.eh-strip-mode-active #gn,
body.eh-strip-mode-active #gj,
body.eh-strip-mode-active .sb,
body.eh-strip-mode-active .sn,
body.eh-strip-mode-active #i3,
body.eh-strip-mode-active #i4,
body.eh-strip-mode-active #i5,
body.eh-strip-mode-active #i6,
body.eh-strip-mode-active #i7,
body.eh-strip-mode-active .c,
body.eh-strip-mode-active .c1,
body.eh-strip-mode-active .c2,
body.eh-strip-mode-active .c3,
body.eh-strip-mode-active #footer,
body.eh-strip-mode-active .dp,
body.eh-strip-mode-active #dp,
body.eh-strip-mode-active .ip,
body.eh-strip-mode-active #ip,
body.eh-strip-mode-active .ft,
body.eh-strip-mode-active #ft,
body.eh-strip-mode-active .gpc,
body.eh-strip-mode-active #gpc,
body.eh-strip-mode-active div.d,
body.eh-strip-mode-active #i2 > div:not(.sn):not(#eh-strip-controls):not(#eh-topbar-info),
body.eh-strip-mode-active #i2 > p,
body.eh-strip-mode-active #i2 > span:not(.sn),
body.eh-strip-mode-active #i2 > a:not(#eh-strip-controls a),
body.eh-strip-mode-active .eh-strip-hidden-bottom {
display: none !important;
}
/* Show topbar info cleanly in both strip and single-page modes with stable height */
body:not(.eh-strip-mode-active) #eh-topbar-info {
display: inline-flex !important;
height: 24px !important;
line-height: 24px !important;
vertical-align: middle !important;
}
body:not(.eh-strip-mode-active) #i2 {
min-height: 28px !important;
box-sizing: border-box !important;
}
/* Hide strip container when strip mode is inactive */
html:not(.eh-strip-mode-active) #eh-strip-container,
body:not(.eh-strip-mode-active) #eh-strip-container {
display: none !important;
}
/* TOP BAR: Fully extends to both edges; auto-hides unless hovered or focused */
html.eh-strip-mode-active #i2,
body.eh-strip-mode-active #i2 {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
width: 100% !important;
max-width: 100vw !important;
margin: 0 !important;
padding: 6px 12px !important;
box-sizing: border-box !important;
z-index: 100000 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
transform: translateY(-100%) !important;
opacity: 0 !important;
visibility: hidden !important;
box-shadow: none !important;
border: none !important;
pointer-events: none !important;
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.22s ease, visibility 0.22s !important;
}
body.eh-theme-light #i2 {
background-color: #edeada !important;
}
body.eh-theme-dark #i2 {
background-color: rgba(52, 53, 59, 0.97) !important;
backdrop-filter: blur(8px) !important;
}
body.eh-theme-light.eh-topbar-visible #i2 {
border-bottom: 1px solid #5c0d12 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important;
}
body.eh-theme-dark.eh-topbar-visible #i2 {
border-bottom: 1px solid #202020 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
/* Top-bar text in strip modes: one rule per theme, keyed on the <html> theme class so the
colour and stroke are identical on every load (the theme class is never set on <body>). */
html.eh-theme-light.eh-strip-mode-active #i2,
html.eh-theme-light.eh-strip-mode-active #i2 a:not(.eh-strip-btn),
html.eh-theme-light.eh-strip-mode-active #i2 span:not(.eh-strip-btn):not(#eh-strip-controls *),
html.eh-theme-light.eh-strip-mode-active #i2 b:not(#eh-strip-controls *),
html.eh-theme-light.eh-strip-mode-active #eh-topbar-info,
html.eh-theme-light.eh-strip-mode-active .eh-strip-page-counter {
color: #5c0d12 !important;
paint-order: stroke fill !important;
-webkit-text-stroke: 0.6px #ffffff !important;
text-shadow: none !important;
}
html.eh-theme-dark.eh-strip-mode-active #i2,
html.eh-theme-dark.eh-strip-mode-active #i2 a:not(.eh-strip-btn),
html.eh-theme-dark.eh-strip-mode-active #i2 span:not(.eh-strip-btn):not(#eh-strip-controls *),
html.eh-theme-dark.eh-strip-mode-active #i2 b:not(#eh-strip-controls *),
html.eh-theme-dark.eh-strip-mode-active #eh-topbar-info,
html.eh-theme-dark.eh-strip-mode-active .eh-strip-page-counter {
color: #dddddd !important;
paint-order: stroke fill !important;
-webkit-text-stroke: 0.6px #000000 !important;
text-shadow: none !important;
}
body.eh-strip-mode-active.eh-topbar-visible #i2 {
transform: translateY(0) !important;
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto !important;
}
/* Container constraints in horizontal mode: maximize vertical space */
html.eh-strip-mode-active.strip-horizontal,
body.eh-strip-mode-active.strip-horizontal {
overflow: hidden !important;
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
padding: 0 !important;
}
html.eh-strip-mode-active.strip-horizontal #i1,
body.eh-strip-mode-active.strip-horizontal #i1 {
width: 100% !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
outline: none !important;
box-sizing: border-box !important;
overflow: hidden !important;
position: relative !important;
}
/* Vertical mode: Clean layout with no excessive bottom padding */
html.eh-strip-mode-active.strip-vertical #i1,
body.eh-strip-mode-active.strip-vertical #i1 {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
/* COMPLETELY SEAMLESS RIGHT-TO-LEFT (RTL) HORIZONTAL STRIP */
#eh-strip-container.layout-horizontal {
display: flex;
flex-direction: row;
direction: rtl; /* RIGHT TO LEFT (Manga standard) */
align-items: center;
overflow-x: auto;
overflow-y: hidden;
height: 100vh !important;
max-height: 100vh !important;
width: 100vw;
box-sizing: border-box;
padding: 0;
margin: 0;
gap: 0;
border: none;
scroll-behavior: auto;
overscroll-behavior-x: contain;
overflow-anchor: none !important;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #4f535b rgba(0, 0, 0, 0.22);
}
.eh-theme-light #eh-strip-container.layout-horizontal {
scrollbar-color: #5c0d12 rgba(0, 0, 0, 0.22);
}
/* Items are sized via aspect-ratio and width calculated to match rendered 100vh height */
#eh-strip-container.layout-horizontal .eh-strip-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex: 0 0 auto;
height: 100vh !important;
max-height: 100vh !important;
width: calc(100vh * var(--eh-ar, 0.707));
aspect-ratio: var(--eh-ar, 0.707);
margin: 0;
padding: 0;
border: none;
position: relative;
box-sizing: border-box;
overflow: hidden;
overflow-anchor: none !important;
}
#eh-strip-container.layout-horizontal .eh-strip-item img.eh-main-img {
/* Fill the item exactly: the item's width is derived from this same image's aspect ratio,
so there is no sub-pixel/aspect mismatch left over to show up as a gap between pages. */
height: 100% !important;
max-height: 100vh !important;
width: 100% !important;
max-width: none;
object-fit: fill;
display: block;
margin: 0;
padding: 0;
border: none;
box-shadow: none;
vertical-align: middle;
position: relative;
z-index: 2;
}
/* End Card at the end of horizontal strip: generous leeway allows the last image to be centered */
.eh-strip-item.eh-strip-end-card {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
height: 100vh !important;
width: auto !important;
min-width: max(200px, calc(50vw - (100vh * var(--eh-ar, 0.707)) / 2 + 60px)) !important;
aspect-ratio: auto !important;
padding: 0 40px !important;
flex: 0 0 auto;
z-index: 3;
box-sizing: border-box !important;
}
/* Start Spacer for Horizontal RTL mode: provides generous leeway on the right so Page 1 can be centered */
.eh-strip-start-spacer {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
height: 100vh !important;
max-height: 100vh !important;
width: max(40px, calc(50vw - (100vh * var(--eh-ar, 0.707)) / 2)) !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
box-sizing: border-box !important;
flex: 0 0 auto !important;
pointer-events: none !important;
overflow-anchor: none !important;
background: transparent !important;
}
#eh-strip-container.layout-vertical .eh-strip-start-spacer {
display: none !important;
}
/* COMPLETELY SEAMLESS VERTICAL STRIP */
#eh-strip-container.layout-vertical {
display: flex;
flex-direction: column;
align-items: center;
overflow-y: visible;
overflow-x: hidden;
width: 100%;
margin: 0 auto;
padding: 0;
gap: 0;
border: none;
overflow-anchor: none !important;
}
#eh-strip-container.layout-vertical .eh-strip-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: var(--eh-max-w, 100%);
aspect-ratio: var(--eh-ar, 0.707);
margin: 0 auto;
padding: 0;
border: none;
position: relative;
box-sizing: border-box;
overflow: hidden;
overflow-anchor: none !important;
}
#eh-strip-container.layout-vertical .eh-strip-item img.eh-main-img {
max-width: 100%;
width: 100%;
height: auto;
aspect-ratio: var(--eh-ar, 0.707);
display: block;
margin: 0 auto;
padding: 0;
border: none;
box-shadow: none;
position: relative;
z-index: 2;
}
/* Top Bar Image Info */
#eh-topbar-info {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: arial, helvetica, sans-serif;
font-size: 9pt;
font-weight: normal;
margin: 0 10px;
white-space: nowrap;
user-select: none;
-webkit-text-stroke: none !important;
text-shadow: none !important;
}
#eh-topbar-info a {
text-decoration: underline;
}
/* Thumbnail placeholders: one small <canvas> per page holding just that page's region of the
gallery sprite sheet. object-fit: cover means a rounding mismatch crops, it never stretches. */
.eh-placeholder-thumb-wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.eh-placeholder-thumb-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
object-fit: fill; /* same fit as the final image, so nothing shifts when it replaces the thumbnail */
opacity: 1;
pointer-events: none;
z-index: 1;
}
/* "Page N..." text only appears if the thumbnail is still missing after a moment (no flash) */
.eh-placeholder-content.eh-delayed {
opacity: 0;
animation: eh-fade-in 0.2s ease 0.45s forwards;
}
@keyframes eh-fade-in {
to { opacity: 1; }
}
/* Clean, Theme-Consistent Solid Neutral Placeholder */
.eh-strip-item.eh-placeholder {
box-sizing: border-box;
position: relative;
}
/* Only show & animate the spinner when the page is actively loading */
.eh-placeholder:not(.eh-loading) .eh-spinner {
display: none !important;
}
.eh-theme-light .eh-strip-item.eh-placeholder,
.eh-theme-dark .eh-strip-item.eh-placeholder {
background-color: transparent !important;
outline: none !important;
border: none !important;
}
/* Discreet, elegant frosted loading capsule */
.eh-placeholder-content {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 7px;
user-select: none;
position: relative;
z-index: 2;
background: rgba(0, 0, 0, 0.42);
padding: 5px 12px;
border-radius: 14px;
backdrop-filter: blur(3px);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.eh-placeholder-content.eh-placeholder-retry {
flex-direction: column;
gap: 8px;
padding: 12px 18px;
border-radius: 8px;
pointer-events: auto;
}
.eh-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.22);
border-top-color: #ffffff;
border-radius: 50%;
animation: eh-spin 0.7s linear infinite;
flex-shrink: 0;
}
.eh-theme-light .eh-spinner {
border-top-color: #5c0d12;
border-color: rgba(92, 13, 18, 0.2);
}
.eh-theme-dark .eh-spinner {
border-top-color: #ffffff;
border-color: rgba(255, 255, 255, 0.22);
}
@keyframes eh-spin {
to { transform: rotate(360deg); }
}
.eh-placeholder-text {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, arial, sans-serif;
font-size: 8.5pt;
font-weight: 600;
color: #ffffff;
opacity: 0.9;
letter-spacing: 0.3px;
line-height: 1;
white-space: nowrap;
}
.eh-theme-light .eh-placeholder-text {
color: #5c0d12;
}
.eh-theme-dark .eh-placeholder-text {
color: #eeeeee;
}
/* Scrollbar styling with comfortable leeway for horizontal sliding */
#eh-strip-container.layout-horizontal::-webkit-scrollbar {
height: 12px;
}
#eh-strip-container.layout-horizontal::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.22);
}
.eh-theme-light #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb {
background: #5c0d12;
border-radius: 6px;
}
.eh-theme-light #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb:hover {
background: #87141b;
}
.eh-theme-dark #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb {
background: #4f535b;
border-radius: 6px;
}
.eh-theme-dark #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb:hover {
background: #6a6f7b;
}
/* Native-Themed Single Bottom Return to Gallery Button (Compact, No Extra Space) */
.eh-bottom-gallery-bar {
display: flex !important;
justify-content: center !important;
align-items: center !important;
margin: 15px auto 18px auto !important;
padding: 6px 0 !important;
text-align: center !important;
clear: both !important;
width: 100% !important;
}
.eh-bottom-gallery-bar a {
display: inline-block;
padding: 6px 20px;
font-family: arial, helvetica, sans-serif;
font-size: 10pt;
font-weight: bold;
text-decoration: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease-in-out;
}
.eh-theme-light .eh-bottom-gallery-bar a {
background-color: #edeada;
color: #5c0d12;
border: 1px solid #5c0d12;
}
.eh-theme-light .eh-bottom-gallery-bar a:hover {
background-color: #f2efdf;
color: #9b4e03;
border-color: #9b4e03;
}
.eh-theme-dark .eh-bottom-gallery-bar a {
background-color: #34353b;
color: #dddddd;
border: 1px solid #4f535b;
}
.eh-theme-dark .eh-bottom-gallery-bar a:hover {
background-color: #43464e;
color: #ffffff;
border-color: #727783;
}
/* Infinite Scroll Button */
#eh-infinite-scroll-wrap {
display: inline-flex !important;
align-items: center !important;
margin-left: 8px !important;
vertical-align: middle !important;
}
#eh-infinite-scroll-toggle {
font-family: inherit;
font-size: 9pt;
line-height: normal;
box-sizing: border-box;
vertical-align: middle;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
min-height: 22px !important;
height: 22px;
}
/* Light Theme: keep the hover background (#F3F0E0) and border (#977273) when active */
.eh-theme-light #eh-infinite-scroll-toggle.active {
background-color: #F3F0E0 !important;
border-color: #977273 !important;
color: #5C0D12 !important;
box-shadow: none !important;
}
/* Dark Theme: keep the hover background (#43464e) and border (#aeaeae) when active */
.eh-theme-dark #eh-infinite-scroll-toggle.active {
background-color: #43464e !important;
border-color: #aeaeae !important;
color: #ffffff !important;
box-shadow: none !important;
}
html:not(.eh-strip-mode-active),
body:not(.eh-strip-mode-active) {
scroll-behavior: auto !important;
/* Chrome's scroll anchoring nudges the scroll position by a few px whenever the image above
the fold changes size on a page turn; we align the page ourselves. */
overflow-anchor: none !important;
}
#i1, #i3 {
overflow-anchor: none !important;
}
/* Single page thumbnail placeholder & stable viewport */
#i3 {
position: relative !important;
text-align: center !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: flex-start !important;
width: 100% !important;
margin: 0 auto !important;
box-sizing: border-box !important;
}
#i3 a {
position: relative !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
max-width: 100% !important;
margin: 0 auto !important;
}
#eh-single-thumb-placeholder {
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 auto !important;
overflow: hidden !important;
box-sizing: border-box !important;
max-width: 100% !important;
background: transparent !important;
z-index: 5 !important;
pointer-events: none !important;
}
.eh-theme-light #eh-single-thumb-placeholder {
background-color: transparent !important;
}
.eh-theme-dark #eh-single-thumb-placeholder {
background-color: transparent !important;
}
#eh-single-thumb-placeholder .eh-placeholder-thumb-wrap {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
overflow: hidden !important;
pointer-events: none !important;
z-index: 1 !important;
}
#eh-single-thumb-placeholder .eh-placeholder-thumb-img {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
}
#i1,
#i3,
#i3 a {
overflow: visible !important;
max-height: none !important;
}
#i3,
#i3 a {
height: auto !important;
}
#i3 img#img {
flex: 0 0 auto !important; /* never let the flex container squash width while height stays fixed */
display: block !important;
margin: 0 auto !important;
object-fit: contain !important;
box-sizing: border-box !important;
}
/* Completely eliminate focus rings and selection boxes around single page images and anchors */
#i3,
#i3:focus,
#i3:focus-visible,
#i3 a,
#i3 a:focus,
#i3 a:focus-visible,
#i3 a:active,
#i3 img,
#i3 img:focus,
#i3 img:focus-visible,
#i3 img:active,
#img,
#img:focus,
#img:focus-visible,
#img:active,
#eh-single-thumb-placeholder,
#eh-single-thumb-placeholder:focus {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
-webkit-tap-highlight-color: transparent !important;
user-select: none !important;
}
/* Inline Favorites Menu (Replaces #taglist in #gd4) */
#gd4.eh-fav-active {
height: auto !important;
max-height: 100% !important;
overflow: visible !important;
}
#gd4.eh-fav-active > :not(#eh-inline-favorites-container) {
display: none !important;
}
#eh-inline-favorites-container {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
padding: 0 4px 4px 4px !important;
margin: 0 !important;
font-family: arial, helvetica, sans-serif !important;
font-size: 9pt !important;
line-height: normal !important;
color: inherit !important;
overflow: visible !important;
}
#eh-inline-favorites-container p.eh-fav-header {
margin: 0 0 6px 0 !important;
padding: 0 !important;
text-align: center !important;
font-weight: normal !important;
font-size: 9pt !important;
line-height: 1.25 !important;
}
#eh-inline-favorites-container .eh-fav-body {
display: flex !important;
flex-direction: row !important;
justify-content: space-between !important;
align-items: flex-start !important;
gap: 12px !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
#eh-inline-favorites-container .eh-fav-cats {
display: flex !important;
flex-direction: column !important;
flex: 0 0 auto !important;
width: auto !important;
min-width: 140px !important;
max-width: 165px !important;
box-sizing: border-box !important;
margin: 0 !important;
padding: 0 !important;
gap: 2px !important;
}
#eh-inline-favorites-container .eh-fav-cat-item {
display: flex !important;
align-items: center !important;
cursor: pointer !important;
user-select: none !important;
padding: 1px 4px !important;
border-radius: 3px !important;
gap: 6px !important;
height: 20px !important;
box-sizing: border-box !important;
}
#eh-inline-favorites-container .eh-fav-cat-item:hover {
background: rgba(255, 255, 255, 0.08) !important;
}
.eh-theme-light #eh-inline-favorites-container .eh-fav-cat-item:hover {
background: rgba(0, 0, 0, 0.06) !important;
}
#eh-inline-favorites-container .eh-fav-cat-item input[type="radio"] {
margin: 0 !important;
cursor: pointer !important;
flex-shrink: 0 !important;
}
#eh-inline-favorites-container .eh-fav-cat-diamond,
#eh-inline-favorites-container .i {
width: 19px !important;
height: 19px !important;
min-width: 19px !important;
min-height: 19px !important;
background-image: url('https://ehgt.org/g/fav.png') !important;
background-repeat: no-repeat !important;
display: inline-block !important;
vertical-align: middle !important;
flex-shrink: 0 !important;
box-sizing: border-box !important;
}
#eh-inline-favorites-container .eh-fav-cat-label {
font-size: 9pt !important;
font-weight: normal !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
#eh-inline-favorites-container .eh-fav-note-pane {
display: flex !important;
flex-direction: column !important;
flex: 1 1 0% !important;
min-width: 0 !important;
max-width: 480px !important;
margin-right: 35px !important; /* Space on the right so it doesn't touch the panel edge */
box-sizing: border-box !important;
}
#eh-inline-favorites-container .eh-fav-note-title {
font-weight: bold !important;
text-align: center !important;
margin-bottom: 4px !important;
font-size: 9pt !important;
}
#eh-inline-favorites-container textarea.eh-fav-note-textarea {
width: 100% !important;
height: 125px !important;
min-height: 110px !important;
max-height: 135px !important;
box-sizing: border-box !important;
resize: none !important;
font-family: inherit !important;
font-size: 9pt !important;
padding: 6px 8px !important;
border-radius: 3px !important;
outline: none !important;
}
.eh-theme-dark #eh-inline-favorites-container textarea.eh-fav-note-textarea {
background-color: #27282c !important;
color: #eeeeee !important;
border: 1px solid #4f535b !important;
}
.eh-theme-light #eh-inline-favorites-container textarea.eh-fav-note-textarea {
background-color: #fcfbfa !important;
color: #333333 !important;
border: 1px solid #b5a4a4 !important;
}
#eh-inline-favorites-container .eh-fav-slots {
margin: 4px 0 0 0 !important;
text-align: right !important;
font-size: 8pt !important;
opacity: 0.9 !important;
}
#eh-inline-favorites-container .eh-fav-actions {
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 10px !important;
margin-top: 8px !important;
width: 100% !important;
}
#eh-inline-favorites-container input[type="submit"],
#eh-inline-favorites-container input[type="button"],
#eh-inline-favorites-container .eh-fav-submit-btn,
#eh-inline-favorites-container .eh-fav-remove-btn,
#eh-inline-favorites-container .eh-fav-cancel-btn {
font-family: arial, helvetica, sans-serif !important;
font-size: 9pt !important;
font-weight: bold !important;
min-height: 23px !important;
line-height: 17px !important;
padding: 2px 14px !important;
border-radius: 3px !important;
cursor: pointer !important;
box-sizing: border-box !important;
outline: none !important;
transition: background-color 0.1s ease, border-color 0.1s ease !important;
}
.eh-theme-light #eh-inline-favorites-container input[type="submit"],
.eh-theme-light #eh-inline-favorites-container input[type="button"],
.eh-theme-light #eh-inline-favorites-container .eh-fav-submit-btn,
.eh-theme-light #eh-inline-favorites-container .eh-fav-remove-btn,
.eh-theme-light #eh-inline-favorites-container .eh-fav-cancel-btn {
background-color: #EDEADA !important;
color: #5C0D12 !important;
border: 2px solid #B5A4A4 !important;
}
.eh-theme-light #eh-inline-favorites-container input[type="submit"]:hover,
.eh-theme-light #eh-inline-favorites-container input[type="button"]:hover,
.eh-theme-light #eh-inline-favorites-container .eh-fav-submit-btn:hover,
.eh-theme-light #eh-inline-favorites-container .eh-fav-remove-btn:hover,
.eh-theme-light #eh-inline-favorites-container .eh-fav-cancel-btn:hover {
background-color: #F3F0E0 !important;
border-color: #977273 !important;
color: #5C0D12 !important;
}
.eh-theme-dark #eh-inline-favorites-container input[type="submit"],
.eh-theme-dark #eh-inline-favorites-container input[type="button"],
.eh-theme-dark #eh-inline-favorites-container .eh-fav-submit-btn,
.eh-theme-dark #eh-inline-favorites-container .eh-fav-remove-btn,
.eh-theme-dark #eh-inline-favorites-container .eh-fav-cancel-btn {
background-color: #34353b !important;
color: #dddddd !important;
border: 2px solid #8d8d8d !important;
}
.eh-theme-dark #eh-inline-favorites-container input[type="submit"]:hover,
.eh-theme-dark #eh-inline-favorites-container input[type="button"]:hover,
.eh-theme-dark #eh-inline-favorites-container .eh-fav-submit-btn:hover,
.eh-theme-dark #eh-inline-favorites-container .eh-fav-remove-btn:hover,
.eh-theme-dark #eh-inline-favorites-container .eh-fav-cancel-btn:hover {
background-color: #43464e !important;
border-color: #aeaeae !important;
color: #ffffff !important;
}
/* Suppress foreign/ComicRead quick-favorite popup injected into #gd3 */
#gd3 > :not(#gdc):not(#gdn):not(#gdd):not(#gdr):not(#gdf):not(.c) {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
opacity: 0 !important;
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
}
.ido,
div.ido {
position: relative !important;
}
/* Relocated #favact to the top right of .ido (light gray box) */
#eh-favact-top,
#favact.eh-favact-top,
.eh-favact-top {
display: inline-flex !important;
justify-content: flex-end !important;
gap: 6px !important;
position: absolute !important;
top: 6px !important;
right: 0px !important;
float: none !important;
clear: none !important;
margin: 0 !important;
padding: 0 !important;
text-align: right !important;
box-sizing: border-box !important;
white-space: nowrap !important;
width: auto !important;
height: auto !important;
line-height: normal !important;
z-index: 100 !important;
}
#favact.eh-favact-top select,
.eh-favact-top select {
font-family: inherit !important;
font-size: 9pt !important;
box-sizing: border-box !important;
margin: 0 !important;
vertical-align: middle !important;
height: 26px !important;
min-height: 26px !important;
line-height: 20px !important;
border-radius: 3px !important;
}
#favact.eh-favact-top input[type="submit"],
#favact.eh-favact-top input[type="button"],
#favact.eh-favact-top input[value="Apply"],
#favact.eh-favact-top input[value="Confirm"],
#favact.eh-favact-top button,
.eh-favact-top input[type="submit"],
.eh-favact-top input[type="button"],
.eh-favact-top input[value="Apply"],
.eh-favact-top input[value="Confirm"],
.eh-favact-top button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
font-family: arial, helvetica, sans-serif !important;
font-size: 9pt !important;
font-weight: bold !important;
padding: 1px 12px 2px !important;
margin: 0 0 0 2px !important;
border-radius: 3px !important;
cursor: pointer !important;
box-sizing: border-box !important;
vertical-align: middle !important;
height: 26px !important;
min-height: 26px !important;
line-height: 20px !important;
white-space: nowrap !important;
transition: background-color 0.1s ease, border-color 0.1s ease !important;
}
/* Checkbox aligned horizontally with the dropdown and button */
#alltoggle,
[name="alltoggle"],
.alltoggle,
#favact input[type="checkbox"],
.eh-favact-top input[type="checkbox"],
#favact.eh-favact-top input[type="checkbox"],
#eh-favact-top input[type="checkbox"] {
margin: 0 5px 0 0 !important;
padding: 0 !important;
vertical-align: middle !important;
position: static !important;
top: 0 !important;
cursor: pointer !important;
align-self: center !important;
}
#favact.eh-favact-top label,
#favact.eh-favact-top span,
.eh-favact-top label,
.eh-favact-top span,
#eh-favact-top label,
#eh-favact-top span {
display: inline-flex !important;
align-items: center !important;
vertical-align: middle !important;
font-size: 9pt !important;
line-height: normal !important;
}
#favact.eh-favact-top div,
.eh-favact-top div,
#eh-favact-top div {
display: inline-flex !important;
vertical-align: middle !important;
font-size: 9pt !important;
line-height: normal !important;
}
/* Light Theme (E-Hentai) */
.eh-theme-light #favact.eh-favact-top select,
.eh-theme-light .eh-favact-top select {
background-color: #EDEADA !important;
color: #5C0D12 !important;
border: 2px solid #B5A4A4 !important;
}
.eh-theme-light #favact.eh-favact-top input[type="submit"],
.eh-theme-light #favact.eh-favact-top input[type="button"],
.eh-theme-light #favact.eh-favact-top input[value="Apply"],
.eh-theme-light #favact.eh-favact-top input[value="Confirm"],
.eh-theme-light #favact.eh-favact-top button,
.eh-theme-light .eh-favact-top input[type="submit"],
.eh-theme-light .eh-favact-top input[type="button"],
.eh-theme-light .eh-favact-top input[value="Apply"],
.eh-theme-light .eh-favact-top input[value="Confirm"],
.eh-theme-light .eh-favact-top button {
background-color: #EDEADA !important;
color: #5C0D12 !important;
border: 2px solid #B5A4A4 !important;
}
.eh-theme-light #favact.eh-favact-top input[type="submit"]:hover,
.eh-theme-light #favact.eh-favact-top input[type="button"]:hover,
.eh-theme-light #favact.eh-favact-top input[value="Apply"]:hover,
.eh-theme-light #favact.eh-favact-top input[value="Confirm"]:hover,
.eh-theme-light #favact.eh-favact-top button:hover,
.eh-theme-light .eh-favact-top input[type="submit"]:hover,
.eh-theme-light .eh-favact-top input[type="button"]:hover,
.eh-theme-light .eh-favact-top input[value="Apply"]:hover,
.eh-theme-light .eh-favact-top input[value="Confirm"]:hover,
.eh-theme-light .eh-favact-top button:hover {
background-color: #F3F0E0 !important;
border-color: #977273 !important;
color: #5C0D12 !important;
}
/* Dark Theme (ExHentai) */
.eh-theme-dark #favact.eh-favact-top select,
.eh-theme-dark .eh-favact-top select {
background-color: #34353b !important;
color: #dddddd !important;
border: 2px solid #8d8d8d !important;
}
.eh-theme-dark #favact.eh-favact-top input[type="submit"],
.eh-theme-dark #favact.eh-favact-top input[type="button"],
.eh-theme-dark #favact.eh-favact-top input[value="Apply"],
.eh-theme-dark #favact.eh-favact-top input[value="Confirm"],
.eh-theme-dark #favact.eh-favact-top button,
.eh-theme-dark .eh-favact-top input[type="submit"],
.eh-theme-dark .eh-favact-top input[type="button"],
.eh-theme-dark .eh-favact-top input[value="Apply"],
.eh-theme-dark .eh-favact-top input[value="Confirm"],
.eh-theme-dark .eh-favact-top button {
background-color: #34353b !important;
color: #dddddd !important;
border: 2px solid #8d8d8d !important;
}
.eh-theme-dark #favact.eh-favact-top input[type="submit"]:hover,
.eh-theme-dark #favact.eh-favact-top input[type="button"]:hover,
.eh-theme-dark #favact.eh-favact-top input[value="Apply"]:hover,
.eh-theme-dark #favact.eh-favact-top input[value="Confirm"]:hover,
.eh-theme-dark #favact.eh-favact-top button:hover,
.eh-theme-dark .eh-favact-top input[type="submit"]:hover,
.eh-theme-dark .eh-favact-top input[type="button"]:hover,
.eh-theme-dark .eh-favact-top input[value="Apply"]:hover,
.eh-theme-dark .eh-favact-top input[value="Confirm"]:hover,
.eh-theme-dark .eh-favact-top button:hover {
background-color: #43464e !important;
border-color: #aeaeae !important;
color: #ffffff !important;
}
`;
function injectStyles() {
const style = document.createElement('style');
style.id = 'eh-fast-reader-core-css';
style.textContent = CORE_CSS;
const root = document.head || document.documentElement;
if (root) {
root.appendChild(style);
} else {
const observer = new MutationObserver(() => {
const el = document.head || document.documentElement;
if (el) {
el.appendChild(style);
applyEarlyClasses(document.documentElement);
observer.disconnect();
}
});
observer.observe(document, { childList: true });
}
}
injectStyles();
const isReaderUrl = location.pathname.startsWith('/s/');
let earlyStripActive = true;
try {
earlyStripActive = localStorage.getItem('eh_strip_mode_enabled') !== '0';
} catch {}
let earlyLayout = 'horizontal';
try {
earlyLayout = localStorage.getItem('eh_strip_layout') || 'horizontal';
} catch {}
const isEarlyDark = location.hostname.includes('exhentai');
function applyEarlyClasses(el) {
if (!el) return;
el.classList.add(isEarlyDark ? 'eh-theme-dark' : 'eh-theme-light');
if (isReaderUrl && earlyStripActive) {
el.classList.add('eh-strip-mode-active', 'strip-' + earlyLayout);
}
}
applyEarlyClasses(document.documentElement);
// =========================================================================
// 1. MAIN-WORLD INJECTION
// =========================================================================
function injectMainWorldEngine() {
const run = () => {
let injected = false;
try {
const script = document.createElement('script');
script.textContent = `(${mainWorldCode.toString()})();`;
(document.head || document.documentElement).appendChild(script);
script.remove();
injected = true;
} catch (e) {
console.warn('ExFastReader: Script injection failed, falling back to direct execution:', e);
}
if (!injected && typeof mainWorldCode === 'function') {
mainWorldCode();
}
};
if (document.head || document.documentElement) {
run();
} else {
const observer = new MutationObserver(() => {
if (document.head || document.documentElement) {
observer.disconnect();
run();
}
});
observer.observe(document, { childList: true });
}
}
injectMainWorldEngine();
// =========================================================================
// 2. INCOGNITO SETTINGS AUTO-SYNC (FAST-LANE)
// =========================================================================
function getCookie(name) {
const matches = document.cookie.match(
new RegExp('(?:^|; )' + name.replace(/([\.$?*|{}\(\)\[\]\/\+^])/g, '\\$1') + '=([^;]*)')
);
return matches ? decodeURIComponent(matches[1]) : undefined;
}
function getSavedUconfig() {
try {
if (typeof GM_getValue === 'function') {
const v = GM_getValue('eh_saved_uconfig');
if (v && typeof v === 'string') return v;
}
} catch {}
try {
const v = localStorage.getItem('eh_saved_uconfig');
if (v && typeof v === 'string') return v;
} catch {}
return null;
}
function setSavedUconfig(val) {
if (!val || typeof val !== 'string') return;
try {
if (typeof GM_setValue === 'function') {
GM_setValue('eh_saved_uconfig', val);
}
} catch {}
try {
localStorage.setItem('eh_saved_uconfig', val);
} catch {}
}
async function checkAndSyncSettings() {
// 1. AUTO-ADVANCE FROM PONI TOKEN:
// When landing on exhentai.org/?poni=..., cookies have already been set by the response headers.
// Strip ?poni= and immediately replace with the clean URL so you don't get stuck waiting for F5.
if (location.search.includes('poni=')) {
const params = new URLSearchParams(location.search);
const poni = params.get('poni');
if (poni) {
const key = 'eh_poni_' + poni.slice(0, 24);
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, '1');
const clean = new URL(location.href);
clean.searchParams.delete('poni');
location.replace(clean.href);
return;
}
}
return;
}
// 2. If stuck on an interstitial bounce page, wait for the DOM and auto-click the redirect link
if (location.pathname.includes('bounce') || location.pathname.includes('login')) {
const followBounce = () => {
const link = document.querySelector('a[href*="poni="], a[href*="exhentai.org"]');
if (link && link.href) {
location.replace(link.href);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', followBounce, { once: true });
} else {
followBounce();
}
return;
}
// If currently on the settings page, capture and save the latest uconfig cookie
if (location.pathname.startsWith('/uconfig')) {
const cur = getCookie('uconfig');
if (cur) setSavedUconfig(cur);
return;
}
const isReader = location.pathname.startsWith('/s/');
const syncCookieName = 'eh_uconfig_synced';
const syncKey = 'eh_uconfig_synced_' + location.hostname;
let memberId, uconfig, syncAttempted;
try {
memberId = getCookie('ipb_member_id');
uconfig = getCookie('uconfig');
syncAttempted = getCookie(syncCookieName) || sessionStorage.getItem(syncKey);
} catch {
return;
}
// Always update saved cache if uconfig is present
if (uconfig) {
setSavedUconfig(uconfig);
return;
}
if (!memberId || memberId === '0' || syncAttempted) {
return;
}
// FAST LANE: If we have a cached uconfig string in storage, inject it instantly without fetching!
const saved = getSavedUconfig();
const cookieDomain = '.' + location.hostname.replace(/^.*?(e-hentai\.org|exhentai\.org)$/, '$1');
if (saved) {
document.cookie = `uconfig=${saved}; path=/; domain=${cookieDomain}; SameSite=Lax; max-age=31536000`;
document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
try { sessionStorage.setItem(syncKey, 'done'); } catch {}
if (!isReader) {
location.replace(location.href);
}
return;
}
// On reader pages, silently sync in background without reloading
if (isReader) {
try {
sessionStorage.setItem(syncKey, 'attempted');
document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
fetch(location.origin + '/uconfig.php', {
method: 'GET',
credentials: 'include',
priority: 'high'
}).then((res) => {
if (res.ok) {
sessionStorage.setItem(syncKey, 'done');
document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
const newU = getCookie('uconfig');
if (newU) setSavedUconfig(newU);
}
}).catch(() => {});
} catch {}
return;
}
// COLD SYNC (First time with no cached uconfig):
// Prevent unstyled flash while fetching
let blocker = null;
try {
blocker = document.createElement('style');
blocker.id = 'eh-fast-sync-blocker';
blocker.textContent = 'html { opacity: 0 !important; }';
(document.head || document.documentElement).appendChild(blocker);
setTimeout(() => { if (blocker && blocker.parentNode) blocker.remove(); }, 1800);
} catch {}
try {
sessionStorage.setItem(syncKey, 'attempted');
document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
const res = await fetch(location.origin + '/uconfig.php', {
method: 'GET',
credentials: 'include',
priority: 'high'
});
if (res.ok) {
sessionStorage.setItem(syncKey, 'done');
document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
const newU = getCookie('uconfig');
if (newU) setSavedUconfig(newU);
location.replace(location.href);
} else if (blocker) {
blocker.remove();
}
} catch {
if (blocker) blocker.remove();
}
}
checkAndSyncSettings();
// =========================================================================
// 3. CORE BACKGROUND ENGINE & BIDIRECTIONAL STRIP READER
// =========================================================================
function mainWorldCode() {
if (window.__exFastReaderLoaded) return;
window.__exFastReaderLoaded = true;
if ('scrollRestoration' in history) {
try { history.scrollRestoration = 'manual'; } catch {}
}
// Skip all reader/index logic entirely on auth, bounce, and handshake URLs
if (
location.search.includes('poni=') ||
location.pathname.includes('bounce') ||
location.pathname.includes('login')
) {
return;
}
const isReaderPage = location.pathname.startsWith('/s/');
const isGalleryPage = location.pathname.startsWith('/g/');
const isIndexPage = !isReaderPage && !isGalleryPage && (
location.pathname === '/' ||
location.pathname === '' ||
location.pathname.startsWith('/watched') ||
location.pathname.startsWith('/favorites') ||
location.pathname.startsWith('/popular') ||
location.pathname.startsWith('/tag/')
);
const PREFETCH_DEPTH = 3;
const IMAGE_TIMEOUT_MS = 10000;
const MAX_RETRIES = 3;
const MAX_CONCURRENT_LOADS = 2;
function safeStorageGet(storage, key, fallback = null) {
try {
const val = storage.getItem(key);
return val !== null ? val : fallback;
} catch {
return fallback;
}
}
function safeStorageSet(storage, key, val) {
try {
storage.setItem(key, val);
} catch {}
}
function safeStorageRemove(storage, key) {
try {
storage.removeItem(key);
} catch {}
}
const pageRespCache = new Map();
const pageImgKeyCache = new Map();
const pageImgSrcCache = new Map();
const pageNlCodes = new Map();
const preloadedImages = new Map();
const inFlightFetches = new Map();
const inFlightGalleryFetches = new Map();
const fetchedGalleryPages = new Map();
const pageDimensions = new Map();
const pageItemMap = new Map();
const loadedImages = new Set();
const loadingPages = new Map(); // pageNo -> startTime
const failedPages = new Map();
const reloadingPages = new Set();
const activeRetryTimers = new Map();
let prefetchThumbTimer = null;
let prefetchMetaTimer = null;
let pumpObserverTimer = null;
function isPageInActiveWindow(pageNo, margin = 6) {
if (!stripActive || !pageNo) return false;
const cur = currentPageNum || 1;
const visible = getVisiblePageNums();
if (visible.includes(pageNo)) return true;
const minP = Math.min(...visible, cur) - margin;
const maxP = Math.max(...visible, cur) + margin;
return pageNo >= minP && pageNo <= maxP;
}
function abortPageLoad(pageNo) {
if (!pageNo) return;
if (activeRetryTimers.has(pageNo)) {
clearTimeout(activeRetryTimers.get(pageNo));
activeRetryTimers.delete(pageNo);
}
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
const item = pageItemMap.get(pageNo);
if (item) {
item.classList.remove('eh-loading');
if (typeof item._abortImg === 'function') {
item._abortImg();
item._abortImg = null;
} else if (item._currentImgToken) {
const img = item._currentImgToken;
img.onload = null;
img.onerror = null;
img.removeAttribute('src');
img.src = '';
item._currentImgToken = null;
}
}
}
// ---- Request discipline -------------------------------------------------
// Fast scrolling used to start a download for every page that flew past, which hammered the
// server (errors / image limits) and left the page you finally stopped on waiting in line.
// Global Circuit Breaker & Anti-Flood Manager to prevent request/retry storms
const apiCircuitBreaker = {
backoffUntil: 0,
backoffDelay: 2000,
consecutiveErrors: 0,
lastErrorMsg: '',
isBlocked(isPriority = false) {
const now = Date.now();
if (now < this.backoffUntil) {
if (!isPriority) return true;
// Priority requests wait or bypass only if remaining backoff is small (< 3.5s)
return (this.backoffUntil - now) > 3500;
}
return false;
},
recordSuccess() {
this.consecutiveErrors = 0;
this.backoffDelay = 2000;
this.lastErrorMsg = '';
},
recordError(status, errorText = '') {
this.consecutiveErrors++;
const now = Date.now();
const errStr = String(errorText || '');
const isRateLimit = status === 429 || status === 509 ||
/too fast|limit exceeded|rate limit|excessive|banned|quota|flood/i.test(errStr);
const isServerDown = status === 503 || status === 502 || status === 504;
if (isRateLimit) {
this.backoffDelay = Math.min(60000, Math.max(8000, this.backoffDelay * 2));
this.backoffUntil = now + this.backoffDelay;
this.lastErrorMsg = errStr || (status === 509 ? 'Image/Bandwidth Limit Exceeded (509)' : 'Rate limit exceeded (429)');
if (typeof showFailureToast === 'function') {
showFailureToast(`[Ex Fast Reader] ${this.lastErrorMsg}. Requests paused for ${Math.round(this.backoffDelay / 1000)}s.`);
}
console.warn(`[ExFastReader] Rate limit encountered (${status}, "${errStr}"). Backoff: ${this.backoffDelay}ms`);
} else if (isServerDown) {
this.backoffDelay = Math.min(30000, Math.max(4000, this.backoffDelay * 1.5));
this.backoffUntil = now + this.backoffDelay;
console.warn(`[ExFastReader] Server unavailable (${status}). Backoff: ${this.backoffDelay}ms`);
} else if (this.consecutiveErrors >= 3) {
this.backoffDelay = Math.min(20000, 2500 * Math.pow(1.5, this.consecutiveErrors - 3));
this.backoffUntil = now + this.backoffDelay;
console.warn(`[ExFastReader] ${this.consecutiveErrors} consecutive errors. Backoff: ${Math.round(this.backoffDelay)}ms`);
}
},
reset() {
this.backoffUntil = 0;
this.consecutiveErrors = 0;
this.backoffDelay = 2000;
this.lastErrorMsg = '';
}
};
function makeLimiter(max, minInterval = 40) {
let active = 0;
let lastTime = 0;
let timer = null;
const queue = [];
function pump() {
timer = null;
while (active < max && queue.length > 0) {
const now = Date.now();
const elapsed = now - lastTime;
if (elapsed < minInterval) {
if (!timer) {
timer = setTimeout(pump, minInterval - elapsed);
}
return;
}
lastTime = Date.now();
active++;
const next = queue.shift();
if (next) next();
}
}
return {
acquire(priority) {
if (active < max && queue.length === 0) {
const now = Date.now();
if (now - lastTime >= minInterval) {
lastTime = now;
active++;
return Promise.resolve();
}
}
return new Promise((resolve) => {
if (priority) queue.unshift(resolve);
else queue.push(resolve);
pump();
});
},
release() {
active = Math.max(0, active - 1);
pump();
}
};
}
const apiLimiter = makeLimiter(3, 50);
const galleryLimiter = makeLimiter(2, 60);
const SETTLE_MS = 180;
let lastPageChangeAt = 0;
const pageChangeTimes = [];
let settleTimer = null;
function notePageChange() {
const now = Date.now();
lastPageChangeAt = now;
pageChangeTimes.push(now);
if (pageChangeTimes.length > 12) pageChangeTimes.shift();
}
// True while the user is flicking through pages and hasn't stopped yet
function isFastScrolling() {
const now = Date.now();
if (now - lastPageChangeAt > SETTLE_MS) return false;
let n = 0;
for (const t of pageChangeTimes) if (now - t <= 600) n++;
return n >= 3;
}
function scheduleSettleCheck() {
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = null;
if (stripActive) {
checkAndLoadNearbyPages();
} else {
prefetchSingleMode(currentPageNum);
}
}, SETTLE_MS + 30);
}
// Tight "still wanted" window used to cancel stale downloads
function isPageNeeded(p, visible, cur) {
if (visible.includes(p)) return true;
const ahead = PREFETCH_DEPTH + 1;
const lo = Math.min(...visible, cur) - (scrollDirection === 'backward' ? ahead : 2);
const hi = Math.max(...visible, cur) + (scrollDirection === 'backward' ? 2 : ahead);
return p >= lo && p <= hi;
}
const pageTopBarInfoCache = new Map();
const pageFileSizeCache = new Map();
const downloadSpeedSamples = [];
const MAX_SPEED_SAMPLES = 8;
const pageThumbnailCache = new Map();
const pageDisplayDims = new Map();
// "originalW x originalH" -> served display size. Pages with the same original size are served at
// exactly the same size, so a placeholder can reuse it with no rounding at all.
const displayByOriginal = new Map();
// The server resizes originals down to the resolution cap; whether it floors, rounds or ceils the
// height is unknown, so learn it from every page where original and served sizes are both known.
const roundVotes = { floor: 0, round: 0, ceil: 0 };
function serverRound(v) {
let mode = 'round';
let best = roundVotes.round;
if (roundVotes.floor > best) { mode = 'floor'; best = roundVotes.floor; }
if (roundVotes.ceil > best) { mode = 'ceil'; best = roundVotes.ceil; }
return mode === 'floor' ? Math.floor(v) : (mode === 'ceil' ? Math.ceil(v) : Math.round(v));
}
// What each placeholder was sized with, so a wrong guess is logged when the real size arrives
const placeholderGuess = new Map();
const pageBottomCache = new Map();
// =======================================================================
// THUMBNAIL SUBSYSTEM
// The gallery gives us either sprite sheets (many thumbs per image; each page is a region
// x/y/w/h inside it) or one small image per page. Either way the "sheet" image is loaded ONCE,
// kept in a small LRU, and every placeholder gets its own tiny <canvas> with only its region
// drawn into it. Nothing ever scales a whole sheet by a huge percentage, and nothing depends on
// knowing the sheet's size up front (which is what used to produce stretched placeholders).
// =======================================================================
const THUMB_SHEET_LIMIT = 90;
const thumbSheets = new Map(); // url -> { url, img, ready, failedAt, tries, waiters:Set<page> }
const thumbLimiter = makeLimiter(4);
function startThumbSheetLoad(entry, high) {
entry.ready = false;
entry.failedAt = 0;
if (entry.queued) return;
entry.queued = true;
// Bounded concurrency; the newest high-priority request (the page you are on) goes first
thumbLimiter.acquire(high).then(() => {
let freed = false;
const free = () => {
if (!freed) { freed = true; entry._free = null; thumbLimiter.release(); }
};
entry.queued = false;
if (thumbSheets.get(entry.url) !== entry) { free(); return; } // evicted while waiting
const img = new Image();
img.decoding = 'async';
img.fetchPriority = high ? 'high' : 'low';
entry.img = img;
entry._free = free;
img.onload = () => {
img.onload = null;
img.onerror = null;
free();
if (thumbSheets.get(entry.url) !== entry || entry.img !== img) return;
if (!(img.naturalWidth > 0 && img.naturalHeight > 0)) {
failThumbSheet(entry);
return;
}
entry.ready = true;
entry.tries = 0;
const pages = Array.from(entry.waiters);
entry.waiters.clear();
for (const p of pages) notifyThumbReady(p);
};
img.onerror = () => {
img.onload = null;
img.onerror = null;
free();
if (thumbSheets.get(entry.url) !== entry || entry.img !== img) return;
failThumbSheet(entry);
};
img.src = entry.url;
});
}
function failThumbSheet(entry) {
entry.ready = false;
entry.tries += 1;
if (entry.tries <= 2) {
setTimeout(() => {
if (thumbSheets.get(entry.url) === entry && !entry.ready) startThumbSheetLoad(entry, true);
}, 800 * entry.tries);
} else {
entry.failedAt = Date.now();
}
}
function releaseThumbSheet(entry) {
if (!entry) return;
if (entry._free) entry._free();
if (!entry.img) return;
entry.img.onload = null;
entry.img.onerror = null;
entry.img.removeAttribute('src');
entry.img.src = '';
entry.ready = false;
}
function getThumbSheet(url, high = false) {
if (!url) return null;
let entry = thumbSheets.get(url);
if (entry) {
// refresh LRU position
thumbSheets.delete(url);
thumbSheets.set(url, entry);
if (!entry.ready && entry.failedAt && Date.now() - entry.failedAt > 15000) {
entry.tries = 0;
startThumbSheetLoad(entry, high);
}
return entry;
}
entry = { url, img: null, ready: false, failedAt: 0, tries: 0, queued: false, _free: null, waiters: new Set() };
thumbSheets.set(url, entry);
startThumbSheetLoad(entry, high);
while (thumbSheets.size > THUMB_SHEET_LIMIT) {
const oldKey = thumbSheets.keys().next().value;
releaseThumbSheet(thumbSheets.get(oldKey));
thumbSheets.delete(oldKey);
}
return entry;
}
let lastWarmPage = -1;
function warmThumbsAround(page, force = false) {
if (!isReaderPage || !page) return;
if (stripActive && isFastScrolling()) return; // flicking past: only load what is actually shown
if (!force && page === lastWarmPage) return;
lastWarmPage = page;
const lo = Math.max(1, page - 8);
const hi = page + 24;
for (let p = lo; p <= hi; p++) {
const info = pageThumbnailCache.get(p);
if (info && info.url) getThumbSheet(info.url, Math.abs(p - page) <= 3);
}
}
function createThumbCanvas(info, entry) {
const img = entry.img;
if (!img || !(img.naturalWidth > 0 && img.naturalHeight > 0)) return null;
let sx = 0, sy = 0, sw = img.naturalWidth, sh = img.naturalHeight;
if (info.type === 'sprite') {
sx = info.x || 0;
sy = info.y || 0;
sw = info.w;
sh = info.h;
if (!(sw > 0 && sh > 0)) return null;
if (sx >= img.naturalWidth || sy >= img.naturalHeight) return null;
// Never read outside the sheet: a bad offset must not smear into a neighbour
sw = Math.min(sw, img.naturalWidth - sx);
sh = Math.min(sh, img.naturalHeight - sy);
if (!(sw > 0 && sh > 0)) return null;
}
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(sw));
canvas.height = Math.max(1, Math.round(sh));
canvas.className = 'eh-placeholder-thumb-img eh-thumb-canvas';
try {
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
} catch {
return null;
}
return canvas;
}
// Puts the page's thumbnail into `host` (a strip item or the single-page placeholder wrapper).
// Idempotent. Returns true when the thumbnail is on screen, false if it is not available yet
// (in which case the page is queued and repainted as soon as its sheet has loaded).
function paintThumb(host, page) {
if (!host || !page) return false;
const info = pageThumbnailCache.get(page);
if (!info || !info.url) return false;
const existing = host.querySelector(':scope > .eh-placeholder-thumb-wrap');
const keyOf = () => [page, info.url, info.x || 0, info.y || 0, info.w || 0, info.h || 0].join('|');
if (existing && host._thumbKey === keyOf()) return true;
const entry = getThumbSheet(info.url, Math.abs(page - (currentPageNum || page)) <= 3);
if (!entry) return false;
if (!entry.ready) {
entry.waiters.add(page);
return false;
}
const canvas = createThumbCanvas(info, entry);
if (!canvas) return false;
// Single-image thumbnails only learn their size once loaded
let learnedAr = 0;
if (info.type === 'url') {
if (entry.img.naturalWidth > 0 && entry.img.naturalHeight > 0) {
info.w = entry.img.naturalWidth;
info.h = entry.img.naturalHeight;
learnedAr = info.w / info.h;
}
}
// Fill the placeholder exactly (identical to how the final image is fitted). Only if the
// thumbnail's ratio is clearly off (> 6%) crop instead of stretching it.
const boxAr = (pageDimensions.get(page) || {}).aspectRatio || 0;
const thumbAr = canvas.width / canvas.height;
canvas.style.objectFit = (boxAr > 0 && Math.abs(thumbAr / boxAr - 1) > 0.06) ? 'cover' : 'fill';
const wrap = document.createElement('div');
wrap.className = 'eh-placeholder-thumb-wrap';
wrap.appendChild(canvas);
if (existing) {
existing.replaceWith(wrap);
} else {
const content = host.querySelector(':scope > .eh-placeholder-content');
host.insertBefore(wrap, content || null);
}
host._thumbKey = keyOf();
if (learnedAr > 0) updateThumbnailAspectRatio(page, learnedAr);
return true;
}
// A thumbnail (or its sheet) just became available for this page: paint it wherever it is shown.
function notifyThumbReady(page) {
const item = pageItemMap.get(page);
if (item && item.classList.contains('eh-placeholder') && !item.querySelector('img.eh-main-img')) {
applyItemDimensions(item, page);
paintThumb(item, page);
}
if (!stripActive && page === currentPageNum) {
showSinglePageThumbnailPlaceholder(page);
}
}
let maxDisplayW = 0;
let maxDisplayH = 0;
let lastExactW = parseInt(safeStorageGet(sessionStorage, 'eh_last_exact_w', '0'), 10) || 0;
if (lastExactW < 400) lastExactW = 0;
let lastExactH = parseInt(safeStorageGet(sessionStorage, 'eh_last_exact_h', '0'), 10) || 0;
if (lastExactH < 400) lastExactH = 0;
// Width cap the site applies to served images (your "image resolution" setting).
// Remembered for the session so even the very first placeholder is the right size.
let displayCapW = parseInt(safeStorageGet(sessionStorage, 'eh_display_cap_w', '0'), 10) || 0;
if (displayCapW < 400) displayCapW = 0;
let cachedGid = null;
let cachedShowkey = null;
let cachedApiUrl = null;
let lastSyncedIndicatorPage = -1;
let thumbsPerPage = parseInt(safeStorageGet(localStorage, 'eh_thumbs_per_page', '0'), 10) || 40;
let defaultAspectRatio = 0.707;
let detectedDisplayLimit = 0;
let cachedTotalPages = 0;
let stallTimer = null;
let isReloading = false;
let currentPageNum = (function () {
if (typeof isReaderPage !== 'undefined' && isReaderPage) {
const m = location.pathname.match(/\/s\/[0-9a-zA-Z]+\/\d+-(\d+)/i);
if (m) return parseInt(m[1], 10) || 1;
if (typeof window.currentpage === 'number' && window.currentpage > 0) return window.currentpage;
if (typeof window.startpage === 'number' && window.startpage > 0) return window.startpage;
}
return 1;
})();
let lastSingleTurnTime = 0;
function parseFileSizeInBytes(rawStr) {
if (!rawStr || typeof rawStr !== 'string') return 0;
const parts = rawStr.split('::');
const targetStr = parts.length >= 3 ? parts[2] : rawStr;
const m = targetStr.match(/(\d+(?:\.\d+)?)\s*([kKMmGg]?[iI]?[bB]+)/i) ||
rawStr.match(/(\d+(?:\.\d+)?)\s*([kKMmGg][iI]?[bB]+)/i);
if (!m) return 0;
const num = parseFloat(m[1]);
if (isNaN(num) || num <= 0) return 0;
const unit = m[2].toUpperCase();
if (unit.startsWith('G')) return Math.round(num * 1024 * 1024 * 1024);
if (unit.startsWith('M')) return Math.round(num * 1024 * 1024);
if (unit.startsWith('K')) return Math.round(num * 1024);
if (unit.startsWith('B')) return Math.round(num);
return 0;
}
function getPageFileSize(pageNo) {
if (!pageNo) return 1.5 * 1024 * 1024;
if (pageFileSizeCache.has(pageNo)) {
const cached = pageFileSizeCache.get(pageNo);
if (cached > 0) return cached;
}
const topInfo = pageTopBarInfoCache.get(pageNo);
if (topInfo) {
const sz = parseFileSizeInBytes(topInfo);
if (sz > 0) {
pageFileSizeCache.set(pageNo, sz);
return sz;
}
}
if (pageRespCache.has(pageNo)) {
const resp = pageRespCache.get(pageNo);
const candidates = [resp.i, resp.d, resp.i5, resp.i2, resp.i4, resp.i1, resp.i3];
for (const c of candidates) {
if (!c) continue;
const sz = parseFileSizeInBytes(c);
if (sz > 0) {
pageFileSizeCache.set(pageNo, sz);
return sz;
}
}
}
if (pageNo === currentPageNum) {
const raw = (document.getElementById('i2')?.textContent || '') + ' ' +
(document.getElementById('i4')?.textContent || '') + ' ' +
(document.getElementById('i1')?.textContent || '');
const sz = parseFileSizeInBytes(raw);
if (sz > 0) {
pageFileSizeCache.set(pageNo, sz);
return sz;
}
}
const dim = pageDimensions.get(pageNo);
if (dim && dim.width > 0 && dim.height > 0) {
const estimated = Math.round(dim.width * dim.height * 0.35);
if (estimated > 100 * 1024) {
return estimated;
}
}
return 1.5 * 1024 * 1024;
}
function recordDownloadSpeedSample(pageNo, durationSec) {
if (!durationSec || durationSec <= 0.05) return;
const sizeBytes = getPageFileSize(pageNo);
if (!sizeBytes || sizeBytes <= 0) return;
const speed = sizeBytes / durationSec;
if (speed <= 0 || !isFinite(speed)) return;
downloadSpeedSamples.push({ speed, timestamp: Date.now() });
while (downloadSpeedSamples.length > MAX_SPEED_SAMPLES) {
downloadSpeedSamples.shift();
}
const currentSpeed = getCurrentDownloadSpeed();
safeStorageSet(sessionStorage, 'eh_last_speed', String(Math.round(currentSpeed)));
}
function getCurrentDownloadSpeed() {
const now = Date.now();
while (downloadSpeedSamples.length > 0 && now - downloadSpeedSamples[0].timestamp > 300000) {
downloadSpeedSamples.shift();
}
if (downloadSpeedSamples.length > 0) {
const sorted = downloadSpeedSamples.map(s => s.speed).sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const medianSpeed = sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
return Math.max(100 * 1024, medianSpeed);
}
const saved = parseInt(safeStorageGet(sessionStorage, 'eh_last_speed', '0'), 10);
if (saved > 50 * 1024 && saved < 500 * 1024 * 1024) {
return saved;
}
if (typeof navigator !== 'undefined' && navigator.connection && typeof navigator.connection.downlink === 'number' && navigator.connection.downlink > 0) {
const bps = (navigator.connection.downlink * 1000 * 1000) / 8;
return Math.max(200 * 1024, bps);
}
return 1.5 * 1024 * 1024;
}
function calculateImageTimeout(pageNo) {
const fileSize = getPageFileSize(pageNo);
const speed = getCurrentDownloadSpeed();
const expectedTransferSec = fileSize / speed;
const baseLatencySec = 4.0;
const slowTimeoutMs = Math.max(6000, Math.round((baseLatencySec + expectedTransferSec * 2.2) * 1000));
const hardTimeoutMs = Math.max(12000, Math.round((baseLatencySec * 1.5 + expectedTransferSec * 4.0) * 1000));
const watchdogTimeoutMs = hardTimeoutMs + 5000;
return {
fileSize,
speed,
expectedTransferSec,
slowTimeoutMs,
hardTimeoutMs,
watchdogTimeoutMs
};
}
function normalizeApiUrl(url) {
const defaultApi = location.origin + '/api.php';
if (!url) return defaultApi;
try {
const parsed = new URL(url, location.origin);
const isAllowed =
parsed.origin === location.origin ||
parsed.hostname === 'api.e-hentai.org' ||
parsed.hostname === 'e-hentai.org' ||
parsed.hostname === 'exhentai.org' ||
parsed.hostname.endsWith('.e-hentai.org') ||
parsed.hostname.endsWith('.exhentai.org');
if (!isAllowed) {
return defaultApi;
}
return parsed.href;
} catch {
return defaultApi;
}
}
function safeParseJson(str) {
if (!str || typeof str !== 'string') return null;
const trimmed = str.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null;
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
let _currentApiUrl = location.origin + '/api.php';
if (isReaderPage) {
try {
if (window.api_url) {
_currentApiUrl = normalizeApiUrl(window.api_url);
}
Object.defineProperty(window, 'api_url', {
configurable: true,
enumerable: true,
get() {
return _currentApiUrl;
},
set(val) {
_currentApiUrl = normalizeApiUrl(val);
}
});
} catch (e) {
window.api_url = _currentApiUrl;
}
if (typeof window.api_response === 'function' && !window.api_response.__eh_safe) {
const origApiResponse = window.api_response;
window.api_response = function (xhr) {
try {
if (xhr && typeof xhr.responseText === 'string') {
const parsed = safeParseJson(xhr.responseText);
if (parsed) return parsed;
return { error: xhr.responseText.trim() || 'Invalid response' };
}
return origApiResponse.apply(this, arguments);
} catch {
return { error: 'Invalid JSON response' };
}
};
window.api_response.__eh_safe = true;
}
}
function pruneGalleryLocalStorage(newGid) {
try {
let list = JSON.parse(safeStorageGet(localStorage, 'eh_gdata_recent', '[]') || '[]');
list = list.filter(id => id !== newGid);
list.unshift(newGid);
while (list.length > 8) {
const oldGid = list.pop();
safeStorageRemove(localStorage, 'eh_gdata_' + oldGid);
safeStorageRemove(localStorage, 'eh_gallery_' + oldGid);
safeStorageRemove(sessionStorage, 'eh_gdata_' + oldGid);
safeStorageRemove(sessionStorage, 'eh_gallery_' + oldGid);
}
safeStorageSet(localStorage, 'eh_gdata_recent', JSON.stringify(list));
} catch {}
}
const GDATA_VERSION = 3;
function saveGalleryDataToSession(gid) {
if (!gid) return;
try {
const prevStr = safeStorageGet(sessionStorage, 'eh_gdata_' + gid) || safeStorageGet(localStorage, 'eh_gdata_' + gid);
const prev = prevStr ? JSON.parse(prevStr) : {
gid,
totalPages: cachedTotalPages || 0,
thumbsPerPage: thumbsPerPage || 40,
keys: {},
dims: {},
info: {},
thumbs: {},
spriteDims: {}
};
if (prev.v !== GDATA_VERSION) {
// Older data may hold guessed thumbnail sizes or corrupted dims: start those parts clean
prev.v = GDATA_VERSION;
prev.dims = {};
prev.thumbs = {};
prev.spriteDims = {};
}
if (cachedTotalPages) prev.totalPages = cachedTotalPages;
if (thumbsPerPage) prev.thumbsPerPage = thumbsPerPage;
for (const [p, k] of pageImgKeyCache.entries()) {
prev.keys[p] = k;
}
for (const [p, d] of pageDimensions.entries()) {
prev.dims[p] = { w: d.width, h: d.height, ar: d.aspectRatio };
}
for (const [p, inf] of pageTopBarInfoCache.entries()) {
prev.info[p] = inf;
}
if (!prev.fileSizes) prev.fileSizes = {};
for (const [p, sz] of pageFileSizeCache.entries()) {
prev.fileSizes[p] = sz;
}
if (displayCapW) prev.displayCapW = displayCapW;
if (lastExactW) prev.lastExactW = lastExactW;
if (lastExactH) prev.lastExactH = lastExactH;
for (const [p, th] of pageThumbnailCache.entries()) {
if (!th || !th.url) continue;
if (th.type === 'sprite' && !(th.w > 0 && th.h > 0)) continue;
prev.thumbs[p] = th;
}
delete prev.spriteDims;
const serialized = JSON.stringify(prev);
safeStorageSet(sessionStorage, 'eh_gdata_' + gid, serialized);
safeStorageSet(localStorage, 'eh_gdata_' + gid, serialized);
pruneGalleryLocalStorage(gid);
} catch (err) {
console.warn('ExFastReader: Failed to save gallery session data', err);
}
}
function loadGalleryDataFromSession(gid) {
if (!gid) return false;
try {
let raw = safeStorageGet(sessionStorage, 'eh_gdata_' + gid);
if (!raw) {
raw = safeStorageGet(localStorage, 'eh_gdata_' + gid);
}
if (!raw) return false;
const data = JSON.parse(raw);
if (!data) return false;
if (data.v !== GDATA_VERSION) {
// Legacy data: thumbnails/sheet sizes or corrupted dims: start clean
data.dims = null;
data.thumbs = null;
data.spriteDims = null;
}
if (data.totalPages && data.totalPages > cachedTotalPages) {
cachedTotalPages = data.totalPages;
}
if (data.thumbsPerPage) {
thumbsPerPage = data.thumbsPerPage;
}
if (data.keys) {
for (const [p, k] of Object.entries(data.keys)) {
pageImgKeyCache.set(parseInt(p, 10), k);
}
}
if (data.dims) {
for (const [p, d] of Object.entries(data.dims)) {
const page = parseInt(p, 10);
const w = (d.w && d.w >= 400) ? d.w : 0;
const h = (d.h && d.h >= 400) ? d.h : 0;
if (!pageDimensions.has(page) || pageDimensions.get(page).width === 0) {
pageDimensions.set(page, { width: w, height: h, aspectRatio: d.ar });
}
}
}
if (data.info) {
for (const [p, inf] of Object.entries(data.info)) {
pageTopBarInfoCache.set(parseInt(p, 10), inf);
}
}
if (data.fileSizes) {
for (const [p, sz] of Object.entries(data.fileSizes)) {
pageFileSizeCache.set(parseInt(p, 10), sz);
}
}
if (data.displayCapW && (!displayCapW || displayCapW < 400)) {
if (data.displayCapW >= 400) {
displayCapW = data.displayCapW;
safeStorageSet(sessionStorage, 'eh_display_cap_w', String(displayCapW));
}
}
if (data.lastExactW && (!lastExactW || lastExactW < 400)) {
if (data.lastExactW >= 400) {
lastExactW = data.lastExactW;
lastExactH = data.lastExactH;
safeStorageSet(sessionStorage, 'eh_last_exact_w', String(lastExactW));
safeStorageSet(sessionStorage, 'eh_last_exact_h', String(lastExactH));
}
}
if (data.thumbs) {
for (const [p, th] of Object.entries(data.thumbs)) {
const page = parseInt(p, 10);
if (!th || !th.url) continue;
if (th.type === 'sprite' && !(th.w > 0 && th.h > 0)) continue;
pageThumbnailCache.set(page, th);
}
}
if (isReaderPage) {
try { warmThumbsAround(getCurrentPageMeta().page || 1, true); } catch {}
}
return true;
} catch (err) {
console.warn('ExFastReader: Failed to load gallery session data', err);
return false;
}
}
let stripActive = safeStorageGet(localStorage, 'eh_strip_mode_enabled', '1') !== '0';
let currentLayout = safeStorageGet(localStorage, 'eh_strip_layout', 'horizontal');
function setStripClasses(active, layout) {
const root = document.documentElement;
const body = document.body;
const targets = body ? [root, body] : [root];
for (const el of targets) {
if (!el) continue;
if (active) {
el.classList.add('eh-strip-mode-active');
el.classList.remove('strip-horizontal', 'strip-vertical');
el.classList.add('strip-' + layout);
} else {
el.classList.remove('eh-strip-mode-active', 'strip-horizontal', 'strip-vertical', 'eh-topbar-visible');
}
}
}
if (isReaderPage) {
setStripClasses(stripActive, currentLayout);
if (!document.body && stripActive) {
let bodyObsTimer = null;
const bodyObs = new MutationObserver(() => {
if (document.body) {
if (bodyObsTimer) clearTimeout(bodyObsTimer);
setStripClasses(stripActive, currentLayout);
bodyObs.disconnect();
}
});
bodyObs.observe(document.documentElement, { childList: true });
bodyObsTimer = setTimeout(() => {
bodyObs.disconnect();
}, 10000);
}
}
const mInitialGidMatch = location.pathname.match(/\/s\/[0-9a-zA-Z]+\/(\d+)-/i) || location.pathname.match(/\/g\/(\d+)\//i);
if (mInitialGidMatch) {
cachedGid = parseInt(mInitialGidMatch[1], 10);
loadGalleryDataFromSession(cachedGid);
if (isReaderPage) {
const initMeta = getCurrentPageMeta();
currentPageNum = initMeta.page || 1;
if (initMeta.imgKey) {
pageImgKeyCache.set(currentPageNum, initMeta.imgKey);
}
const pDim = pageDimensions.get(currentPageNum) || (pageDimensions.size > 0 ? pageDimensions.values().next().value : null);
if (pDim && pDim.aspectRatio) {
defaultAspectRatio = pDim.aspectRatio;
if (stripActive) applyGlobalAspectRatio(defaultAspectRatio);
}
}
}
let lastStripTurnTime = 0;
let scrollRafId = null;
let lastHistoryPage = -1;
let historyDebounceTimer = null;
let lastUpdatePage = -1;
let isPumpingQueue = false;
let isTransitioningLayout = false;
let isAnchoring = false;
let targetScrollLeft = null;
let animScrollRaf = null;
let targetScrollTop = null;
let animScrollTopRaf = null;
let cachedFrontUrl = location.origin + '/';
let cachedOnionUrl = '';
let cachedGalleryUrl = '';
let scrollDirection = 'forward';
let lastScrollY = typeof window !== 'undefined' ? window.scrollY : 0;
let lastScrollLeft = 0;
let stripItemObserver = null;
function schedulePumpNearbyPages() {
if (pumpObserverTimer) return;
pumpObserverTimer = setTimeout(() => {
pumpObserverTimer = null;
if (stripActive) checkAndLoadNearbyPages();
}, 40);
}
function getStripItemObserver() {
if (!stripItemObserver && typeof IntersectionObserver !== 'undefined') {
stripItemObserver = new IntersectionObserver((entries) => {
let shouldPump = false;
for (const entry of entries) {
if (entry.isIntersecting) {
const p = parseInt(entry.target.dataset.page, 10);
if (p) {
if (entry.target.classList.contains('eh-placeholder') && !entry.target.querySelector('.eh-placeholder-thumb-wrap')) {
applyThumbnailToPlaceholder(entry.target, p);
}
if (isPageEligibleToLoad(p)) {
shouldPump = true;
}
}
}
}
if (shouldPump) {
schedulePumpNearbyPages();
}
}, {
root: currentLayout === 'horizontal' ? document.getElementById('eh-strip-container') : null,
rootMargin: '350px'
});
}
return stripItemObserver;
}
function cancelKeyboardAnimation() {
if (animScrollRaf) {
cancelAnimationFrame(animScrollRaf);
animScrollRaf = null;
}
if (animScrollTopRaf) {
cancelAnimationFrame(animScrollTopRaf);
animScrollTopRaf = null;
}
targetScrollLeft = null;
targetScrollTop = null;
}
function withScrollAnchor(fn, targetPageNo = null) {
if (!stripActive || isAnchoring || isTransitioningLayout) {
fn();
return;
}
const container = document.getElementById('eh-strip-container');
if (!container) {
fn();
return;
}
if (targetPageNo !== null && targetPageNo > currentPageNum) {
fn();
return;
}
// If user is actively scrolling, don't intervene with scroll modifications
if (Date.now() - lastScrollTime < 350 || isFastScrolling()) {
fn();
return;
}
isAnchoring = true;
try {
let anchorEl = null;
let anchorRightBefore = 0;
let anchorTopBefore = 0;
const vpX = window.innerWidth / 2;
const vpY = window.innerHeight / 2;
let closestDist = Infinity;
for (const [p, item] of pageItemMap.entries()) {
if (Math.abs(p - currentPageNum) > 3) continue;
if (!item || !item.isConnected) continue;
const r = item.getBoundingClientRect();
if (currentLayout === 'horizontal') {
if (r.right > 0 && r.left < window.innerWidth) {
const center = (r.left + r.right) / 2;
const dist = Math.abs(center - vpX);
if (dist < closestDist) {
closestDist = dist;
anchorEl = item;
}
}
} else {
if (r.bottom > 0 && r.top < window.innerHeight) {
const center = (r.top + r.bottom) / 2;
const dist = Math.abs(center - vpY);
if (dist < closestDist) {
closestDist = dist;
anchorEl = item;
}
}
}
}
if (!anchorEl) {
anchorEl = pageItemMap.get(currentPageNum);
}
if (anchorEl && anchorEl.isConnected) {
const r = anchorEl.getBoundingClientRect();
anchorRightBefore = r.right;
anchorTopBefore = r.top;
}
fn();
if (anchorEl && anchorEl.isConnected && (anchorRightBefore || anchorTopBefore)) {
const rAfter = anchorEl.getBoundingClientRect();
if (currentLayout === 'horizontal') {
const deltaX = rAfter.right - anchorRightBefore;
if (Math.abs(deltaX) >= 2.0) {
const roundX = Math.round(deltaX);
container.scrollLeft += roundX;
lastScrollLeft = container.scrollLeft;
if (targetScrollLeft !== null) {
targetScrollLeft += roundX;
const maxScroll = 0;
const minScroll = -(container.scrollWidth - container.clientWidth);
targetScrollLeft = Math.max(minScroll, Math.min(maxScroll, targetScrollLeft));
}
}
} else {
const deltaY = rAfter.top - anchorTopBefore;
if (Math.abs(deltaY) >= 2.0) {
const roundY = Math.round(deltaY);
window.scrollBy(0, roundY);
lastScrollY = window.scrollY;
if (targetScrollTop !== null) {
targetScrollTop += roundY;
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
}
}
}
}
} finally {
isAnchoring = false;
}
}
function scrollByEvenIncrement(delta) {
if (typeof disarmSingleAlign === 'function') disarmSingleAlign();
if (delta < 0) {
scrollDirection = 'backward';
} else if (delta > 0) {
scrollDirection = 'forward';
}
if (stripActive && currentLayout === 'horizontal') {
const container = document.getElementById('eh-strip-container');
if (!container) return;
const maxScroll = 0;
const minScroll = -(container.scrollWidth - container.clientWidth);
if (targetScrollLeft === null || isNaN(targetScrollLeft)) {
targetScrollLeft = container.scrollLeft;
}
targetScrollLeft -= delta;
if (targetScrollLeft > maxScroll) targetScrollLeft = maxScroll;
if (targetScrollLeft < minScroll) targetScrollLeft = minScroll;
animateHorizontalScroll(container);
} else {
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
let targetTop = (targetScrollTop !== null && !isNaN(targetScrollTop)) ? targetScrollTop : window.scrollY;
targetTop += delta;
if (targetTop < 0) targetTop = 0;
if (targetTop > maxScroll) targetTop = maxScroll;
targetScrollTop = targetTop;
animateVerticalScroll();
}
}
function animateHorizontalScroll(container) {
if (animScrollRaf) return;
function step() {
if (targetScrollLeft === null) {
animScrollRaf = null;
return;
}
const current = container.scrollLeft;
const diff = targetScrollLeft - current;
if (Math.abs(diff) <= 1) {
container.scrollLeft = targetScrollLeft;
targetScrollLeft = null;
animScrollRaf = null;
scheduleScrollUpdate();
return;
}
const move = diff * 0.28;
const nextVal = current + (Math.abs(move) < 1 ? Math.sign(diff) : move);
container.scrollLeft = nextVal;
if (container.scrollLeft === current) {
targetScrollLeft = null;
animScrollRaf = null;
scheduleScrollUpdate();
return;
}
scheduleScrollUpdate();
animScrollRaf = requestAnimationFrame(step);
}
animScrollRaf = requestAnimationFrame(step);
}
function animateVerticalScroll() {
if (animScrollTopRaf) return;
function step() {
if (targetScrollTop === null) {
animScrollTopRaf = null;
return;
}
const current = window.scrollY;
const diff = targetScrollTop - current;
if (Math.abs(diff) <= 1) {
window.scrollTo(0, targetScrollTop);
targetScrollTop = null;
animScrollTopRaf = null;
scheduleScrollUpdate();
return;
}
const move = diff * 0.28;
const nextVal = current + (Math.abs(move) < 1 ? Math.sign(diff) : move);
window.scrollTo(0, nextVal);
if (window.scrollY === current) {
targetScrollTop = null;
animScrollTopRaf = null;
scheduleScrollUpdate();
return;
}
scheduleScrollUpdate();
animScrollTopRaf = requestAnimationFrame(step);
}
animScrollTopRaf = requestAnimationFrame(step);
}
function extractAndHideBottomLinks() {
let frontUrl = location.origin + '/';
let onionUrl = '';
if (location.hostname.includes('exhentai')) {
onionUrl = 'http://exhentai55ld2wyap5juskbm67czulomrouspvlunwfuhqack263eccd.onion';
} else {
onionUrl = 'http://e-hentai.org';
}
const footerLinks = document.querySelectorAll('#footer a, .ft a, .dp a, #dp a');
for (const a of footerLinks) {
const href = a.getAttribute('href') || a.href || '';
const txt = a.textContent.trim().toLowerCase();
if (href.includes('wiki') || href.includes('forum')) continue;
if (txt === 'front page' || txt === 'front' || txt.startsWith('front page')) {
try {
const u = new URL(href, location.origin);
if (u.origin === location.origin) frontUrl = u.href;
} catch {}
}
if (txt.includes('onion') || txt.includes('tor') || href.includes('.onion')) {
if (href) onionUrl = href;
}
}
cachedFrontUrl = frontUrl;
cachedOnionUrl = onionUrl;
return { frontUrl, onionUrl };
}
function syncThemeClass() {
const isDark =
location.hostname.includes('exhentai') ||
(document.body &&
window.getComputedStyle(document.body).backgroundColor.includes('52') &&
window.getComputedStyle(document.body).backgroundColor.includes('53'));
document.documentElement.classList.remove('eh-theme-dark', 'eh-theme-light');
document.documentElement.classList.add(isDark ? 'eh-theme-dark' : 'eh-theme-light');
}
function clearFailureToast() {
const t = document.getElementById('eh-toast-fail');
if (t) t.remove();
}
function showFailureToast(msg) {
let toast = document.getElementById('eh-toast-fail');
if (!toast) {
toast = document.createElement('div');
toast.id = 'eh-toast-fail';
toast.className = 'eh-toast-fail';
document.body.appendChild(toast);
toast.addEventListener('click', () => {
safeStorageRemove(sessionStorage, 'eh_retry_' + location.pathname + '_' + currentPageNum);
safeStorageRemove(sessionStorage, 'eh_retry_' + location.pathname);
toast.remove();
executeBrokenReload();
});
}
toast.textContent = msg;
}
function parseTemplate(html) {
const t = document.createElement('template');
t.innerHTML = html;
return t.content;
}
function getBaseGalleryUrl() {
if (cachedGalleryUrl && /\/g\/\d+\/[0-9a-fA-F]{10}/i.test(cachedGalleryUrl)) {
return cachedGalleryUrl.replace(/\?.*$/, '').replace(/\/?$/, '/');
}
const mPath = location.pathname.match(/\/s\/[0-9a-zA-Z]+\/(\d+)-/i);
const curGid = mPath ? mPath[1] : (getPageParams()?.gid || '');
if (curGid) {
const stored = safeStorageGet(sessionStorage, 'eh_gallery_' + curGid) || safeStorageGet(localStorage, 'eh_gallery_' + curGid);
if (stored && /\/g\/\d+\/[0-9a-fA-F]{10}/i.test(stored)) {
cachedGalleryUrl = stored.replace(/\?.*$/, '').replace(/\/?$/, '/');
return cachedGalleryUrl;
}
}
// 1. Check all <a> tags for exact /g/{gid}/{token}/ link
const links = document.querySelectorAll('a[href*="/g/"]');
for (const a of links) {
const href = a.getAttribute('href') || a.href || '';
const m = href.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
if (m) {
if (!curGid || m[1] === String(curGid)) {
cachedGalleryUrl = `${location.origin}/g/${m[1]}/${m[2]}/`;
if (curGid) { safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); safeStorageSet(localStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); }
return cachedGalleryUrl;
}
}
}
// 2. Check document.referrer
if (document.referrer) {
const mRef = document.referrer.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
if (mRef && (!curGid || mRef[1] === String(curGid))) {
cachedGalleryUrl = `${location.origin}/g/${mRef[1]}/${mRef[2]}/`;
if (curGid) { safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); safeStorageSet(localStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); }
return cachedGalleryUrl;
}
}
// 3. Scan scripts without whole-DOM serialization
for (const s of document.scripts) {
const txt = s.textContent || '';
if (curGid) {
const rGid = new RegExp('/g/' + curGid + '/([0-9a-fA-F]{10})', 'i');
const mGid = txt.match(rGid);
if (mGid) {
cachedGalleryUrl = `${location.origin}/g/${curGid}/${mGid[1]}/`;
safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl);
return cachedGalleryUrl;
}
}
const mAny = txt.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
if (mAny) {
cachedGalleryUrl = `${location.origin}/g/${mAny[1]}/${mAny[2]}/`;
if (curGid) { safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); safeStorageSet(localStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); }
return cachedGalleryUrl;
}
}
return '';
}
function detectInitialThumbsPerPage() {
const curMeta = getCurrentPageMeta();
const page = curMeta.page;
const refUrl = document.referrer || '';
let foundP = null;
const returnLinks = document.querySelectorAll('a[href*="/g/"]');
for (const a of returnLinks) {
const href = a.getAttribute('href') || a.href || '';
const m = href.match(/[?&]p=(\d+)/);
if (m) {
foundP = parseInt(m[1], 10);
break;
}
}
if (foundP === null && refUrl) {
const m = refUrl.match(/[?&]p=(\d+)/);
if (m) foundP = parseInt(m[1], 10);
}
if (foundP !== null && page > 1) {
for (const count of [20, 40, 100]) {
if (Math.floor((page - 1) / count) === foundP) {
thumbsPerPage = count;
safeStorageSet(localStorage, 'eh_thumbs_per_page', String(count));
break;
}
}
}
}
function getGalleryUrl(pageNo = null) {
const base = getBaseGalleryUrl();
if (!base) return '';
const targetPage = pageNo || currentPageNum || 1;
const count = (typeof thumbsPerPage === 'number' && thumbsPerPage > 0) ? thumbsPerPage : 40;
const pageIndex = Math.max(0, Math.floor((targetPage - 1) / count));
try {
const u = new URL(base, location.origin);
if (pageIndex > 0) {
u.searchParams.set('p', String(pageIndex));
} else {
u.searchParams.delete('p');
}
return u.href;
} catch {
const cleanBase = base.replace(/[?&]p=\d+/g, '').replace(/\?$/, '');
return pageIndex > 0 ? (cleanBase + (cleanBase.includes('?') ? '&' : '?') + 'p=' + pageIndex) : cleanBase;
}
}
function getCurrentPageMeta() {
const m = location.pathname.match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
let page = m ? parseInt(m[2], 10) : 0;
let key = m ? m[1] : '';
if (!page && typeof window.currentpage === 'number' && window.currentpage > 0) {
page = window.currentpage;
}
if (!page && typeof window.startpage === 'number' && window.startpage > 0) {
page = window.startpage;
}
if (!key && typeof window.currentkey === 'string' && window.currentkey) {
key = window.currentkey;
}
if (!key && typeof window.startkey === 'string' && window.startkey) {
key = window.startkey;
}
if (!page || isNaN(page)) {
const curSpan = document.querySelector('#i2 .sn span:first-child, #i4 .sn span:first-child');
if (curSpan) {
const p = parseInt(curSpan.textContent.trim(), 10);
if (!isNaN(p)) page = p;
}
}
return { page: Math.max(1, page || 1), imgKey: key };
}
function applyGlobalAspectRatio(ar) {
if (!ar || isNaN(ar) || ar <= 0) return;
document.documentElement.style.setProperty('--eh-ar', String(ar));
const container = document.getElementById('eh-strip-container');
if (container) {
container.style.setProperty('--eh-ar', String(ar));
}
}
function extractNlCode(source = document) {
if (!source) return null;
if (typeof source === 'object' && !(source instanceof Node)) {
if (typeof source.nl === 'string' && source.nl.trim()) return source.nl.trim();
const searchSources = [source.i6, source.i5, source.i7, source.i3, source.i2, source.i4, source.i1, source.n];
for (const s of searchSources) {
if (!s || typeof s !== 'string') continue;
const m = s.match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i) ||
s.match(/id=["']loadfail["'][^>]*onclick=["']return\s*nl\(['"]([^'"]+)['"]\)/i) ||
s.match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
if (m) return m[1].trim();
}
try {
const raw = JSON.stringify(source);
const mRaw = raw.match(/nl\s*(?:\\(|\()\s*['"\ ]*([0-9a-zA-Z\-_]+)['"\ ]*\s*(?:\\)|\))/i) ||
raw.match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
if (mRaw) return mRaw[1].trim();
} catch {}
return null;
}
const doc = source;
const loadfail = doc.getElementById ? doc.getElementById('loadfail') : null;
if (loadfail) {
const m = (loadfail.getAttribute('onclick') || '').match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
if (m) return m[1].trim();
const mHref = (loadfail.getAttribute('href') || '').match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
if (mHref) return mHref[1].trim();
}
const img = doc.getElementById ? doc.getElementById('img') : null;
if (img) {
const m = (img.getAttribute('onerror') || '').match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
if (m) return m[1].trim();
}
const allNlLinks = doc.querySelectorAll ? doc.querySelectorAll('a[onclick*="nl("], a[href*="nl="]') : [];
for (const a of allNlLinks) {
const m = (a.getAttribute('onclick') || '').match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
if (m) return m[1].trim();
const mHref = (a.getAttribute('href') || '').match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
if (mHref) return mHref[1].trim();
}
const i3 = doc.getElementById ? doc.getElementById('i3') : null;
if (i3) {
const m = i3.innerHTML.match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
if (m) return m[1].trim();
}
const i6 = doc.getElementById ? doc.getElementById('i6') : null;
if (i6) {
const m = i6.innerHTML.match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
if (m) return m[1].trim();
}
if (typeof location !== 'undefined' && location.search) {
const mUrl = location.search.match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
if (mUrl) return mUrl[1].trim();
}
return null;
}
const extractInitialNlCode = extractNlCode;
function detectInitialPageDimensions() {
let foundW = 0, foundH = 0;
if (typeof window.xres === 'number' && typeof window.yres === 'number' && window.xres > 0 && window.yres > 0) {
foundW = window.xres;
foundH = window.yres;
}
if (!foundW || !foundH) {
for (const s of document.scripts) {
const txt = s.textContent || '';
const mX = txt.match(/var\s+xres\s*=\s*(\d+)/);
const mY = txt.match(/var\s+yres\s*=\s*(\d+)/);
if (mX && mY) {
foundW = parseInt(mX[1], 10);
foundH = parseInt(mY[1], 10);
break;
}
}
}
const curImg = document.getElementById('img');
if (curImg) {
const sw = parseInt(curImg.style?.width, 10);
const sh = parseInt(curImg.style?.height, 10);
if (sw > 0 && sh > 0) {
detectedDisplayLimit = Math.max(detectedDisplayLimit, sw, sh);
if (!foundW || !foundH) { foundW = sw; foundH = sh; }
} else if (curImg.naturalWidth > 0 && curImg.naturalHeight > 0) {
detectedDisplayLimit = Math.max(detectedDisplayLimit, curImg.naturalWidth, curImg.naturalHeight);
if (!foundW || !foundH) { foundW = curImg.naturalWidth; foundH = curImg.naturalHeight; }
}
}
if (!foundW || !foundH) {
const infoText = (document.getElementById('i2')?.textContent || '') + ' ' + (document.getElementById('i4')?.textContent || '') + ' ' + (document.getElementById('i1')?.textContent || '');
const mDim = infoText.match(/(\d{3,5})\s*x\s*(\d{3,5})/);
if (mDim) {
const w = parseInt(mDim[1], 10);
const h = parseInt(mDim[2], 10);
if (w > 0 && h > 0) {
foundW = w;
foundH = h;
}
}
}
if (foundW > 0 && foundH > 0) {
const ar = foundW / foundH;
if (ar >= 0.3 && ar <= 2.5) {
defaultAspectRatio = ar > 1.05 ? (ar / 2) : ar;
}
pageDimensions.set(currentPageNum, { width: foundW, height: foundH, aspectRatio: ar });
recordDisplayDims(currentPageNum, foundW, foundH);
applyGlobalAspectRatio(defaultAspectRatio);
return;
}
if (pageDimensions.has(currentPageNum) && pageDimensions.get(currentPageNum).aspectRatio) {
defaultAspectRatio = pageDimensions.get(currentPageNum).aspectRatio;
applyGlobalAspectRatio(defaultAspectRatio);
}
}
function updateThumbnailAspectRatio(pageNo, ar) {
if (!pageNo || !ar || isNaN(ar) || ar < 0.2 || ar > 5.0) return;
const prevDim = pageDimensions.get(pageNo);
if (prevDim && prevDim.width >= 400 && prevDim.height >= 400) return;
const prevAr = prevDim?.aspectRatio || 0;
if (prevAr && Math.abs(prevAr - ar) < 0.002) return;
withScrollAnchor(() => {
pageDimensions.set(pageNo, {
width: 0,
height: 0,
aspectRatio: ar
});
if (Math.abs(defaultAspectRatio - 0.707) < 0.001 && ar >= 0.4 && ar <= 1.0) {
defaultAspectRatio = ar;
}
const item = pageItemMap.get(pageNo);
if (item) {
applyItemDimensions(item, pageNo);
}
}, pageNo);
if (!stripActive && pageNo === currentPageNum) {
const singleWrap = document.getElementById('eh-single-thumb-placeholder');
if (singleWrap) {
showSinglePageThumbnailPlaceholder(pageNo);
}
}
}
function updatePageDimensions(pageNo, width, height) {
if (!width || !height) return;
// Thumbnail sizes (< 400px) must only update aspect ratio, never full-page display dimensions
if (width < 400 || height < 400) {
updateThumbnailAspectRatio(pageNo, width / height);
return;
}
detectedDisplayLimit = Math.max(detectedDisplayLimit, width, height);
const ar = width / height;
const prevDim = pageDimensions.get(pageNo);
// Keep the stored size only when it is already real (>= 400px) and within 0.2% of the loaded
// ratio. A ratio that came from a thumbnail (width 0) is quantised by +-0.5px on a ~200px image,
// so it must always be replaced by the real one; the old 2% rule froze that error in place.
const prevReal = prevDim && prevDim.width >= 400 && prevDim.height >= 400;
if (prevReal && prevDim.aspectRatio && Math.abs(prevDim.aspectRatio / ar - 1) < 0.002) {
let changed = false;
if ((!prevDim.width || prevDim.width < 400) && width >= 400) {
prevDim.width = width;
prevDim.height = height;
changed = true;
}
if (changed && currentPageNum === pageNo) {
updateTopBarInfo(pageNo);
}
return;
}
withScrollAnchor(() => {
pageDimensions.set(pageNo, { width, height, aspectRatio: ar });
if (Math.abs(defaultAspectRatio - 0.707) < 0.001 && ar >= 0.4 && ar <= 1.0) {
defaultAspectRatio = ar;
}
const item = pageItemMap.get(pageNo);
if (item) {
applyItemDimensions(item, pageNo);
if (item.classList.contains('eh-placeholder') && !item.querySelector('img.eh-main-img')) {
applyThumbnailToPlaceholder(item, pageNo);
}
}
}, pageNo);
if (!stripActive && pageNo === currentPageNum) {
const singleWrap = document.getElementById('eh-single-thumb-placeholder');
if (singleWrap) {
showSinglePageThumbnailPlaceholder(pageNo);
}
}
if (currentPageNum === pageNo) {
updateTopBarInfo(pageNo);
}
}
function extractKeysFromText(text) {
if (!text) return [];
const found = new Set();
for (const m of text.matchAll(/load_image\((\d+),\s*['"]([^'"]+)['"]\)/g)) {
const p = parseInt(m[1], 10);
pageImgKeyCache.set(p, m[2]);
found.add(p);
}
for (const m of text.matchAll(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/g)) {
const p = parseInt(m[2], 10);
pageImgKeyCache.set(p, m[1]);
found.add(p);
}
return Array.from(found);
}
function extractKeysFromHtml(html) {
if (!html) return;
try {
const template = document.createElement('template');
template.innerHTML = html;
const links = template.content.querySelectorAll('a');
for (const a of links) {
const oc = a.getAttribute('onclick') || '';
const mClick = oc.match(/load_image\((\d+),\s*['"]([^'"]+)['"]\)/i);
if (mClick) {
pageImgKeyCache.set(parseInt(mClick[1], 10), mClick[2]);
}
const href = a.getAttribute('href') || a.href || '';
const mHref = href.match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
if (mHref) {
pageImgKeyCache.set(parseInt(mHref[2], 10), mHref[1]);
}
}
} catch {}
}
function extractKeysFromResp(resp, pageNo = null) {
if (!resp) return;
const htmlParts = [];
if (resp.i2) htmlParts.push(resp.i2);
if (resp.i4) htmlParts.push(resp.i4);
if (resp.n) htmlParts.push(resp.n);
const combined = htmlParts.join(' ');
extractKeysFromText(combined);
extractKeysFromHtml(combined);
const targetP = pageNo || (resp.p ? parseInt(resp.p, 10) : null);
if (targetP) {
const nlCode = extractNlCode(resp);
if (nlCode) {
pageNlCodes.set(targetP, nlCode);
}
let w = resp.x ? parseInt(resp.x, 10) : 0;
let h = resp.y ? parseInt(resp.y, 10) : 0;
if ((!w || !h) && resp.i3) {
const mDim = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px;\s*height:\s*(\d+)px/i) ||
resp.i3.match(/style=["'][^"']*height:\s*(\d+)px;\s*width:\s*(\d+)px/i);
if (mDim) {
w = parseInt(mDim[1], 10);
h = parseInt(mDim[2], 10);
}
}
if (w > 0 && h > 0) {
updatePageDimensions(targetP, w, h);
}
}
if (!cachedGalleryUrl) {
const combinedAll = (resp.i1 || '') + ' ' + (resp.i2 || '');
const mG = combinedAll.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
if (mG) {
cachedGalleryUrl = `${location.origin}/g/${mG[1]}/${mG[2]}/`;
discoverAndFetchGallery(currentPageNum);
prefetchNearbyThumbnails(currentPageNum);
}
}
}
function extractKeysFromDoc(doc) {
const html =
(doc.getElementById('i2')?.innerHTML || '') + ' ' +
(doc.getElementById('i4')?.innerHTML || '') + ' ' +
(doc.getElementById('i1')?.innerHTML || '');
extractKeysFromText(html);
extractKeysFromHtml(html);
const nl = extractInitialNlCode(doc);
if (nl) {
pageNlCodes.set(currentPageNum, nl);
}
}
function cleanImageInfo(rawStr) {
if (!rawStr) return '';
let text = rawStr.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
text = text.replace(/^(?:<|<)[\s\d]+(?:>|>)\s*/i, '').trim();
text = text.replace(/^Page\s+\d+[:_\-]\s*/i, '').trim();
if (text.includes('::')) {
const parts = text.split('::').map(s => s.trim());
if (parts.length >= 2) {
let fn = parts[0].replace(/^(?:<|<)[\s\d]+(?:>|>)\s*/i, '').trim();
fn = fn.replace(/^Page\s+\d+[:_\-]\s*/i, '').trim();
fn = fn.replace(/.*?(?:Download|source)\s+(?:original\s+)?(?:\d+\s*x\s*\d+\s+)?(?:[\d\.]+\s*[kKMmGg]?[iI]?[bB]+\s+)?/i, '').trim();
const dims = parts[1].trim();
const sz = parts[2] ? ` :: ${parts[2].split(/\s+(?:Download|source)/i)[0].trim()}` : '';
return fn ? `${fn} :: ${dims}${sz}` : `${dims}${sz}`;
}
}
return text;
}
function extractInfoFromResp(resp, pageNo = null) {
if (!resp) return '';
let dSnippet = resp.d || resp.i || '';
if (!dSnippet && resp.i2) {
const m = resp.i2.match(/<div[^>]*class=["']d["'][^>]*>([\s\S]*?)<\/div>/i);
if (m) dSnippet = m[1];
}
if (!dSnippet && resp.i4) {
const m = resp.i4.match(/<div[^>]*class=["']d["'][^>]*>([\s\S]*?)<\/div>/i);
if (m) dSnippet = m[1];
}
const candidates = [dSnippet, resp.i, resp.d, resp.i5, resp.i2, resp.i4, resp.i1, resp.i3];
for (const html of candidates) {
if (!html) continue;
const cleaned = cleanImageInfo(html);
if (cleaned && cleaned.includes('::') && cleaned.includes('x')) {
if (pageNo) {
const sz = parseFileSizeInBytes(cleaned);
if (sz > 0) pageFileSizeCache.set(pageNo, sz);
}
return cleaned;
}
}
let filename = '';
let dimStr = '';
if (resp.i3) {
const mImg = resp.i3.match(/<img[^>]+src=["']([^"']+)["']/i);
const imgSrc = mImg ? mImg[1] : resp.i3;
const mSrc = imgSrc.match(/\/([^\s"'\/]+\.[a-zA-Z0-9]+)(?:["']|\?)/i);
if (mSrc && !mSrc[1].includes('blank') && !mSrc[1].includes('loadfail') && !mSrc[1].includes('fullimg')) {
filename = mSrc[1];
}
const mDim = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px;\s*height:\s*(\d+)px/i) ||
resp.i3.match(/style=["'][^"']*height:\s*(\d+)px;\s*width:\s*(\d+)px/i) ||
resp.i3.match(/(\d{3,5})\s*x\s*(\d{3,5})/);
if (mDim) {
dimStr = `${mDim[1]} x ${mDim[2]}`;
}
}
if (resp.x && resp.y) {
dimStr = `${resp.x} x ${resp.y}`;
}
if (!dimStr && pageNo && pageDimensions.has(pageNo)) {
const d = pageDimensions.get(pageNo);
if (d.width && d.height) {
dimStr = `${d.width} x ${d.height}`;
}
}
if (filename && dimStr) return `${filename} :: ${dimStr}`;
if (dimStr) return dimStr;
return filename;
}
function getOrCreateTopBarInfo() {
const i2 = document.getElementById('i2');
if (!i2) return null;
let infoContainer = document.getElementById('eh-topbar-info');
if (!infoContainer) {
infoContainer = document.createElement('div');
infoContainer.id = 'eh-topbar-info';
infoContainer.className = 'eh-topbar-info';
let initialRaw = '';
const dEl = i2.querySelector('.d');
if (dEl) {
initialRaw = dEl.textContent;
dEl.remove();
}
for (const child of Array.from(i2.childNodes)) {
if (child.nodeType === Node.ELEMENT_NODE) {
if (child.classList.contains('sn') || child.id === 'eh-strip-controls' || child.id === 'eh-topbar-info') continue;
if (!initialRaw) initialRaw += ' ' + child.textContent;
child.remove();
} else if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
if (!initialRaw) initialRaw += ' ' + child.textContent;
child.remove();
}
}
const controls = document.getElementById('eh-strip-controls');
if (controls && controls.parentNode === i2) {
i2.insertBefore(infoContainer, controls);
} else {
i2.appendChild(infoContainer);
}
const cleaned = cleanImageInfo(initialRaw);
if (cleaned) {
pageTopBarInfoCache.set(currentPageNum, cleaned);
infoContainer.innerHTML = `<div>${cleaned}</div>`;
}
}
return infoContainer;
}
let isUpdatingTopBar = false;
function updateTopBarInfo(pageNo) {
if (isUpdatingTopBar) return;
const infoContainer = getOrCreateTopBarInfo();
if (!infoContainer) return;
isUpdatingTopBar = true;
try {
// 1. Inspect live image in DOM (strip item or single-page image)
const item = pageItemMap.get(pageNo);
const img = item ? item.querySelector('img.eh-main-img') : (pageNo === currentPageNum ? document.getElementById('img') : null);
let currentW = 0, currentH = 0;
if (img && img.naturalWidth > 0 && img.naturalHeight > 0) {
currentW = img.naturalWidth;
currentH = img.naturalHeight;
if (!pageDimensions.has(pageNo) || pageDimensions.get(pageNo).width === 0) {
updatePageDimensions(pageNo, currentW, currentH);
}
}
// 2. Inspect pageDimensions cache
if (!currentW || !currentH) {
const dim = pageDimensions.get(pageNo);
if (dim && dim.width > 0 && dim.height > 0) {
currentW = dim.width;
currentH = dim.height;
}
}
// 3. Inspect pageRespCache
let respInfo = '';
if (pageRespCache.has(pageNo)) {
const resp = pageRespCache.get(pageNo);
respInfo = extractInfoFromResp(resp, pageNo);
if (respInfo && respInfo.includes('x') && !currentW) {
const m = respInfo.match(/(\d{2,5})\s*x\s*(\d{2,5})/);
if (m) {
currentW = parseInt(m[1], 10);
currentH = parseInt(m[2], 10);
}
}
}
// 4. Inspect cached info string
let cachedStr = pageTopBarInfoCache.get(pageNo) || '';
if (cachedStr && cachedStr.includes('x') && !currentW) {
const m = cachedStr.match(/(\d{2,5})\s*x\s*(\d{2,5})/);
if (m) {
currentW = parseInt(m[1], 10);
currentH = parseInt(m[2], 10);
}
}
// 5. Render (throttled to avoid layout thrashing during scroll)
let finalStr = '';
if (respInfo && respInfo.includes('::') && respInfo.includes('x')) {
pageTopBarInfoCache.set(pageNo, respInfo);
finalStr = respInfo;
} else if (cachedStr && cachedStr.includes('::') && cachedStr.includes('x')) {
finalStr = cachedStr;
} else {
const baseName = (respInfo && !respInfo.includes('x')) ? respInfo :
(cachedStr && !cachedStr.includes('x')) ? cachedStr : '';
if (baseName && currentW > 0 && currentH > 0) {
finalStr = `${baseName} :: ${currentW} x ${currentH}`;
pageTopBarInfoCache.set(pageNo, finalStr);
} else if (currentW > 0 && currentH > 0) {
finalStr = `Page ${pageNo} :: ${currentW} x ${currentH}`;
} else if (baseName) {
finalStr = baseName;
}
}
if (infoContainer._lastText !== finalStr) {
infoContainer._lastText = finalStr;
infoContainer.innerHTML = finalStr ? `<div>${finalStr}</div>` : '';
}
// Bottom info line in #i4. The API doesn't always hand it back in a field we write
// directly, so set the text ourselves ('.d' on some layouts, a plain <div> on others).
const i4 = document.getElementById('i4');
if (i4 && finalStr) {
const target = i4.querySelector('.d') ||
Array.from(i4.children).find(c =>
c.tagName === 'DIV' && !c.classList.contains('sn') && c.id !== 'eh-topbar-info' && !c.querySelector('.sn'));
if (target && target.textContent !== finalStr) {
target.textContent = finalStr;
}
}
} finally {
isUpdatingTopBar = false;
}
}
function detectInitialDisplayLimit() {
const curImg = document.getElementById('img');
if (curImg) {
const sw = parseInt(curImg.style?.width, 10);
const sh = parseInt(curImg.style?.height, 10);
if (sw > 0 && sh > 0) {
detectedDisplayLimit = Math.max(detectedDisplayLimit, sw, sh);
return;
}
if (curImg.naturalWidth > 0 && curImg.naturalHeight > 0) {
detectedDisplayLimit = Math.max(detectedDisplayLimit, curImg.naturalWidth, curImg.naturalHeight);
return;
}
}
for (const resp of pageRespCache.values()) {
if (resp && resp.x && resp.y) {
const w = parseInt(resp.x, 10);
const h = parseInt(resp.y, 10);
if (w > 0 && h > 0) {
detectedDisplayLimit = Math.max(detectedDisplayLimit, w, h);
return;
}
}
}
}
function recordDisplayDims(pageNo, w, h) {
if (!(w >= 400 && h >= 400)) return;
const orig = pageDimensions.get(pageNo);
const guess = placeholderGuess.get(pageNo);
if (guess) {
placeholderGuess.delete(pageNo);
if (guess.width !== w || guess.height !== h) {
console.info('[ExFastReader] placeholder size miss, page ' + pageNo + ': guessed ' +
guess.width + 'x' + guess.height + ' (' + guess.src + '), actual ' + w + 'x' + h, guess);
}
}
if (orig && orig.width >= 400 && orig.height >= 400 && orig.width > w) {
const raw = w * orig.height / orig.width;
if (Math.abs(raw - Math.round(raw)) > 0.02) {
if (h === Math.round(raw)) roundVotes.round++;
if (h === Math.floor(raw)) roundVotes.floor++;
if (h === Math.ceil(raw)) roundVotes.ceil++;
}
}
pageDisplayDims.set(pageNo, { width: w, height: h, aspectRatio: w / h });
if (orig && orig.width >= 400 && orig.height >= 400) {
displayByOriginal.set(orig.width + 'x' + orig.height, { width: w, height: h });
}
maxDisplayW = Math.max(maxDisplayW, w);
maxDisplayH = Math.max(maxDisplayH, h);
lastExactW = w;
lastExactH = h;
safeStorageSet(sessionStorage, 'eh_last_exact_w', String(w));
safeStorageSet(sessionStorage, 'eh_last_exact_h', String(h));
let newCap = displayCapW;
if (orig && orig.width > w * 1.02) {
newCap = w; // downscaled by the server: this width is definitely the cap
} else if (!displayCapW || displayCapW < 400) {
newCap = w;
} else if (w > displayCapW) {
newCap = w;
}
if (newCap !== displayCapW && newCap >= 400) {
displayCapW = newCap;
displayByOriginal.clear(); // served sizes depend on the cap
if (orig && orig.width >= 400 && orig.height >= 400) {
displayByOriginal.set(orig.width + 'x' + orig.height, { width: w, height: h });
}
safeStorageSet(sessionStorage, 'eh_display_cap_w', String(newCap));
}
}
function parseRespDisplayDims(resp) {
if (!resp) return null;
let w = resp.x ? parseInt(resp.x, 10) : 0;
let h = resp.y ? parseInt(resp.y, 10) : 0;
if ((!w || !h) && resp.i3) {
const mW = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px/i);
const mH = resp.i3.match(/style=["'][^"']*height:\s*(\d+)px/i);
if (mW) w = parseInt(mW[1], 10);
if (mH) h = parseInt(mH[1], 10);
}
return (w > 0 && h > 0) ? { width: w, height: h, aspectRatio: w / h } : null;
}
function getPageTargetDimensions(pageNo) {
// 1. The server's own x/y for this page: exactly what the site's native code uses
const fromResp = parseRespDisplayDims(pageRespCache.get(pageNo));
if (fromResp) {
recordDisplayDims(pageNo, fromResp.width, fromResp.height);
return { ...fromResp, exact: true };
}
// 2. Previously confirmed size for this page
const known = pageDisplayDims.get(pageNo);
if (known) return { ...known, exact: true };
// 3. Preloaded image: natural size is the served (display) size
const preloaded = preloadedImages.get(pageNo);
if (preloaded && preloaded.img && preloaded.img.naturalWidth > 0 && preloaded.img.naturalHeight > 0) {
const nw = preloaded.img.naturalWidth;
const nh = preloaded.img.naturalHeight;
recordDisplayDims(pageNo, nw, nh);
return { width: nw, height: nh, aspectRatio: nw / nh, exact: true };
}
// 4. Estimate (placeholder only).
// Gallery thumbnails only give an aspect ratio with a few % of rounding noise, which is what
// made the placeholder the wrong height. Pages in a gallery are usually the same size, so if
// the thumbnail ratio is close to the last real page we reuse that page's exact size.
const dim = pageDimensions.get(pageNo);
const ow = (dim?.width && dim.width >= 400) ? dim.width : 0;
const oh = (dim?.height && dim.height >= 400) ? dim.height : 0;
const th = pageThumbnailCache.get(pageNo);
const thumbAr = (th && th.w > 0 && th.h > 0) ? (th.w / th.h) : 0;
const ar = dim?.aspectRatio || (ow > 0 && oh > 0 ? ow / oh : 0) || thumbAr;
const near = nearestKnownDisplay(pageNo);
const refW = near ? near.width : 0;
const refH = near ? near.height : 0;
const refAr = (refW >= 400 && refH >= 400) ? refW / refH : 0;
const smallerOriginal = ow >= 400 && refW >= 400 && ow < refW * 0.98;
if (ar > 0) {
// Served width = original width, capped by the site's resolution setting; with nothing
// better to go on assume the neighbouring page's width (not the largest ever seen)
const w = ow > 0 ? (displayCapW > 0 ? Math.min(ow, displayCapW) : ow) : (refW || displayCapW);
if (w > 0) {
const thumbInfo = th ? { w: th.w, h: th.h } : null;
const guessed = (r, src) => {
placeholderGuess.set(pageNo, { width: r.width, height: r.height, src, ow, oh, thumb: thumbInfo, displayCapW });
return r;
};
const haveOriginal = ow > 0 && oh > 0;
// Same original size as a page we already know: identical served size, no rounding.
if (haveOriginal) {
const hit = displayByOriginal.get(ow + 'x' + oh);
if (hit) {
return guessed({ width: hit.width, height: hit.height, aspectRatio: hit.width / hit.height, exact: false }, 'same original size');
}
// Known original: compute the served height the way the server does (learned rounding).
// No neighbour reuse here: a ratio tolerance of even 0.1% is ~2px on a 1935px image.
const hh = w < ow ? serverRound(w * oh / ow) : oh;
return guessed({ width: Math.round(w), height: hh, aspectRatio: w / hh, exact: false }, 'original size, scaled');
}
// Thumbnail ratio only. Reuse the neighbouring page's exact size when the ratios agree within
// the thumbnail's own quantisation noise. (This used to sit after an unconditional return and
// could never run.)
if (refAr > 0 && refW === Math.round(w)) {
const tol = (th && th.w > 0 && th.h > 0) ? 1.5 * (0.5 / th.w + 0.5 / th.h) : 0.01;
if (Math.abs(ar / refAr - 1) <= tol) {
return guessed({ width: refW, height: refH, aspectRatio: refAr, exact: false }, 'neighbour size');
}
}
return guessed({ width: Math.round(w), height: Math.round(w / ar), aspectRatio: ar, exact: false }, 'thumbnail ratio');
}
}
if (refAr > 0 && (!ar || Math.abs(ar - refAr) < 0.05)) {
return { width: refW, height: refH, aspectRatio: refAr, exact: false };
}
const fallbackAr = ar || defaultAspectRatio || 0.707;
const fallbackW = refW || displayCapW || (typeof window !== 'undefined' ? Math.min(1280, Math.max(480, window.innerWidth - 20)) : 1000);
return { width: fallbackW, height: Math.round(fallbackW / fallbackAr), aspectRatio: fallbackAr, exact: false };
}
// Nearest page whose real served size we know. Neighbouring pages almost always share a size,
// so this is a far better guess for a placeholder than "the biggest width seen anywhere".
function nearestKnownDisplay(pageNo) {
let best = null;
let bestDist = Infinity;
for (const [p, d] of pageDisplayDims) {
const dist = Math.abs(p - pageNo);
if (dist < bestDist || (dist === bestDist && p < best.page)) {
bestDist = dist;
best = { page: p, width: d.width, height: d.height };
}
}
if (best) return best;
if (lastExactW >= 400 && lastExactH >= 400) return { page: 0, width: lastExactW, height: lastExactH };
return null;
}
// Mirrors the site's own update_window_extents(): fit to (innerWidth - 20), min 480.
function fitDimsToWindow(w, h) {
const maxW = Math.max(480, window.innerWidth - 20);
if (w > maxW) {
h = Math.round(h * maxW / w);
w = maxW;
}
return { width: w, height: h };
}
// The native routine reads the globals xres/yres, which only apply_json_state updates.
// We bypass apply_json_state in single mode, so keep them in sync ourselves.
function syncNativeExtents(w, h, exact) {
if (!(w > 0 && h > 0)) return { width: w, height: h };
if (exact) {
try { window.xres = w; window.yres = h; } catch {}
}
const f = fitDimsToWindow(w, h);
const i1 = document.getElementById('i1');
if (i1) {
const curW = parseInt(i1.style.width, 10);
if (curW !== f.width) {
i1.style.width = f.width + 'px';
}
}
return f;
}
function applySingleImageSizing(img, pageNo) {
if (!img || stripActive) return;
let d = parseRespDisplayDims(pageRespCache.get(pageNo)) || pageDisplayDims.get(pageNo);
const loaded = img._eh_page === pageNo && !img._eh_obsolete && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
if (!d && loaded) {
d = { width: img.naturalWidth, height: img.naturalHeight };
}
if (!d) return;
recordDisplayDims(pageNo, d.width, d.height);
const f = syncNativeExtents(d.width, d.height, true);
const curW = parseInt(img.style.width, 10);
const curH = parseInt(img.style.height, 10);
// Exact comparison: a 2px tolerance here left the final image up to 2px away from the true
// size whenever the placeholder had been an estimate.
if (curW !== f.width) {
img.style.width = f.width + 'px';
}
if (curH !== f.height) {
img.style.height = f.height + 'px';
}
img.style.aspectRatio = String(d.width / d.height);
alignIfPending();
if (loaded) {
requestAnimationFrame(() => {
alignIfPending();
disarmSingleAlign();
});
}
}
function applyCurrentSingleSizing() {
if (!isReaderPage || stripActive) return;
const img = document.getElementById('img');
if (img) applySingleImageSizing(img, currentPageNum);
}
// Safety net: whatever happened during a page turn, once the current page's image has
// loaded make sure it is sized correctly, visible, and no placeholder is left over it.
function reconcileSinglePage() {
if (!isReaderPage || stripActive) return;
const img = document.getElementById('img');
if (!img) return;
const ready = img._eh_page === currentPageNum && !img._eh_obsolete && img.complete && img.naturalWidth > 0;
if (!ready) return;
applySingleImageSizing(img, currentPageNum);
hideSinglePageThumbnailPlaceholder();
}
function scheduleSingleReconcile() {
[120, 500, 1500].forEach((ms) => setTimeout(reconcileSinglePage, ms));
}
document.addEventListener('load', (e) => {
if (e.target && e.target.id === 'img') reconcileSinglePage();
}, true);
let singleResizeRaf = null;
window.addEventListener('resize', () => {
if (!isReaderPage || stripActive || singleResizeRaf) return;
singleResizeRaf = requestAnimationFrame(() => {
singleResizeRaf = null;
applyCurrentSingleSizing();
});
}, { passive: true });
// After a page turn the layout keeps changing (placeholder -> exact size -> loaded image).
// A single scrollTo gets clamped by the old, shorter document, so keep re-aligning to the
// top of the image until it has loaded or the user scrolls on their own.
let singleAlignPending = false;
let singleAlignTimer = null;
function armSingleAlign() {
singleAlignPending = true;
if (singleAlignTimer) clearTimeout(singleAlignTimer);
singleAlignTimer = setTimeout(() => { singleAlignPending = false; }, 2000);
}
function disarmSingleAlign() {
singleAlignPending = false;
if (singleAlignTimer) { clearTimeout(singleAlignTimer); singleAlignTimer = null; }
}
['pointerdown', 'touchstart', 'wheel'].forEach((evt) => {
window.addEventListener(evt, (e) => {
if (!stripActive && evt !== 'wheel') {
if (!e.target?.closest?.('#i3 a, #next, #prev, #i2, .sn')) {
disarmSingleAlign();
}
} else if (evt === 'wheel') {
disarmSingleAlign();
}
cancelKeyboardAnimation();
}, { passive: true, capture: true });
});
function alignIfPending() {
if (singleAlignPending && !stripActive) scrollSinglePageToImageTop();
}
function scrollSinglePageToImageTop() {
if (stripActive) return;
const target = document.getElementById('i3') || document.getElementById('img');
if (!target) return;
const rect = target.getBoundingClientRect();
const targetTop = Math.max(0, Math.round(window.scrollY + rect.top));
if (Math.abs(window.scrollY - targetTop) >= 1) {
window.scrollTo(0, targetTop);
}
}
function applyThumbnailToSinglePlaceholder(wrap, pageNo) {
if (!wrap || wrap.dataset.page !== String(pageNo)) return;
// The wrapper is reused across page turns: never leave the previous page's thumbnail behind
if (wrap._thumbPage !== pageNo) {
wrap.textContent = '';
wrap._thumbKey = '';
wrap._thumbPage = pageNo;
}
const painted = paintThumb(wrap, pageNo);
let content = wrap.querySelector(':scope > .eh-placeholder-content');
if (painted) {
if (content) content.remove();
return;
}
if (!content) {
content = document.createElement('div');
content.className = 'eh-placeholder-content eh-delayed';
content.innerHTML = `<div class="eh-placeholder-text">Page ${pageNo}...</div>`;
wrap.appendChild(content);
}
if (!pageThumbnailCache.has(pageNo)) prefetchNearbyThumbnails(pageNo);
}
function showSinglePageThumbnailPlaceholder(pageNo) {
if (stripActive) return;
const i3 = document.getElementById('i3');
if (!i3) return;
const imgEl = i3.querySelector('img#img') || i3.querySelector('img');
if (imgEl && imgEl._eh_page === pageNo && !imgEl._eh_obsolete && imgEl.complete && imgEl.naturalWidth > 0) {
return;
}
const phDim = getPageTargetDimensions(pageNo);
const ar = phDim.aspectRatio;
let targetW = phDim.width, targetH = phDim.height;
if (targetW <= 0 || targetH <= 0) {
const sw = (imgEl && imgEl._eh_page === pageNo) ? parseInt(imgEl.style?.width, 10) : 0;
const sh = (imgEl && imgEl._eh_page === pageNo) ? parseInt(imgEl.style?.height, 10) : 0;
if (sw > 0 && sh > 0) {
targetW = sw;
targetH = sh;
} else {
const effectiveAr = ar || defaultAspectRatio || 0.707;
targetW = displayCapW || lastExactW || (typeof window !== 'undefined' ? Math.min(1280, Math.max(480, window.innerWidth - 20)) : 1000);
targetH = Math.round(targetW / effectiveAr);
}
}
const f = syncNativeExtents(targetW, targetH, phDim.exact);
targetW = f.width;
targetH = f.height;
// Keep imgEl in layout flow with exact target dimensions so #i3 and #i1 never collapse or pop
if (imgEl) {
imgEl.style.width = targetW + 'px';
imgEl.style.height = targetH + 'px';
if (ar > 0) imgEl.style.aspectRatio = String(ar);
else imgEl.style.aspectRatio = '';
imgEl.style.opacity = '0';
imgEl.style.position = 'relative';
imgEl.style.display = 'block';
imgEl.style.pointerEvents = 'none';
imgEl.style.zIndex = '2';
}
let wrap = i3.querySelector('#eh-single-thumb-placeholder');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = 'eh-single-thumb-placeholder';
if (imgEl && imgEl.parentNode && imgEl.parentNode.tagName.toLowerCase() === 'a') {
imgEl.parentNode.insertBefore(wrap, imgEl);
} else {
i3.insertBefore(wrap, i3.firstChild);
}
}
wrap.dataset.page = String(pageNo);
wrap.style.cssText = `
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 auto !important;
overflow: hidden !important;
box-sizing: border-box !important;
z-index: 1 !important;
pointer-events: none !important;
`;
applyThumbnailToSinglePlaceholder(wrap, pageNo);
alignIfPending();
requestAnimationFrame(alignIfPending);
}
function hideSinglePageThumbnailPlaceholder() {
const i3 = document.getElementById('i3');
const imgEl = i3 ? (i3.querySelector('img#img') || i3.querySelector('img')) : null;
if (imgEl) {
imgEl.style.opacity = '1';
imgEl.style.pointerEvents = '';
imgEl.style.position = 'relative';
imgEl.style.display = 'block';
imgEl.style.visibility = 'visible';
imgEl.style.zIndex = '2';
}
const wraps = document.querySelectorAll('#eh-single-thumb-placeholder');
wraps.forEach(w => w.remove());
}
function applyThumbnailToPlaceholder(item, pageNo) {
if (!item || !item.classList.contains('eh-placeholder')) return;
if (item.querySelector('img.eh-main-img')) return;
paintThumb(item, pageNo);
}
async function drainGalleryQueue(queue) {
if (!queue || queue._draining) return;
queue._draining = true;
const worker = async () => {
while (queue.length > 0) {
const pIndex = queue.shift();
await fetchGalleryPage(pIndex);
if (queue.length > 0) {
await new Promise(r => setTimeout(r, 60));
}
}
};
await Promise.all([worker(), worker()]);
queue._draining = false;
}
function schedulePrefetchNearbyThumbnails(curPage) {
if (!isReaderPage) return;
if (prefetchThumbTimer) clearTimeout(prefetchThumbTimer);
prefetchThumbTimer = setTimeout(() => {
prefetchNearbyThumbnails(curPage);
}, 100);
}
function prefetchNearbyThumbnails(curPage) {
if (!isReaderPage) return;
warmThumbsAround(curPage);
if (stripActive && isFastScrolling()) return;
const total = getTotalPages();
const THUMB_WINDOW = 20;
const startP = Math.max(1, curPage - 10);
const endP = total > 0 ? Math.min(total, curPage + THUMB_WINDOW) : (curPage + THUMB_WINDOW);
const galleryPagesToFetch = [];
for (let p = startP; p <= endP; p++) {
if (!pageThumbnailCache.has(p) || !pageImgKeyCache.has(p)) {
const gIndex = Math.max(0, Math.floor((p - 1) / thumbsPerPage));
if (!fetchedGalleryPages.has(gIndex) && !inFlightGalleryFetches.has(gIndex)) {
if (!galleryPagesToFetch.includes(gIndex)) {
galleryPagesToFetch.push(gIndex);
}
}
}
}
if (galleryPagesToFetch.length === 0) return;
const curGIndex = Math.max(0, Math.floor((curPage - 1) / thumbsPerPage));
galleryPagesToFetch.sort((a, b) => Math.abs(a - curGIndex) - Math.abs(b - curGIndex));
const toFetch = galleryPagesToFetch.slice(0, 2);
(async () => {
for (const gIndex of toFetch) {
if (!isReaderPage) break;
const activeGIndex = Math.max(0, Math.floor((currentPageNum - 1) / thumbsPerPage));
if (stripActive && Math.abs(gIndex - activeGIndex) > 3) continue;
await fetchGalleryPage(gIndex, false);
await new Promise(r => setTimeout(r, 80));
}
})();
}
async function discoverAndFetchGallery(targetPage = 1) {
let galleryUrl = getGalleryUrl();
if (!galleryUrl) {
for (let attempt = 0; attempt < 12; attempt++) {
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
galleryUrl = getGalleryUrl();
if (galleryUrl) break;
}
if (!galleryUrl) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => discoverAndFetchGallery(targetPage), { once: true });
}
return;
}
}
const targetIndex = Math.max(0, Math.floor((targetPage - 1) / thumbsPerPage));
if (pageThumbnailCache.has(targetPage) && pageImgKeyCache.has(targetPage)) {
fetchedGalleryPages.set(targetIndex, Date.now());
} else {
await fetchGalleryPage(targetIndex);
}
const total = getTotalPages();
const nextGalleryPage = targetIndex + 1;
const totalGalleryPages = total > 0 ? Math.ceil(total / thumbsPerPage) : 0;
if (nextGalleryPage < totalGalleryPages && !fetchedGalleryPages.has(nextGalleryPage) && !inFlightGalleryFetches.has(nextGalleryPage)) {
fetchGalleryPage(nextGalleryPage);
}
}
function extractDimensionsFromGallery(source) {
if (!source) return 0;
let count = 0;
try {
let doc;
let htmlStr = '';
if (typeof source === 'string') {
htmlStr = source;
doc = new DOMParser().parseFromString(source, 'text/html');
} else if (source && (source.nodeType || source.querySelector)) {
doc = source;
htmlStr = (source.documentElement ? source.documentElement.innerHTML : source.innerHTML) || '';
}
if (!doc) return 0;
const mGpc = htmlStr.match(/Showing\s+(\d+)\s*-\s*(\d+)\s+of\s+([\d,]+)\s+images/i) ||
(doc.querySelector('.gpc, #gpc')?.textContent || '').match(/Showing\s+(\d+)\s*-\s*(\d+)\s+of\s+([\d,]+)\s+images/i);
if (mGpc) {
const sNum = parseInt(mGpc[1], 10);
const eNum = parseInt(mGpc[2], 10);
const tot = parseInt(mGpc[3].replace(/,/g, ''), 10);
if (eNum < tot) {
const countPerPg = eNum - sNum + 1;
if ([20, 40, 100].includes(countPerPg)) {
thumbsPerPage = countPerPg;
safeStorageSet(localStorage, 'eh_thumbs_per_page', String(countPerPg));
}
}
if (tot > 0 && (!cachedTotalPages || cachedTotalPages < tot)) {
cachedTotalPages = tot;
if (isReaderPage && stripActive) {
ensureAllPlaceholders(tot);
}
}
}
const links = (doc.getElementById('gdt')?.querySelectorAll('a[href*="/s/"]') || doc.querySelectorAll('#gdt a[href*="/s/"]')).length > 0
? (doc.getElementById('gdt')?.querySelectorAll('a[href*="/s/"]') || doc.querySelectorAll('#gdt a[href*="/s/"]'))
: Array.from(doc.querySelectorAll('a[href*="/s/"]'));
const changedThumbPages = [];
withScrollAnchor(() => {
for (const a of links) {
if (a.closest('#gd1, #gleft, #gmid, #gd2, #gd3, #gd4, #gd5')) continue;
const href = a.getAttribute('href') || a.href || '';
const m = href.match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
if (!m) continue;
count++;
const key = m[1];
const page = parseInt(m[2], 10);
pageImgKeyCache.set(page, key);
const img = a.querySelector('img');
const cell = a.closest('.gdtm, .gdtl') || a.parentElement;
let thumbDiv = a.querySelector('div[style*="url("], div[style*="background"]') ||
(a.parentElement?.matches?.('[style*="url("], [style*="background"]') ? a.parentElement : null) ||
a.closest('.gdtm > div');
if (thumbDiv && cell && thumbDiv !== cell && !cell.contains(thumbDiv)) thumbDiv = null;
const parent = a.closest('.gdtm > div') || a.closest('.gdtm') || a.closest('.gdtl') || a.parentElement;
const titleEl = a.querySelector('[title*="Page"]') ||
a.querySelector('[title]') ||
thumbDiv ||
img ||
a ||
parent;
const title = (
titleEl?.getAttribute('title') ||
img?.getAttribute('title') ||
a.getAttribute('title') ||
parent?.getAttribute('title') ||
img?.getAttribute('alt') ||
''
).trim();
const cleanInfo = cleanImageInfo(title);
if (cleanInfo) {
const existingInfo = pageTopBarInfoCache.get(page) || '';
if (!existingInfo || !existingInfo.includes('x') || cleanInfo.includes('x')) {
pageTopBarInfoCache.set(page, cleanInfo);
}
const sz = parseFileSizeInBytes(cleanInfo);
if (sz > 0) pageFileSizeCache.set(page, sz);
if (currentPageNum === page) {
updateTopBarInfo(page);
}
}
let w = 0, h = 0;
// Extract width/height strictly from the image or sprite container (never from .gdtl/.gdtm table cells)
const getElemDims = (el) => {
if (!el) return { ew: 0, eh: 0 };
let ew = parseInt(el.style?.width, 10) || 0;
let eh = parseInt(el.style?.height, 10) || 0;
const s = el.getAttribute ? (el.getAttribute('style') || '') : '';
if (!ew && s) {
const mW = s.match(/(?:^|[;\s])width:\s*(\d+)px/i);
if (mW) ew = parseInt(mW[1], 10);
}
if (!eh && s) {
const mH = s.match(/(?:^|[;\s])height:\s*(\d+)px/i);
if (mH) eh = parseInt(mH[1], 10);
}
if (!ew) ew = parseInt(el.getAttribute?.('width') || el.width || '0', 10);
if (!eh) eh = parseInt(el.getAttribute?.('height') || el.height || '0', 10);
return { ew, eh };
};
if (thumbDiv) {
const td = getElemDims(thumbDiv);
if (td.ew) w = td.ew;
if (td.eh) h = td.eh;
}
if (img) {
const id = getElemDims(img);
if (!w && id.ew) w = id.ew;
if (!h && id.eh) h = id.eh;
}
const dimMatch = (cleanInfo || title).match(/(\d{3,5})\s*x\s*(\d{3,5})/);
let hasRealDim = false;
if (dimMatch) {
const realW = parseInt(dimMatch[1], 10);
const realH = parseInt(dimMatch[2], 10);
if (realW > 0 && realH > 0) {
const ar = realW / realH;
pageDimensions.set(page, { width: realW, height: realH, aspectRatio: ar });
const item = pageItemMap.get(page);
if (item) applyItemDimensions(item, page);
hasRealDim = true;
}
}
if (!hasRealDim && w > 0 && h > 0) {
const ar = w / h;
if (ar >= 0.3 && ar <= 2.8) {
const prev = pageDimensions.get(page);
// Real dimensions always win; only touch layout when the ratio actually changed
if (!prev || (!(prev.width >= 400) && Math.abs((prev.aspectRatio || 0) - ar) >= 0.002)) {
pageDimensions.set(page, { width: 0, height: 0, aspectRatio: ar });
const item = pageItemMap.get(page);
if (item) applyItemDimensions(item, page);
if (!stripActive && page === currentPageNum) {
showSinglePageThumbnailPlaceholder(page);
}
}
}
}
const spriteStyle = (thumbDiv?.getAttribute('style') || '') + ' ' + (a.getAttribute('style') || '');
const mUrl = spriteStyle.match(/url\(["']?([^"')]+)["']?\)/i);
const bgUrl = mUrl ? mUrl[1].trim() : '';
const rawSrc = img ? (img.getAttribute('src') || img.src || '') : '';
const isBlank = !rawSrc || rawSrc.includes('blank.gif') || rawSrc.includes('/t.png') || rawSrc.endsWith('/t.png') || rawSrc.startsWith('data:image');
let newInfo = null;
if (bgUrl) {
// Sprite: the region needs its REAL w/h (never guessed) plus its offset inside the sheet
if (w > 0 && h > 0) {
const num = '(-?\\d+(?:\\.\\d+)?)(?:px)?';
const mPos = spriteStyle.match(new RegExp('background-position:\\s*' + num + '\\s+' + num, 'i')) ||
spriteStyle.match(new RegExp('url\\([^)]+\\)\\s+' + num + '\\s+' + num, 'i')) ||
spriteStyle.match(new RegExp(num + '\\s+' + num + '\\s+no-repeat', 'i'));
const posX = mPos ? Math.abs(parseFloat(mPos[1]) || 0) : 0;
const posY = mPos ? Math.abs(parseFloat(mPos[2]) || 0) : 0;
newInfo = { type: 'sprite', page, x: posX, y: posY, w, h, url: bgUrl };
}
} else if (img && !isBlank) {
newInfo = { type: 'url', page, url: rawSrc, w, h };
}
if (newInfo) {
const prevInfo = pageThumbnailCache.get(page);
const same = prevInfo && prevInfo.url === newInfo.url &&
(prevInfo.x || 0) === (newInfo.x || 0) && (prevInfo.y || 0) === (newInfo.y || 0);
if (!same) {
pageThumbnailCache.set(page, newInfo);
changedThumbPages.push(page);
}
}
}
});
// If page 1 was not in the thumbnail grid (e.g. gallery page 2+), derive its thumbnail from #gd1 cover
if (!pageThumbnailCache.has(1)) {
for (const el of doc.querySelectorAll('#gd1 [style*="url("], #gd1 img')) {
const m = (el.getAttribute('style') || '').match(/url\(["']?([^"')]+)["']?\)/i);
const bgUrl = m ? m[1].trim() : '';
const rawSrc = el.tagName === 'IMG' ? (el.getAttribute('src') || el.src || '') : '';
const coverUrl = bgUrl || rawSrc;
if (coverUrl && !coverUrl.includes('blank.gif') && !coverUrl.includes('/t.png') && !coverUrl.startsWith('data:image')) {
let cw = parseInt(el.style?.width, 10) || parseInt(el.getAttribute?.('width') || el.width || '0', 10) || 0;
let ch = parseInt(el.style?.height, 10) || parseInt(el.getAttribute?.('height') || el.height || '0', 10) || 0;
pageThumbnailCache.set(1, { type: 'url', page: 1, url: coverUrl, w: cw, h: ch });
changedThumbPages.push(1);
break;
}
}
}
// Paint after the layout work above, and start loading the sheets near the reading position
for (const p of changedThumbPages) notifyThumbReady(p);
if (isReaderPage && changedThumbPages.length > 0) warmThumbsAround(currentPageNum || 1, true);
if (cachedGid) {
saveGalleryDataToSession(cachedGid);
}
} catch (err) {
console.warn('ExFastReader: Failed to extract gallery dimensions/thumbnails', err);
}
return count;
}
const galleryFailures = new Map();
async function fetchGalleryPage(pageIndex, isPriority = false, forceRetry = false) {
if (pageIndex < 0) return;
if (apiCircuitBreaker.isBlocked(isPriority)) return;
const lastFetch = fetchedGalleryPages.get(pageIndex);
if (!forceRetry && lastFetch && Date.now() - lastFetch < 30000) {
return;
}
if (forceRetry && lastFetch && Date.now() - lastFetch < 3000) {
return;
}
if (inFlightGalleryFetches.has(pageIndex)) {
return inFlightGalleryFetches.get(pageIndex);
}
let baseGalleryUrl = getBaseGalleryUrl();
if (!baseGalleryUrl) {
for (let i = 0; i < 8; i++) {
await new Promise(r => setTimeout(r, 80));
baseGalleryUrl = getBaseGalleryUrl();
if (baseGalleryUrl) break;
}
}
if (!baseGalleryUrl) return;
const promise = (async () => {
await galleryLimiter.acquire(isPriority);
try {
if (apiCircuitBreaker.isBlocked(isPriority)) return;
// Far from the reading position by the time it's our turn: skip it
if (!isPriority && !forceRetry && isReaderPage) {
const activeG = Math.max(0, Math.floor(((currentPageNum || 1) - 1) / thumbsPerPage));
if (Math.abs(pageIndex - activeG) > 3) return;
}
let fetchUrl;
try {
const u = new URL(baseGalleryUrl, location.origin);
if (pageIndex > 0) {
u.searchParams.set('p', String(pageIndex));
} else {
u.searchParams.delete('p');
}
fetchUrl = u.href;
} catch {
const cleanBase = baseGalleryUrl.replace(/[?&]p=\d+/g, '').replace(/\?$/, '');
fetchUrl = pageIndex > 0 ? (cleanBase + (cleanBase.includes('?') ? '&' : '?') + 'p=' + pageIndex) : cleanBase;
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = setTimeout(() => { if (controller) controller.abort(); }, 16000);
const fetchOptions = {
credentials: 'include',
signal: controller ? controller.signal : undefined
};
if (isPriority) {
fetchOptions.priority = 'high';
}
const res = await fetch(fetchUrl, fetchOptions).finally(() => clearTimeout(timer));
if (res.ok) {
fetchedGalleryPages.set(pageIndex, Date.now());
galleryFailures.delete(pageIndex);
const html = await res.text();
extractKeysFromText(html);
extractDimensionsFromGallery(html);
} else {
const fails = (galleryFailures.get(pageIndex) || 0) + 1;
galleryFailures.set(pageIndex, fails);
const cooldown = Math.min(60000, 5000 * Math.pow(2, fails - 1));
fetchedGalleryPages.set(pageIndex, Date.now() - 30000 + cooldown);
if (res.status === 429 || res.status === 509) {
apiCircuitBreaker.recordError(res.status, 'Gallery rate limit');
}
}
} catch (err) {
if (err && err.name !== 'AbortError') {
console.warn('ExFastReader: Gallery fetch failed for p=' + pageIndex, err.message || err);
}
const fails = (galleryFailures.get(pageIndex) || 0) + 1;
galleryFailures.set(pageIndex, fails);
const cooldown = Math.min(60000, 5000 * Math.pow(2, fails - 1));
fetchedGalleryPages.set(pageIndex, Date.now() - 30000 + cooldown);
} finally {
galleryLimiter.release();
inFlightGalleryFetches.delete(pageIndex);
}
})();
inFlightGalleryFetches.set(pageIndex, promise);
return promise;
}
const inFlightKeyLookups = new Map();
async function ensureKeysForPage(targetPageNo, isPriority = true) {
if (pageImgKeyCache.has(targetPageNo)) {
return pageImgKeyCache.get(targetPageNo);
}
const total = getTotalPages();
if (total > 0 && targetPageNo > total) {
return null;
}
if (inFlightKeyLookups.has(targetPageNo)) {
return inFlightKeyLookups.get(targetPageNo);
}
const lookupPromise = (async () => {
const primaryIndex = Math.max(0, Math.floor((targetPageNo - 1) / thumbsPerPage));
const lastPrimaryFetch = fetchedGalleryPages.get(primaryIndex) || 0;
const needPrimaryFetch = (Date.now() - lastPrimaryFetch > 8000) || (!pageThumbnailCache.has(targetPageNo));
if (needPrimaryFetch) {
await fetchGalleryPage(primaryIndex, isPriority, false);
}
if (pageImgKeyCache.has(targetPageNo)) {
return pageImgKeyCache.get(targetPageNo);
}
// Check alternate counts (20, 100, 40) only if primaryIndex succeeded without finding key
for (const altCount of [20, 40, 100]) {
if (altCount === thumbsPerPage) continue;
const altIndex = Math.max(0, Math.floor((targetPageNo - 1) / altCount));
if (altIndex !== primaryIndex) {
const lastAltFetch = fetchedGalleryPages.get(altIndex) || 0;
if (Date.now() - lastAltFetch > 8000) {
await fetchGalleryPage(altIndex, isPriority, false);
if (pageImgKeyCache.has(targetPageNo)) {
thumbsPerPage = altCount;
safeStorageSet(localStorage, 'eh_thumbs_per_page', String(altCount));
return pageImgKeyCache.get(targetPageNo);
}
}
}
}
return pageImgKeyCache.get(targetPageNo) || null;
})();
inFlightKeyLookups.set(targetPageNo, lookupPromise);
try {
return await lookupPromise;
} finally {
inFlightKeyLookups.delete(targetPageNo);
}
}
function getNextMeta(doc) {
const nextEl = doc.querySelector('a#next');
if (!nextEl) return null;
const oc = nextEl.getAttribute('onclick') || '';
const mClick = oc.match(/load_image\((\d+),\s*['"]([^'"]+)['"]\)/i);
if (mClick) return { page: parseInt(mClick[1], 10), imgKey: mClick[2] };
const mHref = (nextEl.getAttribute('href') || nextEl.href || '').match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
if (mHref) return { page: parseInt(mHref[2], 10), imgKey: mHref[1] };
return null;
}
function getTotalPages() {
if (cachedTotalPages > 0) return cachedTotalPages;
const spans = document.querySelectorAll('#i2 .sn span, #i4 .sn span');
for (let i = 0; i < spans.length; i++) {
const t = parseInt(spans[i].textContent.trim(), 10);
if (!isNaN(t) && t > 1) {
if (i + 1 < spans.length) {
const nextT = parseInt(spans[i + 1].textContent.trim(), 10);
if (!isNaN(nextT) && nextT >= t) {
cachedTotalPages = nextT;
return nextT;
}
}
cachedTotalPages = t;
return t;
}
}
const snEl = document.querySelector('#i2 .sn, #i4 .sn, .sn');
if (snEl) {
const match = snEl.textContent.match(/(\d+)\s*\/\s*(\d+)/);
if (match) {
const total = parseInt(match[2], 10);
if (!isNaN(total) && total > 0) {
cachedTotalPages = total;
return total;
}
}
}
return 0;
}
function getPageParams() {
if (cachedGid && cachedShowkey) {
return { gid: cachedGid, showkey: cachedShowkey, api_url: cachedApiUrl };
}
let gid = window.gid || cachedGid;
let showkey = window.showkey || cachedShowkey;
let api_url = window.api_url || cachedApiUrl;
if (!gid || !showkey) {
for (const s of document.scripts) {
const txt = s.textContent || '';
if (!gid) {
const mG = txt.match(/var\s+gid\s*=\s*(\d+)/);
if (mG) gid = parseInt(mG[1], 10);
}
if (!showkey) {
const mS = txt.match(/var\s+showkey\s*=\s*['"]([^'"]+)['"]/);
if (mS) showkey = mS[1];
}
if (!api_url) {
const mA = txt.match(/var\s+api_url\s*=\s*['"]([^'"]+)['"]/);
if (mA) api_url = mA[1];
}
if (gid && showkey && api_url) break;
}
}
if (gid) cachedGid = parseInt(gid, 10);
if (showkey) cachedShowkey = showkey;
if (api_url) cachedApiUrl = normalizeApiUrl(api_url);
else cachedApiUrl = location.origin + '/api.php';
return { gid: cachedGid || 0, showkey, api_url: cachedApiUrl };
}
function pruneCaches(activePage) {
if (stripActive) {
const UNLOAD_DISTANCE = 80;
for (const p of Array.from(loadedImages)) {
if (Math.abs(p - activePage) > UNLOAD_DISTANCE) {
const item = pageItemMap.get(p);
if (item) {
if (typeof item._abortImg === 'function') {
item._abortImg();
item._abortImg = null;
}
const img = item.querySelector('img.eh-main-img');
if (img) {
img.onload = null;
img.onerror = null;
img.removeAttribute('src');
img.src = '';
img.remove();
}
item._currentImgToken = null;
loadedImages.delete(p);
renderPlaceholderContent(item, p);
}
}
}
// Immediately abort any in-flight image loads that fell outside active window
for (const [p, startTime] of loadingPages.entries()) {
if (Math.abs(p - activePage) > (PREFETCH_DEPTH + 3)) {
abortPageLoad(p);
}
}
// Unload distant placeholder thumbnail images (> 80 pages away) to free memory
const THUMB_UNLOAD_DISTANCE = 80;
for (const [p, item] of pageItemMap.entries()) {
if (Math.abs(p - activePage) > THUMB_UNLOAD_DISTANCE && item.classList.contains('eh-placeholder')) {
const oldThumbs = item.querySelectorAll('.eh-placeholder-thumb-img, .eh-placeholder-thumb-wrap');
if (oldThumbs.length > 0) {
oldThumbs.forEach(t => {
if (t.tagName === 'IMG') {
t.onload = null;
t.onerror = null;
t.removeAttribute('src');
t.src = '';
} else {
t.querySelectorAll('img').forEach(i => {
i.onload = null;
i.onerror = null;
i.removeAttribute('src');
i.src = '';
});
}
t.remove();
});
}
}
}
}
for (const [page, record] of preloadedImages.entries()) {
if (stripActive || page < activePage - 4 || page > activePage + 8) {
if (record && !record.isReady && record.img) {
record.img.onload = null;
record.img.onerror = null;
record.img.removeAttribute('src');
record.img.src = '';
}
preloadedImages.delete(page);
}
}
const respLimit = stripActive ? 80 : 40;
if (pageRespCache.size > respLimit) {
for (const page of pageRespCache.keys()) {
if (Math.abs(page - activePage) > (stripActive ? 40 : 14)) {
pageRespCache.delete(page);
if (pageRespCache.size <= respLimit) break;
}
}
}
if (pageImgSrcCache.size > 100) {
for (const page of pageImgSrcCache.keys()) {
if (Math.abs(page - activePage) > 50) {
pageImgSrcCache.delete(page);
if (pageImgSrcCache.size <= 100) break;
}
}
}
for (const key of inFlightFetches.keys()) {
const p = parseInt(key, 10);
if (!isNaN(p) && Math.abs(p - activePage) > 20) {
inFlightFetches.delete(key);
}
}
if (pageTopBarInfoCache.size > 150) {
for (const page of pageTopBarInfoCache.keys()) {
if (Math.abs(page - activePage) > 75) {
pageTopBarInfoCache.delete(page);
if (pageTopBarInfoCache.size <= 150) break;
}
}
}
if (pageFileSizeCache.size > 150) {
for (const page of pageFileSizeCache.keys()) {
if (Math.abs(page - activePage) > 75) {
pageFileSizeCache.delete(page);
if (pageFileSizeCache.size <= 150) break;
}
}
}
if (pageNlCodes.size > 80) {
for (const page of pageNlCodes.keys()) {
if (Math.abs(page - activePage) > 40) {
pageNlCodes.delete(page);
if (pageNlCodes.size <= 80) break;
}
}
}
if (failedPages.size > 50) {
for (const page of failedPages.keys()) {
if (Math.abs(page - activePage) > 40) {
failedPages.delete(page);
if (failedPages.size <= 50) break;
}
}
}
const curGalleryIdx = Math.max(0, Math.floor((activePage - 1) / thumbsPerPage));
if (fetchedGalleryPages.size > 40) {
for (const gIdx of fetchedGalleryPages.keys()) {
if (Math.abs(gIdx - curGalleryIdx) > 15) {
fetchedGalleryPages.delete(gIdx);
if (fetchedGalleryPages.size <= 40) break;
}
}
}
if (pageImgKeyCache.size > 500) {
for (const page of pageImgKeyCache.keys()) {
if (Math.abs(page - activePage) > 250) {
pageImgKeyCache.delete(page);
if (pageImgKeyCache.size <= 500) break;
}
}
}
}
function fetchApi(pageNo, imgKey, nlCode = null, forceFresh = false, isPriority = false) {
const parsedPage = parseInt(pageNo, 10);
const tot = getTotalPages();
if (!parsedPage || isNaN(parsedPage) || parsedPage < 1 || (tot > 0 && parsedPage > tot)) {
return Promise.resolve(null);
}
if (!imgKey || typeof imgKey !== 'string' || !/^[0-9a-zA-Z]+$/.test(imgKey)) {
return Promise.resolve(null);
}
// If circuit breaker is active and this is not a priority request, avoid even queueing it
if (apiCircuitBreaker.isBlocked(isPriority)) {
return Promise.resolve(null);
}
const cacheKey = parsedPage + (nlCode ? '_' + nlCode : '');
if (!forceFresh && inFlightFetches.has(cacheKey)) {
return inFlightFetches.get(cacheKey);
}
const { gid, showkey } = getPageParams();
if (!gid || !showkey || !imgKey) return Promise.resolve(null);
const requestPayload = {
method: 'showpage',
gid: parseInt(gid, 10),
page: parsedPage,
imgkey: String(imgKey).trim(),
showkey: showkey
};
if (nlCode) {
requestPayload.nl = nlCode;
}
const fetchPromise = (async () => {
await apiLimiter.acquire(isPriority);
let slotReleased = false;
const releaseSlot = () => {
if (!slotReleased) { slotReleased = true; apiLimiter.release(); }
};
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = setTimeout(() => {
if (controller) controller.abort();
}, 18000);
try {
if (apiCircuitBreaker.isBlocked(isPriority)) return null;
// Queued while the user scrolled/turned on: don't spend a request on a page nobody is near anymore
if (stripActive && !isPageInActiveWindow(parsedPage, 6)) return null;
// Single mode prefetches API data up to 6 pages ahead; drop far-away requests
if (!stripActive && !isPriority && Math.abs(parsedPage - currentPageNum) > 6) return null;
if (!stripActive && isPriority && !forceFresh && Math.abs(parsedPage - currentPageNum) > 2) return null;
// If priority request arrives during backoff, wait briefly (up to 3s)
if (isPriority && apiCircuitBreaker.backoffUntil > Date.now()) {
const waitTime = Math.min(3000, apiCircuitBreaker.backoffUntil - Date.now());
if (waitTime > 0) await new Promise(r => setTimeout(r, waitTime));
}
const fetchOptions = {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(requestPayload),
signal: controller ? controller.signal : undefined
};
if (isPriority) {
fetchOptions.priority = 'high';
}
const res = await fetch(location.origin + '/api.php', fetchOptions);
clearTimeout(timer);
if (!res.ok) {
apiCircuitBreaker.recordError(res.status, `HTTP ${res.status}`);
console.warn(`[ExFastReader] API returned HTTP ${res.status} on page ${parsedPage}`);
return null;
}
const text = await res.text();
const data = safeParseJson(text);
if (data && !data.error) {
apiCircuitBreaker.recordSuccess();
cacheBottomFromResp(parsedPage, data);
return data;
} else {
let errStr = data?.error || '';
if (!errStr && typeof text === 'string') {
const trimmed = text.trim();
if (trimmed.length > 0 && trimmed.length < 200 && !trimmed.startsWith('<')) {
errStr = trimmed;
} else if (trimmed.includes('Key expired') || trimmed.includes('Invalid key')) {
errStr = 'Key expired';
} else if (trimmed.includes('Image limit exceeded')) {
errStr = 'Image limit exceeded';
} else if (trimmed.includes('temporarily banned')) {
errStr = 'IP temporarily banned';
}
}
if (!errStr) errStr = 'Invalid API response';
// If key is expired or invalid, invalidate it so a fresh key is fetched from the gallery
if (/key expired|invalid key/i.test(errStr)) {
pageImgKeyCache.delete(parsedPage);
}
apiCircuitBreaker.recordError(200, errStr);
console.warn(`[ExFastReader] API returned error on page ${parsedPage}:`, errStr);
return null;
}
} catch (err) {
clearTimeout(timer);
if (err && err.name === 'AbortError') {
return null;
}
apiCircuitBreaker.recordError(0, err && err.message);
console.warn(`[ExFastReader] Request failed on page ${parsedPage}:`, (err && err.message) || 'Network error');
return null;
} finally {
clearTimeout(timer);
releaseSlot();
inFlightFetches.delete(cacheKey);
}
})();
inFlightFetches.set(cacheKey, fetchPromise);
return fetchPromise;
}
function prefetchSingleMode(cur) {
if (stripActive || isFastScrolling()) return;
if (apiCircuitBreaker.isBlocked(false)) return;
const total = getTotalPages();
prefetchNearbyThumbnails(cur);
// Fetch API data for nearest pages ahead/behind, skipping failed pages
const targets = [cur + 1, cur + 2, cur + 3, cur - 1, cur + 4, cur - 2]
.filter(p => p >= 1 && (!total || p <= total) && !failedPages.has(p));
let delay = 0;
for (const p of targets) {
if (apiCircuitBreaker.isBlocked(false)) break;
const wantImage = p >= cur - 1 && p <= cur + 3;
const handleResp = (resp) => {
const d = parseRespDisplayDims(resp);
if (d) recordDisplayDims(p, d.width, d.height);
if (!wantImage) return;
const i3Doc = parseTemplate(resp.i3);
const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
const imgSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
if (imgSrc) {
pageImgSrcCache.set(p, imgSrc);
if (!preloadedImages.has(p)) preloadSingleImage(p, imgSrc);
}
};
if (pageRespCache.has(p)) {
handleResp(pageRespCache.get(p));
continue;
}
const runFetch = (key) => {
if (!key || apiCircuitBreaker.isBlocked(false)) return;
fetchApi(p, key).then((resp) => {
if (resp) {
pageRespCache.set(p, resp);
extractKeysFromResp(resp, p);
const extracted = extractInfoFromResp(resp, p);
if (extracted) pageTopBarInfoCache.set(p, extracted);
handleResp(resp);
}
});
};
const targetP = p;
setTimeout(() => {
if (stripActive || currentPageNum !== cur || apiCircuitBreaker.isBlocked(false)) return;
const imgKey = pageImgKeyCache.get(targetP);
if (imgKey) {
runFetch(imgKey);
} else {
ensureKeysForPage(targetP, false).then((key) => {
if (key) runFetch(key);
});
}
}, delay);
delay += 60;
}
}
function preloadSingleImage(pageNo, src, attempt = 0) {
if (preloadedImages.has(pageNo)) return;
const img = new Image();
img.fetchPriority = 'low';
img.decoding = 'async';
const startTime = Date.now();
const record = { img, isReady: false, promise: null, startTime };
let settled = false;
let stallId = null;
const finish = () => {
settled = true;
if (stallId) { clearTimeout(stallId); stallId = null; }
};
const retry = (why) => {
if (preloadedImages.get(pageNo) === record) preloadedImages.delete(pageNo);
if (attempt >= MAX_RETRIES) return;
if (apiCircuitBreaker.isBlocked(false)) return;
console.warn(`ExFastReader: Preload of page ${pageNo} ${why}, retry ${attempt + 1}/${MAX_RETRIES}`);
const delay = Math.min(6000, 1000 * Math.pow(1.8, attempt));
setTimeout(async () => {
if (stripActive || preloadedImages.has(pageNo) || apiCircuitBreaker.isBlocked(false)) return;
const key = pageImgKeyCache.get(pageNo);
if (!key) return;
const fresh = await fetchApi(pageNo, key, pageNlCodes.get(pageNo) || null, true);
if (!fresh || fresh.error || stripActive) return;
pageRespCache.set(pageNo, fresh);
const newNl = extractNlCode(fresh);
if (newNl) pageNlCodes.set(pageNo, newNl);
const d = parseRespDisplayDims(fresh);
if (d) recordDisplayDims(pageNo, d.width, d.height);
const i3Doc = parseTemplate(fresh.i3);
const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
const newSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
if (newSrc) {
pageImgSrcCache.set(pageNo, newSrc);
preloadSingleImage(pageNo, newSrc, attempt + 1);
}
}, delay);
};
record.promise = new Promise((resolve) => {
img.onload = async () => {
img.onload = null;
img.onerror = null;
finish();
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
record.isReady = false;
resolve(null);
retry('0x0 dimension on preload');
return;
}
try {
if ('decode' in img) await img.decode();
} catch {}
record.isReady = true;
updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
recordDownloadSpeedSample(pageNo, durationSec);
resolve(img);
};
img.onerror = () => {
img.onload = null;
img.onerror = null;
finish();
record.isReady = false;
resolve(null);
retry('failed to load');
};
const { hardTimeoutMs } = calculateImageTimeout(pageNo);
stallId = setTimeout(() => {
if (settled || record.isReady) return;
if (preloadedImages.get(pageNo) !== record) { finish(); return; }
finish();
img.onload = null;
img.onerror = null;
img.removeAttribute('src');
img.src = '';
resolve(null);
retry(`stalled (> ${(hardTimeoutMs / 1000).toFixed(1)}s)`);
}, hardTimeoutMs);
img.src = src;
});
preloadedImages.set(pageNo, record);
}
function startPrefetch() {
const nextMeta = getNextMeta(document);
if (nextMeta) {
pageImgKeyCache.set(nextMeta.page, nextMeta.imgKey);
}
prefetchSingleMode(currentPageNum);
}
// =========================================================================
// 4. BIDIRECTIONAL STRIP MODE ENGINE (RTL & VERTICAL WITH PLACEHOLDERS)
// =========================================================================
function getOrCreateStripContainer() {
if (!isReaderPage || !stripActive) return null;
let container = document.getElementById('eh-strip-container');
if (!container) {
container = document.createElement('div');
container.id = 'eh-strip-container';
container.className = `eh-strip-container layout-${currentLayout}`;
const i3 = document.getElementById('i3');
if (i3 && i3.parentNode) {
i3.parentNode.insertBefore(container, i3.nextSibling);
} else {
const i1 = document.getElementById('i1') || document.body;
i1.appendChild(container);
}
container.addEventListener('scroll', () => {
lastScrollTime = Date.now();
scheduleScrollUpdate();
}, { passive: true });
container.addEventListener('pointerover', (e) => {
if (Date.now() - lastScrollTime < 350 || isFastScrolling()) return;
const item = e.target.closest('.eh-strip-item');
if (item && item.dataset.page && item.classList.contains('eh-placeholder')) {
const page = parseInt(item.dataset.page, 10);
if (page && isPageEligibleToLoad(page)) {
loadPage(page, 0);
}
}
}, { passive: true });
}
return container;
}
function ensureStartSpacer() {
if (!isReaderPage || !stripActive) return null;
const container = getOrCreateStripContainer();
if (!container) return null;
let spacer = document.getElementById('eh-strip-start-spacer');
if (!spacer) {
spacer = document.createElement('div');
spacer.id = 'eh-strip-start-spacer';
spacer.className = 'eh-strip-start-spacer';
if (container.firstChild) {
container.insertBefore(spacer, container.firstChild);
} else {
container.appendChild(spacer);
}
} else if (spacer !== container.firstChild) {
container.insertBefore(spacer, container.firstChild);
}
return spacer;
}
function ensureBottomGalleryButton() {
if (!stripActive) return;
const url = getGalleryUrl();
if (currentLayout === 'vertical') {
const existingEndCard = document.getElementById('eh-strip-end-card');
if (existingEndCard) existingEndCard.remove();
let bar = document.getElementById('eh-bottom-gallery-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'eh-bottom-gallery-bar';
bar.className = 'eh-bottom-gallery-bar';
bar.innerHTML = `<a href="${url}">← Return to Gallery</a>`;
const container = getOrCreateStripContainer();
if (container.parentNode) {
container.parentNode.insertBefore(bar, container.nextSibling);
}
} else {
const link = bar.querySelector('a');
if (link) link.href = url;
}
} else {
const existingBar = document.getElementById('eh-bottom-gallery-bar');
if (existingBar) existingBar.remove();
const container = getOrCreateStripContainer();
let endCard = document.getElementById('eh-strip-end-card');
if (!endCard) {
endCard = document.createElement('div');
endCard.id = 'eh-strip-end-card';
endCard.className = 'eh-strip-item eh-strip-end-card';
endCard.innerHTML = `<div class="eh-bottom-gallery-bar"><a href="${url}">← Return to Gallery</a></div>`;
container.appendChild(endCard);
} else {
const link = endCard.querySelector('a');
if (link) link.href = url;
if (endCard.nextSibling) {
container.appendChild(endCard);
}
}
}
}
function applyItemDimensions(item, pageNo) {
const dim = pageDimensions.get(pageNo);
const th = pageThumbnailCache.get(pageNo);
const thumbAr = (th && th.w > 0 && th.h > 0) ? (th.w / th.h) : 0;
// One source of truth for placeholder size: the same estimate the single-page view uses
const tgt = getPageTargetDimensions(pageNo);
const ar = (tgt && tgt.aspectRatio > 0) ? tgt.aspectRatio : (dim?.aspectRatio || thumbAr || defaultAspectRatio);
item.style.setProperty('--eh-ar', String(ar));
item.style.aspectRatio = String(ar);
if (currentLayout === 'horizontal') {
item.style.width = `calc(100vh * ${ar})`;
item.style.maxWidth = '';
} else {
item.style.width = '100%';
const maxW = (tgt && tgt.width > 500) ? tgt.width : ((dim && dim.width && dim.width > 500) ? dim.width : 0);
if (maxW > 500) {
item.style.setProperty('--eh-max-w', maxW + 'px');
} else {
item.style.setProperty('--eh-max-w', '100%');
}
}
}
function renderPlaceholderContent(item, pageNo, retryCount = 0) {
item.classList.add('eh-placeholder');
if (loadingPages.has(pageNo)) {
item.classList.add('eh-loading');
} else {
item.classList.remove('eh-loading');
}
const oldContent = item.querySelector('.eh-placeholder-content');
if (oldContent) oldContent.remove();
// Only eagerly attach thumbnails for immediate visible neighbors; others are loaded lazily by IntersectionObserver
if (Math.abs(pageNo - currentPageNum) <= 6) {
applyThumbnailToPlaceholder(item, pageNo);
}
// Only show the spinner & status text if the image is having trouble loading
const failInfo = failedPages.get(pageNo);
const isTroubled = (retryCount > 0) || (failInfo && failInfo.count > 0);
if (isTroubled) {
if (failInfo && failInfo.count >= MAX_RETRIES) {
showRetryUI(item, pageNo);
} else {
const content = document.createElement('div');
content.className = 'eh-placeholder-content';
const spinner = document.createElement('div');
spinner.className = 'eh-spinner';
const text = document.createElement('div');
text.className = 'eh-placeholder-text';
text.textContent = `Retrying Page ${pageNo}...`;
content.appendChild(spinner);
content.appendChild(text);
item.appendChild(content);
}
}
}
function ensureAllPlaceholders(tot) {
if (!isReaderPage || !stripActive || !tot || tot <= 0) return;
const container = getOrCreateStripContainer();
let added = false;
withScrollAnchor(() => {
ensureStartSpacer();
const obs = getStripItemObserver();
const endCard = document.getElementById('eh-strip-end-card');
let batchFrag = null;
let batchNextEl = null;
const flushBatch = () => {
if (batchFrag && batchFrag.childNodes.length > 0) {
if (batchNextEl && batchNextEl.isConnected) {
container.insertBefore(batchFrag, batchNextEl);
} else if (endCard && endCard.isConnected) {
container.insertBefore(batchFrag, endCard);
} else {
container.appendChild(batchFrag);
}
batchFrag = null;
batchNextEl = null;
}
};
for (let p = 1; p <= tot; p++) {
if (pageItemMap.has(p)) {
flushBatch();
continue;
}
const item = document.createElement('div');
item.className = 'eh-strip-item eh-placeholder';
item.dataset.page = String(p);
applyItemDimensions(item, p);
renderPlaceholderContent(item, p);
pageItemMap.set(p, item);
if (obs) obs.observe(item);
if (!batchFrag) {
batchFrag = document.createDocumentFragment();
let nextEl = null;
for (let nextP = p + 1; nextP <= tot; nextP++) {
if (pageItemMap.has(nextP)) {
const el = pageItemMap.get(nextP);
if (el && el.isConnected) {
nextEl = el;
break;
}
}
}
batchNextEl = nextEl;
}
batchFrag.appendChild(item);
added = true;
}
flushBatch();
});
if (added) {
syncPageIndicator(currentPageNum);
}
}
function createOrGetPlaceholder(pageNo) {
if (!isReaderPage || !stripActive) return null;
const container = getOrCreateStripContainer();
if (!container) return null;
let item = pageItemMap.get(pageNo);
if (item) return item;
item = document.createElement('div');
item.className = 'eh-strip-item eh-placeholder';
item.dataset.page = String(pageNo);
applyItemDimensions(item, pageNo);
renderPlaceholderContent(item, pageNo);
pageItemMap.set(pageNo, item);
const obs = getStripItemObserver();
if (obs) obs.observe(item);
withScrollAnchor(() => {
ensureStartSpacer();
const existing = container.querySelectorAll('.eh-strip-item:not(.eh-strip-end-card)');
let inserted = false;
for (const el of existing) {
const p = parseInt(el.dataset.page, 10);
if (p > pageNo) {
container.insertBefore(item, el);
inserted = true;
break;
}
}
if (!inserted) {
const endCard = document.getElementById('eh-strip-end-card');
if (endCard) {
container.insertBefore(item, endCard);
} else {
container.appendChild(item);
}
}
});
return item;
}
function renderImageInItem(pageNo, imgSrc, retryCount = 0) {
if (!isReaderPage || !stripActive) return;
let item = pageItemMap.get(pageNo);
if (!item) {
item = createOrGetPlaceholder(pageNo);
}
if (!item) return;
const existingImg = item.querySelector('img.eh-main-img');
if (existingImg && existingImg.src === imgSrc && loadedImages.has(pageNo)) {
loadingPages.delete(pageNo);
setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);
return;
}
const img = document.createElement('img');
img.alt = `Page ${pageNo}`;
img.className = 'eh-main-img';
img.decoding = 'async';
const startTime = Date.now();
const dim = pageDimensions.get(pageNo);
if (dim && dim.width && dim.height && dim.width > 500) {
img.width = dim.width;
img.height = dim.height;
}
item._currentImgToken = img;
let slowTimerId = null;
let hardTimerId = null;
let isSettled = false;
const cleanupTimers = () => {
if (slowTimerId) { clearTimeout(slowTimerId); slowTimerId = null; }
if (hardTimerId) { clearTimeout(hardTimerId); hardTimerId = null; }
};
const cleanupImg = () => {
cleanupTimers();
img.onload = null;
img.onerror = null;
};
const abortImg = () => {
isSettled = true;
cleanupImg();
img.removeAttribute('src');
img.src = '';
};
item._abortImg = abortImg;
const handleFailure = (reason, isSlowStall = false) => {
if (isSettled) return;
isSettled = true;
item._abortImg = null;
cleanupImg();
// Release socket connection immediately
img.removeAttribute('src');
img.src = '';
loadingPages.delete(pageNo);
if (item) item.classList.remove('eh-loading');
setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);
if (item._currentImgToken !== img) return;
console.warn(`ExFastReader: Image for page ${pageNo} ${reason}, attempt ${retryCount + 1}/${MAX_RETRIES}`);
if (retryCount < MAX_RETRIES) {
loadingPages.set(pageNo, Date.now());
const delay = isSlowStall ? 100 : Math.min(2000, 400 * Math.pow(1.5, retryCount));
setTimeout(() => {
if (!stripActive || !isPageInActiveWindow(pageNo)) {
loadingPages.delete(pageNo);
return;
}
reloadPageImage(pageNo, retryCount + 1);
}, delay);
} else {
loadingPages.delete(pageNo);
loadedImages.delete(pageNo);
if (isPageInActiveWindow(pageNo)) {
recordPageFailure(pageNo, reason);
showRetryUI(item, pageNo);
}
setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);
}
};
img.onload = async () => {
if (!stripActive || isSettled) return;
isSettled = true;
item._abortImg = null;
cleanupImg();
loadingPages.delete(pageNo);
if (item) item.classList.remove('eh-loading');
setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);
if (item._currentImgToken !== img) return;
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
handleFailure('0x0 dimension on arrival', false);
return;
}
try {
if ('decode' in img) await img.decode();
} catch {}
if (item._currentImgToken !== img) return;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
recordDisplayDims(pageNo, img.naturalWidth, img.naturalHeight);
updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
}
if (!item.contains(img)) {
item.appendChild(img);
}
item.classList.remove('eh-placeholder');
const content = item.querySelector('.eh-placeholder-content');
if (content) content.remove();
const thumbs = item.querySelectorAll('.eh-placeholder-thumb-img, .eh-placeholder-thumb-wrap');
thumbs.forEach(t => {
if (t.tagName === 'IMG') {
t.onload = null;
t.onerror = null;
t.removeAttribute('src');
t.src = '';
} else {
t.querySelectorAll('img').forEach(i => { i.onload = null; i.onerror = null; i.removeAttribute('src'); i.src = ''; });
}
t.remove();
});
const oldImg = item.querySelector('img.eh-main-img');
if (oldImg && oldImg !== img) {
oldImg.onload = null;
oldImg.onerror = null;
oldImg.removeAttribute('src');
oldImg.src = '';
oldImg.remove();
}
loadedImages.add(pageNo);
failedPages.delete(pageNo);
const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
recordDownloadSpeedSample(pageNo, durationSec);
if (currentPageNum === pageNo) {
updateTopBarInfo(pageNo);
}
};
img.onerror = () => {
handleFailure('network load error', false);
};
// Dynamic stall timeouts based on image file size and measured download speed
const { fileSize, speed, slowTimeoutMs, hardTimeoutMs } = calculateImageTimeout(pageNo);
slowTimerId = setTimeout(() => {
if (!isPageInActiveWindow(pageNo)) {
abortImg();
loadingPages.delete(pageNo);
return;
}
if (!img.complete || img.naturalWidth === 0) {
console.warn(`ExFastReader: Page ${pageNo} (${(fileSize / 1024).toFixed(0)} KB) is exceptionally slow (> ${(slowTimeoutMs / 1000).toFixed(1)}s at ${(speed / (1024 * 1024)).toFixed(2)} MB/s). Triggering failover bounce.`);
if (retryCount < MAX_RETRIES) {
handleFailure(`exceptionally slow (> ${(slowTimeoutMs / 1000).toFixed(1)}s)`, true);
}
}
}, slowTimeoutMs);
// Hard stall timeout safeguard based on file size and download speed
hardTimerId = setTimeout(() => {
if (!isPageInActiveWindow(pageNo)) {
abortImg();
loadingPages.delete(pageNo);
return;
}
if (!img.complete || img.naturalWidth === 0) {
handleFailure(`hard stalled (> ${(hardTimeoutMs / 1000).toFixed(1)}s)`, false);
}
}, hardTimeoutMs);
img.src = imgSrc;
}
function showRetryUI(item, pageNo) {
if (!item) return;
item.classList.add('eh-placeholder');
item.classList.remove('eh-loading');
applyThumbnailToPlaceholder(item, pageNo);
const oldContent = item.querySelector('.eh-placeholder-content');
if (oldContent) oldContent.remove();
const content = document.createElement('div');
content.className = 'eh-placeholder-content eh-placeholder-retry';
content.style.cursor = 'pointer';
content.innerHTML = `
<div style="font-size: 20px;">⚠️</div>
<div class="eh-placeholder-text">Page ${pageNo} failed to load.</div>
<button type="button" class="eh-strip-btn active" style="margin-top: 4px;">Click to reload broken image</button>
`;
const handleRetry = (e) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
content.onclick = null;
const btn = content.querySelector('button');
if (btn) btn.onclick = null;
if (activeRetryTimers.has(pageNo)) {
clearTimeout(activeRetryTimers.get(pageNo));
activeRetryTimers.delete(pageNo);
}
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
failedPages.delete(pageNo);
pageImgSrcCache.delete(pageNo);
pageRespCache.delete(pageNo);
pageImgKeyCache.delete(pageNo);
apiCircuitBreaker.reset();
clearFailureToast();
renderPlaceholderContent(item, pageNo, 1);
reloadPageImage(pageNo, 0);
};
content.onclick = handleRetry;
const retryBtn = content.querySelector('button');
if (retryBtn) {
retryBtn.onclick = handleRetry;
}
item.appendChild(content);
}
async function reloadPageImage(pageNo, retryCount = 0) {
if (!stripActive) {
reloadBrokenImageSingle(pageNo, true);
return;
}
if (!isPageInActiveWindow(pageNo)) {
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
return;
}
if (retryCount >= MAX_RETRIES) {
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
recordPageFailure(pageNo, 'Exceeded max retries');
const item = pageItemMap.get(pageNo);
if (item) showRetryUI(item, pageNo);
return;
}
reloadingPages.add(pageNo);
loadingPages.set(pageNo, Date.now());
pageImgSrcCache.delete(pageNo);
pageRespCache.delete(pageNo);
const item = pageItemMap.get(pageNo);
if (item && !item.querySelector('img.eh-main-img')) {
renderPlaceholderContent(item, pageNo, retryCount + 1);
}
const nl = pageNlCodes.get(pageNo) || null;
let imgKey = pageImgKeyCache.get(pageNo);
if (!imgKey) {
imgKey = await ensureKeysForPage(pageNo, true);
}
if (!isPageInActiveWindow(pageNo)) {
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
return;
}
const { gid } = getPageParams();
// 1. Try HTML failover fetch: works whether nl is known or null!
if (imgKey && gid) {
try {
const failoverUrl = `${location.origin}/s/${imgKey}/${gid}-${pageNo}` + (nl ? `?nl=${encodeURIComponent(nl)}` : '');
const res = await fetch(failoverUrl, { credentials: 'include', priority: 'high' });
if (!res.ok) {
apiCircuitBreaker.recordError(res.status, `HTML failover HTTP ${res.status}`);
} else {
const html = await res.text();
if (/image limit exceeded|temporarily banned|excessive pageloads/i.test(html)) {
apiCircuitBreaker.recordError(509, 'Image limit exceeded / IP banned');
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const newImg = doc.getElementById('img');
const newSrc = newImg ? (newImg.getAttribute('src') || newImg.src) : null;
const nextNl = extractNlCode(doc);
if (nextNl) pageNlCodes.set(pageNo, nextNl);
if (newSrc && !newSrc.includes('blank.gif') && !newSrc.includes('509.gif')) {
reloadingPages.delete(pageNo);
pageImgSrcCache.set(pageNo, newSrc);
renderImageInItem(pageNo, newSrc, retryCount);
return;
}
}
} catch (err) {
console.warn(`ExFastReader: HTML failover fetch failed for page ${pageNo}:`, (err && err.message) || err);
}
}
if (!isPageInActiveWindow(pageNo)) {
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
return;
}
// 2. Fallback: try fetchApi
if (imgKey) {
try {
const freshResp = await fetchApi(pageNo, imgKey, nl || null, true, true);
if (freshResp) {
pageRespCache.set(pageNo, freshResp);
extractKeysFromResp(freshResp, pageNo);
const nextNl = extractNlCode(freshResp);
if (nextNl) pageNlCodes.set(pageNo, nextNl);
const extracted = extractInfoFromResp(freshResp, pageNo);
if (extracted) pageTopBarInfoCache.set(pageNo, extracted);
if (currentPageNum === pageNo) updateTopBarInfo(pageNo);
if (freshResp.i3) {
const i3Doc = parseTemplate(freshResp.i3);
const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
const freshSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
if (freshSrc) {
reloadingPages.delete(pageNo);
pageImgSrcCache.set(pageNo, freshSrc);
renderImageInItem(pageNo, freshSrc, retryCount);
return;
}
}
}
} catch (err) {
console.warn(`ExFastReader: API reload failed for page ${pageNo}:`, (err && err.message) || err);
}
}
recordPageFailure(pageNo, 'Image reload failed');
if (retryCount + 1 < MAX_RETRIES) {
const backoff = Math.min(8000, 1500 * Math.pow(1.8, retryCount));
if (activeRetryTimers.has(pageNo)) {
clearTimeout(activeRetryTimers.get(pageNo));
}
const timer = setTimeout(() => {
activeRetryTimers.delete(pageNo);
if (stripActive && isPageInActiveWindow(pageNo)) {
reloadPageImage(pageNo, retryCount + 1);
} else {
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
}
}, backoff);
activeRetryTimers.set(pageNo, timer);
} else {
reloadingPages.delete(pageNo);
loadingPages.delete(pageNo);
if (item && !item.querySelector('img.eh-main-img')) {
showRetryUI(item, pageNo);
}
}
}
function isPageEligibleToLoad(pageNo) {
if (!pageNo || isNaN(pageNo) || pageNo < 1) return false;
const tot = getTotalPages();
if (tot > 0 && pageNo > tot) return false;
if (loadedImages.has(pageNo) || loadingPages.has(pageNo) || reloadingPages.has(pageNo)) return false;
const failInfo = failedPages.get(pageNo);
if (failInfo) {
if (failInfo.count >= MAX_RETRIES) return false;
const isVisible = getVisiblePageNums().includes(pageNo);
const cooldown = Math.min(15000, (isVisible ? 1500 : 3000) * Math.pow(2, failInfo.count - 1));
if (Date.now() - failInfo.lastAttempt < cooldown) {
return false;
}
}
return true;
}
function recordPageFailure(pageNo, reason) {
const current = failedPages.get(pageNo) || { count: 0, lastAttempt: 0 };
current.count += 1;
current.lastAttempt = Date.now();
failedPages.set(pageNo, current);
console.warn(`ExFastReader: Page ${pageNo} failed (attempt ${current.count}): ${reason}`);
if (current.count >= MAX_RETRIES) {
const item = pageItemMap.get(pageNo);
if (item && !item.querySelector('img.eh-main-img')) {
showRetryUI(item, pageNo);
}
}
}
function getVisiblePagesAndCurrent() {
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const vpCenterX = vpW / 2;
const vpCenterY = vpH / 2;
const isHoriz = currentLayout === 'horizontal';
if (pageItemMap.size === 0) {
return { visible: [currentPageNum || 1], current: currentPageNum || 1 };
}
// 1. Instant O(1) probe at viewport center
let seedPage = null;
const centerEl = document.elementFromPoint(vpCenterX, vpCenterY)?.closest('.eh-strip-item');
if (centerEl && centerEl.dataset.page) {
const p = parseInt(centerEl.dataset.page, 10);
if (p && pageItemMap.has(p)) seedPage = p;
}
// 2. Fast check around currentPageNum or immediate neighbors
if (!seedPage && currentPageNum && pageItemMap.has(currentPageNum)) {
for (const testP of [currentPageNum, currentPageNum - 1, currentPageNum + 1]) {
const item = pageItemMap.get(testP);
if (item && item.isConnected) {
const r = item.getBoundingClientRect();
const inView = isHoriz ? (r.right > 0 && r.left < vpW) : (r.bottom > 0 && r.top < vpH);
if (inView) {
seedPage = testP;
break;
}
}
}
}
// 3. Fallback: monotonic binary search over pageItemMap keys (log2(N) <= 9 probes for 500 items)
if (!seedPage) {
const pages = Array.from(pageItemMap.keys()).sort((a, b) => a - b);
let low = 0, high = pages.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const p = pages[mid];
const item = pageItemMap.get(p);
if (!item || !item.isConnected) {
low = mid + 1;
continue;
}
const r = item.getBoundingClientRect();
if (isHoriz) {
// In RTL flex-row: earlier pages (page 1) are on the right (higher X).
if (r.left >= vpW) {
low = mid + 1;
} else if (r.right <= 0) {
high = mid - 1;
} else {
seedPage = p;
break;
}
} else {
if (r.bottom <= 0) {
low = mid + 1;
} else if (r.top >= vpH) {
high = mid - 1;
} else {
seedPage = p;
break;
}
}
}
}
if (!seedPage) {
seedPage = currentPageNum || 1;
}
const visible = [];
let bestPage = seedPage;
let minCenterDist = Infinity;
let centerCoveredPage = null;
function testPage(p) {
const item = pageItemMap.get(p);
if (!item || !item.isConnected) return false;
const r = item.getBoundingClientRect();
if (isHoriz) {
if (r.right > 0 && r.left < vpW) {
visible.push(p);
if (r.left <= vpCenterX && r.right >= vpCenterX) centerCoveredPage = p;
const dist = Math.abs((r.left + r.right) / 2 - vpCenterX);
if (dist < minCenterDist) {
minCenterDist = dist;
bestPage = p;
}
return true;
}
} else {
if (r.bottom > 0 && r.top < vpH) {
visible.push(p);
if (r.top <= vpCenterY && r.bottom >= vpCenterY) centerCoveredPage = p;
const dist = Math.abs((r.top + r.bottom) / 2 - vpCenterY);
if (dist < minCenterDist) {
minCenterDist = dist;
bestPage = p;
}
return true;
}
}
return false;
}
testPage(seedPage);
// Expand outward backwards
for (let p = seedPage - 1; p >= 1; p--) {
if (!testPage(p)) break;
}
// Expand outward forwards
const total = getTotalPages() || (seedPage + 100);
for (let p = seedPage + 1; p <= total; p++) {
if (!testPage(p)) break;
}
if (visible.length === 0) {
visible.push(bestPage);
}
visible.sort((a, b) => a - b);
const current = centerCoveredPage !== null ? centerCoveredPage : bestPage;
return { visible, current };
}
function getVisiblePageNums() {
return getVisiblePagesAndCurrent().visible;
}
function getCurrentlyVisiblePageNum() {
return getVisiblePagesAndCurrent().current;
}
function schedulePrefetchStripMetadata(curPage) {
if (!stripActive) return;
if (prefetchMetaTimer) clearTimeout(prefetchMetaTimer);
prefetchMetaTimer = setTimeout(() => {
prefetchStripMetadata(curPage);
}, 150);
}
function prefetchStripMetadata(cur) {
if (!stripActive) return;
if (apiCircuitBreaker.isBlocked(false)) return;
const total = getTotalPages();
const targets = [cur + 1, cur + 2]
.filter(p => p >= 1 && (!total || p <= total) && !failedPages.has(p));
for (const p of targets) {
if (apiCircuitBreaker.isBlocked(false)) break;
if (pageRespCache.has(p)) continue;
const dim = pageDimensions.get(p);
if (dim && dim.width >= 400 && dim.height >= 400) continue;
const imgKey = pageImgKeyCache.get(p);
if (imgKey) {
fetchApi(p, imgKey).then((resp) => {
if (resp && !resp.error) {
pageRespCache.set(p, resp);
extractKeysFromResp(resp, p);
const extracted = extractInfoFromResp(resp, p);
if (extracted) pageTopBarInfoCache.set(p, extracted);
}
});
}
}
}
function checkAndLoadNearbyPages(targetPage, precomputedVisible = null) {
if (!isReaderPage || !stripActive || isPumpingQueue) return;
isPumpingQueue = true;
try {
const cur = targetPage || currentPageNum;
const total = getTotalPages();
const visiblePages = precomputedVisible || getVisiblePageNums();
// Flicking through pages: start nothing new, drop everything that isn't on screen, and
// resume a moment after the scrolling stops (nearest page first).
if (isFastScrolling()) {
for (const p of Array.from(loadingPages.keys())) {
if (!visiblePages.includes(p)) abortPageLoad(p);
}
scheduleSettleCheck();
return;
}
// Abort in-flight loads for pages that are no longer near where we are
for (const p of Array.from(loadingPages.keys())) {
if (!isPageNeeded(p, visiblePages, cur)) {
abortPageLoad(p);
}
}
schedulePrefetchNearbyThumbnails(cur);
schedulePrefetchStripMetadata(cur);
const candidates = [];
const sortedVisible = scrollDirection === 'backward' ? [...visiblePages].reverse() : visiblePages;
for (const vp of sortedVisible) {
if (!candidates.includes(vp)) candidates.push(vp);
}
if (!candidates.includes(cur)) candidates.push(cur);
if (scrollDirection === 'backward') {
const backDepth = PREFETCH_DEPTH + 1;
for (let b = 1; b <= backDepth; b++) {
const p = cur - b;
if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
candidates.push(p);
}
}
for (let f = 1; f <= 2; f++) {
const p = cur + f;
if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
candidates.push(p);
}
}
} else {
const forwardDepth = currentLayout === 'horizontal' ? (PREFETCH_DEPTH + 1) : PREFETCH_DEPTH;
for (let f = 1; f <= forwardDepth; f++) {
const p = cur + f;
if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
candidates.push(p);
}
}
for (let b = 1; b <= 2; b++) {
const p = cur - b;
if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
candidates.push(p);
}
}
}
for (const pageNo of candidates) {
const isVisible = visiblePages.includes(pageNo) || pageNo === cur;
const limit = isVisible ? (MAX_CONCURRENT_LOADS + 2) : MAX_CONCURRENT_LOADS;
if (loadingPages.size >= limit) {
if (!isVisible) break;
continue;
}
if (isPageEligibleToLoad(pageNo)) {
loadPage(pageNo, 0);
}
}
} finally {
isPumpingQueue = false;
}
}
async function loadPage(pageNo, retryCount = 0) {
if (!isReaderPage || !stripActive || !isPageEligibleToLoad(pageNo)) return;
if (!isPageInActiveWindow(pageNo)) return;
loadingPages.set(pageNo, Date.now());
let renderStarted = false;
const item = pageItemMap.get(pageNo);
if (item) item.classList.add('eh-loading');
try {
let imgKey = pageImgKeyCache.get(pageNo);
if (!imgKey) {
imgKey = await ensureKeysForPage(pageNo, true);
}
if (!stripActive || !isPageInActiveWindow(pageNo)) {
return;
}
if (!imgKey) {
if (retryCount < MAX_RETRIES) {
loadingPages.delete(pageNo);
const backoff = Math.min(6000, 1000 * Math.pow(1.8, retryCount));
setTimeout(() => {
if (stripActive && isPageInActiveWindow(pageNo)) {
loadPage(pageNo, retryCount + 1);
}
}, backoff);
return;
}
recordPageFailure(pageNo, 'Missing image key');
const it = pageItemMap.get(pageNo);
if (it && !it.querySelector('img.eh-main-img')) showRetryUI(it, pageNo);
return;
}
let imgSrc = pageImgSrcCache.get(pageNo);
if (!imgSrc) {
let resp = pageRespCache.get(pageNo);
if (!resp) {
const isVis = getVisiblePageNums().includes(pageNo);
resp = await fetchApi(pageNo, imgKey, null, false, isVis);
if (resp && !resp.error) {
pageRespCache.set(pageNo, resp);
}
}
if (!stripActive || !isPageInActiveWindow(pageNo)) {
return;
}
if (!resp || resp.error || !resp.i3) {
const reason = !resp ? 'API request timed out or network error' : (resp.error ? `API error: ${resp.error}` : 'API response missing i3 container');
recordPageFailure(pageNo, reason);
if (retryCount < MAX_RETRIES) {
const backoff = Math.min(8000, 1200 * Math.pow(1.8, retryCount));
setTimeout(() => {
if (stripActive && isPageInActiveWindow(pageNo)) reloadPageImage(pageNo, retryCount + 1);
}, backoff);
} else {
const it = pageItemMap.get(pageNo);
if (it && !it.querySelector('img.eh-main-img')) showRetryUI(it, pageNo);
}
return;
}
if (!stripActive || !isPageInActiveWindow(pageNo)) {
return;
}
const newNl = extractNlCode(resp);
if (newNl) {
pageNlCodes.set(pageNo, newNl);
}
const i3Doc = parseTemplate(resp.i3);
const imgEl = i3Doc.querySelector('img#img');
imgSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
if (imgSrc) {
pageImgSrcCache.set(pageNo, imgSrc);
}
}
if (!stripActive || !isPageInActiveWindow(pageNo)) {
return;
}
if (imgSrc) {
renderStarted = true;
renderImageInItem(pageNo, imgSrc, retryCount);
} else {
recordPageFailure(pageNo, 'Image element missing src');
}
} catch (err) {
if (!stripActive || !isPageInActiveWindow(pageNo)) {
return;
}
recordPageFailure(pageNo, (err && err.message) || 'Exception during load');
} finally {
if (!renderStarted) {
loadingPages.delete(pageNo);
const it = pageItemMap.get(pageNo);
if (it) it.classList.remove('eh-loading');
}
setTimeout(() => {
if (stripActive) checkAndLoadNearbyPages();
}, 50);
}
}
function scheduleScrollUpdate() {
lastScrollTime = Date.now();
if (!isReaderPage || !stripActive || scrollRafId || isTransitioningLayout) return;
scrollRafId = requestAnimationFrame(() => {
scrollRafId = null;
onScrollThrottled();
});
}
function onScrollThrottled() {
if (!isReaderPage || !stripActive || isTransitioningLayout) return;
lastScrollTime = Date.now();
if (!animScrollRaf) targetScrollLeft = null;
if (!animScrollTopRaf) targetScrollTop = null;
const container = document.getElementById('eh-strip-container');
if (currentLayout === 'horizontal' && container) {
const curLeft = container.scrollLeft;
if (curLeft > lastScrollLeft + 2) {
scrollDirection = 'backward';
} else if (curLeft < lastScrollLeft - 2) {
scrollDirection = 'forward';
}
lastScrollLeft = curLeft;
} else {
const curY = window.scrollY;
if (curY < lastScrollY - 2) {
scrollDirection = 'backward';
} else if (curY > lastScrollY + 2) {
scrollDirection = 'forward';
}
lastScrollY = curY;
}
const { visible: visiblePages, current: activePage } = getVisiblePagesAndCurrent();
if (activePage < currentPageNum) {
scrollDirection = 'backward';
} else if (activePage > currentPageNum) {
scrollDirection = 'forward';
}
if (activePage !== currentPageNum) notePageChange();
currentPageNum = activePage;
syncPageIndicator(activePage);
updateTopBarInfo(activePage);
if (activePage !== lastUpdatePage) {
lastUpdatePage = activePage;
pruneCaches(activePage);
}
checkAndLoadNearbyPages(activePage, visiblePages);
schedulePrefetchNearbyThumbnails(activePage);
if (activePage !== lastHistoryPage) {
lastHistoryPage = activePage;
if (historyDebounceTimer) clearTimeout(historyDebounceTimer);
historyDebounceTimer = setTimeout(() => {
updatePageUrl(activePage, false);
}, 150);
}
}
function syncPageIndicator(activePage, force = false) {
if (!force && activePage === lastSyncedIndicatorPage) return;
lastSyncedIndicatorPage = activePage;
const total = getTotalPages();
const pageDisplay = document.getElementById('eh-strip-page-display');
if (pageDisplay) {
pageDisplay.textContent = total ? `Page ${activePage} / ${total}` : `Page ${activePage}`;
}
const curSpan = document.querySelector('#i2 .sn span:first-child');
if (curSpan && curSpan.textContent !== String(activePage)) {
curSpan.textContent = activePage;
}
const galUrl = getGalleryUrl(activePage);
const galleryBtn = document.getElementById('eh-strip-gallery-btn');
if (galleryBtn) {
galleryBtn.href = galUrl;
}
const bottomLinks = document.querySelectorAll('#eh-bottom-gallery-bar a, #eh-strip-end-card a');
for (const a of bottomLinks) {
a.href = galUrl;
}
}
function enableStripMode() {
stripActive = true;
safeStorageSet(localStorage, 'eh_strip_mode_enabled', '1');
setStripClasses(true, currentLayout);
updateToggleButtonUI();
const container = getOrCreateStripContainer();
container.className = `eh-strip-container layout-${currentLayout}`;
const initialMeta = getCurrentPageMeta();
if (!currentPageNum || currentPageNum < 1) {
currentPageNum = initialMeta.page || 1;
}
const targetPage = currentPageNum;
if (initialMeta.imgKey && !pageImgKeyCache.has(targetPage)) {
pageImgKeyCache.set(targetPage, initialMeta.imgKey);
}
detectInitialPageDimensions();
applyGlobalAspectRatio(defaultAspectRatio);
extractKeysFromDoc(document);
const initNl = extractInitialNlCode(document);
if (initNl) {
pageNlCodes.set(targetPage, initNl);
}
extractAndHideBottomLinks();
ensureStartSpacer();
const totalPages = getTotalPages();
if (totalPages > 0) {
const frag = document.createDocumentFragment();
for (let p = 1; p <= totalPages; p++) {
if (!pageItemMap.has(p)) {
const item = document.createElement('div');
item.className = 'eh-strip-item eh-placeholder';
item.dataset.page = String(p);
applyItemDimensions(item, p);
renderPlaceholderContent(item, p);
pageItemMap.set(p, item);
frag.appendChild(item);
}
}
const endCard = document.getElementById('eh-strip-end-card');
if (endCard) {
container.insertBefore(frag, endCard);
} else {
container.appendChild(frag);
}
} else {
createOrGetPlaceholder(targetPage);
}
const currentImg = document.getElementById('img');
if (currentImg && currentImg.src && currentImg._eh_page === targetPage && currentImg.complete && currentImg.naturalWidth > 0) {
pageImgSrcCache.set(targetPage, currentImg.src);
if (!loadedImages.has(targetPage)) {
renderImageInItem(targetPage, currentImg.src, 0);
}
}
ensureBottomGalleryButton();
discoverAndFetchGallery(currentPageNum);
isTransitioningLayout = true;
instantScrollToPage(currentPageNum);
const obs = getStripItemObserver();
if (obs) {
for (const it of pageItemMap.values()) {
obs.observe(it);
}
}
requestAnimationFrame(() => {
instantScrollToPage(currentPageNum);
isTransitioningLayout = false;
scheduleScrollUpdate();
checkAndLoadNearbyPages(currentPageNum);
});
lastUpdatePage = currentPageNum;
syncPageIndicator(currentPageNum);
updateTopBarInfo(currentPageNum);
}
function disableStripMode() {
stripActive = false;
cancelKeyboardAnimation();
safeStorageSet(localStorage, 'eh_strip_mode_enabled', '0');
if (historyDebounceTimer) {
clearTimeout(historyDebounceTimer);
historyDebounceTimer = null;
}
if (scrollRafId) {
cancelAnimationFrame(scrollRafId);
scrollRafId = null;
}
if (stripItemObserver) {
stripItemObserver.disconnect();
stripItemObserver = null;
}
const targetPage = getCurrentlyVisiblePageNum() || currentPageNum || 1;
currentPageNum = targetPage;
// Grab the already-loaded image from the strip item for the current page (before the strip is torn down)
const currentStripItem = pageItemMap.get(targetPage);
const currentStripImg = currentStripItem?.querySelector('img.eh-main-img');
const stripImgReady = Boolean(currentStripImg && currentStripImg.complete && currentStripImg.naturalWidth > 0);
const loadedStripSrc = stripImgReady ? currentStripImg.src : pageImgSrcCache.get(targetPage);
const loadedStripDim = stripImgReady
? {
width: currentStripImg.naturalWidth,
height: currentStripImg.naturalHeight,
aspectRatio: currentStripImg.naturalWidth / currentStripImg.naturalHeight
}
: null;
if (loadedStripDim) {
recordDisplayDims(targetPage, loadedStripDim.width, loadedStripDim.height);
}
for (const item of pageItemMap.values()) {
if (typeof item._abortImg === 'function') {
item._abortImg();
item._abortImg = null;
}
item._currentImgToken = null;
}
// The native #img still holds whichever page the tab was opened on. If the strip is sitting on
// a different page (e.g. a placeholder), un-hiding #i3 would flash that stale image at its stale
// size. Blank it and show the target page's placeholder BEFORE the single-page view becomes visible.
const nativeImg = document.getElementById('img');
if (nativeImg && (nativeImg._eh_page !== targetPage || nativeImg._eh_obsolete)) {
nativeImg._eh_obsolete = false;
nativeImg._eh_page = 0; // not any real page: keeps initImageMonitor from acting on the blank image
nativeImg.__eh_monitored = false;
nativeImg.onload = null;
nativeImg.onerror = null;
nativeImg.style.opacity = '0';
nativeImg.removeAttribute('src');
showSinglePageThumbnailPlaceholder(targetPage);
}
setStripClasses(false, currentLayout);
updateToggleButtonUI();
const container = document.getElementById('eh-strip-container');
if (container) {
container.removeEventListener('scroll', scheduleScrollUpdate);
container.remove();
}
const bottomBar = document.getElementById('eh-bottom-gallery-bar');
if (bottomBar) bottomBar.remove();
const startSpacer = document.getElementById('eh-strip-start-spacer');
if (startSpacer) startSpacer.remove();
pageItemMap.clear();
loadedImages.clear();
loadingPages.clear();
failedPages.clear();
// Instant transition: display the current page in single-page mode immediately
if (loadedStripSrc) {
displaySinglePageImage(targetPage, loadedStripSrc, loadedStripDim);
} else {
turnSinglePage(targetPage);
}
updateTopBarInfo(currentPageNum);
initImageMonitor();
startPrefetch();
}
function toggleStripMode() {
if (stripActive) {
disableStripMode();
} else {
enableStripMode();
}
}
function toggleStripLayout() {
const targetPage = currentPageNum || getCurrentlyVisiblePageNum() || 1;
cancelKeyboardAnimation();
isTransitioningLayout = true;
currentLayout = currentLayout === 'horizontal' ? 'vertical' : 'horizontal';
safeStorageSet(localStorage, 'eh_strip_layout', currentLayout);
setStripClasses(true, currentLayout);
const container = document.getElementById('eh-strip-container');
if (container) {
container.className = `eh-strip-container layout-${currentLayout}`;
if (currentLayout === 'vertical') {
container.scrollLeft = 0;
}
for (const [p, item] of pageItemMap.entries()) {
applyItemDimensions(item, p);
}
}
if (stripItemObserver) {
stripItemObserver.disconnect();
stripItemObserver = null;
}
const obs = getStripItemObserver();
if (obs) {
for (const item of pageItemMap.values()) {
obs.observe(item);
}
}
ensureBottomGalleryButton();
updateToggleButtonUI();
currentPageNum = targetPage;
lastUpdatePage = targetPage;
syncPageIndicator(targetPage);
updateTopBarInfo(targetPage);
if (container) void container.offsetHeight;
instantScrollToPage(targetPage);
requestAnimationFrame(() => {
instantScrollToPage(targetPage);
setTimeout(() => {
instantScrollToPage(targetPage);
currentPageNum = targetPage;
lastUpdatePage = targetPage;
syncPageIndicator(targetPage);
isTransitioningLayout = false;
scheduleScrollUpdate();
checkAndLoadNearbyPages(targetPage);
}, 60);
});
}
function getSingleModeLowerArea() {
const i5 = document.getElementById('i5');
const i6 = document.getElementById('i6');
const i7 = document.getElementById('i7');
const loadfail = document.getElementById('loadfail');
const loadfailParent = loadfail ? loadfail.closest('.idi, div, p') : null;
let textMatch = null;
const candidates = document.querySelectorAll('#i1 > div, #i1 > p, div.idi');
for (const el of candidates) {
const txt = (el.textContent || '').toLowerCase();
if (txt.includes('download original') || txt.includes('reload broken image') || txt.includes('show galleries')) {
textMatch = el;
break;
}
}
return i5 || i6 || i7 || loadfailParent || textMatch;
}
function placeStripControls() {
let controls = document.getElementById('eh-strip-controls');
if (!controls) return;
if (stripActive) {
controls.classList.remove('single-mode-bottom');
const i2 = document.getElementById('i2');
if (i2 && controls.parentNode !== i2) {
const topbarInfo = document.getElementById('eh-topbar-info');
if (topbarInfo) {
i2.insertBefore(controls, topbarInfo.nextSibling);
} else {
i2.appendChild(controls);
}
}
} else {
controls.classList.add('single-mode-bottom');
// Locate the 30x30px icon container that links back to the gallery (#i5 / div.sb)
const galleryIcon = document.getElementById('i5') ||
document.querySelector('div.sb') ||
document.querySelector('#i1 a[href*="/g/"] img')?.closest('div, p');
if (galleryIcon && galleryIcon.parentNode) {
// Insert directly underneath (after) the 30x30px gallery icon
if (galleryIcon.nextElementSibling !== controls || controls.parentNode !== galleryIcon.parentNode) {
galleryIcon.insertAdjacentElement('afterend', controls);
}
} else {
// Fallback placement if the gallery icon container isn't found
const lowerArea = getSingleModeLowerArea();
if (lowerArea && lowerArea.parentNode) {
if (controls.nextSibling !== lowerArea || controls.parentNode !== lowerArea.parentNode) {
lowerArea.parentNode.insertBefore(controls, lowerArea);
}
} else {
const i4 = document.getElementById('i4');
if (i4 && i4.parentNode) {
if (i4.nextElementSibling !== controls || controls.parentNode !== i4.parentNode) {
i4.insertAdjacentElement('afterend', controls);
}
} else {
const i1 = document.getElementById('i1') || document.body;
if (controls.parentNode !== i1) {
i1.appendChild(controls);
}
}
}
}
}
}
function updateToggleButtonUI() {
const modeBtn = document.getElementById('eh-strip-mode-toggle');
const statusText = document.getElementById('eh-strip-status');
const dirBtn = document.getElementById('eh-strip-dir-toggle');
const dirText = document.getElementById('eh-strip-dir-text');
const pageDisplay = document.getElementById('eh-strip-page-display');
const galleryBtn = document.getElementById('eh-strip-gallery-btn');
placeStripControls();
if (modeBtn && statusText) {
if (stripActive) {
modeBtn.classList.add('active');
statusText.textContent = 'ON';
} else {
modeBtn.classList.remove('active');
statusText.textContent = 'OFF';
}
}
if (dirBtn && dirText) {
dirBtn.style.display = stripActive ? 'inline-flex' : 'none';
dirText.textContent = currentLayout === 'horizontal' ? 'Horizontal (RTL)' : 'Vertical';
}
if (pageDisplay) {
pageDisplay.style.display = stripActive ? 'inline-block' : 'none';
}
if (galleryBtn) {
galleryBtn.style.display = stripActive ? 'inline-flex' : 'none';
galleryBtn.href = getGalleryUrl();
}
const frontBtn = document.getElementById('eh-strip-front-btn');
const onionBtn = document.getElementById('eh-strip-onion-btn');
if (frontBtn) {
frontBtn.style.display = stripActive ? 'inline-flex' : 'none';
frontBtn.href = cachedFrontUrl || (location.origin + '/');
}
if (onionBtn) {
onionBtn.style.display = stripActive && cachedOnionUrl ? 'inline-flex' : 'none';
if (cachedOnionUrl) onionBtn.href = cachedOnionUrl;
}
}
function injectReaderToggle() {
let controls = document.getElementById('eh-strip-controls');
if (!controls) {
controls = document.createElement('div');
controls.id = 'eh-strip-controls';
controls.className = 'eh-strip-controls';
const modeBtn = document.createElement('button');
modeBtn.type = 'button';
modeBtn.id = 'eh-strip-mode-toggle';
modeBtn.className = 'eh-strip-btn' + (stripActive ? ' active' : '');
modeBtn.title = 'Toggle Seamless Strip Reading Mode';
modeBtn.innerHTML = `Strip Mode: <span id="eh-strip-status">${stripActive ? 'ON' : 'OFF'}</span>`;
modeBtn.addEventListener('click', toggleStripMode);
const dirBtn = document.createElement('button');
dirBtn.type = 'button';
dirBtn.id = 'eh-strip-dir-toggle';
dirBtn.className = 'eh-strip-btn';
dirBtn.title = 'Switch Orientation (Horizontal RTL / Vertical)';
dirBtn.style.display = stripActive ? 'inline-flex' : 'none';
dirBtn.innerHTML = `<span id="eh-strip-dir-text">${currentLayout === 'horizontal' ? 'Horizontal (RTL)' : 'Vertical'}</span>`;
dirBtn.addEventListener('click', toggleStripLayout);
const galleryBtn = document.createElement('a');
galleryBtn.id = 'eh-strip-gallery-btn';
galleryBtn.className = 'eh-strip-btn';
galleryBtn.title = 'Return to Gallery';
galleryBtn.href = getGalleryUrl();
galleryBtn.textContent = 'Gallery';
galleryBtn.style.display = stripActive ? 'inline-flex' : 'none';
const frontBtn = document.createElement('a');
frontBtn.id = 'eh-strip-front-btn';
frontBtn.className = 'eh-strip-btn';
frontBtn.title = 'Front Page';
frontBtn.href = cachedFrontUrl || (location.origin + '/');
frontBtn.textContent = 'Front Page';
frontBtn.style.display = stripActive ? 'inline-flex' : 'none';
const onionBtn = document.createElement('a');
onionBtn.id = 'eh-strip-onion-btn';
onionBtn.className = 'eh-strip-btn';
onionBtn.title = 'Tor / Onion Mirror';
onionBtn.href = cachedOnionUrl || '#';
onionBtn.textContent = 'Tor / Onion';
onionBtn.target = '_blank';
onionBtn.rel = 'noopener noreferrer';
onionBtn.style.display = stripActive && cachedOnionUrl ? 'inline-flex' : 'none';
const pageDisplay = document.createElement('span');
pageDisplay.id = 'eh-strip-page-display';
pageDisplay.className = 'eh-strip-page-counter';
pageDisplay.style.display = stripActive ? 'inline-block' : 'none';
controls.appendChild(modeBtn);
controls.appendChild(dirBtn);
controls.appendChild(galleryBtn);
controls.appendChild(frontBtn);
controls.appendChild(onionBtn);
controls.appendChild(pageDisplay);
const i2 = document.getElementById('i2');
if (i2) {
i2.appendChild(controls);
} else {
document.body.appendChild(controls);
}
}
placeStripControls();
updateToggleButtonUI();
syncPageIndicator(getCurrentlyVisiblePageNum());
}
let lastScrollTime = 0;
window.addEventListener(
'mousemove',
(e) => {
if (!isReaderPage || !stripActive) return;
if (Date.now() - lastScrollTime < 350) {
document.body.classList.remove('eh-topbar-visible');
return;
}
const i2 = document.getElementById('i2');
const isVisible = document.body.classList.contains('eh-topbar-visible');
const bottom = (i2 && isVisible) ? Math.max(50, i2.getBoundingClientRect().bottom) : 30;
if (e.clientY <= bottom) {
document.body?.classList.add('eh-topbar-visible');
} else {
document.body?.classList.remove('eh-topbar-visible');
}
},
{ passive: true }
);
document.addEventListener('mouseleave', () => {
if (!isReaderPage || !stripActive) return;
document.body.classList.remove('eh-topbar-visible');
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
});
window.addEventListener('blur', () => {
if (!isReaderPage || !stripActive) return;
document.body.classList.remove('eh-topbar-visible');
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
});
document.addEventListener('click', (e) => {
if (!isReaderPage || !stripActive) return;
const i2 = document.getElementById('i2');
if (i2 && i2.contains(e.target)) {
const btn = e.target.closest('button, a');
if (btn) {
setTimeout(() => { if (btn.blur) btn.blur(); }, 50);
}
} else if (i2 && e.clientY > 50) {
document.body.classList.remove('eh-topbar-visible');
}
}, { passive: true });
window.addEventListener('scroll', (e) => {
if (!isReaderPage || !stripActive) return;
lastScrollTime = Date.now();
document.body.classList.remove('eh-topbar-visible');
scheduleScrollUpdate();
}, { passive: true });
window.addEventListener(
'wheel',
(e) => {
disarmSingleAlign();
cancelKeyboardAnimation();
if (!isReaderPage || !stripActive || isTransitioningLayout) return;
const container = document.getElementById('eh-strip-container');
if (!container) return;
if (e.target && e.target.closest('#i2')) return;
if (currentLayout === 'horizontal') {
if (Math.abs(e.deltaY) >= Math.abs(e.deltaX)) {
e.preventDefault();
const rawDelta = e.deltaMode === 1 ? e.deltaY * 33 : (e.deltaMode === 2 ? e.deltaY * 100 : e.deltaY);
const clampedDelta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 100);
container.scrollLeft -= clampedDelta;
scheduleScrollUpdate();
}
}
},
{ passive: false }
);
let resizeTimer = null;
function handleViewportResize() {
if (!isReaderPage || !stripActive) return;
isTransitioningLayout = true;
cancelKeyboardAnimation();
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
instantScrollToPage(currentPageNum);
isTransitioningLayout = false;
scheduleScrollUpdate();
}, 70);
}
window.addEventListener('resize', handleViewportResize, { passive: true });
document.addEventListener('fullscreenchange', handleViewportResize, { passive: true });
document.addEventListener('webkitfullscreenchange', handleViewportResize, { passive: true });
function instantScrollToPage(targetPage) {
if (!isReaderPage || !stripActive) return;
const container = document.getElementById('eh-strip-container');
if (!container) return;
let item = pageItemMap.get(targetPage);
if (!item) item = createOrGetPlaceholder(targetPage);
if (!item) return;
cancelKeyboardAnimation();
if (currentLayout === 'horizontal') {
window.scrollTo(0, 0);
const itemRect = item.getBoundingClientRect();
const contRect = container.getBoundingClientRect();
const deltaX = ((itemRect.left + itemRect.right) / 2) - ((contRect.left + contRect.right) / 2);
const maxScroll = 0;
const minScroll = -(container.scrollWidth - container.clientWidth);
let targetLeft = container.scrollLeft + deltaX;
if (targetLeft > maxScroll) targetLeft = maxScroll;
if (targetLeft < minScroll) targetLeft = minScroll;
container.scrollLeft = targetLeft;
lastScrollLeft = targetLeft;
} else {
if (container) container.scrollLeft = 0;
const itemRect = item.getBoundingClientRect();
const deltaY = (itemRect.height <= window.innerHeight)
? (((itemRect.top + itemRect.bottom) / 2) - (window.innerHeight / 2))
: itemRect.top;
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
let targetTop = window.scrollY + deltaY;
if (targetTop < 0) targetTop = 0;
if (targetTop > maxScroll) targetTop = maxScroll;
window.scrollTo(0, targetTop);
lastScrollY = targetTop;
}
}
function scrollToPage(targetPage) {
if (!isReaderPage || !stripActive) return;
const total = getTotalPages();
if (total > 0) {
if (targetPage < 1) targetPage = 1;
if (targetPage > total) targetPage = total;
} else if (targetPage < 1) {
targetPage = 1;
}
currentPageNum = targetPage;
syncPageIndicator(targetPage);
updateTopBarInfo(targetPage);
checkAndLoadNearbyPages(targetPage);
let item = pageItemMap.get(targetPage);
if (!item) item = createOrGetPlaceholder(targetPage);
if (!item) return;
const container = document.getElementById('eh-strip-container');
if (!container) return;
cancelKeyboardAnimation();
if (currentLayout === 'horizontal') {
const itemRect = item.getBoundingClientRect();
const contRect = container.getBoundingClientRect();
const deltaX = ((itemRect.left + itemRect.right) / 2) - ((contRect.left + contRect.right) / 2);
const maxScroll = 0;
const minScroll = -(container.scrollWidth - container.clientWidth);
let targetLeft = container.scrollLeft + deltaX;
if (targetLeft > maxScroll) targetLeft = maxScroll;
if (targetLeft < minScroll) targetLeft = minScroll;
targetScrollLeft = targetLeft;
animateHorizontalScroll(container);
} else {
const itemRect = item.getBoundingClientRect();
const deltaY = (itemRect.height <= window.innerHeight)
? (((itemRect.top + itemRect.bottom) / 2) - (window.innerHeight / 2))
: itemRect.top;
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
let targetTop = window.scrollY + deltaY;
if (targetTop < 0) targetTop = 0;
if (targetTop > maxScroll) targetTop = maxScroll;
targetScrollTop = targetTop;
animateVerticalScroll();
}
}
window.addEventListener(
'keydown',
(e) => {
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) {
return;
}
if (e.ctrlKey || e.altKey || e.metaKey) {
return;
}
if (isReaderPage) {
if (stripActive) {
const container = document.getElementById('eh-strip-container');
if (!container) return;
const isHoriz = currentLayout === 'horizontal';
const step = 140;
const pageStep = Math.round((isHoriz ? window.innerWidth : window.innerHeight) * 0.85);
if (e.key === '[' || e.key === '{') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const now = Date.now();
if (now - lastStripTurnTime < 180) return;
lastStripTurnTime = now;
scrollDirection = 'backward';
scrollToPage(getCurrentlyVisiblePageNum() - 1);
return;
}
if (e.key === ']' || e.key === '}') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const now = Date.now();
if (now - lastStripTurnTime < 180) return;
lastStripTurnTime = now;
scrollDirection = 'forward';
scrollToPage(getCurrentlyVisiblePageNum() + 1);
return;
}
if (e.key === 'Home') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
cancelKeyboardAnimation();
if (isHoriz) {
targetScrollLeft = 0;
animateHorizontalScroll(container);
} else {
targetScrollTop = 0;
animateVerticalScroll();
}
return;
}
if (e.key === 'End') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
cancelKeyboardAnimation();
if (isHoriz) {
targetScrollLeft = -(container.scrollWidth - container.clientWidth);
animateHorizontalScroll(container);
} else {
targetScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
animateVerticalScroll();
}
return;
}
if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
const visPage = getCurrentlyVisiblePageNum();
reloadPageImage(visPage, 0);
return;
}
let delta = 0;
if (
e.key === 'ArrowDown' || e.key === 's' || e.key === 'S' ||
e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A'
) {
delta = step;
} else if (
e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W' ||
e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D'
) {
delta = -step;
} else if (e.key === 'PageDown' || (e.key === ' ' && !e.shiftKey)) {
delta = pageStep;
} else if (e.key === 'PageUp' || (e.key === ' ' && e.shiftKey)) {
delta = -pageStep;
}
if (delta !== 0) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
scrollByEvenIncrement(delta);
}
return;
}
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const isRepeat = e.repeat;
const minInterval = isRepeat ? 70 : 40;
const now = Date.now();
if (now - lastSingleTurnTime < minInterval) return;
lastSingleTurnTime = now;
const targetP = currentPageNum + 1;
turnSinglePage(targetP, isRepeat);
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const isRepeat = e.repeat;
const minInterval = isRepeat ? 70 : 40;
const now = Date.now();
if (now - lastSingleTurnTime < minInterval) return;
lastSingleTurnTime = now;
const targetP = currentPageNum - 1;
turnSinglePage(targetP, isRepeat);
} else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
scrollByEvenIncrement(140);
} else if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
scrollByEvenIncrement(-140);
} else if (e.key === 'PageDown' || (e.key === ' ' && !e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
scrollByEvenIncrement(Math.round(window.innerHeight * 0.85));
} else if (e.key === 'PageUp' || (e.key === ' ' && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
scrollByEvenIncrement(-Math.round(window.innerHeight * 0.85));
} else if (e.key === 'Home') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
targetScrollTop = 0;
animateVerticalScroll();
} else if (e.key === 'End') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
targetScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
animateVerticalScroll();
} else if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
executeBrokenReload();
}
} else if (isGalleryPage) {
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
const nextThumb = document.querySelector('table.ptb td.ptds + td a, table.ptt td.ptds + td a') || document.querySelector('table.ptb td:last-child a');
if (nextThumb && nextThumb.href && nextThumb.textContent.trim() !== '<') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (typeof window.__eh_navigateGalleryPage === 'function') {
window.__eh_navigateGalleryPage(nextThumb.href, true);
} else {
nextThumb.click();
}
}
} else if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
const curThumb = document.querySelector('table.ptb td.ptds, table.ptt td.ptds');
const prevThumb = curThumb?.previousElementSibling?.querySelector('a');
if (prevThumb && prevThumb.href) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (typeof window.__eh_navigateGalleryPage === 'function') {
window.__eh_navigateGalleryPage(prevThumb.href, true);
} else {
prevThumb.click();
}
}
}
}
},
true
);
window.addEventListener('popstate', (e) => {
if (isGalleryPage) {
if (typeof window.__eh_navigateGalleryPage === 'function') {
window.__eh_navigateGalleryPage(location.href, false);
}
return;
}
const targetMeta = getCurrentPageMeta();
const page = (e.state && e.state.page) || targetMeta.page;
if (page && page !== currentPageNum) {
if (stripActive) {
scrollToPage(page);
} else {
// Back/forward already moved the URL: never push a new history entry for this page
popSuppressPage = page;
turnSinglePage(page);
}
}
});
let pendingSinglePage = null;
let singlePageTurnRequestId = 0;
let popSuppressPage = null;
const singleReloadAttempts = new Map();
function updatePageUrl(pageNo, isPush = false) {
if (!pageNo || pageNo < 1) return;
const gid = cachedGid || getPageParams().gid;
let targetUrl = '';
if (pageRespCache.has(pageNo) && pageRespCache.get(pageNo).s) {
targetUrl = (window.base_url || '/') + pageRespCache.get(pageNo).s;
} else {
const key = pageImgKeyCache.get(pageNo);
if (key && gid) {
targetUrl = `${location.origin}/s/${key}/${gid}-${pageNo}`;
}
}
if (!targetUrl) return;
if (isPush && popSuppressPage === pageNo) isPush = false;
const cleanPath = targetUrl.replace(/^https?:\/\/[^\/]+/, '');
if (location.pathname === cleanPath && !location.search.includes('nl=')) return;
try {
if (isPush && window.history.pushState) {
window.history.pushState({ page: pageNo }, document.title, targetUrl);
} else if (window.history.replaceState) {
window.history.replaceState({ page: pageNo }, document.title, targetUrl);
}
} catch {}
}
function updateNativePageCounters(pageNo) {
const curSpans = document.querySelectorAll('#i2 .sn span:first-child, #i4 .sn span:first-child');
curSpans.forEach(s => {
if (s.textContent !== String(pageNo)) {
s.textContent = String(pageNo);
}
});
const tot = getTotalPages();
const pageDisplay = document.getElementById('eh-strip-page-display');
if (pageDisplay) {
pageDisplay.textContent = tot ? `Page ${pageNo} / ${tot}` : `Page ${pageNo}`;
}
const galUrl = getGalleryUrl(pageNo);
const galleryBtn = document.getElementById('eh-strip-gallery-btn');
if (galleryBtn) {
galleryBtn.href = galUrl;
}
}
function updateSinglePageImageLink(pageNo) {
const nextP = pageNo + 1;
const tot = getTotalPages();
const link = document.querySelector('#i3 a');
if (!link) return;
link.setAttribute('tabindex', '-1');
if (!link.__eh_unfocusable) {
link.__eh_unfocusable = true;
link.addEventListener('mousedown', (e) => e.preventDefault());
}
if (tot > 0 && nextP > tot) {
link.onclick = (e) => {
e.preventDefault();
if (link.blur) link.blur();
const galUrl = getGalleryUrl();
if (galUrl) location.href = galUrl;
return false;
};
return;
}
link.onclick = (e) => {
e.preventDefault();
if (link.blur) link.blur();
turnSinglePage(nextP);
return false;
};
}
function updateNavigationControls(pageNo) {
updateNativePageCounters(pageNo);
updateSinglePageImageLink(pageNo);
const prevP = pageNo - 1;
const nextP = pageNo + 1;
const total = getTotalPages();
const prevLinks = document.querySelectorAll('#prev, a#prev');
prevLinks.forEach(a => {
if (prevP >= 1) {
a.onclick = (e) => { e.preventDefault(); turnSinglePage(prevP); return false; };
if (pageRespCache.has(prevP)) {
const r = pageRespCache.get(prevP);
if (r.s) a.href = (window.base_url || '/') + r.s;
}
}
});
const nextLinks = document.querySelectorAll('#next, a#next');
nextLinks.forEach(a => {
if (!total || nextP <= total) {
a.onclick = (e) => { e.preventDefault(); turnSinglePage(nextP); return false; };
if (pageRespCache.has(nextP)) {
const r = pageRespCache.get(nextP);
if (r.s) a.href = (window.base_url || '/') + r.s;
}
}
});
}
function displaySinglePageImage(targetPage, src, dim = null) {
let img = document.getElementById('img');
if (!img) return;
img._eh_reqId = singlePageTurnRequestId;
img._eh_obsolete = false;
img.__eh_monitored = false;
// Drop the previous page's load/error listeners and stall timer: their closures hold the OLD
// page number and would otherwise size this image with the wrong page's data.
if (typeof img.__eh_cleanup === 'function') {
img.__eh_cleanup();
img.__eh_cleanup = null;
}
clearStallTimer();
// The site's inline onerror triggers its own full-page failover and races with ours
if (img.hasAttribute('onerror')) img.removeAttribute('onerror');
const targetDim = dim || getPageTargetDimensions(targetPage);
if (targetDim.width && targetDim.height) {
const f = syncNativeExtents(targetDim.width, targetDim.height, dim ? true : targetDim.exact !== false);
img.style.width = f.width + 'px';
img.style.height = f.height + 'px';
} else {
img.style.width = '';
img.style.height = '';
}
if (targetDim.aspectRatio) {
img.style.aspectRatio = String(targetDim.aspectRatio);
} else {
img.style.aspectRatio = '';
}
img.style.maxWidth = 'none';
img.style.objectFit = 'contain';
img.style.position = 'relative';
img.style.pointerEvents = '';
img.style.display = 'block';
img.style.visibility = 'visible';
img.style.zIndex = '2';
img._eh_page = targetPage;
const preRec = preloadedImages.get(targetPage);
const isPreloadedReady = Boolean(preRec && preRec.isReady && preRec.img && preRec.img.naturalWidth > 0);
img.fetchPriority = 'high';
img.decoding = isPreloadedReady ? 'sync' : 'auto';
if (img.src !== src) {
img.src = src;
}
// Only reveal when the visible element itself holds the decoded bitmap. Revealing just because
// a preload finished showed a blank frame whenever the element had not picked it up yet.
if (img.complete && img.naturalWidth > 0) {
img.style.opacity = '1';
applySingleImageSizing(img, targetPage);
updatePageDimensions(targetPage, img.naturalWidth, img.naturalHeight);
hideSinglePageThumbnailPlaceholder();
clearFailureToast();
singleReloadAttempts.delete(targetPage);
} else {
img.style.opacity = '0';
showSinglePageThumbnailPlaceholder(targetPage);
}
alignIfPending();
requestAnimationFrame(alignIfPending);
updateNavigationControls(targetPage);
if (!applyBottomSections(targetPage)) ensureBottomForPage(targetPage);
initImageMonitor();
scheduleSingleReconcile();
onPageTurn(targetPage);
}
// The site's own apply_json_state refreshes #i4-#i7 (image info, return-to-gallery link,
// "show galleries with this image", "download original", reload links). We bypass it in
// single-page mode, so replicate that part here.
const BOTTOM_SECTION_IDS = ['i4', 'i5', 'i6', 'i7'];
function cacheBottomFromResp(pageNo, resp) {
if (!resp || !pageNo) return;
const entry = {};
let any = false;
for (const k of BOTTOM_SECTION_IDS) {
if (typeof resp[k] === 'string') {
entry[k] = resp[k];
any = true;
}
}
if (!any) return;
pageBottomCache.set(pageNo, entry);
while (pageBottomCache.size > 60) {
pageBottomCache.delete(pageBottomCache.keys().next().value);
}
}
function applyBottomSections(pageNo) {
if (stripActive || pageNo !== currentPageNum) return false;
const entry = pageBottomCache.get(pageNo);
if (!entry) return false;
for (const k of BOTTOM_SECTION_IDS) {
if (typeof entry[k] !== 'string') continue;
const el = document.getElementById(k);
if (!el) continue;
if (el.__eh_html === entry[k]) continue;
el.innerHTML = entry[k];
el.__eh_html = entry[k];
}
// Rebind prev/next + counters on the freshly written markup, restore our info text
updateNavigationControls(pageNo);
updateTopBarInfo(pageNo);
return true;
}
async function ensureBottomForPage(pageNo) {
if (pageBottomCache.has(pageNo)) {
applyBottomSections(pageNo);
return;
}
const cachedResp = pageRespCache.get(pageNo);
if (cachedResp) {
cacheBottomFromResp(pageNo, cachedResp);
applyBottomSections(pageNo);
return;
}
const key = pageImgKeyCache.get(pageNo);
if (!key) return;
const r = await fetchApi(pageNo, key, null, false, true);
if (r && !r.error) {
pageRespCache.set(pageNo, r);
cacheBottomFromResp(pageNo, r);
applyBottomSections(pageNo);
}
}
// Returns true when the response was consumed (displayed, or no longer relevant), false when it
// carried no usable image so the caller should retry instead of leaving the page stuck.
function applySinglePageResponse(resp, targetPage, requestId = null) {
cacheBottomFromResp(targetPage, resp);
if (requestId !== null && requestId !== singlePageTurnRequestId) return true;
if (currentPageNum !== targetPage) return true;
const extracted = extractInfoFromResp(resp, targetPage);
if (extracted) pageTopBarInfoCache.set(targetPage, extracted);
updateTopBarInfo(targetPage);
updatePageUrl(targetPage, false);
extractKeysFromResp(resp, targetPage);
const newNl = extractNlCode(resp);
if (newNl) pageNlCodes.set(targetPage, newNl);
let imgSrc = null;
if (resp && resp.i3) {
const i3Doc = parseTemplate(resp.i3);
const respImg = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
imgSrc = respImg ? (respImg.getAttribute('src') || respImg.src) : null;
}
if (!imgSrc) {
imgSrc = pageImgSrcCache.get(targetPage);
} else {
pageImgSrcCache.set(targetPage, imgSrc);
}
let w = resp.x ? parseInt(resp.x, 10) : 0;
let h = resp.y ? parseInt(resp.y, 10) : 0;
if ((!w || !h) && resp.i3) {
const mW = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px/i);
const mH = resp.i3.match(/style=["'][^"']*height:\s*(\d+)px/i);
if (mW) w = parseInt(mW[1], 10);
if (mH) h = parseInt(mH[1], 10);
}
if (w > 0 && h > 0) {
recordDisplayDims(targetPage, w, h);
updatePageDimensions(targetPage, w, h);
}
if (imgSrc) {
displaySinglePageImage(targetPage, imgSrc, (w && h ? { width: w, height: h, aspectRatio: w / h } : null));
}
if (pendingSinglePage === targetPage) pendingSinglePage = null;
return Boolean(imgSrc);
}
let singleTurnPrefetchTimer = null;
// Fetches and shows the current single page, retrying with backoff. Previously a failed or
// timed-out API call just logged a warning and left the thumbnail placeholder up forever.
async function loadSingleContent(targetPage, reqId, attempt = 0) {
const stale = () => stripActive || reqId !== singlePageTurnRequestId || currentPageNum !== targetPage;
let key = pageImgKeyCache.get(targetPage);
if (!key) key = await ensureKeysForPage(targetPage, true);
if (stale()) return;
let resp = null;
if (key) {
resp = await fetchApi(targetPage, key, pageNlCodes.get(targetPage) || null, attempt > 0, true);
if (stale()) return;
}
if (resp && !resp.error && resp.i3) {
pageRespCache.set(targetPage, resp);
extractKeysFromResp(resp, targetPage);
if (applySinglePageResponse(resp, targetPage, reqId)) return;
}
if (attempt < MAX_RETRIES) {
const delay = Math.min(6000, 1000 * Math.pow(1.8, attempt));
setTimeout(() => {
if (!stale()) loadSingleContent(targetPage, reqId, attempt + 1);
}, delay);
return;
}
showFailureToast(`Page ${targetPage} failed to load. Click to retry.`);
}
async function turnSinglePage(targetPage, isKeyRepeat = false, passedKey = null) {
if (stripActive) return;
const total = getTotalPages();
if (targetPage < 1 || (total > 0 && targetPage > total)) return;
notePageChange();
if (popSuppressPage !== null && targetPage !== popSuppressPage) popSuppressPage = null;
clearFailureToast();
const thisRequestId = ++singlePageTurnRequestId;
pendingSinglePage = targetPage;
currentPageNum = targetPage;
window.currentpage = targetPage;
if (passedKey) pageImgKeyCache.set(targetPage, passedKey);
// Clear any prior stall timer immediately
clearStallTimer();
// Align scroll to top ONCE immediately upon user-initiated page turn
if (document.activeElement && document.activeElement.closest('#i3')) {
document.activeElement.blur();
}
armSingleAlign();
// Update counters, nav links, topbar text, and URL immediately (0 ms)
syncPageIndicator(targetPage);
updateNavigationControls(targetPage);
applyBottomSections(targetPage);
updateTopBarInfo(targetPage);
cancelKeyboardAnimation();
updatePageUrl(targetPage, true);
scrollSinglePageToImageTop();
requestAnimationFrame(alignIfPending);
// Respect fast scrolling: settle before initiating heavy prefetch cascades
if (isFastScrolling()) {
if (singleTurnPrefetchTimer) {
clearTimeout(singleTurnPrefetchTimer);
singleTurnPrefetchTimer = null;
}
scheduleSettleCheck();
} else {
if (singleTurnPrefetchTimer) clearTimeout(singleTurnPrefetchTimer);
singleTurnPrefetchTimer = setTimeout(() => {
if (!stripActive && currentPageNum === targetPage) {
prefetchSingleMode(targetPage);
}
}, 120);
}
// FAST LANE: Check preloadedImages or pageImgSrcCache
const preloaded = preloadedImages.get(targetPage);
const isPreloaded = preloaded && preloaded.isReady;
const cachedSrc = isPreloaded ? preloaded.img.src : pageImgSrcCache.get(targetPage);
if (cachedSrc) {
displaySinglePageImage(targetPage, cachedSrc);
updatePageUrl(targetPage, true);
if (pendingSinglePage === targetPage) pendingSinglePage = null;
return;
}
// If we have API response cached, apply it directly
if (pageRespCache.has(targetPage)) {
const resp = pageRespCache.get(targetPage);
applySinglePageResponse(resp, targetPage, thisRequestId);
return;
}
let key = passedKey || pageImgKeyCache.get(targetPage);
// Exact size not known yet: any placeholder would be a guess that then has to snap to the
// real size. The API answer is tiny and usually back within a couple hundred ms, so give it
// a brief head start (the previous page stays on screen meanwhile) and size once.
if (key && !getPageTargetDimensions(targetPage).exact) {
const early = await Promise.race([
fetchApi(targetPage, key, null, false, true),
new Promise((resolve) => setTimeout(() => resolve(null), 300))
]);
if (thisRequestId !== singlePageTurnRequestId || currentPageNum !== targetPage) return;
if (early && !early.error && early.i3) {
pageRespCache.set(targetPage, early);
extractKeysFromResp(early, targetPage);
if (applySinglePageResponse(early, targetPage, thisRequestId)) return;
}
}
// Show thumbnail placeholder while fetching
showSinglePageThumbnailPlaceholder(targetPage);
try {
await loadSingleContent(targetPage, thisRequestId, 0);
} finally {
if (pendingSinglePage === targetPage) {
pendingSinglePage = null;
}
}
}
function hookReader() {
if (typeof window.load_image === 'function' && !window.load_image.__eh_hooked) {
const origLoadImage = window.load_image;
window.origLoadImage = origLoadImage;
window.load_image = function (page, imgKey) {
currentPageNum = page;
window.currentpage = page;
if (imgKey) window.currentkey = imgKey;
if (!stripActive) {
turnSinglePage(page, false, imgKey);
return false;
}
updatePageUrl(page, true);
if (pageRespCache.has(page)) {
onPageTurn(page);
return false;
}
return origLoadImage.apply(this, arguments);
};
window.load_image.__eh_hooked = true;
}
if (typeof window.update_window_extents === 'function' && !window.update_window_extents.__eh_hooked) {
const origExtents = window.update_window_extents;
window.update_window_extents = function () {
// Native version sizes from the xres/yres globals, which go stale when we bypass
// apply_json_state. In single mode, size from the current page's real dimensions.
if (!isReaderPage || stripActive) return origExtents.apply(this, arguments);
applyCurrentSingleSizing();
};
window.update_window_extents.__eh_hooked = true;
}
function extractPageNumFromResp(resp) {
if (!resp) return null;
if (typeof resp.p === 'number' && resp.p > 0) return resp.p;
if (typeof resp.p === 'string' && /^\d+$/.test(resp.p)) return parseInt(resp.p, 10);
if (typeof resp.s === 'string') {
const m = resp.s.match(/\d+-(\d+)/);
if (m) return parseInt(m[1], 10);
}
const html = (resp.i2 || '') + ' ' + (resp.i4 || '');
if (html) {
const m = html.match(/class=["']sn["'][^>]*>[\s\S]*?<span>(\d+)<\/span>/i) ||
html.match(/<span>(\d+)<\/span>\s*\/\s*<span>\d+<\/span>/i);
if (m) return parseInt(m[1], 10);
}
return null;
}
if (typeof window.apply_json_state === 'function' && !window.apply_json_state.__eh_hooked) {
const origApply = window.apply_json_state;
window.origApply = origApply;
window.apply_json_state = function () {
const resp = arguments[0];
const parsedP = extractPageNumFromResp(resp);
const activeP = parsedP || currentPageNum;
if (resp && activeP) {
cacheBottomFromResp(activeP, resp);
extractKeysFromResp(resp, activeP);
const newNl = extractNlCode(resp);
if (newNl) pageNlCodes.set(activeP, newNl);
}
// In single-page mode, route cleanly through our update pipeline
// to prevent native code from demolishing #i1 and reflowing the page!
if (!stripActive) {
if (resp && parsedP) {
pageRespCache.set(parsedP, resp);
if (parsedP === currentPageNum) {
applySinglePageResponse(resp, parsedP);
}
}
return false;
}
return origApply.apply(this, arguments);
};
window.apply_json_state.__eh_hooked = true;
}
if (typeof window.api_response === 'function' && !window.api_response.__eh_safe) {
const origApiResponse = window.api_response;
window.api_response = function (xhr) {
try {
if (xhr && typeof xhr.responseText === 'string') {
const parsed = safeParseJson(xhr.responseText);
if (parsed) return parsed;
return { error: xhr.responseText.trim() || 'Invalid response' };
}
return origApiResponse.apply(this, arguments);
} catch {
return { error: 'Invalid JSON response' };
}
};
window.api_response.__eh_safe = true;
}
}
let turnDebounce = null;
function onPageTurn(activePage) {
if (turnDebounce) clearTimeout(turnDebounce);
turnDebounce = setTimeout(() => {
injectReaderToggle();
syncThemeClass();
pruneCaches(activePage || currentPageNum);
initImageMonitor();
if (stripActive) {
startPrefetch();
} else {
if (singleTurnPrefetchTimer) clearTimeout(singleTurnPrefetchTimer);
singleTurnPrefetchTimer = setTimeout(() => {
if (!stripActive) prefetchSingleMode(activePage || currentPageNum);
}, 180);
}
}, 25);
}
function clearStallTimer() {
if (stallTimer) {
clearTimeout(stallTimer);
stallTimer = null;
}
}
// In-place failover: ask the server for the page again with ?nl= (it rotates to another image
// server), swap the new source into #img and keep the reader state. The old implementation did a
// full location.replace(), which reset scroll, mode state and every cache on each failure.
async function reloadBrokenImageSingle(pageNo = currentPageNum, manual = false) {
if (stripActive) return;
clearStallTimer();
if (manual) {
singleReloadAttempts.delete(pageNo);
apiCircuitBreaker.reset();
pageImgKeyCache.delete(pageNo);
pageImgSrcCache.delete(pageNo);
pageRespCache.delete(pageNo);
}
const attempt = singleReloadAttempts.get(pageNo) || 0;
if (attempt >= MAX_RETRIES + 1) {
isReloading = false;
showFailureToast(`Page ${pageNo} failed to load. Click to retry.`);
return;
}
singleReloadAttempts.set(pageNo, attempt + 1);
isReloading = true;
const reqId = singlePageTurnRequestId;
const stale = () => stripActive || pageNo !== currentPageNum || reqId !== singlePageTurnRequestId;
let newSrc = null;
try {
const nl = pageNlCodes.get(pageNo) || null;
const { gid } = getPageParams();
let imgKey = pageImgKeyCache.get(pageNo);
if (!imgKey && pageNo === getCurrentPageMeta().page) {
imgKey = getCurrentPageMeta().imgKey;
}
if (!imgKey) imgKey = await ensureKeysForPage(pageNo, true);
if (stale()) return;
// 1. Page HTML (with ?nl= if known) so the server notes the failure and hands out a fresh mirror
if (imgKey && gid) {
const url = `${location.origin}/s/${imgKey}/${gid}-${pageNo}` + (nl ? `?nl=${encodeURIComponent(nl)}` : '');
try {
const res = await fetch(url, { credentials: 'include', priority: 'high' });
if (!res.ok) {
apiCircuitBreaker.recordError(res.status, `Single HTML failover HTTP ${res.status}`);
} else {
const html = await res.text();
if (/image limit exceeded|temporarily banned|excessive pageloads/i.test(html)) {
apiCircuitBreaker.recordError(509, 'Image limit exceeded / IP banned');
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const newImg = doc.getElementById('img');
const src = newImg ? (newImg.getAttribute('src') || newImg.src) : null;
const nextNl = extractNlCode(doc);
if (nextNl) pageNlCodes.set(pageNo, nextNl);
if (src && !src.includes('blank.gif') && !src.includes('509.gif')) newSrc = src;
}
} catch (err) {
console.warn(`ExFastReader: failover fetch failed for page ${pageNo}:`, (err && err.message) || err);
}
if (stale()) return;
}
// 2. API showpage with the nl code
if (!newSrc && imgKey) {
const fresh = await fetchApi(pageNo, imgKey, nl || null, true, true);
if (stale()) return;
if (fresh && !fresh.error && fresh.i3) {
pageRespCache.set(pageNo, fresh);
extractKeysFromResp(fresh, pageNo);
const nextNl = extractNlCode(fresh);
if (nextNl) pageNlCodes.set(pageNo, nextNl);
const i3Doc = parseTemplate(fresh.i3);
const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
newSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
}
}
} catch (err) {
console.warn(`ExFastReader: single-page reload failed for page ${pageNo}:`, (err && err.message) || err);
} finally {
isReloading = false;
}
if (stale()) return;
if (newSrc) {
preloadedImages.delete(pageNo);
pageImgSrcCache.set(pageNo, newSrc);
const cur = document.getElementById('img');
if (cur) {
cur.__eh_monitored = false;
// Same URL handed back: clear the broken state so the assignment really re-requests it
if (cur.src === newSrc) cur.removeAttribute('src');
}
displaySinglePageImage(pageNo, newSrc);
return;
}
// Nothing usable yet: back off exponentially and try again, then surface a click-to-retry toast
const delay = Math.min(8000, 1500 * Math.pow(1.8, attempt));
setTimeout(() => {
if (!stale()) reloadBrokenImageSingle(pageNo);
}, delay);
}
const bounceSinglePageImage = reloadBrokenImageSingle;
function executeBrokenReload() {
apiCircuitBreaker.reset();
clearFailureToast();
if (stripActive) {
const visPage = getCurrentlyVisiblePageNum();
if (visPage) {
if (activeRetryTimers.has(visPage)) {
clearTimeout(activeRetryTimers.get(visPage));
activeRetryTimers.delete(visPage);
}
reloadingPages.delete(visPage);
loadingPages.delete(visPage);
failedPages.delete(visPage);
pageImgKeyCache.delete(visPage);
pageImgSrcCache.delete(visPage);
pageRespCache.delete(visPage);
reloadPageImage(visPage, 0);
}
} else {
const cur = currentPageNum || 1;
failedPages.delete(cur);
pageImgKeyCache.delete(cur);
pageImgSrcCache.delete(cur);
pageRespCache.delete(cur);
reloadBrokenImageSingle(cur, true);
}
}
function initImageMonitor() {
if (stripActive) return;
detectInitialPageDimensions();
const img = document.getElementById('img');
if (!img) return;
if (img._eh_page !== undefined && img._eh_page !== currentPageNum) return;
if (img._eh_obsolete) return;
// Bail out BEFORE touching the stall timer: onPageTurn() and the MutationObserver call this
// again a few ms after the page was displayed, and clearing the timer first meant slow
// images were never detected (no retry, no error, just a stuck load).
if (img.__eh_monitored) return;
isReloading = false;
clearStallTimer();
if (typeof img.__eh_cleanup === 'function') {
img.__eh_cleanup();
img.__eh_cleanup = null;
}
img.__eh_monitored = true;
const pageNo = currentPageNum;
if (img._eh_page === undefined) {
img._eh_page = pageNo;
}
let bounceTriggered = false;
const startTime = Date.now();
const triggerBounce = (reason) => {
if (bounceTriggered || isReloading) return;
bounceTriggered = true;
clearStallTimer();
img.removeEventListener('error', onError);
img.removeEventListener('load', onLoad);
console.warn(`ExFastReader: Single page ${pageNo} image ${reason}. Triggering in-place failover bounce.`);
bounceSinglePageImage(pageNo);
};
const onError = () => {
if (img._eh_page !== currentPageNum || img._eh_obsolete) return;
triggerBounce('network load error');
};
const onLoad = () => {
clearStallTimer();
img.removeEventListener('error', onError);
img.removeEventListener('load', onLoad);
if (img._eh_page !== currentPageNum || img._eh_obsolete) return;
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
triggerBounce('0x0 dimension after load');
} else {
const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
recordDownloadSpeedSample(pageNo, durationSec);
// Reveal only once the bitmap is decoded; revealing on 'load' alone paints a blank frame
// right after the placeholder is removed, which showed up as a flash.
const reveal = () => {
if (img._eh_page !== currentPageNum || img._eh_obsolete || stripActive) return;
applySingleImageSizing(img, pageNo);
updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
img.style.opacity = '1';
hideSinglePageThumbnailPlaceholder();
clearFailureToast();
singleReloadAttempts.delete(pageNo);
};
if (typeof img.decode === 'function') {
img.decode().then(reveal, reveal);
} else {
reveal();
}
}
};
if (img.complete) {
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
triggerBounce('0x0 dimension on arrival');
return;
}
img.style.opacity = '1';
applySingleImageSizing(img, pageNo);
updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
hideSinglePageThumbnailPlaceholder();
clearFailureToast();
singleReloadAttempts.delete(pageNo);
} else {
// Not loaded into the visible element yet: keep it hidden behind the thumbnail until its own
// load event fires (a finished background preload does not mean #img has the bitmap).
showSinglePageThumbnailPlaceholder(pageNo);
img.addEventListener('error', onError, { once: true });
img.addEventListener('load', onLoad, { once: true });
img.__eh_cleanup = () => {
img.removeEventListener('error', onError);
img.removeEventListener('load', onLoad);
bounceTriggered = true; // neutralise this closure's stall timer / late events
};
const { slowTimeoutMs } = calculateImageTimeout(pageNo);
// If this image was already downloading as a background preload, count that time too
const rec = preloadedImages.get(pageNo);
const alreadyWaited = (rec && rec.startTime && !rec.isReady) ? (Date.now() - rec.startTime) : 0;
const stallDelay = Math.max(800, slowTimeoutMs - alreadyWaited);
stallTimer = setTimeout(() => {
if (img._eh_page !== currentPageNum || img._eh_obsolete) return;
if (!img.complete || img.naturalWidth === 0) {
triggerBounce(`slow / stalled (> ${(slowTimeoutMs / 1000).toFixed(1)}s)`);
}
}, stallDelay);
}
}
function initObservers() {
const i2 = document.getElementById('i2');
if (i2 && !i2.__eh_observed) {
i2.__eh_observed = true;
const obs = new MutationObserver(() => {
if (!document.getElementById('eh-strip-controls')) {
injectReaderToggle();
}
});
obs.observe(i2, { childList: true });
}
const i1 = document.getElementById('i1') || document.body;
if (i1 && !i1.__eh_observed) {
i1.__eh_observed = true;
const obs = new MutationObserver(() => {
if (!stripActive) {
placeStripControls();
}
});
obs.observe(i1, { childList: true });
}
const container = document.getElementById('i3');
if (container && !container.__eh_observed) {
container.__eh_observed = true;
let obsDebounce = null;
const obs = new MutationObserver(() => {
if (obsDebounce) clearTimeout(obsDebounce);
obsDebounce = setTimeout(() => {
if (!stripActive) initImageMonitor();
}, 20);
});
obs.observe(container, { childList: true });
}
}
// Watchdog to ensure no page stays stuck in loadingPages permanently
if (isReaderPage) {
setInterval(() => {
if (!stripActive || loadingPages.size === 0) return;
const now = Date.now();
for (const [p, startTime] of loadingPages.entries()) {
const { watchdogTimeoutMs } = calculateImageTimeout(p);
if (now - startTime > watchdogTimeoutMs) {
if (!isPageInActiveWindow(p)) {
abortPageLoad(p);
} else {
console.warn(`ExFastReader Watchdog: Page ${p} exceeded ${(watchdogTimeoutMs / 1000).toFixed(1)}s load limit. Releasing slot.`);
abortPageLoad(p);
if (!pageItemMap.get(p)?.querySelector('img.eh-main-img')) {
recordPageFailure(p, `Watchdog load timeout > ${(watchdogTimeoutMs / 1000).toFixed(1)}s`);
const failInfo = failedPages.get(p);
if (failInfo && failInfo.count >= MAX_RETRIES) {
const it = pageItemMap.get(p);
if (it) showRetryUI(it, p);
} else {
setTimeout(() => {
if (stripActive && isPageInActiveWindow(p)) {
reloadPageImage(p, failInfo ? failInfo.count : 1);
}
}, 1200);
}
}
}
}
}
}, 3000);
}
function initIndexInfiniteScroll() {
if (!isIndexPage) return;
let infiniteScrollActive = safeStorageGet(localStorage, 'eh_infinite_scroll_enabled', '1') !== '0';
let infiniteScrollCooldownUntil = 0;
let infiniteScrollFailures = 0;
let isLoadingNext = false;
let nextPageUrl = getNextPageUrl(document);
const loadedUrls = new Set([location.href]);
let sentinelObserver = null;
function findThumbnailDropdown() {
let sel = document.querySelector("select[onchange*='inline_set=dm_']");
if (sel && !sel.closest('#favact, .favact, [id*="favact"]')) return sel;
sel = document.querySelector("select[onchange*='inline_set'], select[name*='inline_set']");
if (sel && !sel.closest('#favact, .favact, [id*="favact"]')) return sel;
const allSelects = document.querySelectorAll('select');
for (const s of allSelects) {
if (s.closest('#favact, .favact, [id*="favact"]') || s.name === 'favact' || s.id === 'favsel' || s.name === 'ddact') continue;
const oc = s.getAttribute('onchange') || '';
if (oc.includes('inline_set') || oc.includes('dm_')) return s;
for (const opt of s.options) {
const txt = (opt.textContent || '').trim().toLowerCase();
if (txt === 'thumbnail' || txt === 'thumbnails' || opt.value === 't' || opt.value === 'dm_t') {
return s;
}
}
}
return null;
}
function getFavoritesForm() {
const sampleCb = document.querySelector('.itg input[type="checkbox"], input[name^="modifygals"]');
if (sampleCb) {
const f = sampleCb.closest('form');
if (f) return f;
}
const itg = document.querySelector('.itg');
if (itg) {
const f = itg.closest('form');
if (f) return f;
}
const postForm = document.querySelector('form[method="post"][action*="favorites"], form[method="POST"][action*="favorites"], form[method="post"]');
if (postForm) return postForm;
const favform = document.getElementById('favform') || document.querySelector('form[name="favform"]');
if (favform && (!favform.method || favform.method.toLowerCase() === 'post')) return favform;
return null;
}
function getFavactElement() {
const byId = document.getElementById('favact');
if (byId && byId.id !== 'eh-favact-top' && byId.tagName.toLowerCase() !== 'select' && byId.tagName.toLowerCase() !== 'input') {
return byId;
}
const byClass = document.querySelector('div.favact:not(.eh-favact-top), p.favact:not(.eh-favact-top)');
if (byClass) return byClass;
const inputEl = document.querySelector('select[name="favact"], select[name="ddact"], input[name="apply"], select#favsel');
if (inputEl) {
const parent = inputEl.closest('#favact, .favact, div, p');
if (parent && parent !== document.body && parent !== document.documentElement && !parent.classList.contains('eh-favact-top') && parent.id !== 'eh-favact-top') {
return parent;
}
}
return byId || null;
}
function toggleAllGalleries(checked) {
if (typeof window.toggle_all === 'function') {
try {
window.toggle_all(checked);
} catch (err) {
console.warn('ExFastReader: native toggle_all error', err);
}
}
const galleryCheckboxes = document.querySelectorAll(
'.itg input[type="checkbox"], input[name^="modifygals"], input[name*="modifygals"]'
);
galleryCheckboxes.forEach((cb) => {
cb.checked = checked;
});
document.querySelectorAll(
'#alltoggle, [name="alltoggle"], .alltoggle, #eh-alltoggle-top, input[type="checkbox"][onclick*="toggle_all"]'
).forEach((cb) => {
cb.checked = checked;
});
}
function executeFavAction(selectedAction) {
const form = getFavoritesForm();
if (!form) return;
if (!form.id) form.id = 'favform';
let ddactInput = form.querySelector('select[name="ddact"], select[name="favact"]');
if (ddactInput) {
ddactInput.value = selectedAction;
} else {
let hiddenAct = form.querySelector('input[name="ddact"].eh-sync-input');
if (!hiddenAct) {
hiddenAct = document.createElement('input');
hiddenAct.type = 'hidden';
hiddenAct.name = 'ddact';
hiddenAct.className = 'eh-sync-input';
form.appendChild(hiddenAct);
}
hiddenAct.value = selectedAction;
}
let hiddenApply = form.querySelector('input[name="apply"].eh-sync-input');
if (!hiddenApply) {
hiddenApply = document.createElement('input');
hiddenApply.type = 'hidden';
hiddenApply.name = 'apply';
hiddenApply.value = 'Apply';
hiddenApply.className = 'eh-sync-input';
form.appendChild(hiddenApply);
}
const checkedBoxes = document.querySelectorAll(
'.itg input[type="checkbox"]:checked, input[name^="modifygals"]:checked'
);
checkedBoxes.forEach((cb) => {
if (cb.form !== form) {
cb.setAttribute('form', form.id);
}
});
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
function getNextPageUrl(doc = document) {
const ptdsNext = doc.querySelector('table.ptb td.ptds + td a, table.ptt td.ptds + td a');
if (ptdsNext && ptdsNext.href && ptdsNext.textContent.trim() !== '<') {
return ptdsNext.href;
}
const searchnavNext = doc.querySelector('.searchnav a[href*="next="], .searchnav a#next');
if (searchnavNext && searchnavNext.href) {
return searchnavNext.href;
}
const lastA = doc.querySelector('table.ptb td:last-child a, table.ptt td:last-child a');
if (lastA && lastA.href && (lastA.textContent.trim() === '>' || lastA.textContent.trim() === '»')) {
return lastA.href;
}
const relNext = doc.querySelector('a[rel="next"]');
if (relNext && relNext.href) {
return relNext.href;
}
return null;
}
function getOrCreateSentinel() {
let sentinel = document.getElementById('eh-infinite-sentinel');
if (!sentinel) {
sentinel = document.createElement('div');
sentinel.id = 'eh-infinite-sentinel';
sentinel.className = 'eh-infinite-sentinel';
sentinel.style.cssText = 'text-align: center; padding: 18px 0; margin: 12px auto; font-family: arial, helvetica, sans-serif; font-size: 10pt; font-weight: bold; width: 100%; clear: both; user-select: none;';
const itg = document.querySelector('.itg');
const ptb = document.querySelector('table.ptb, .itg + .searchnav, #favform + .searchnav');
if (itg && itg.parentNode) {
if (ptb && ptb.parentNode === itg.parentNode) {
itg.parentNode.insertBefore(sentinel, ptb);
} else if (itg.nextSibling) {
itg.parentNode.insertBefore(sentinel, itg.nextSibling);
} else {
itg.parentNode.appendChild(sentinel);
}
}
}
return sentinel;
}
function updateSentinelState(state) {
const sentinel = getOrCreateSentinel();
if (!sentinel) return;
if (!infiniteScrollActive) {
sentinel.style.display = 'none';
return;
}
sentinel.style.display = 'block';
if (state === 'loading') {
sentinel.innerHTML = `
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px;">
<span class="eh-spinner" style="display: inline-block;"></span>
<span>Loading next page...</span>
</div>
`;
} else if (state === 'end') {
sentinel.innerHTML = `<span style="opacity: 0.7;">— All pages loaded —</span>`;
} else if (state === 'error') {
sentinel.innerHTML = `
<span style="color: #ef4444; margin-right: 8px;">Failed to load next page.</span>
<button type="button" class="eh-strip-btn active" id="eh-infinite-retry-btn">Retry</button>
`;
const btn = document.getElementById('eh-infinite-retry-btn');
if (btn) {
btn.onclick = () => {
infiniteScrollCooldownUntil = 0;
loadNextPage();
};
}
} else {
sentinel.innerHTML = '';
}
}
async function loadNextPage() {
if (!infiniteScrollActive || isLoadingNext || !nextPageUrl || loadedUrls.has(nextPageUrl)) return;
if (Date.now() < infiniteScrollCooldownUntil) return;
isLoadingNext = true;
updateSentinelState('loading');
const fetchUrl = nextPageUrl;
loadedUrls.add(fetchUrl);
try {
const res = await fetch(fetchUrl, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const currentItg = document.querySelector('.itg');
const newItg = doc.querySelector('.itg');
if (currentItg && newItg) {
if (currentItg.tagName.toLowerCase() === 'table') {
const currentTbody = currentItg.querySelector(':scope > tbody') || currentItg;
const newTbody = newItg.querySelector(':scope > tbody') || newItg;
const newRows = Array.from(newTbody.children).filter(
el => el.tagName && el.tagName.toLowerCase() === 'tr' && !el.querySelector('th')
);
for (const row of newRows) {
currentTbody.appendChild(document.importNode(row, true));
}
} else {
const newItems = Array.from(newItg.children);
for (const item of newItems) {
currentItg.appendChild(document.importNode(item, true));
}
}
}
const curPtb = document.querySelector('table.ptb');
const newPtb = doc.querySelector('table.ptb');
if (curPtb && newPtb) {
curPtb.innerHTML = newPtb.innerHTML;
}
nextPageUrl = getNextPageUrl(doc);
syncFavactPosition();
infiniteScrollFailures = 0;
infiniteScrollCooldownUntil = 0;
if (nextPageUrl && !loadedUrls.has(nextPageUrl)) {
updateSentinelState('idle');
} else {
nextPageUrl = null;
updateSentinelState('end');
}
} catch (err) {
console.warn('ExFastReader: Infinite scroll page load error', err);
loadedUrls.delete(fetchUrl);
infiniteScrollFailures++;
const cooldown = Math.min(30000, 2500 * Math.pow(2, infiniteScrollFailures - 1));
infiniteScrollCooldownUntil = Date.now() + cooldown;
updateSentinelState('error');
} finally {
isLoadingNext = false;
}
}
function setupObserver() {
if (sentinelObserver) {
sentinelObserver.disconnect();
sentinelObserver = null;
}
if (!infiniteScrollActive) return;
const sentinel = getOrCreateSentinel();
if (!sentinel) return;
if (typeof IntersectionObserver !== 'undefined') {
sentinelObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
loadNextPage();
}
}
}, { rootMargin: '600px' });
sentinelObserver.observe(sentinel);
}
}
let isSyncingFavact = false;
function syncFavactPosition() {
if (!location.pathname.startsWith('/favorites')) return;
if (isSyncingFavact) return;
isSyncingFavact = true;
try {
const bottomFavact = getFavactElement();
const ido = document.querySelector('.ido') || document.querySelector('div.ido');
if (!ido) return;
if (ido.style.position !== 'relative') {
ido.style.setProperty('position', 'relative', 'important');
}
let topFavact = document.getElementById('eh-favact-top');
if (infiniteScrollActive) {
if (!topFavact && bottomFavact) {
topFavact = bottomFavact.cloneNode(true);
topFavact.id = 'eh-favact-top';
topFavact.classList.add('eh-favact-top');
const cloneCheckbox = topFavact.querySelector('#alltoggle, [name="alltoggle"]');
if (cloneCheckbox) cloneCheckbox.id = 'eh-alltoggle-top';
ido.insertBefore(topFavact, ido.firstChild);
} else if (topFavact) {
topFavact.style.display = 'inline-flex';
}
const topSelect = topFavact ? topFavact.querySelector('select') : null;
const bottomSelect = bottomFavact ? bottomFavact.querySelector('select') : null;
const topApply = topFavact ? topFavact.querySelector('input[type="submit"], input[type="button"], button') : null;
const bottomApply = bottomFavact ? bottomFavact.querySelector('input[type="submit"], input[type="button"], button') : null;
// Two-way dropdown sync
if (topSelect && !topSelect.__eh_bound) {
topSelect.__eh_bound = true;
topSelect.addEventListener('change', () => {
if (bottomSelect) bottomSelect.value = topSelect.value;
});
}
if (bottomSelect && !bottomSelect.__eh_bound) {
bottomSelect.__eh_bound = true;
bottomSelect.addEventListener('change', () => {
if (topSelect) topSelect.value = bottomSelect.value;
});
}
// Apply button handling
if (topApply && !topApply.__eh_bound) {
topApply.__eh_bound = true;
topApply.addEventListener('click', (e) => {
e.preventDefault();
executeFavAction(topSelect ? topSelect.value : (bottomSelect ? bottomSelect.value : ''));
});
}
if (bottomApply && !bottomApply.__eh_bound) {
bottomApply.__eh_bound = true;
bottomApply.addEventListener('click', (e) => {
e.preventDefault();
executeFavAction(bottomSelect ? bottomSelect.value : (topSelect ? topSelect.value : ''));
});
}
} else {
if (topFavact) {
topFavact.style.display = 'none';
}
}
} catch (err) {
console.warn('ExFastReader: syncFavactPosition error', err);
} finally {
isSyncingFavact = false;
}
} // Delegated listener for alltoggle checkboxes (both top and bottom)
document.addEventListener('click', (e) => {
const toggleCb = e.target.closest('#alltoggle, [name="alltoggle"], .alltoggle, #eh-alltoggle-top, input[type="checkbox"][onclick*="toggle_all"]');
if (toggleCb && toggleCb.type === 'checkbox') {
setTimeout(() => {
toggleAllGalleries(toggleCb.checked);
}, 0);
}
});
// Delegated listener for individual gallery checkboxes to keep master checkboxes synced
document.addEventListener('change', (e) => {
if (e.target && e.target.type === 'checkbox') {
if (e.target.matches('.itg input[type="checkbox"], input[name^="modifygals"], input[name*="modifygals"]')) {
const all = document.querySelectorAll('.itg input[type="checkbox"], input[name^="modifygals"], input[name*="modifygals"]');
const checked = document.querySelectorAll('.itg input[type="checkbox"]:checked, input[name^="modifygals"]:checked, input[name*="modifygals"]:checked');
const isAll = all.length > 0 && all.length === checked.length;
document.querySelectorAll('#alltoggle, [name="alltoggle"], .alltoggle, #eh-alltoggle-top').forEach((cb) => {
cb.checked = isAll;
});
}
}
}, { passive: true });
function toggleInfiniteScroll() {
infiniteScrollActive = !infiniteScrollActive;
safeStorageSet(localStorage, 'eh_infinite_scroll_enabled', infiniteScrollActive ? '1' : '0');
syncFavactPosition();
const btn = document.getElementById('eh-infinite-scroll-toggle');
if (btn) {
btn.classList.toggle('active', infiniteScrollActive);
}
if (infiniteScrollActive) {
infiniteScrollCooldownUntil = 0;
updateSentinelState('idle');
setupObserver();
const scrollDist = document.documentElement.scrollHeight - (window.scrollY + window.innerHeight);
if (scrollDist < 800) {
loadNextPage();
}
} else {
updateSentinelState('idle');
if (sentinelObserver) {
sentinelObserver.disconnect();
sentinelObserver = null;
}
}
}
function injectToggle() {
const dropdown = findThumbnailDropdown();
if (!dropdown || document.getElementById('eh-infinite-scroll-toggle')) return Boolean(dropdown);
const wrap = document.createElement('span');
wrap.id = 'eh-infinite-scroll-wrap';
wrap.style.cssText = 'display: inline-flex; align-items: center; margin-left: 8px; vertical-align: middle; height: 100%;';
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'eh-infinite-scroll-toggle';
btn.className = 'eh-strip-btn' + (infiniteScrollActive ? ' active' : '');
btn.title = 'Toggle Infinite Scrolling';
btn.textContent = 'Infinite Scroll';
const syncDropdownStyle = () => {
if (!dropdown.isConnected || !btn.isConnected) return;
const cs = window.getComputedStyle(dropdown);
if (cs.fontFamily) btn.style.fontFamily = cs.fontFamily;
if (cs.fontSize) btn.style.fontSize = cs.fontSize;
if (cs.fontWeight) btn.style.fontWeight = cs.fontWeight;
const h = dropdown.offsetHeight;
if (h > 0) {
btn.style.height = h + 'px';
btn.style.boxSizing = 'border-box';
}
};
btn.addEventListener('click', (e) => {
e.preventDefault();
toggleInfiniteScroll();
btn.blur();
});
wrap.appendChild(btn);
dropdown.insertAdjacentElement('afterend', wrap);
syncDropdownStyle();
requestAnimationFrame(syncDropdownStyle);
setTimeout(syncDropdownStyle, 100);
if (infiniteScrollActive) {
setupObserver();
}
syncFavactPosition();
return true;
}
if (location.pathname.startsWith('/favorites')) {
syncFavactPosition();
let favDebounce = null;
const favObs = new MutationObserver(() => {
if (favDebounce) return;
favDebounce = setTimeout(() => {
favDebounce = null;
syncFavactPosition();
}, 80);
});
favObs.observe(document.body || document.documentElement, { childList: true, subtree: true });
setTimeout(() => favObs.disconnect(), 15000);
}
window.addEventListener('scroll', () => {
if (!infiniteScrollActive || isLoadingNext || !nextPageUrl || Date.now() < infiniteScrollCooldownUntil) return;
const scrollDist = document.documentElement.scrollHeight - (window.scrollY + window.innerHeight);
if (scrollDist < 800) {
loadNextPage();
}
}, { passive: true });
let injected = injectToggle();
if (!injected) {
let obsTimeout = null;
const obs = new MutationObserver(() => {
if (injectToggle()) {
if (obsTimeout) clearTimeout(obsTimeout);
obs.disconnect();
}
});
obs.observe(document.documentElement, { childList: true, subtree: true });
obsTimeout = setTimeout(() => obs.disconnect(), 10000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
if (!nextPageUrl) nextPageUrl = getNextPageUrl(document);
syncFavactPosition();
if (infiniteScrollActive) setupObserver();
if (!document.getElementById('eh-infinite-scroll-toggle')) injectToggle();
}, { once: true });
}
}
syncThemeClass();
if (isReaderPage) {
const initialMeta = getCurrentPageMeta();
currentPageNum = initialMeta.page || 1;
if (initialMeta.imgKey) {
pageImgKeyCache.set(currentPageNum, initialMeta.imgKey);
}
detectInitialPageDimensions();
const initialImg = document.getElementById('img');
if (initialImg) {
initialImg._eh_page = currentPageNum;
initialImg._eh_obsolete = false;
const sw = parseInt(initialImg.style?.width, 10);
const sh = parseInt(initialImg.style?.height, 10);
if (sw >= 400 && sh >= 400) {
recordDisplayDims(currentPageNum, sw, sh);
} else if (initialImg.naturalWidth >= 400 && initialImg.naturalHeight >= 400) {
recordDisplayDims(currentPageNum, initialImg.naturalWidth, initialImg.naturalHeight);
}
}
}
if (isReaderPage) {
let pollCount = 0;
let earlyControlsInjected = false;
let readerStarted = false;
function startReaderEarly() {
if (readerStarted) return;
const i3 = document.getElementById('i3');
const img = document.getElementById('img');
if (!i3 && !img && document.readyState === 'loading') return;
readerStarted = true;
const curMeta = getCurrentPageMeta();
if (curMeta && curMeta.page) currentPageNum = curMeta.page;
if (curMeta && curMeta.imgKey) pageImgKeyCache.set(currentPageNum, curMeta.imgKey);
detectInitialThumbsPerPage();
detectInitialPageDimensions();
extractKeysFromDoc(document);
const initNl = extractInitialNlCode(document);
if (initNl) {
pageNlCodes.set(currentPageNum, initNl);
}
extractAndHideBottomLinks();
discoverAndFetchGallery(currentPageNum);
prefetchNearbyThumbnails(currentPageNum);
if (stripActive) {
enableStripMode();
} else {
updateNavigationControls(currentPageNum);
initImageMonitor();
startPrefetch();
injectReaderToggle();
}
}
// Start mounting and rendering the reader immediately
startReaderEarly();
const hookTimer = setInterval(() => {
pollCount++;
startReaderEarly();
const { gid, showkey } = getPageParams();
const i2 = document.getElementById('i2');
if (i2 && !earlyControlsInjected) {
earlyControlsInjected = true;
injectReaderToggle();
syncThemeClass();
initObservers();
}
const canHook = typeof window.load_image === 'function' &&
typeof window.apply_json_state === 'function' &&
gid && showkey;
if (canHook) {
hookReader();
clearInterval(hookTimer);
} else if (pollCount > 400) {
clearInterval(hookTimer);
}
}, 25);
} else if (isGalleryPage) {
let lastFavToggleTime = 0;
let cachedPopupData = null; // { note: '', slots: '', names: [], popupItems: [], isFavorited: bool, favcat: num }
let savedFavLinkInitialHtml = null;
const DEFAULT_FAV_NAMES = [
'Favorites 0', 'Favorites 1', 'Favorites 2', 'Favorites 3', 'Favorites 4',
'Favorites 5', 'Favorites 6', 'Favorites 7', 'Favorites 8', 'Favorites 9'
];
function getSavedFavNames() {
try {
const raw = safeStorageGet(localStorage, 'eh_fav_cat_names', null);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length >= 10) return parsed;
}
} catch {}
return [...DEFAULT_FAV_NAMES];
}
function detectCurrentCategory() {
if (cachedPopupData && typeof cachedPopupData.favcat === 'number' && cachedPopupData.isFavorited) {
return cachedPopupData.favcat;
}
const favDiv = document.getElementById('fav');
if (favDiv) {
const style = (favDiv.getAttribute('style') || '') + ' ' + (favDiv.style.cssText || '') + ' ' + (favDiv.style.backgroundPosition || '');
const m = style.match(/(?:0(?:px|%)?|left)\s+(-?\d+)px/i) ||
style.match(/background-position:\s*[^;]*?(-?\d+)px/i) ||
style.match(/(-?\d+)px\s+no-repeat/i) ||
style.match(/0px\s+(-?\d+)px/i);
if (m) {
const posY = parseInt(m[1], 10);
const idx = Math.round((-2 - posY) / 19);
if (idx >= 0 && idx <= 9) return idx;
}
const title = favDiv.getAttribute('title');
if (title && title.trim()) {
const names = getSavedFavNames();
const idx = names.findIndex(n => n.toLowerCase() === title.trim().toLowerCase());
if (idx !== -1) return idx;
const mNum = title.match(/Favorites\s+(\d)/i);
if (mNum) {
const catNum = parseInt(mNum[1], 10);
if (catNum >= 0 && catNum <= 9) return catNum;
}
}
}
const favLink = document.getElementById('favoritelink');
if (favLink) {
const txt = favLink.textContent.trim().toLowerCase();
if (txt && !txt.includes('add to')) {
const names = getSavedFavNames();
const idx = names.findIndex(n => n.toLowerCase() === txt);
if (idx !== -1) return idx;
const mNum = txt.match(/favorites\s+(\d)/i);
if (mNum) {
const catNum = parseInt(mNum[1], 10);
if (catNum >= 0 && catNum <= 9) return catNum;
}
}
}
return null;
}
function isGalleryFavorited() {
if (cachedPopupData && typeof cachedPopupData.isFavorited === 'boolean') {
return cachedPopupData.isFavorited;
}
const favLink = document.getElementById('favoritelink');
if (favLink) {
const txt = favLink.textContent.trim().toLowerCase();
if (txt.includes('edit')) return true;
if (txt && !txt.includes('add to')) return true;
}
const favDiv = document.getElementById('fav');
if (favDiv) {
if (favDiv.getAttribute('title')) return true;
const style = (favDiv.getAttribute('style') || '') + ' ' + (favDiv.style.cssText || '');
if (style.includes('fav.png') || /background(-image)?:\s*url/i.test(style)) return true;
}
return detectCurrentCategory() !== null;
}
// Intercept mousedown, pointerdown, and click on window to prevent foreign scripts (ComicRead, etc.) or native popup
['mousedown', 'pointerdown', 'click'].forEach((evtType) => {
window.addEventListener(evtType, (e) => {
const favTarget = e.target.closest('#gdf, #favoritelink, [onclick*="pop_fav"], [onclick*="addfav"]');
if (favTarget) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (evtType === 'click') {
openInlineFavorites();
}
return false;
}
}, true);
});
// Override pop_fav, popUp, window.open
try {
const _popFavOverride = function () {
openInlineFavorites();
return false;
};
Object.defineProperty(window, 'pop_fav', {
configurable: true,
enumerable: true,
get() { return _popFavOverride; },
set() {}
});
} catch {
window.pop_fav = function () { openInlineFavorites(); return false; };
}
const _origPopUp = window.popUp;
window.popUp = function (url, w, h) {
if (typeof url === 'string' && (url.includes('addfav') || url.includes('act=addfav'))) {
openInlineFavorites();
return false;
}
if (typeof _origPopUp === 'function') {
return _origPopUp.apply(this, arguments);
}
return false;
};
const _origWindowOpen = window.open;
window.open = function (url, name, specs) {
if (typeof url === 'string' && (url.includes('addfav') || url.includes('act=addfav'))) {
openInlineFavorites();
return null;
}
if (typeof _origWindowOpen === 'function') {
return _origWindowOpen.apply(this, arguments);
}
return null;
};
function purgeForeignPopups() {
const gd3 = document.getElementById('gd3');
if (gd3) {
Array.from(gd3.children).forEach((child) => {
const id = child.id;
const cls = child.className;
if (!['gdc', 'gdn', 'gdd', 'gdr', 'gdf'].includes(id) && cls !== 'c') {
child.remove();
}
});
}
}
function sanitizeFavElements() {
const oldGdf = document.getElementById('gdf');
if (oldGdf && !oldGdf.__eh_sanitized) {
const favLink = oldGdf.querySelector('#favoritelink');
if (favLink && !savedFavLinkInitialHtml && favLink.querySelector('img')) {
savedFavLinkInitialHtml = favLink.innerHTML;
}
const newGdf = oldGdf.cloneNode(true);
newGdf.__eh_sanitized = true;
newGdf.removeAttribute('onclick');
const newFavLink = newGdf.querySelector('#favoritelink');
if (newFavLink) newFavLink.removeAttribute('onclick');
newGdf.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
openInlineFavorites();
});
oldGdf.parentNode.replaceChild(newGdf, oldGdf);
}
purgeForeignPopups();
}
function attachRemoveHandler(btn, form, container) {
if (!btn || btn.__eh_bound) return;
btn.__eh_bound = true;
btn.addEventListener('click', async (e) => {
e.preventDefault();
btn.disabled = true;
btn.value = 'Removing...';
const submitBtn = container.querySelector('.eh-fav-submit-btn');
const cancelBtn = container.querySelector('.eh-fav-cancel-btn');
if (submitBtn) submitBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
const gid = m ? m[1] : (window.gid || '');
const token = m ? m[2] : (window.token || '');
const postUrl = `${location.origin}/gallerypopups.php?gid=${gid}&t=${token}&act=addfav`;
const formData = new FormData();
formData.append('favcat', 'favdel');
formData.append('favnote', '');
formData.append('apply', 'Apply Changes');
formData.append('update', '1');
try {
const res = await fetch(postUrl, {
method: 'POST',
body: formData,
credentials: 'include'
});
if (res.ok) {
cachedPopupData = {
note: '',
slots: (cachedPopupData ? cachedPopupData.slots : ''),
names: (cachedPopupData ? cachedPopupData.names : getSavedFavNames()),
popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
isFavorited: false,
favcat: null
};
updateGalleryFavoriteUI('favdel', 'Remove from Favorites');
closeInlineFavorites();
} else {
throw new Error('HTTP ' + res.status);
}
} catch (err) {
console.warn('ExFastReader: Failed to remove favorite', err);
alert('Failed to remove favorite. Please try again.');
btn.disabled = false;
btn.value = 'Remove from Favorites';
if (submitBtn) submitBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
}
});
}
// Silently prefetch popup info (exact diamonds, note, slots, custom names) straight from the popup in background
async function silentPrefetchPopupInfo() {
if (cachedPopupData && typeof cachedPopupData.isFavorited === 'boolean') return cachedPopupData;
const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
const gid = m ? m[1] : (window.gid || '');
const token = m ? m[2] : (window.token || '');
if (!gid || !token) return null;
const popupUrl = `${location.origin}/gallerypopups.php?gid=${gid}&t=${token}&act=addfav`;
try {
const res = await fetch(popupUrl, { credentials: 'include' });
if (!res.ok) return null;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
let note = '';
const noteEl = doc.querySelector('textarea[name="favnote"]');
if (noteEl) note = noteEl.value || noteEl.textContent || '';
let slots = '';
const mSlots = html.match(/(\d+\s*\/\s*\d+\s+favorite note slots used[^\n<\[]*(?:<a[^>]*>\[\?\]<\/a>)?)/i);
if (mSlots) {
slots = mSlots[1];
} else {
const allElements = doc.querySelectorAll('p, div');
for (const p of allElements) {
if (p.children.length <= 1 && p.textContent.includes('favorite note slots used')) {
slots = p.innerHTML.trim();
break;
}
}
}
if (slots && !slots.includes('href=')) {
slots += ` <a href="https://ehwiki.org/wiki/Favorite" target="_blank">[?]</a>`;
}
const catDivs = doc.querySelectorAll('.nosel > div, div.nosel > div');
const names = getSavedFavNames();
const popupItems = [];
if (catDivs.length >= 10) {
catDivs.forEach((div, i) => {
if (i < 10) {
const txt = div.textContent.trim();
if (txt && !txt.toLowerCase().includes('remove')) {
names[i] = txt;
}
const diamondDiv = div.querySelector('div.i, div[style*="background"]');
const diamondStyle = diamondDiv ? diamondDiv.getAttribute('style') : `background-image:url('https://ehgt.org/g/fav.png'); background-position:0px ${-2 - 19 * i}px;`;
popupItems.push({ idx: i, name: txt || names[i], style: diamondStyle });
}
});
safeStorageSet(localStorage, 'eh_fav_cat_names', JSON.stringify(names));
}
const hasApply = Boolean(doc.querySelector('input[value*="Apply Changes"], input[value="Apply Changes"]'));
const hasRemove = Boolean(doc.querySelector('input[value*="Remove"], [onclick*="favdel"]'));
const checkedRadio = doc.querySelector('input[name="favcat"]:checked');
const checkedCat = checkedRadio ? parseInt(checkedRadio.value, 10) : null;
const isFavorited = hasApply || hasRemove || (checkedCat !== null && isGalleryFavorited());
const favcat = checkedCat !== null ? checkedCat : (detectCurrentCategory() ?? 0);
cachedPopupData = { note, slots, names, popupItems, isFavorited, favcat };
// If the menu is currently open, seamlessly sync the extracted fields
const container = document.getElementById('eh-inline-favorites-container');
if (container && container.style.display !== 'none') {
const textarea = container.querySelector('#eh-fav-note-textarea');
if (textarea && !textarea.value && note) {
textarea.value = note;
}
if (slots) {
const slotsEl = container.querySelector('.eh-fav-slots');
if (slotsEl) slotsEl.innerHTML = slots;
}
if (popupItems.length > 0) {
popupItems.forEach((item) => {
const label = container.querySelector(`.eh-fav-cat-label[data-cat-idx="${item.idx}"]`);
if (label && label.textContent !== item.name) {
label.textContent = item.name;
label.title = item.name;
}
const diamond = container.querySelector(`.eh-fav-cat-diamond[data-cat-idx="${item.idx}"]`);
if (diamond && item.style) {
diamond.setAttribute('style', item.style);
}
});
}
const submitBtn = container.querySelector('.eh-fav-submit-btn');
if (submitBtn) {
submitBtn.value = isFavorited ? 'Apply Changes' : 'Add to Favorites';
}
const targetRadio = container.querySelector(`input[name="favcat"][value="${favcat}"]`);
if (targetRadio) {
targetRadio.checked = true;
}
let removeBtn = container.querySelector('#eh-fav-remove-btn');
if (isFavorited) {
if (!removeBtn) {
removeBtn = document.createElement('input');
removeBtn.type = 'button';
removeBtn.className = 'eh-fav-remove-btn';
removeBtn.id = 'eh-fav-remove-btn';
removeBtn.value = 'Remove from Favorites';
const actionsEl = container.querySelector('.eh-fav-actions');
const cancelBtn = container.querySelector('.eh-fav-cancel-btn');
if (actionsEl && cancelBtn) {
actionsEl.insertBefore(removeBtn, cancelBtn);
}
const formEl = container.querySelector('#eh-fav-form');
attachRemoveHandler(removeBtn, formEl, container);
}
} else if (removeBtn) {
removeBtn.remove();
}
}
return cachedPopupData;
} catch {
return null;
}
}
function openInlineFavorites() {
const now = Date.now();
if (now - lastFavToggleTime < 300) return;
lastFavToggleTime = now;
purgeForeignPopups();
const gd4 = document.getElementById('gd4');
if (!gd4) return;
let container = document.getElementById('eh-inline-favorites-container');
if (container && container.style.display !== 'none') {
closeInlineFavorites();
return;
}
// Clean up any stray containers
const allContainers = document.querySelectorAll('#eh-inline-favorites-container');
if (allContainers.length > 1) {
for (let i = 1; i < allContainers.length; i++) allContainers[i].remove();
container = allContainers[0];
}
// Hide ALL other elements inside #gd4 (tag list, voting, and enter new tags boxes)
gd4.classList.add('eh-fav-active');
Array.from(gd4.children).forEach((child) => {
if (child !== container) {
if (child._eh_prev_display === undefined) child._eh_prev_display = child.style.display;
child.style.setProperty('display', 'none', 'important');
}
});
document.querySelectorAll('#tagmenu_new, #tagmenu_act, #tagmenu, #tagform').forEach((el) => {
if (el.closest('#gd4')) {
el.style.setProperty('display', 'none', 'important');
}
});
if (!container) {
container = document.createElement('div');
container.id = 'eh-inline-favorites-container';
gd4.appendChild(container);
}
container.style.display = 'block';
// INSTANT SYNCHRONOUS BUILD: 0ms delay, no loading spinner
renderBuiltInFavoriteForm(container);
// Silently sync note/slots/folder names in the background
silentPrefetchPopupInfo();
}
function closeInlineFavorites() {
const gd4 = document.getElementById('gd4');
if (gd4) {
gd4.classList.remove('eh-fav-active');
Array.from(gd4.children).forEach((child) => {
if (child.id !== 'eh-inline-favorites-container') {
child.style.display = child._eh_prev_display !== undefined ? child._eh_prev_display : '';
}
});
}
const container = document.getElementById('eh-inline-favorites-container');
if (container) {
container.style.display = 'none';
}
}
function updateGalleryFavoriteUI(catVal, submitVal) {
const favDiv = document.getElementById('fav');
const favLink = document.getElementById('favoritelink');
const gdf = document.getElementById('gdf');
if (catVal === 'favdel' || (submitVal && submitVal.toLowerCase().includes('remove'))) {
if (favDiv) {
favDiv.style.background = '';
favDiv.style.width = '';
favDiv.style.height = '';
favDiv.style.display = '';
favDiv.style.marginRight = '';
favDiv.removeAttribute('title');
favDiv.innerHTML = '';
}
if (favLink) {
favLink.innerHTML = savedFavLinkInitialHtml || '<img src="https://ehgt.org/g/mr.gif" /> Add to Favorites';
}
if (gdf) {
gdf.querySelectorAll('.i, img[src*="fav.png"], div[style*="fav.png"]').forEach((el) => {
if (el !== favDiv) el.remove();
});
}
cachedPopupData = {
note: '',
slots: (cachedPopupData ? cachedPopupData.slots : ''),
names: (cachedPopupData ? cachedPopupData.names : getSavedFavNames()),
popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
isFavorited: false,
favcat: null
};
} else if (catVal !== null && catVal !== undefined) {
const catIdx = parseInt(catVal, 10);
if (!isNaN(catIdx)) {
const names = getSavedFavNames();
const chosenName = names[catIdx] || ('Favorites ' + catIdx);
if (favDiv) {
favDiv.innerHTML = '';
favDiv.style.background = `transparent url('https://ehgt.org/g/fav.png') 0px ${-2 - 19 * catIdx}px no-repeat`;
favDiv.style.width = '19px';
favDiv.style.height = '19px';
favDiv.style.display = 'inline-block';
favDiv.style.marginRight = '4px';
favDiv.title = chosenName;
}
if (favLink) {
favLink.textContent = chosenName;
}
if (gdf) {
gdf.querySelectorAll('.i, img[src*="fav.png"], div[style*="fav.png"]').forEach((el) => {
if (el !== favDiv) el.remove();
});
}
cachedPopupData = {
note: (cachedPopupData ? cachedPopupData.note : ''),
slots: (cachedPopupData ? cachedPopupData.slots : ''),
names,
popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
isFavorited: true,
favcat: catIdx
};
}
}
}
function renderBuiltInFavoriteForm(container) {
const currentCat = detectCurrentCategory();
const isAlreadyFav = isGalleryFavorited();
const submitText = isAlreadyFav ? 'Apply Changes' : 'Add to Favorites';
const checkedCat = currentCat !== null ? currentCat : 0;
const favNames = (cachedPopupData && cachedPopupData.names) ? cachedPopupData.names : getSavedFavNames();
const noteVal = (cachedPopupData && cachedPopupData.note) ? cachedPopupData.note : '';
const slotsHtml = (cachedPopupData && cachedPopupData.slots) ? cachedPopupData.slots : `0 / 1000 favorite note slots used. <a href="https://ehwiki.org/wiki/Favorite" target="_blank">[?]</a>`;
const popupItems = cachedPopupData && cachedPopupData.popupItems ? cachedPopupData.popupItems : [];
let catsHtml = '';
for (let i = 0; i < 10; i++) {
const name = favNames[i] || `Favorites ${i}`;
const isChecked = (i === checkedCat);
const customStyle = popupItems[i]?.style || `background-image:url('https://ehgt.org/g/fav.png'); background-position:0px ${-2 - 19 * i}px;`;
catsHtml += `
<label class="eh-fav-cat-item">
<input type="radio" name="favcat" value="${i}" ${isChecked ? 'checked' : ''} />
<div class="eh-fav-cat-diamond i" data-cat-idx="${i}" style="${customStyle}"></div>
<span class="eh-fav-cat-label" data-cat-idx="${i}" title="${name}">${name}</span>
</label>
`;
}
const removeBtnHtml = isAlreadyFav
? `<input type="button" class="eh-fav-remove-btn" id="eh-fav-remove-btn" value="Remove from Favorites" />`
: '';
const headerText = 'Choose a favorite category to save this gallery under. You can also add a note to it if you wish.';
container.innerHTML = `
<form class="eh-fav-form" id="eh-fav-form" method="post">
<input type="hidden" name="update" value="1" />
<p class="eh-fav-header">${headerText}</p>
<div class="eh-fav-body">
<div class="eh-fav-cats">
${catsHtml}
</div>
<div class="eh-fav-note-pane">
<div class="eh-fav-note-title">Favorite Note (Max 200 Characters)</div>
<textarea name="favnote" id="eh-fav-note-textarea" maxlength="200" class="eh-fav-note-textarea" placeholder="">${noteVal}</textarea>
<div class="eh-fav-slots">${slotsHtml}</div>
<div class="eh-fav-actions">
<input type="submit" name="apply" class="eh-fav-submit-btn" value="${submitText}" />
${removeBtnHtml}
<input type="button" class="eh-fav-cancel-btn" value="Cancel" />
</div>
</div>
</div>
</form>
`;
const form = container.querySelector('#eh-fav-form');
const cancelBtn = container.querySelector('.eh-fav-cancel-btn');
const submitBtn = container.querySelector('.eh-fav-submit-btn');
const removeBtn = container.querySelector('#eh-fav-remove-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', (e) => {
e.preventDefault();
closeInlineFavorites();
});
}
if (removeBtn) {
attachRemoveHandler(removeBtn, form, container);
}
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const curSubmitVal = submitBtn ? submitBtn.value : 'Add to Favorites';
const selectedRadio = form.querySelector('input[name="favcat"]:checked');
const chosenCat = selectedRadio ? selectedRadio.value : '0';
const chosenNote = form.querySelector('#eh-fav-note-textarea')?.value || '';
const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
const gid = m ? m[1] : (window.gid || '');
const token = m ? m[2] : (window.token || '');
const postUrl = `${location.origin}/gallerypopups.php?gid=${gid}&t=${token}&act=addfav`;
const formData = new FormData();
formData.append('favcat', chosenCat);
formData.append('favnote', chosenNote);
formData.append('apply', curSubmitVal);
formData.append('update', '1');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.value = 'Saving...';
}
if (removeBtn) removeBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
try {
const res = await fetch(postUrl, {
method: 'POST',
body: formData,
credentials: 'include'
});
if (res.ok) {
cachedPopupData = {
note: chosenNote,
slots: (cachedPopupData ? cachedPopupData.slots : ''),
names: (cachedPopupData ? cachedPopupData.names : getSavedFavNames()),
popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
isFavorited: true,
favcat: parseInt(chosenCat, 10)
};
updateGalleryFavoriteUI(chosenCat, curSubmitVal);
closeInlineFavorites();
} else {
throw new Error('HTTP ' + res.status);
}
} catch (err) {
console.warn('ExFastReader: Failed to save favorite', err);
alert('Failed to save favorite. Please try again.');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.value = curSubmitVal;
}
if (removeBtn) removeBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
}
});
}
}
function setupInlineFavorites() {
const favLink = document.getElementById('favoritelink');
if (favLink && favLink.querySelector('img')) {
savedFavLinkInitialHtml = favLink.innerHTML;
}
sanitizeFavElements();
silentPrefetchPopupInfo();
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const container = document.getElementById('eh-inline-favorites-container');
if (container && container.style.display !== 'none') {
closeInlineFavorites();
}
}
});
}
function initGalleryPage() {
setupInlineFavorites();
const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
const curGid = m ? parseInt(m[1], 10) : 0;
if (m) {
const gUrl = location.origin + `/g/${m[1]}/${m[2]}/`;
safeStorageSet(sessionStorage, 'eh_gallery_' + m[1], gUrl);
safeStorageSet(localStorage, 'eh_gallery_' + m[1], gUrl);
cachedGalleryUrl = gUrl;
}
if (curGid) {
cachedGid = curGid;
loadGalleryDataFromSession(curGid);
}
const gpcEl = document.querySelector('.gpc, #gpc');
if (gpcEl) {
const mGpc = gpcEl.textContent.match(/Showing\s+(\d+)\s*-\s*(\d+)\s+of\s+([\d,]+)\s+images/i);
if (mGpc) {
const sNum = parseInt(mGpc[1], 10);
const eNum = parseInt(mGpc[2], 10);
const tot = parseInt(mGpc[3].replace(/,/g, ''), 10);
if (eNum < tot) {
const count = eNum - sNum + 1;
if ([20, 40, 100].includes(count)) {
thumbsPerPage = count;
safeStorageSet(localStorage, 'eh_thumbs_per_page', String(count));
}
}
if (tot > 0) cachedTotalPages = tot;
}
}
const thumbCount = document.querySelectorAll('#gdt .gdtm, #gdt .gdtl').length;
if ([20, 40, 100].includes(thumbCount)) {
thumbsPerPage = thumbCount;
safeStorageSet(localStorage, 'eh_thumbs_per_page', String(thumbCount));
}
// Immediately parse all thumbnails, dimensions, keys, and sprites directly from the gallery DOM
extractDimensionsFromGallery(document);
if (curGid) {
saveGalleryDataToSession(curGid);
// Cache data for current gallery page; avoid mass background downloads
const nextGal = 1;
const totalGalPages = Math.ceil(cachedTotalPages / thumbsPerPage);
if (nextGal < totalGalPages && !fetchedGalleryPages.has(nextGal) && !inFlightGalleryFetches.has(nextGal)) {
fetchGalleryPage(nextGal);
}
}
if ('scrollRestoration' in history) {
try { history.scrollRestoration = 'manual'; } catch {}
}
const MAX_PREFETCH_LINKS = 12;
const prefetchLinks = [];
const prefetchedUrls = new Set();
function safePrefetchUrl(url) {
if (!url || prefetchedUrls.has(url)) return;
try {
prefetchedUrls.add(url);
while (prefetchLinks.length >= MAX_PREFETCH_LINKS) {
const oldLink = prefetchLinks.shift();
if (oldLink && oldLink.parentNode) {
oldLink.remove();
}
}
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
prefetchLinks.push(link);
} catch {}
}
let isGalleryNavigating = false;
async function navigateGalleryPage(targetUrl, pushState = true) {
if (isGalleryNavigating) return;
isGalleryNavigating = true;
const gdt = document.getElementById('gdt');
const ptt = document.querySelector('table.ptt');
const ptb = document.querySelector('table.ptb');
const gpc = document.querySelector('.gpc, #gpc');
try {
const res = await fetch(targetUrl, { credentials: 'include' });
if (!res.ok) {
location.href = targetUrl;
return;
}
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newPtt = doc.querySelector('table.ptt');
const newGpc = doc.querySelector('.gpc, #gpc');
const newGdt = doc.getElementById('gdt');
const newPtb = doc.querySelector('table.ptb');
// 1. Update in natural top-to-bottom DOM order to eliminate reflow jumping
if (newPtt && ptt) {
ptt.innerHTML = newPtt.innerHTML;
}
if (newGpc && gpc) {
gpc.innerHTML = newGpc.innerHTML;
}
if (newGdt && gdt) {
gdt.innerHTML = newGdt.innerHTML;
}
if (newPtb && ptb) {
ptb.innerHTML = newPtb.innerHTML;
}
if (doc.title) {
document.title = doc.title;
}
if (pushState) {
window.history.pushState({ galleryUrl: targetUrl }, doc.title || document.title, targetUrl);
}
// 2. Parse and cache the newly loaded thumbnails and dimensions
extractDimensionsFromGallery(doc);
if (curGid) {
saveGalleryDataToSession(curGid);
}
// 3. Prefetch next page thumbnail if present
const nextP = document.querySelector('table.ptb td.ptds + td a') || document.querySelector('table.ptb td:last-child a');
if (nextP && nextP.href && nextP.textContent.trim() !== '<') {
safePrefetchUrl(nextP.href);
}
} catch (err) {
console.warn('ExFastReader: Gallery AJAX navigation failed, falling back to full navigation', err);
location.href = targetUrl;
} finally {
isGalleryNavigating = false;
}
}
window.__eh_navigateGalleryPage = navigateGalleryPage;
document.addEventListener('click', (e) => {
const cell = e.target.closest('table.ptt td, table.ptb td, table.ptt a, table.ptb a, .searchnav td, .searchnav a');
if (!cell) return;
const link = cell.tagName === 'A' ? cell : cell.querySelector('a');
if (!link) return;
const rawHref = link.getAttribute('href') || link.href;
if (!rawHref || rawHref === '#' || rawHref.startsWith('javascript:')) return;
let targetUrl;
try {
targetUrl = new URL(rawHref, location.href).href;
} catch {
targetUrl = link.href;
}
if (!targetUrl) return;
try {
const parsedTarget = new URL(targetUrl, location.origin);
if (parsedTarget.origin !== location.origin) return;
const mCur = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
const mTar = parsedTarget.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
const isSameGallery = (mCur && mTar && mCur[1] === mTar[1]) ||
(parsedTarget.pathname.replace(/\/+$/, '') === location.pathname.replace(/\/+$/, ''));
if (!isSameGallery) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
navigateGalleryPage(targetUrl, true);
} catch (err) {
console.warn('ExFastReader: Gallery link check error', err);
}
}, true);
// Listen for reader navigation clicks to guarantee session storage synchronization
document.addEventListener('click', (e) => {
const a = e.target.closest('a[href*="/s/"]');
if (a && curGid) {
saveGalleryDataToSession(curGid);
}
}, { capture: true, passive: true });
// Prefetch reader page on hover for instant transition
document.addEventListener('pointerover', (e) => {
const a = e.target.closest('a[href*="/s/"]');
if (a && a.href && !a.__eh_prefetched) {
a.__eh_prefetched = true;
safePrefetchUrl(a.href);
}
}, { capture: true, passive: true });
const nextThumb = document.querySelector('table.ptb td.ptds + td a') || document.querySelector('table.ptb td:last-child a');
if (nextThumb && nextThumb.href && nextThumb.textContent.trim() !== '<') {
safePrefetchUrl(nextThumb.href);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGalleryPage, { once: true });
} else {
initGalleryPage();
}
} else if (isIndexPage) {
initIndexInfiniteScroll();
}
}
})();