QoL changes to Hitomi, e.g., show page number, long-strip reader mode, "my favourites" page, ...
// ==UserScript==
// @name Hitomi Enhancer
// @namespace http://tampermonkey.net/
// @version 8.0
// @description QoL changes to Hitomi, e.g., show page number, long-strip reader mode, "my favourites" page, ...
// @match *://hitomi.la/*
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ==========================================
// MODULE 1: Global Configuration & Core
// Purpose: Defines all static variables, CSS selectors, storage keys, default settings,
// and responsive breakpoints used across the entire userscript.
// ==========================================
const CONFIG = {
selectors: {
galleryParent: '.gallery-preview',
galleryThumbs: '.thumbnail-list',
desktopReader: '#comicImages',
mobileReader: '#mobileImages',
pagination: '.simplePagerNav',
listTitles: '.gallery-content > div > h1.lillie > a',
pageNumberInjection: {
container: '.dj-desc tbody',
placeAfter: 'tr:last-child'
},
galleryPageInjection: {
container: '.gallery-info tbody',
placeAfter: 'tr:last-child'
},
galleryHeader: '.gallery h1',
coverImage: 'div.cover > a > picture > img',
},
breakpoints: {
mobile: 768
},
storageKeys: {
showPageCount: 'hitomi_show_page_count',
showGalleryPageCount: 'hitomi_show_gallery_page_count',
galleryEnabled: 'hitomi_auto_gallery',
readerEnabled: 'hitomi_auto_reader',
gapSize: 'hitomi_gap_size',
gapColor: 'hitomi_gap_color',
devLogEnabled: 'hitomi_dev_log',
gridColumnsDesktop: 'gallery_grid_columns_desktop',
gridColumnsMobile: 'gallery_grid_columns_mobile',
scrollableGallery: 'hitomi_scrollable_gallery',
gridGapV: 'hitomi_grid_gap_v',
gridGapH: 'hitomi_grid_gap_h',
scrollGalleryHeight: 'hitomi_scroll_h',
showFloatingPageIndicator: 'hitomi_floating_page_indicator',
wrapTitles: 'hitomi_wrap_titles',
scrollTitles: 'hitomi_scroll_titles',
favorites: 'hitomi_favorites',
favEntryWidth: 'hitomi_fav_width'
},
defaults: {
showPageCount: true,
showGalleryPageCount: true,
galleryEnabled: false,
readerEnabled: true,
gapSize: 15,
gapColor: '#000000',
devLogEnabled: false,
gridColumnsDesktop: 7,
gridColumnsMobile: 2,
scrollableGallery: true,
gridGapV: 10,
gridGapH: 10,
scrollGalleryHeight: 700,
showFloatingPageIndicator: true,
wrapTitles: false,
scrollTitles: true,
favEntryWidth: 180
},
ui: {
modalId: 'hitomi-ui-modal'
}
};
// ==========================================
// MODULE 2: Template Helper Module
// Purpose: Centralized repository for all HTML structures, dynamically generated
// CSS blocks, and UI template functions injected into the page.
// ==========================================
const TemplateModule = {
CSS: {
uiStyles: `
#hitomi-ui-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(250, 250, 255, 0.95); border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 20px; padding: 24px; width: 90%; max-width: 380px; box-sizing: border-box; z-index: 2000000; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); font-family: 'Segoe UI', sans-serif; color: #1a1b26; display: none; max-height: 90vh; overflow-y: auto;}
#hitomi-ui-modal input, #hitomi-ui-modal button { cursor: default; }
.ui-card { background: rgba(0, 0, 0, 0.03); border-radius: 15px; padding: 16px; margin-top: 10px; border: 1px solid rgba(0, 0, 0, 0.05); }
.ui-title { font-size: 1.2rem;
font-weight: bold;
margin-top: -10px;
margin-bottom: 15px;
text-align: center;
color: #1a1b26;
position: sticky;
top: -24px;
background: rgba(250, 250, 255, 1);
z-index: 10;
padding: 10px 0;
cursor: move;
user-select: none;
}
.ui-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-weight: 500; }
.ui-input { width: 60px; padding: 4px 8px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.1); text-align: center; font-family: inherit; font-weight: 600; background: white; }
.ui-color-input { width: 40px; height: 30px; padding: 0; border: 1px solid rgba(0,0,0,0.1); border-radius: 5px; cursor: pointer; background: none; }
.switch { position: relative; display: inline-block; width: 44px; height: 22px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background-color: rgba(0, 0, 0, 0.1); transition: .4s; border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
input:checked + .slider { background-color: #007bff; }
input:checked + .slider:before { transform: translateX(22px); }
.ui-btn-group { display: flex; gap: 8px; margin-top: 20px; flex-wrap: wrap; position: sticky; bottom: -24px; background: rgba(250, 250, 255, 1); padding: 15px 0 24px 0; margin-bottom: -24px; z-index: 10; border-top: 1px solid rgba(0,0,0,0.08); }
.ui-btn { flex: 1; min-width: 80px; padding: 10px; border-radius: 10px; border: none; cursor: pointer; font-weight: 600; transition: all 0.2s; font-size: 0.85rem; }
.ui-close-btn { background: #007bff; color: white; }
.ui-close-btn:hover { background: #0056b3; }
.ui-reset-btn { background: rgba(0, 0, 0, 0.05); color: #1a1b26; border: 1px solid rgba(0, 0, 0, 0.1); }
.ui-reset-btn:hover { background: rgba(0, 0, 0, 0.1); }
.ui-cancel-btn { background: rgba(255, 0, 0, 0.05); color: #d00; border: 1px solid rgba(255, 0, 0, 0.1); }
.ui-cancel-btn:hover { background: rgba(255, 0, 0, 0.1); }
.ui-input:disabled { background: #eeeeee !important; cursor: not-allowed; color: #999; }
`,
mobileListWrap: (mobileWidth) => `
@media (max-width: ${mobileWidth}px) {
.gallery-content > div { display: flex !important; flex-direction: column !important; }
h1.lillie { order: 1 !important; }
.artist-list { order: 2 !important; margin-bottom: 5px !important; }
a.lillie { order: 3 !important; position: relative !important; float: none !important; margin-left: 0 !important; width: fit-content !important; }
.dj-content { order: 4 !important; padding-top: 10px !important; }
.dj-img-cont { top: auto !important; position: relative !important; }
}
`,
desktopListWrap: (shiftValue) => `; position: relative !important; top: ${shiftValue}px !important;`,
scrollTitles: `
h1.lillie {
overflow-x: auto !important;
white-space: nowrap !important;
scrollbar-width: none;
}
h1.lillie::-webkit-scrollbar { height: 4px; }
h1.lillie::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }
`,
galleryGrid: (cols, gapH, gapV) => `
.thumbnail-list { display: flex !important; flex-wrap: wrap !important; justify-content: flex-start !important; padding: 0 !important; margin: 0 !important; }
.thumbnail-list li { display: block; width: calc(100% / ${cols} - ${gapH}px) !important; margin: ${gapV / 2}px ${gapH / 2}px !important; height: auto !important; }
.thumbnail-container { width: 100% !important; height: unset !important; display: block !important; }
.thumbnail-container img { width: 100% !important; height: auto !important; max-width: none !important; max-height: none !important; display: block !important; object-fit: contain !important; }
div.content { padding-right: 10px !important; }
`,
galleryScrollable: (height) => `
ul.thumbnail-list { max-height: ${height}px !important; overflow-y: auto !important; overflow-x: hidden !important; border: 1px solid rgba(0,0,0,0.1); border-radius: 5px; }
ul.thumbnail-list li { display: block !important; }
.simplePagerNav { display: none !important; }
ul.thumbnail-list::-webkit-scrollbar { width: 8px; }
ul.thumbnail-list::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
ul.thumbnail-list::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }
`,
readerBox: `display:flex;flex-direction:column;width:100%;background:#ffffff;`,
readerWrapper: (maxWidth, gapColor, topPadding) => `display: grid; grid-template-areas: "content"; width: 100%; max-width: ${maxWidth}; margin: 0 auto; background-color: ${gapColor}; min-height: 80vh; padding-top: ${topPadding}; align-items: center; justify-items: center; color: #888; font-family: sans-serif; font-size: 14px;`,
readerImage: `grid-area: "content"; width: auto; max-width: 100%; height: auto; display: block; opacity: 0; transition: opacity 0.3s; z-index: 1;`,
floatingIndicator: `position: fixed; bottom: 0.3vh; left: 50%; transform: translateX(-50%); color: rgba(255, 255, 255, 0.5); font-size: clamp(10px, 3.5vw, 20px); font-family: sans-serif; font-weight: bold; z-index: 9999; pointer-events: none; text-shadow: 0px 1px 2px rgba(0,0,0,0.5);`,
favBtnLink: `display: block; margin-top: 5px; text-decoration: none;`,
favBtnHeader: `text-align: center; transition: background 0.2s; margin: 0; padding: 0;`,
favOverlay: `position: fixed; inset: 0; background: rgba(0,0,0,0.9); z-index: 1000000; overflow-y: auto; padding: 20px; display: none; font-family: sans-serif;`,
favContent: `display: flex; flex-wrap: wrap; gap: 15px; margin-top: 60px; justify-content: center; max-width: 1200px; margin-left: auto; margin-right: auto;`,
favCard: (customWidth) => `width: ${customWidth}px; background: #1a1a1a; padding: 0; border-radius: 4px; display: flex; flex-direction: column; border: 1px solid #333; overflow: hidden;`,
favImg: (imgHeight) => `width: 100%; height: ${imgHeight}px; object-fit: contain; background: #000;`,
favCardBody: `padding: 8px; text-align: left;`,
favCardTitle: `font-size: 12px; margin: 0; height: 32px; overflow: hidden; color: #ddd; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;`,
favCardDate: `font-size: 10px; margin-top: 6px; color: #666; font-style: italic;`,
favRemoveBtn: `background: #e83e8c; color: white; border: none; padding: 5px; cursor: pointer; font-weight: bold; font-size: 11px;`,
favControls: `position: fixed; top: 20px; right: 20px; display: flex; gap: 10px; z-index: 1000001;`,
favCloseBtn: `padding: 10px 20px; background: #333; color: white; border: none; cursor: pointer; border-radius: 5px; font-weight: bold;`,
favActionBtn: `padding: 10px 20px; background: #007bff; color: white; border: none; cursor: pointer; border-radius: 5px; font-weight: bold; transition: background 0.2s;`,
exportChoiceOverlay: `
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 1000005;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
`,
exportChoiceBox: `
background: #1a1b26;
padding: 30px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
width: 90%;
max-width: 360px;
text-align: center;
box-sizing: border-box;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
color: #fff;
font-family: 'Segoe UI', sans-serif;
`,
},
HTML: {
uiSettingsTemplate: (conf) => `
<div class="ui-title">Hitomi enhancer Settings</div>
<div class="ui-card">
<div style="font-weight:700; margin-bottom:12px; color: #007bff;">Front & Search Page UI Enhance</div>
<div class="ui-row"><span>Show Page Number</span><label class="switch"><input type="checkbox" id="set-pagecount" ${conf.pageCount ? 'checked' : ''}><span class="slider"></span></label></div>
<div style="height: 1px; background: rgba(0,0,0,0.08); margin: 12px 0;"></div>
<div class="ui-row">
<span>Wrap Gallery Titles</span>
<label class="switch">
<input type="checkbox" id="set-wrap-titles" ${conf.wrapTitles ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
<div class="ui-row" id="row-scroll-titles">
<span>Scroll Titles (Horizontal)</span>
<label class="switch">
<input type="checkbox" id="set-scroll-titles" ${conf.scrollTitles ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
</div>
<div class="ui-card">
<div style="font-weight:700; margin-bottom:12px; color: #007bff;">Gallery Page UI Enhance</div>
<div class="ui-row"><span>Show Page Number</span><label class="switch"><input type="checkbox" id="set-gal-pagecount" ${conf.galPageCount ? 'checked' : ''}><span class="slider"></span></label></div>
<div style="height: 1px; background: rgba(0,0,0,0.08); margin: 12px 0;"></div>
<div class="ui-row"><span>Scrollable Thumbnails</span><label class="switch"><input type="checkbox" id="set-scrollable-gal" ${conf.scrollableGal ? 'checked' : ''}><span class="slider"></span></label></div>
<div class="ui-row" id="row-scroll-h"><span>Scroll Area Height (px)</span><input type="number" id="set-scroll-h" class="ui-input" value="${conf.scrollH}" min="100" max="2000"></div>
<div style="height: 1px; background: rgba(0,0,0,0.08); margin: 12px 0;"></div>
<div class="ui-row"><span>Horizontal Gap | | | (px) </span><input type="number" id="set-gap-h" class="ui-input" value="${conf.gapH}" min="0" max="100"></div>
<div class="ui-row"><span>Vertical Gap ☰ (px) </span><input type="number" id="set-gap-v" class="ui-input" value="${conf.gapV}" min="0" max="100"></div>
<div class="ui-row" style="flex-direction: column; align-items: flex-start; gap: 8px;">
<div style="display: flex; justify-content: space-between; width: 100%;">
<span>Grid Columns (Desktop)</span>
<span id="grid-cols-desktop-display" style="font-weight: bold; color: #007bff;">${conf.colsDesktop}</span>
</div>
<input type="range" id="set-grid-cols-desktop" style="width: 100%; cursor: pointer;" value="${conf.colsDesktop}" min="2" max="12" step="1">
</div>
<div class="ui-row" style="flex-direction: column; align-items: flex-start; gap: 8px; margin-top: 10px;">
<div style="display: flex; justify-content: space-between; width: 100%;">
<span>Grid Columns (Mobile)</span>
<span id="grid-cols-mobile-display" style="font-weight: bold; color: #007bff;">${conf.colsMobile}</span>
</div>
<input type="range" id="set-grid-cols-mobile" style="width: 100%; cursor: pointer;" value="${conf.colsMobile}" min="1" max="5" step="1">
</div>
</div>
<div class="ui-card">
<div style="font-weight:700; margin-bottom:12px; color: #007bff;">Long-Strip Reader Mode</div>
<div class="ui-row"><span>Enable on Gallery Page</span><label class="switch"><input type="checkbox" id="set-gallery" ${conf.gallery ? 'checked' : ''}><span class="slider"></span></label></div>
<div class="ui-row"><span>Enable on Reader Page</span><label class="switch"><input type="checkbox" id="set-reader" ${conf.reader ? 'checked' : ''}><span class="slider"></span></label></div>
<div style="height: 1px; background: rgba(0,0,0,0.08); margin: 12px 0;"></div>
<div class="ui-row" id="row-floating-page"><span>Floating Page Indicator <br>(Reader page)</span><label class="switch"><input type="checkbox" id="set-floating-page" ${conf.floatingPage ? 'checked' : ''}><span class="slider"></span></label></div>
<div style="height: 1px; background: rgba(0,0,0,0.08); margin: 12px 0;"></div>
<div class="ui-row"><span>Gap Size (px)</span><input type="number" id="set-gap" class="ui-input" value="${conf.gap}" min="0" max="500"></div>
<div class="ui-row"><span>Gap Color</span><input type="color" id="set-gap-color" class="ui-color-input" value="${conf.color}"></div>
</div>
<div class="ui-card">
<div style="font-weight:700; margin-bottom:12px; color: #ff4b4b;">Favorite Page Settings</div>
<div class="ui-row" style="flex-direction: column; align-items: flex-start; gap: 8px;">
<div style="display: flex; justify-content: space-between; width: 100%;">
<span>Entry Width (px)</span>
<span id="fav-entry-width-display" style="font-weight: bold; color: #ff4b4b;">${conf.favWidth}</span>
</div>
<input type="range" id="set-fav-entry-width" style="width: 100%; cursor: pointer;" value="${conf.favWidth}" min="100" max="400" step="5">
</div>
</div>
<div class="ui-card">
<div style="font-weight:700; margin-bottom:12px; color: #666;">Developer</div>
<div class="ui-row"><span>Show Console Log</span><label class="switch"><input type="checkbox" id="set-devlog" ${conf.devlog ? 'checked' : ''}><span class="slider"></span></label></div>
</div>
<div class="ui-btn-group">
<button class="ui-btn ui-reset-btn">Reset</button>
<button class="ui-btn ui-cancel-btn">Cancel</button>
<button class="ui-btn ui-close-btn">Save & Close</button>
</div>
`,
pageCountRow: (count, className) => `
<tr class="${className}">
<td style="padding-right: 10px;">Pages</td>
<td style="color: #007bff; font-weight: bold;">${count}</td>
</tr>
`,
favNavBtn: `<li style="padding: 10px 15px;"><a href="javascript:void(0);" id="hitomi-nav-fav-link" style="color: #ff4b4b; font-weight: bold; text-decoration: none;">favorites</a></li>`,
favEmptyState: `<h2 style="color:#aaa;">No favorites yet.</h2>`,
favCardInner: (fav, imgHeight) => `
<button class="remove-fav-btn" style="${TemplateModule.CSS.favRemoveBtn}">— Remove</button>
<a href="${fav.url}" target="_blank">
<img src="${fav.thumb}" style="${TemplateModule.CSS.favImg(imgHeight)}">
</a>
<div style="${TemplateModule.CSS.favCardBody}">
<p style="${TemplateModule.CSS.favCardTitle}">
<a href="${fav.url}" target="_blank" style="color: #ddd; text-decoration: none;">${fav.title}</a>
</p>
<p style="${TemplateModule.CSS.favCardDate}">Added: ${fav.addedAt ? new Date(fav.addedAt).toLocaleDateString() : 'Unknown'}</p>
</div>
`,
exportChoiceTemplate: `
<div style="margin-bottom: 25px;">
<h2 style="font-size: 1.4rem; margin: 0 0 8px 0; color: #fff; font-weight: 700; white-space: nowrap;">Export Favorites</h2>
<p style="color: #9499b8; font-size: 0.9rem; margin: 0;">Choose the export format</p>
</div>
<button id="btn-export-json" style="width:100%; padding: 14px; margin: 8px 0; background: #007bff; color: white; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; transition: transform 0.1s, background 0.2s;">
{} Export as JSON (Full Backup)
</button>
<button id="btn-export-txt" style="width:100%; padding: 14px; margin: 8px 0; background: #3b3d4d; color: white; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; transition: transform 0.1s, background 0.2s;">
📄 Export as TXT (URLs Only)
</button>
<p style="color: #565f89; font-size: 0.75rem; margin: 10px 0 20px 0; font-style: italic;">
⚠️ TXT files contain URLs only and cannot be imported.
</p>
<button id="btn-export-cancel" style="width: 100%; background: rgba(255, 75, 75, 0.1); color: #ff4b4b; border: 1px solid rgba(255, 75, 75, 0.2); border-radius: 12px; padding: 12px; cursor: pointer; font-weight: 600; transition: all 0.2s;">
Cancel
</button>
`
},
createFavControls: function(onImport, onExport, onClose) {
const container = document.createElement('div');
container.style.cssText = this.CSS.favControls;
// Hidden File Input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.style.display = 'none';
fileInput.onchange = (e) => {
onImport(e);
fileInput.value = ''; // Reset input after use
};
// Import Button
const importBtn = document.createElement('button');
importBtn.innerText = '📥 Import';
importBtn.style.cssText = this.CSS.favActionBtn;
importBtn.onclick = () => fileInput.click();
// Export Button
const exportBtn = document.createElement('button');
exportBtn.innerText = '📤 Export';
exportBtn.style.cssText = this.CSS.favActionBtn;
exportBtn.onclick = onExport;
// Close Button
const closeBtn = document.createElement('button');
closeBtn.innerText = '✖ Close';
closeBtn.style.cssText = this.CSS.favCloseBtn;
closeBtn.onclick = onClose;
// Assemble
container.appendChild(fileInput);
container.appendChild(importBtn);
container.appendChild(exportBtn);
container.appendChild(closeBtn);
return container;
}
};
// ==========================================
// MODULE 3: Data Persistence & Logging
// Purpose: Wraps Tampermonkey's GM_getValue and GM_setValue to handle storing user configuration,
// and provides a centralized logging utility to optionally pipe debug output.
// ==========================================
const StorageModule = {
get: (key) => {
const defaultKey = Object.keys(CONFIG.storageKeys).find(k => CONFIG.storageKeys[k] === key);
return GM_getValue(key, CONFIG.defaults[defaultKey]);
},
set: (key, val) => GM_setValue(key, val)
};
const Logger = {
log: (...args) => { if (StorageModule.get(CONFIG.storageKeys.devLogEnabled)) console.log("[Hitomi enhancer Log]", ...args); },
table: (data) => { if (StorageModule.get(CONFIG.storageKeys.devLogEnabled)) { console.log("[Hitomi enhancer Table]:"); console.table(data); } },
error: (...args) => { if (StorageModule.get(CONFIG.storageKeys.devLogEnabled)) console.error("[Hitomi enhancer Error]", ...args); }
};
// ==========================================
// MODULE 4: UI Effects Submodule
// Purpose: Applies visual states (like disabling dependent inputs) directly in the DOM
// without altering the user's stored settings until saved.
// ==========================================
const UIEffectModule = {
els: {},
init(modal) {
this.els = {
scrollToggle: modal.querySelector('#set-scrollable-gal'),
scrollInput: modal.querySelector('#set-scroll-h'),
scrollRow: modal.querySelector('#row-scroll-h'),
readerToggle: modal.querySelector('#set-reader'),
indicatorToggle: modal.querySelector('#set-floating-page'),
indicatorRow: modal.querySelector('#row-floating-page'),
};
},
applyState(input, row, isEnabled) {
if (!input || !row) return;
input.disabled = !isEnabled;
row.style.opacity = isEnabled ? "1" : "0.5";
row.style.pointerEvents = isEnabled ? "auto" : "none";
},
syncScrollHeight() { this.applyState(this.els.scrollInput, this.els.scrollRow, this.els.scrollToggle?.checked); },
syncReaderOptions() { this.applyState(this.els.indicatorToggle, this.els.indicatorRow, this.els.readerToggle?.checked); }
};
// ==========================================
// MODULE 5: UI Management (Global)
// Purpose: Bootstraps the settings modal, handles drag-and-drop operations, and implements
// the core logic to intercept user inputs for live previews and mutual exclusion of options.
// ==========================================
const UIModule = {
init() {
GM_addStyle(TemplateModule.CSS.uiStyles);
this.createElements();
GM_registerMenuCommand("⚙️ Hitomi Enhancer Settings", () => this.toggle(true));
},
createElements() {
const modal = document.createElement('div');
modal.id = CONFIG.ui.modalId;
const currentConf = {
pageCount: StorageModule.get(CONFIG.storageKeys.showPageCount),
galPageCount: StorageModule.get(CONFIG.storageKeys.showGalleryPageCount),
gallery: StorageModule.get(CONFIG.storageKeys.galleryEnabled),
reader: StorageModule.get(CONFIG.storageKeys.readerEnabled),
gap: StorageModule.get(CONFIG.storageKeys.gapSize),
color: StorageModule.get(CONFIG.storageKeys.gapColor),
devlog: StorageModule.get(CONFIG.storageKeys.devLogEnabled),
colsDesktop: StorageModule.get(CONFIG.storageKeys.gridColumnsDesktop),
colsMobile: StorageModule.get(CONFIG.storageKeys.gridColumnsMobile),
scrollableGal: StorageModule.get(CONFIG.storageKeys.scrollableGallery),
scrollH: StorageModule.get(CONFIG.storageKeys.scrollGalleryHeight),
gapH: StorageModule.get(CONFIG.storageKeys.gridGapH),
gapV: StorageModule.get(CONFIG.storageKeys.gridGapV),
floatingPage: StorageModule.get(CONFIG.storageKeys.showFloatingPageIndicator),
wrapTitles: StorageModule.get(CONFIG.storageKeys.wrapTitles),
scrollTitles: StorageModule.get(CONFIG.storageKeys.scrollTitles),
favWidth: StorageModule.get(CONFIG.storageKeys.favEntryWidth)
};
modal.innerHTML = TemplateModule.HTML.uiSettingsTemplate(currentConf);
document.body.appendChild(modal);
// Pass both modal and overlay to bindEvents
this.bindEvents(modal);
},
bindEvents(modal) {
// --- DRAGGABLE LOGIC for DESKTOP DEVICE ONLY ---
let isDragging = false;
let offset = { x: 0, y: 0 };
modal.addEventListener('mousedown', (e) => {
// Only allow dragging if the clicked element is the title
if (!e.target.classList.contains('ui-title')) return;
isDragging = true;
// Calculate offset from the top-left of the modal
const rect = modal.getBoundingClientRect();
offset.x = e.clientX - rect.left;
offset.y = e.clientY - rect.top;
modal.style.transform = 'none'; // Remove the translate(-50%, -50%) once dragged
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
modal.style.left = (e.clientX - offset.x) + 'px';
modal.style.top = (e.clientY - offset.y) + 'px';
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
// 1. Create a cache to hold changes before saving
const tempSettings = {};
const applyLiveUpdate = (key) => {
// 2. Temporarily mock StorageModule.get so external modules read the unsaved values during the preview
const originalGet = StorageModule.get;
StorageModule.get = (k) => tempSettings.hasOwnProperty(k) ? tempSettings[k] : originalGet(k);
const value = StorageModule.get(key);
if (key === CONFIG.storageKeys.showPageCount) {
document.querySelectorAll('.hitomi-page-count-row').forEach(el => el.style.display = value ? '' : 'none');
if (value) ListPageExecution.processLinks(document.querySelectorAll(CONFIG.selectors.listTitles), window);
}
if (key === CONFIG.storageKeys.showGalleryPageCount) {
document.querySelectorAll('.hitomi-gal-page-count-row').forEach(el => el.style.display = value ? '' : 'none');
if (value) GalleryPageExecution.init();
}
if (key === CONFIG.storageKeys.wrapTitles) {
let styleEl = document.getElementById('hitomi-live-wrap-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'hitomi-live-wrap-style';
document.head.appendChild(styleEl);
}
if (value) {
styleEl.textContent = `${CONFIG.selectors.listTitles} { white-space: normal !important; overflow: visible !important; }`;
if (window.innerWidth > CONFIG.breakpoints.mobile) setTimeout(() => ListWrapLayoutDesktopModule.run(), 50);
} else {
styleEl.textContent = '';
document.querySelectorAll('.gallery-content > div a.lillie').forEach(el => el.style.top = '');
}
}
if (key === CONFIG.storageKeys.scrollTitles) {
let scrollStyle = document.getElementById('hitomi-scroll-title-style');
if (!scrollStyle) {
scrollStyle = document.createElement('style');
scrollStyle.id = 'hitomi-scroll-title-style';
document.head.appendChild(scrollStyle);
}
if (value) {
scrollStyle.textContent = TemplateModule.CSS.scrollTitles;
} else {
scrollStyle.textContent = '';
}
}
const gridKeys = [ CONFIG.storageKeys.gridColumnsDesktop, CONFIG.storageKeys.gridColumnsMobile, CONFIG.storageKeys.gridGapH, CONFIG.storageKeys.gridGapV, CONFIG.storageKeys.scrollableGallery, CONFIG.storageKeys.scrollGalleryHeight ];
if (gridKeys.includes(key)) { if (document.querySelector(CONFIG.selectors.galleryParent)) GalleryGridExecution.init(); }
if (key === CONFIG.storageKeys.gapSize || key === CONFIG.storageKeys.gapColor) {
const gap = StorageModule.get(CONFIG.storageKeys.gapSize);
const color = StorageModule.get(CONFIG.storageKeys.gapColor);
document.querySelectorAll('.hitomi-reader-image-wrapper').forEach(el => { el.style.paddingTop = `${gap}px`; el.style.backgroundColor = color; });
}
if (key === CONFIG.storageKeys.favEntryWidth) {
if (document.getElementById('hitomi-fav-overlay')?.style.display === 'block') { FavoriteModule.Page.renderItems(); }
}
// Restore the original StorageModule.get function after the visual preview finishes executing
StorageModule.get = originalGet;
};
modal.addEventListener('input', (e) => {
const target = e.target;
const id = target.id;
let key, value;
if (id === 'set-grid-cols-desktop') { key = CONFIG.storageKeys.gridColumnsDesktop; document.getElementById('grid-cols-desktop-display').innerText = target.value; }
else if (id === 'set-grid-cols-mobile') { key = CONFIG.storageKeys.gridColumnsMobile; document.getElementById('grid-cols-mobile-display').innerText = target.value; }
else if (id === 'set-fav-entry-width') { key = CONFIG.storageKeys.favEntryWidth; document.getElementById('fav-entry-width-display').innerText = target.value; }
else if (id === 'set-gap-h') { key = CONFIG.storageKeys.gridGapH; }
else if (id === 'set-gap-v') { key = CONFIG.storageKeys.gridGapV; }
else if (id === 'set-gap') { key = CONFIG.storageKeys.gapSize; }
else if (id === 'set-gap-color') { key = CONFIG.storageKeys.gapColor; }
else if (id === 'set-scroll-h') { key = CONFIG.storageKeys.scrollGalleryHeight; }
else if (target.type === 'checkbox') {
if (id === 'set-pagecount') { key = CONFIG.storageKeys.showPageCount; }
if (id === 'set-gal-pagecount') { key = CONFIG.storageKeys.showGalleryPageCount; }
if (id === 'set-gallery') { key = CONFIG.storageKeys.galleryEnabled; }
if (id === 'set-reader') { key = CONFIG.storageKeys.readerEnabled; UIEffectModule.syncReaderOptions(); }
if (id === 'set-floating-page') { key = CONFIG.storageKeys.showFloatingPageIndicator; }
if (id === 'set-devlog') { key = CONFIG.storageKeys.devLogEnabled; }
if (id === 'set-scroll-titles') {
key = CONFIG.storageKeys.scrollTitles;
if (target.checked) {
const wrapToggle = document.getElementById('set-wrap-titles');
if (wrapToggle && wrapToggle.checked) {
wrapToggle.checked = false;
tempSettings[CONFIG.storageKeys.wrapTitles] = false;
applyLiveUpdate(CONFIG.storageKeys.wrapTitles);
}
}
}
if (id === 'set-wrap-titles') {
key = CONFIG.storageKeys.wrapTitles;
if (target.checked) {
const scrollToggle = document.getElementById('set-scroll-titles');
if (scrollToggle && scrollToggle.checked) {
scrollToggle.checked = false;
tempSettings[CONFIG.storageKeys.scrollTitles] = false;
applyLiveUpdate(CONFIG.storageKeys.scrollTitles);
}
}
}
if (id === 'set-scrollable-gal') { key = CONFIG.storageKeys.scrollableGallery; UIEffectModule.syncScrollHeight(); }
}
if (key) {
value = target.type === 'checkbox' ? target.checked : (target.type === 'number' || target.type === 'range' ? parseInt(target.value) : target.value);
// 3. Store in the temporary cache instead of writing to storage immediately
tempSettings[key] = value;
applyLiveUpdate(key);
}
});
UIEffectModule.init(modal);
UIEffectModule.syncScrollHeight();
UIEffectModule.syncReaderOptions();
// 4. Save & Close logic: Commit tempSettings to GM_setValue
modal.querySelector('.ui-close-btn').onclick = () => {
// Save all temporary changes to permanent storage
Object.keys(tempSettings).forEach(k => StorageModule.set(k, tempSettings[k]));
// Refresh the page to apply all changes cleanly
location.reload();
};
// 5. Cancel logic: If changes were made, reload the page to safely discard visual changes
const closeWithoutSaving = () => {
if (Object.keys(tempSettings).length > 0) {
location.reload(); // Refresh if changes were discarded
} else {
this.toggle(false); // Just close if nothing changed
}
};
modal.querySelector('.ui-cancel-btn').onclick = closeWithoutSaving;
modal.querySelector('.ui-reset-btn').onclick = () => this.reset();
},
reset() {
if (confirm("Reset all settings to default?")) {
Object.keys(CONFIG.storageKeys).forEach(k => { StorageModule.set(CONFIG.storageKeys[k], CONFIG.defaults[k]); });
location.reload();
}
},
toggle(show) {
const modal = document.getElementById(CONFIG.ui.modalId);
if (modal) modal.style.display = show ? 'block' : 'none';
}
};
// ==========================================
// MODULE 6: Feature - Long-Strip Reader
// Purpose: Connects to Hitomi's internal JS files metadata to intercept and layout
// images into a seamless vertical long-strip scrolling experience.
// ==========================================
const ReaderExtraction = {
async getImages() {
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const match = window.location.pathname.match(/-?([0-9]+)(?:\.html)?$/);
if (!match || !match[1]) return null;
const galleryid = parseInt(match[1]);
for (let i = 0; i < 20; i++) {
if (win.galleryinfo && typeof win.url_from_url_from_hash === 'function') break;
await new Promise(r => setTimeout(r, 500));
}
if (!win.galleryinfo) return null;
return win.galleryinfo.files.map((image, index) => {
try {
let url;
if (image.hasavif) url = win.url_from_url_from_hash(galleryid, image, 'avif', 'avif');
else if (image.haswebp) url = win.url_from_url_from_hash(galleryid, image, 'webp', 'webp');
else {
const ext = image.name.split('.').pop();
url = win.url_from_url_from_hash(galleryid, image, 'images', ext);
}
return { url, index };
} catch(e) { return null; }
}).filter(item => item !== null);
}
};
const ReaderExecution = {
async run() {
const data = await ReaderExtraction.getImages();
if (!data || data.length === 0) return;
const hash = window.location.hash.match(/#([0-9]+)/);
const startIndex = hash ? parseInt(hash[1]) - 1 : 0;
const gap = StorageModule.get(CONFIG.storageKeys.gapSize);
const gapColor = StorageModule.get(CONFIG.storageKeys.gapColor);
const isMobileViewport = window.innerWidth <= CONFIG.breakpoints.mobile;
let readerTarget = isMobileViewport ? document.querySelector(CONFIG.selectors.mobileReader) : document.querySelector(CONFIG.selectors.desktopReader);
if (readerTarget) {
readerTarget.innerHTML = '';
this.appendImages(readerTarget, data, '100%', startIndex, gap, gapColor);
window.scrollTo(0, 0);
} else {
const galleryTarget = document.querySelector(CONFIG.selectors.galleryParent);
if (galleryTarget) {
const thumbs = galleryTarget.querySelector(CONFIG.selectors.galleryThumbs);
if (thumbs) thumbs.style.display = 'none';
document.querySelectorAll(CONFIG.selectors.pagination).forEach(el => el.style.display = 'none');
const webtoonBox = document.createElement('div');
webtoonBox.style.cssText = TemplateModule.CSS.readerBox;
galleryTarget.appendChild(webtoonBox);
galleryTarget.style.padding = '0';
this.appendImages(webtoonBox, data, '100%', 0, gap, gapColor);
}
}
if (StorageModule.get(CONFIG.storageKeys.showFloatingPageIndicator)) {
this.createFloatingIndicator(data.length);
}
},
appendImages(container, data, maxWidth, startIndex, gap, gapColor) {
data.forEach(item => {
if (item.index < startIndex) return;
const wrapper = document.createElement('div');
const topPadding = `${gap}px`;
wrapper.className = 'hitomi-reader-image-wrapper';
wrapper.dataset.pageIndex = item.index + 1;
wrapper.style.cssText = TemplateModule.CSS.readerWrapper(maxWidth, gapColor, topPadding);
const loadingText = document.createElement('span');
loadingText.innerText = `Loading Image ${item.index + 1}...`;
loadingText.style.gridArea = "content";
wrapper.appendChild(loadingText);
const img = document.createElement('img');
img.src = item.url;
img.style.cssText = TemplateModule.CSS.readerImage;
img.loading = 'lazy';
img.onload = () => {
img.style.opacity = '1';
wrapper.style.minHeight = 'auto';
loadingText.style.display = 'none';
};
wrapper.appendChild(img);
container.appendChild(wrapper);
});
},
createFloatingIndicator(total) {
const existing = document.getElementById('hitomi-floating-page-indicator');
if (existing) existing.remove();
const indicator = document.createElement('div');
indicator.id = 'hitomi-floating-page-indicator';
indicator.style.cssText = TemplateModule.CSS.floatingIndicator;
indicator.innerText = `1 / ${total}`;
document.body.appendChild(indicator);
this.setupScrollObserver(total, indicator);
},
setupScrollObserver(total, indicator) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
indicator.innerText = `${entry.target.dataset.pageIndex} / ${total}`;
}
});
}, { rootMargin: "-49% 0px -49% 0px" });
document.querySelectorAll('.hitomi-reader-image-wrapper').forEach(wrapper => observer.observe(wrapper));
}
};
// ==========================================
// MODULE 7: Feature - List Page Enhancements
// Purpose: Modifies the layout on category/search lists, fetching gallery page lengths
// via remote metadata files, and styles the layout blocks to support text wrapping/scrolling.
// ==========================================
const ListTitleWrapModule = {
init() {
const wrapEnabled = StorageModule.get(CONFIG.storageKeys.wrapTitles);
const scrollEnabled = StorageModule.get(CONFIG.storageKeys.scrollTitles);
if (wrapEnabled) {
let styleEl = document.getElementById('hitomi-live-wrap-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'hitomi-live-wrap-style';
styleEl.textContent = `${CONFIG.selectors.listTitles} { white-space: normal !important; overflow: visible !important; }`;
document.head.appendChild(styleEl);
}
if (window.innerWidth > CONFIG.breakpoints.mobile) {
setTimeout(() => ListWrapLayoutDesktopModule.run(), 50);
} else {
ListWrapLayoutMobileModule.init();
}
} else if (scrollEnabled) {
// Inject the scroll styling if enabled
let scrollStyle = document.getElementById('hitomi-scroll-title-style');
if (!scrollStyle) {
scrollStyle = document.createElement('style');
scrollStyle.id = 'hitomi-scroll-title-style';
scrollStyle.textContent = TemplateModule.CSS.scrollTitles;
document.head.appendChild(scrollStyle);
}
}
}
};
const ListWrapLayoutMobileModule = {
init() {
GM_addStyle(TemplateModule.CSS.mobileListWrap(CONFIG.breakpoints.mobile));
Logger.log("Mobile layout fix applied for wrapped titles.");
}
};
const ListWrapLayoutDesktopModule = {
run() {
document.querySelectorAll('.gallery-content > div').forEach(entry => {
const titleBar = entry.querySelector('h1.lillie');
const coverLink = entry.querySelector('a.lillie');
if (titleBar && coverLink) {
coverLink.style.cssText += TemplateModule.CSS.desktopListWrap(titleBar.offsetHeight / 3);
}
});
Logger.log("Desktop layout fix applied for wrapped titles.");
}
};
const ListExtraction = {
async getPageCount(galleryId, metaDomain) {
const url = `//${metaDomain}/galleries/${galleryId}.js`;
Logger.log(`Fetching metadata from: ${url}`);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
const extractFunc = new Function(`
let galleryinfo;
${text.replace(/var\s+galleryinfo\s*=/, 'galleryinfo =')}
return galleryinfo ? galleryinfo.files.length : 0;
`);
return extractFunc();
} catch (e) {
Logger.error(`Failed for ${galleryId}: ${e.message}`);
return 0;
}
}
};
const ListInjection = {
injectPageCount(link, filesLength) {
const injectCfg = CONFIG.selectors.pageNumberInjection;
const galleryItem = link.closest('.gallery-content > div');
if (!galleryItem) return;
const container = galleryItem.querySelector(injectCfg.container);
if (container && !container.querySelector('.hitomi-page-count-row')) {
const finalHtml = TemplateModule.HTML.pageCountRow(filesLength, 'hitomi-page-count-row');
const sibling = container.querySelector(injectCfg.placeAfter);
if (sibling) sibling.insertAdjacentHTML('afterend', finalHtml);
else container.insertAdjacentHTML('beforeend', finalHtml);
}
}
};
const ListPageExecution = {
init() {
ListTitleWrapModule.init();
if (!StorageModule.get(CONFIG.storageKeys.showPageCount)) return;
this.hijackListRender();
},
hijackListRender() {
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
let originalLimitLists = win.limitLists;
const runPipeline = () => {
const links = document.querySelectorAll(CONFIG.selectors.listTitles);
if (links && links.length > 0) this.processLinks(links, win);
};
if (win.limitLists) {
win.limitLists = function(...args) {
if (originalLimitLists) originalLimitLists.apply(this, args);
runPipeline();
};
} else {
Object.defineProperty(win, "limitLists", {
get: () => function(...args) { runPipeline(); },
set(val) { originalLimitLists = val; }
});
}
setTimeout(runPipeline, 500);
},
processLinks(links, win) {
const metaDomain = win.domain || 'ltn.hitomi.la';
links.forEach(async (link) => {
if (link.dataset.pageCountAdded) return;
link.dataset.pageCountAdded = "true";
const match = link.href.match(/-([0-9]+)\.html/);
if (!match) return;
const filesLength = await ListExtraction.getPageCount(match[1], metaDomain);
if (filesLength > 0) ListInjection.injectPageCount(link, filesLength);
else link.dataset.pageCountAdded = "false";
});
}
};
// ==========================================
// MODULE 8: Feature - Gallery Page Enhancements
// Purpose: Injects total page counts into the detail tables of single gallery items
// and modifies the default thumbnail layout to act as a customized flex grid box.
// ==========================================
const GalleryPageExecution = {
async init() {
if (document.querySelector('.hitomi-gal-page-count-row')) {
document.querySelector('.hitomi-gal-page-count-row').style.display = '';
return;
}
if (!StorageModule.get(CONFIG.storageKeys.showGalleryPageCount)) return;
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
for (let i = 0; i < 20; i++) {
if (win.galleryinfo && win.galleryinfo.files) break;
await new Promise(r => setTimeout(r, 500));
}
if (!win.galleryinfo || !win.galleryinfo.files) {
Logger.error("Could not find galleryinfo on this page.");
return;
}
this.injectPageCount(win.galleryinfo.files.length);
},
injectPageCount(count) {
const injectCfg = CONFIG.selectors.galleryPageInjection;
const container = document.querySelector(injectCfg.container);
if (container && !document.querySelector('.hitomi-gal-page-count-row')) {
const finalHtml = TemplateModule.HTML.pageCountRow(count, 'hitomi-gal-page-count-row');
const sibling = container.querySelector(injectCfg.placeAfter);
if (sibling) sibling.insertAdjacentHTML('afterend', finalHtml);
else container.insertAdjacentHTML('beforeend', finalHtml);
}
}
};
const GalleryGridExecution = {
init() {
const isMobile = window.innerWidth <= CONFIG.breakpoints.mobile;
const cols = isMobile ? StorageModule.get(CONFIG.storageKeys.gridColumnsMobile) : StorageModule.get(CONFIG.storageKeys.gridColumnsDesktop);
const gapH = StorageModule.get(CONFIG.storageKeys.gridGapH);
const gapV = StorageModule.get(CONFIG.storageKeys.gridGapV);
this.applyStyles(cols, gapH, gapV);
if (StorageModule.get(CONFIG.storageKeys.scrollableGallery)) {
this.applyScrollableStyles(StorageModule.get(CONFIG.storageKeys.scrollGalleryHeight));
}
},
applyStyles(cols, gapH, gapV) { GM_addStyle(TemplateModule.CSS.galleryGrid(cols, gapH, gapV)); },
applyScrollableStyles(height) { GM_addStyle(TemplateModule.CSS.galleryScrollable(height)); }
};
// ==========================================
// MODULE 9: Feature - Favorite System
// Purpose: Entire lifecycle implementation of saving, backing up, importing and exploring
// user's saved galleries locally by managing a simulated SPA modal view overlay.
// ==========================================
const FavoriteModule = {
Storage: {
get() { try { return JSON.parse(GM_getValue(CONFIG.storageKeys.favorites, "[]")); } catch(e) { return []; } },
save(array) { GM_setValue(CONFIG.storageKeys.favorites, JSON.stringify(array)); Logger.table(array); },
add(mangaObj) {
const list = this.get();
if (!list.find(m => m.id === mangaObj.id)) { list.unshift(mangaObj); this.save(list); }
},
remove(id) { this.save(this.get().filter(m => m.id !== id)); },
isFavorited(id) { return this.get().some(m => m.id === id); },
},
UI: {
injectGalleryButton() {
const match = window.location.pathname.match(/-?([0-9]+)(?:\.html)?$/);
if (!match) return;
const currentId = match[1];
const container = document.querySelector('.cover-column.lillie');
const dlButton = document.getElementById('dl-button');
if (!container || !dlButton || document.getElementById('hitomi-fav-btn')) return;
const titleNode = document.querySelector(CONFIG.selectors.galleryHeader + ' a');
const title = titleNode ? titleNode.innerText : document.title;
let coverNode = document.querySelector(CONFIG.selectors.coverImage);
let thumb = coverNode ? coverNode.src : '';
if (thumb && thumb.startsWith('//')) thumb = window.location.protocol + thumb;
const btn = document.createElement('a');
btn.id = 'hitomi-fav-btn';
btn.href = 'javascript:void(0);';
btn.style.cssText = TemplateModule.CSS.favBtnLink;
btn.innerHTML = `<h1 style="${TemplateModule.CSS.favBtnHeader}"></h1>`;
const btnHeader = btn.querySelector('h1');
const updateButtonState = () => {
if (FavoriteModule.Storage.isFavorited(currentId)) {
btnHeader.innerText = "♡ Unfavourite";
btnHeader.style.background = "#ff4b4b";
btnHeader.style.color = "white";
} else {
btnHeader.innerText = "♥︎ Add to Favorite";
btnHeader.style.background = "#5cb85c";
btnHeader.style.color = "white";
}
};
btn.onclick = (e) => {
e.preventDefault();
if (FavoriteModule.Storage.isFavorited(currentId)) FavoriteModule.Storage.remove(currentId);
else FavoriteModule.Storage.add({ id: currentId, title: title, url: window.location.href, thumb: thumb, addedAt: Date.now() });
updateButtonState();
};
updateButtonState();
dlButton.after(btn);
}
},
Page: {
injectNavButton() {
if (document.getElementById('hitomi-nav-fav-link')) return;
const navUl = document.querySelector('.navbar nav ul');
if (navUl) {
const li = document.createElement('li');
li.innerHTML = TemplateModule.HTML.favNavBtn;
navUl.appendChild(li);
li.addEventListener('click', (e) => { e.preventDefault(); this.toggleModal(true); });
}
},
// NEW: Added missing downloadFile helper
downloadFile(blob, filename) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
},
toggleModal(show, fromHistory = false) {
let overlay = document.getElementById('hitomi-fav-overlay');
if (show) {
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'hitomi-fav-overlay';
overlay.style.cssText = TemplateModule.CSS.favOverlay;
// FIXED: Implemented handleImport logic
const handleImport = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const importedFavs = JSON.parse(event.target.result);
if (!Array.isArray(importedFavs)) throw new Error("Not an array");
const currentFavs = FavoriteModule.Storage.get();
const existingIds = new Set(currentFavs.map(f => f.id));
let addedCount = 0;
importedFavs.forEach(fav => {
if (fav.id && !existingIds.has(fav.id)) {
currentFavs.push(fav);
addedCount++;
}
});
FavoriteModule.Storage.save(currentFavs);
this.renderItems();
alert(`Successfully imported ${addedCount} new favorites!`);
} catch (err) {
alert("Error importing file. Please ensure it is a valid JSON backup.");
}
};
reader.readAsText(file);
};
const handleExport = () => {
const favs = FavoriteModule.Storage.get();
if (favs.length === 0) return alert('Nothing to export!');
const choiceOverlay = document.createElement('div');
choiceOverlay.id = 'hitomi-export-choice';
choiceOverlay.style.cssText = TemplateModule.CSS.exportChoiceOverlay;
choiceOverlay.innerHTML = `<div style="${TemplateModule.CSS.exportChoiceBox}">${TemplateModule.HTML.exportChoiceTemplate}</div>`;
overlay.appendChild(choiceOverlay);
const closeChoice = () => choiceOverlay.remove();
choiceOverlay.querySelector('#btn-export-json').onclick = () => {
const blob = new Blob([JSON.stringify(favs, null, 2)], { type: 'application/json' });
this.downloadFile(blob, `hitomi_favs_${new Date().toISOString().slice(0, 10)}.json`);
closeChoice();
};
choiceOverlay.querySelector('#btn-export-txt').onclick = () => {
const urlList = favs.map(i => i.url || `https://hitomi.la/galleries/${i.id}.html`).join('\n');
const blob = new Blob([urlList], { type: 'text/plain' });
this.downloadFile(blob, `hitomi_favs_${new Date().toISOString().slice(0, 10)}.txt`);
closeChoice();
};
choiceOverlay.querySelector('#btn-export-cancel').onclick = closeChoice;
};
const handleClose = () => {
window.location.hash === '#favorites' ? window.history.back() : this.toggleModal(false);
};
const controlsContainer = TemplateModule.createFavControls(handleImport, handleExport, handleClose);
const content = document.createElement('div');
content.id = 'hitomi-fav-content';
content.style.cssText = TemplateModule.CSS.favContent;
overlay.appendChild(controlsContainer);
overlay.appendChild(content);
document.body.appendChild(overlay);
}
overlay.style.display = 'block';
document.body.style.overflow = 'hidden';
if (!fromHistory) {
history.pushState(null, '', '#favorites');
// FIXED: Removed the recursive call `this.Page.toggleModal(true)` here
}
this.renderItems();
} else {
if (overlay) {
overlay.style.display = 'none';
const choice = document.getElementById('hitomi-export-choice');
if (choice) choice.remove();
}
document.body.style.overflow = 'auto';
if (!fromHistory && window.location.hash === '#favorites') window.history.back();
}
},
renderItems() {
const content = document.getElementById('hitomi-fav-content');
content.innerHTML = '';
const favs = FavoriteModule.Storage.get();
const customWidth = StorageModule.get(CONFIG.storageKeys.favEntryWidth);
const imgHeight = Math.round(customWidth * 1.39);
if (favs.length === 0) {
content.innerHTML = TemplateModule.HTML.favEmptyState;
return;
}
favs.forEach(fav => {
const card = document.createElement('div');
card.style.cssText = TemplateModule.CSS.favCard(customWidth);
card.innerHTML = TemplateModule.HTML.favCardInner(fav, imgHeight);
card.querySelector('.remove-fav-btn').onclick = () => {
FavoriteModule.Storage.remove(fav.id);
card.remove();
if (content.children.length === 0) content.innerHTML = TemplateModule.HTML.favEmptyState;
};
content.appendChild(card);
});
}
},
init() {
this.Page.injectNavButton();
window.addEventListener('popstate', () => this.Page.toggleModal(window.location.hash === '#favorites', true));
}
};
// ==========================================
// INITIALIZATION PIPELINE
// Purpose: Sets up DOM MutationObserver to detect current page context
// and fire off respective initialization scripts.
// ==========================================
// 1. Add the debounce helper function
const debounce = (func, delay) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), delay);
};
};
function runDetectionPipeline(obs) {
const isReader = window.location.pathname.includes('/reader/');
const isGallery = document.querySelector(CONFIG.selectors.galleryParent) !== null;
const isListPage = document.querySelector(CONFIG.selectors.listTitles) !== null;
if (isReader && StorageModule.get(CONFIG.storageKeys.readerEnabled)) {
ReaderExecution.run();
} else if (isGallery) {
GalleryPageExecution.init();
GalleryGridExecution.init();
FavoriteModule.UI.injectGalleryButton();
if (StorageModule.get(CONFIG.storageKeys.galleryEnabled)) ReaderExecution.run();
} else if (isListPage || window.location.pathname === '/') {
ListPageExecution.init();
}
// This logic stops the observer once the content is found
const shouldStop = isReader || isGallery || isListPage;
if (shouldStop && obs) {
obs.disconnect();
Logger.log("Target found. Observer offline.");
}
}
UIModule.init();
FavoriteModule.init();
// 2. Wrap the pipeline in a 200ms debounce
const debouncedPipeline = debounce((mutations, obs) => {
runDetectionPipeline(obs);
}, 200);
// 3. Initialize the observer with the debounced function
const observer = new MutationObserver(debouncedPipeline);
observer.observe(document.body, { childList: true, subtree: true });
// 4. Run once immediately on load to check if the content is already there
runDetectionPipeline(observer);
})();