Sleazy Fork is available in English.
A redesigned hitomi.la: themeable gallery grid, gallery pages and reader, tag autocomplete, hover previews, a global tag blacklist and a per-tab search builder
// ==UserScript==
// @name Hitomi Revamp
// @namespace hitomi-revamp
// @version 1.0.0
// @author WRE
// @license MIT
// @description A redesigned hitomi.la: themeable gallery grid, gallery pages and reader, tag autocomplete, hover previews, a global tag blacklist and a per-tab search builder
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzhhOGZmMCIvPjxzdG9wIG9mZnNldD0iLjU1IiBzdG9wLWNvbG9yPSIjNGE0ZTlhIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMjMyNDNhIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9InBnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZmZmZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZGNkZGY2Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTUiIGZpbGw9InVybCgjYmcpIi8+PHBhdGggZD0iTTExIDE3LjVjNi41LTIuMiAxMy41LTEuNiAxOSAyLjJWNDhjLTUuNS0zLjYtMTIuNS00LjItMTktMnoiIGZpbGw9InVybCgjcGcpIi8+PHBhdGggZD0iTTUzIDE3LjVjLTYuNS0yLjItMTMuNS0xLjYtMTkgMi4yVjQ4YzUuNS0zLjYgMTIuNS00LjIgMTktMnoiIGZpbGw9InVybCgjcGcpIi8+PHBhdGggZD0iTTE2LjUgMjYuNXYxM00yNC41IDI3djEzTTE2LjUgMzMuM2g4IiBzdHJva2U9IiM0YTRlOWEiIHN0cm9rZS13aWR0aD0iMy4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48cGF0aCBkPSJNNDAgMjdsNi41IDYuNUw0MCA0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYzAzOTVhIiBzdHJva2Utd2lkdGg9IjMuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9zdmc+Cg==
// @match https://hitomi.la/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @grant GM_addStyle
// @grant GM_openInTab
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// ==/UserScript==
(() => {
'use strict';
const LOGO_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8a8ff0"/><stop offset=".55" stop-color="#4a4e9a"/><stop offset="1" stop-color="#23243a"/></linearGradient><linearGradient id="pg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#dcddf6"/></linearGradient></defs><rect width="64" height="64" rx="15" fill="url(#bg)"/><path d="M11 17.5c6.5-2.2 13.5-1.6 19 2.2V48c-5.5-3.6-12.5-4.2-19-2z" fill="url(#pg)"/><path d="M53 17.5c-6.5-2.2-13.5-1.6-19 2.2V48c5.5-3.6 12.5-4.2 19-2z" fill="url(#pg)"/><path d="M16.5 26.5v13M24.5 27v13M16.5 33.3h8" stroke="#4a4e9a" stroke-width="3.2" stroke-linecap="round"/><path d="M40 27l6.5 6.5L40 40" fill="none" stroke="#c0395a" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const LOGO_URL = 'data:image/svg+xml,' + encodeURIComponent(LOGO_SVG);
const VERSION = (typeof GM_info !== 'undefined' && GM_info.script?.version) || '';
const DEFAULTS = {
newUi: true,
theme: 'midnight',
accent: 'indigo',
cardSize: 'm',
cardTags: 'compact',
classicTheme: true,
readerAutoHide: true,
fit: 'height',
rightArrowIsNext: true,
escBack: true,
zoom: 100,
clickHalves: true,
openInReader: false,
newTab: false,
newTabFocus: false,
blMode: 'hide',
stripPos: 'bottom',
thumbSize: 110,
thumbLoad: 'visible',
maxParallel: 2,
delayMs: 150,
};
const SKEY = 'hr-settings';
const S = Object.assign({}, DEFAULTS, GM_getValue(SKEY, {}));
const saveSettings = () => GM_setValue(SKEY, { ...S });
if (S.ui === 'classic') S.newUi = false;
delete S.ui; delete S.readerUi;
if (S.cardTags === true) S.cardTags = 'compact'; else if (S.cardTags === false) S.cardTags = 'off';
const THEMES = {
midnight: { bg: '#141527', surface: '#1d1f35', surface2: '#272a46', border: '#34385f', text: '#e9eaf7', muted: '#9a9dc8', shadow: 'rgba(0,0,0,.5)', scheme: 'dark' },
graphite: { bg: '#151517', surface: '#1e1e21', surface2: '#29292d', border: '#37373d', text: '#ededf0', muted: '#9c9ca6', shadow: 'rgba(0,0,0,.5)', scheme: 'dark' },
oled: { bg: '#000000', surface: '#0d0d0f', surface2: '#19191c', border: '#29292e', text: '#f3f3f6', muted: '#8d8d97', shadow: 'rgba(0,0,0,.7)', scheme: 'dark' },
paper: { bg: '#f3f3f6', surface: '#ffffff', surface2: '#e9e9f0', border: '#d6d6e0', text: '#1a1b26', muted: '#62647a', shadow: 'rgba(20,20,40,.14)', scheme: 'light' },
};
const THEME_NAMES = [['midnight', 'Midnight'], ['graphite', 'Graphite'], ['oled', 'Black'], ['paper', 'Light']];
const ACCENTS = { indigo: '#6d72d8', violet: '#9a5fe0', rose: '#df5279', amber: '#d48d1c', teal: '#1fa392', sky: '#2e8ddf' };
const CARD_W = { s: 160, m: 200, l: 250 };
const W = unsafeWindow;
const isReader = () => location.pathname.startsWith('/reader/');
const readerGalleryId = () => (location.pathname.match(/\/reader\/(\d+)\.html/) || [])[1];
const GALLERY_RE = /^\/(?:manga|doujinshi|gamecg|cg|imageset|anime|galleries)\/(?:.*-)?(\d+)\.html$/;
const pageGalleryId = (location.pathname.match(GALLERY_RE) || [])[1];
const readerUrl = id => `/reader/${id}.html#1`;
const el = (tag, props = {}, ...kids) => { const n = Object.assign(document.createElement(tag), props); n.append(...kids); return n; };
const ready = fn => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn();
const onRemote = (key, fn) => {
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(key, (k, o, n, remote) => { if (remote) fn(n); });
};
function openTab(url) {
const href = new URL(url, location.href).href;
handoff(href);
GM_openInTab(href, { active: S.newTab && S.newTabFocus, insert: true });
}
const curPage = () => {
const n = parseInt(location.hash.slice(1), 10);
return Number.isFinite(n) && n > 0 ? n : 1;
};
function pageCount() {
const n = W.galleryinfo?.files?.length;
if (n) return n;
let max = 0;
document.querySelectorAll('select').forEach(s => {
for (const o of s.options) {
const v = parseInt((o.value || o.textContent).replace(/\D+/g, ''), 10);
if (Number.isFinite(v)) max = Math.max(max, v);
}
});
return max || Infinity;
}
function goToPage(n) {
n = Math.max(1, Math.min(n, pageCount()));
if (n === curPage()) return;
location.replace('#' + n);
}
function goBack() {
const ref = document.referrer;
let sameSite = false;
try { sameSite = ref && new URL(ref).hostname === location.hostname && !ref.includes('/reader/'); } catch {}
if (sameSite && history.length > 1) history.back();
else location.href = document.querySelector('.navbar a.brand')?.href || `/galleries/${readerGalleryId()}.html`;
}
window.addEventListener('keydown', e => {
if (!isReader()) return;
if (e.target instanceof Element && e.target.closest('input, textarea, select, [contenteditable], dialog, #hr-settings-host')) return;
if (e.altKey || e.ctrlKey || e.metaKey) return;
if (menu || dlg?.open || document.querySelector('dialog[open]')) return;
let target = null;
const k = e.key.toLowerCase();
if (k === 'arrowright' || k === 'd') target = curPage() + (S.rightArrowIsNext ? 1 : -1);
else if (k === 'arrowleft' || k === 'a') target = curPage() + (S.rightArrowIsNext ? -1 : 1);
else if (k === 'home') target = 1;
else if (k === 'end') target = pageCount();
else if (k === 't') { stripHidden = !stripHidden; applyLayout(); }
else if (k === '=' || k === '+') setZoom(S.zoom + 5);
else if (k === '-') setZoom(S.zoom - 5);
else if (k === '0') setZoom(100);
else if (k === 'f') toggleFullscreen();
else if (k === 'w' && wantReaderUi()) toggleFit();
else if (k === 'escape' && S.escBack) goBack();
else return;
e.preventDefault(); e.stopImmediatePropagation();
if (target !== null) goToPage(target);
}, true);
document.addEventListener('click', e => {
if (!isReader() || e.button !== 0) return;
const a = e.target instanceof Element && e.target.closest('a[href^="#"]');
if (!a) return;
const n = parseInt(a.getAttribute('href').slice(1), 10);
if (!Number.isFinite(n)) return;
e.preventDefault(); e.stopImmediatePropagation();
goToPage(n);
}, true);
window.addEventListener('click', e => {
if (!isReader() || !S.clickHalves || e.button !== 0) return;
const box = e.target instanceof Element && e.target.closest('#comicImages');
if (!box || getSelection().toString()) return;
e.preventDefault(); e.stopImmediatePropagation();
const r = box.getBoundingClientRect();
const rightHalf = e.clientX >= r.left + r.width / 2;
goToPage(curPage() + (rightHalf ? 1 : -1));
}, true);
const PAGE_PATCH = `(() => {
const fix = (id, two) => {
const sel = document.getElementById(id);
if (!sel || typeof sel.onchange !== 'function') return;
const orig = sel.onchange;
sel.onchange = function (e) {
try {
const val = String(this.value);
const parts = val.split('-');
const first = parseInt(parts[0], 10);
const last = two && parts[1] ? parseInt(parts[1], 10) : first;
const total = our_galleryinfo.length;
enable($('#prevPanel')); enable($('#nextPanel'));
if (first <= 1) disable($('#prevPanel'));
if (last >= total) disable($('#nextPanel'));
if (!two) {
enable($('#prevScene')); enable($('#nextScene'));
if (at_first_scene()) disable($('#prevScene'));
if (at_last_scene()) disable($('#nextScene'));
}
curPanel = two ? String(first) : val;
location.replace('#' + val);
$(this).trigger('blur');
} catch (err) {
console.warn('[hitomi-revamp] falling back to the site page handler', err);
return orig.call(this, e);
}
};
};
fix('single-page-select', false);
fix('two-page-select', true);
})();`;
function patchReader() {
const s = document.createElement('script');
s.textContent = PAGE_PATCH;
document.documentElement.append(s);
s.remove();
if (!S.rightArrowIsNext) return;
const swap = (a, b) => {
const A = document.getElementById(a), B = document.getElementById(b);
if (!A || !B) return;
A.id = b; B.id = a;
return [A, B];
};
const p = swap('nextPanel', 'prevPanel');
if (p) {
p[0].innerHTML = '<i class="icon-chevron-left icon-white"></i> Prev';
p[0].title = 'Left arrow';
p[1].innerHTML = 'Next <i class="icon-chevron-right icon-white"></i>';
p[1].title = 'Right arrow';
}
swap('nextScene', 'prevScene');
}
function setZoom(v) {
S.zoom = Math.max(10, Math.min(300, Math.round(+v || 100)));
saveSettings();
document.documentElement.style.setProperty('--hr-zoom', S.zoom / 100);
syncZoomBox();
}
function syncZoomBox() {
const box = document.getElementById('hr-zoom');
if (box && document.activeElement !== box) box.value = S.zoom;
const out = document.getElementById('hr-zoom-out');
if (out) out.textContent = S.zoom + '%';
}
function addZoomBox(ul) {
const input = el('input', { id: 'hr-zoom', type: 'number', min: 10, max: 300, step: 5, value: S.zoom, title: 'Image size, % of the screen (+ / - / 0)' });
input.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === 'Escape') input.blur();
});
input.addEventListener('input', () => { if (input.value) setZoom(input.value); });
input.addEventListener('change', () => { setZoom(input.value); input.value = S.zoom; });
ul.append(el('li', { className: 'hr-nav-li' }, el('label', { htmlFor: 'hr-zoom' }, 'Size ', input, ' %')));
}
function pageBoxEl(word) {
const input = el('input', { id: 'hr-page', type: 'number', min: 1, step: 1, value: curPage(), title: 'Type a page number and press Enter' });
input.setAttribute('aria-label', 'Page');
const total = el('span', { id: 'hr-total', textContent: '…' });
input.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter') { goToPage(+input.value || 1); input.blur(); }
else if (e.key === 'Escape') input.blur();
});
input.addEventListener('change', () => goToPage(+input.value || 1));
input.addEventListener('blur', updatePageBox);
window.addEventListener('hashchange', updatePageBox);
waitFor(() => W.galleryinfo?.files?.length, 15000).then(updatePageBox);
return word
? el('label', { htmlFor: 'hr-page' }, 'Page ', input, ' / ', total)
: el('label', { htmlFor: 'hr-page', className: 'hr-pagebox' }, input, el('span', { textContent: '/' }), total);
}
function addPageBox(ul) {
ul.append(el('li', { className: 'hr-nav-li' }, pageBoxEl(true)));
updatePageBox();
}
function updatePageBox() {
const input = document.getElementById('hr-page');
const total = document.getElementById('hr-total');
const n = pageCount();
const bar = document.querySelector('#hr-progress i');
if (bar) bar.style.width = Number.isFinite(n) ? Math.min(100, curPage() / n * 100) + '%' : '0';
if (!input) return;
if (Number.isFinite(n)) { total.textContent = n; input.max = n; }
if (document.activeElement !== input) input.value = curPage();
}
function galleryIdFromLink(a) {
try {
const u = new URL(a.dataset.hrInfo || a.href);
if (u.hostname !== location.hostname) return null;
return (u.pathname.match(GALLERY_RE) || [])[1] || null;
} catch { return null; }
}
function rewrite(a) {
if (!S.openInReader || isReader() || a.dataset.hrInfo) return;
const id = galleryIdFromLink(a);
if (!id || id === pageGalleryId) return;
a.dataset.hrInfo = a.href;
a.href = readerUrl(id);
}
for (const ev of ['pointerover', 'focusin', 'touchstart']) {
document.addEventListener(ev, e => {
const a = e.target instanceof Element && e.target.closest('a[href]');
if (a) rewrite(a);
}, { capture: true, passive: true });
}
document.addEventListener('click', e => {
if (isReader() || !S.newTab || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
const a = e.target instanceof Element && e.target.closest('a[href]');
if (!a) return;
const id = galleryIdFromLink(a);
if (!id || id === pageGalleryId) return;
rewrite(a);
e.preventDefault(); e.stopImmediatePropagation();
openTab(a.href);
}, true);
document.addEventListener('mousedown', e => {
const newTabGesture = e.button === 1 || e.button === 2 || (e.button === 0 && (e.ctrlKey || e.metaKey || e.shiftKey));
if (!newTabGesture) return;
const a = e.target instanceof Element && e.target.closest('a[href]');
if (!a) return;
rewrite(a);
handoff(a.href);
}, true);
const TERM_RE = /^\/(tag|artist|series|character|group|type)\/(.+?)-(all|[a-z]+)(?:-\d+)?\.html$/;
function termFromUrl(u) {
if (u.hostname !== location.hostname) return null;
let m = u.pathname.match(TERM_RE);
if (m) {
const ns = m[1];
let val;
try { val = decodeURIComponent(m[2]); } catch { return null; }
val = val.trim().replace(/\s+/g, '_');
if (ns === 'tag') return /^(fe)?male:/.test(val) ? val : 'tag:' + val;
return `${ns}:${val}`;
}
m = u.pathname.match(/^\/index-([a-z]+)(?:-\d+)?\.html$/);
if (m && m[1] !== 'all') return 'language:' + m[1];
return null;
}
function termFromLink(a) {
try { return termFromUrl(new URL(a.href)); } catch { return null; }
}
function normTerm(s) {
let t = String(s).trim().toLowerCase().replace(/^-/, '').replace(/\s+/g, '_');
if (!t) return '';
if (!/^(tag|female|male|artist|series|character|group|type|language):./.test(t)) t = 'tag:' + t;
return t;
}
const QKEY = 'hr-query';
const HKEY = 'hr-handoff';
const HANDOFF_MS = 60000;
const hasQuery = () => { try { return sessionStorage.getItem(QKEY) !== null; } catch { return false; } };
const load = () => { try { return JSON.parse(sessionStorage.getItem(QKEY)) || []; } catch { return []; } };
const store = t => { try { sessionStorage.setItem(QKEY, JSON.stringify(t)); } catch {} };
const save = t => { store(t); renderPanel(); };
const pathKey = u => u.pathname + u.search;
function handoff(url) {
const terms = load();
if (!terms.length) return;
let u;
try { u = new URL(url, location.href); } catch { return; }
if (u.hostname !== location.hostname) return;
const now = Date.now();
const map = {};
for (const [k, v] of Object.entries(GM_getValue(HKEY, {}))) if (now - v.t < HANDOFF_MS) map[k] = v;
map[pathKey(u)] = { terms, t: now };
GM_setValue(HKEY, map);
}
if (!hasQuery()) {
const h = GM_getValue(HKEY, {})[pathKey(location)];
if (h && Date.now() - h.t < HANDOFF_MS) store(h.terms);
}
function pageQuery() {
if (location.pathname === '/search.html') {
if (location.search.length < 2) return null;
try {
const q = decodeURIComponent(location.search.slice(1)).split(/\s+/).filter(Boolean);
return q.length ? q : null;
} catch { return null; }
}
const m = location.pathname.match(TERM_RE);
if (m) {
const t = termFromUrl(new URL(location.href));
if (!t) return null;
return m[3] !== 'all' ? [t, 'language:' + m[3]] : [t];
}
const lm = location.pathname.match(/^\/index-([a-z]+)(?:-\d+)?\.html$/);
if (lm && lm[1] !== 'all') return ['language:' + lm[1]];
return null;
}
{
const pq = pageQuery();
if (pq) store(pq);
}
const bare = t => t.replace(/^-/, '');
function addTerm(term, exclude) {
const t = load().filter(x => bare(x) !== term);
t.push(exclude ? '-' + term : term);
save(t);
}
const searchUrl = terms => '/search.html?' + encodeURIComponent(terms.join(' '));
const BL_KEY = 'hr-blacklist';
let blacklist = GM_getValue(BL_KEY, []);
function setBlacklist(list) {
blacklist = [...new Set(list.map(normTerm).filter(Boolean))].sort();
GM_setValue(BL_KEY, blacklist);
blacklistChanged();
}
const blAdd = t => setBlacklist([...blacklist, t]);
const blRemove = t => setBlacklist(blacklist.filter(x => x !== t));
const blacklistChanged = () => { scanCards(); renderBlacklist(); };
onRemote(BL_KEY, n => { blacklist = n || []; blacklistChanged(); });
onRemote(SKEY, n => { Object.assign(S, DEFAULTS, n); applyTheme(); applyLayout(); syncUi(); scanCards(); });
const CARD_SEL = '.gallery-content > div';
function cardHit(card, bl) {
if (!bl.size) return '';
for (const a of card.querySelectorAll('a[href]')) {
const t = termFromLink(a);
if (t && bl.has(normTerm(t))) return normTerm(t);
}
return '';
}
let blCount = 0;
function scanCards() {
if (isReader() || !document.body) return;
const bl = new Set(blacklist);
let n = 0;
for (const card of document.querySelectorAll(CARD_SEL)) {
const hit = cardHit(card, bl);
card.classList.toggle('hr-bl-hide', !!hit && S.blMode === 'hide');
card.classList.toggle('hr-bl-dim', !!hit && S.blMode === 'dim');
if (hit) { card.dataset.hrBl = hit; n++; }
else if (card.dataset.hrBl) delete card.dataset.hrBl;
}
blCount = n;
updateBadge();
renderApp();
}
let scanTimer = 0;
const scheduleScan = () => { if (!scanTimer) scanTimer = setTimeout(() => { scanTimer = 0; scanCards(); }, 60); };
function updateBadge() {
const fab = document.getElementById('hr-fab');
const b = fab?.querySelector('.hr-badge');
if (!b) return;
const txt = String(blCount);
if (b.textContent !== txt) b.textContent = txt;
b.hidden = !blCount;
fab.title = blCount
? `Hitomi Revamp settings (${blCount} blacklisted ${blCount === 1 ? 'gallery' : 'galleries'} ${S.blMode === 'hide' ? 'hidden' : 'dimmed'} here)`
: 'Hitomi Revamp settings';
}
GM_addStyle(`
:root {
--hr-size: ${S.thumbSize}px;
--hr-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--hr-accent-soft: color-mix(in srgb, var(--hr-accent) 22%, transparent);
--hr-neg: #d6476b;
--hr-neg-soft: color-mix(in srgb, #d6476b 22%, transparent);
--hr-hover: color-mix(in srgb, var(--hr-text) 10%, transparent);
}
#hr-menu { position: fixed; z-index: 2147483647; min-width: 250px; max-width: 320px; padding: 6px; background: var(--hr-surface);
border: 1px solid var(--hr-border); border-radius: 10px; box-shadow: 0 14px 36px var(--hr-shadow);
color: var(--hr-text); font: 14px/1.4 var(--hr-font); color-scheme: var(--hr-scheme); }
#hr-menu .hr-mh { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; padding: 6px 8px 10px; }
#hr-menu .hr-mh .hr-ns { min-width: 0; text-transform: capitalize; }
#hr-menu .hr-mh strong { flex: 1; min-width: 0; font-size: 14px; font-weight: 650; overflow-wrap: anywhere;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
#hr-menu .hr-mh small { flex-basis: 100%; color: var(--hr-muted); font-size: 12px; }
#hr-menu .hr-mi { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: none; border: 0;
color: inherit; padding: 7px 8px; border-radius: 8px; cursor: pointer; font: inherit; }
#hr-menu .hr-mi:hover, #hr-menu .hr-mi:focus-visible { background: var(--hr-surface2); outline: none; }
#hr-menu .hr-mi-ic { flex: none; display: grid; place-items: center; width: 18px; color: var(--hr-muted); }
#hr-menu .hr-mi-txt { display: flex; flex-direction: column; min-width: 0; }
#hr-menu .hr-mi small { color: var(--hr-muted); font-size: 12px; line-height: 1.3; }
#hr-menu .hr-mi.danger, #hr-menu .hr-mi.danger .hr-mi-ic { color: var(--hr-neg); }
#hr-menu hr { border: 0; border-top: 1px solid var(--hr-border); margin: 5px 4px; }
.hr-chip { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 3px 0 11px; border-radius: 999px;
background: var(--hr-accent-soft); color: var(--hr-text); font: 13px/1 var(--hr-font); cursor: pointer;
white-space: nowrap; user-select: none; }
.hr-chip.neg { background: var(--hr-neg-soft); }
.hr-chip-not { color: var(--hr-neg); font-weight: 600; font-size: 11px; }
.hr-chip button { display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%;
background: none; color: var(--hr-muted); cursor: pointer; font: 15px/1 var(--hr-font); }
.hr-chip button:hover { background: var(--hr-hover); color: var(--hr-text); }
.hr-btn { height: 32px; padding: 0 14px; border-radius: 9px; border: 1px solid var(--hr-border); background: transparent;
color: var(--hr-text); font: 600 13px/1 var(--hr-font); cursor: pointer; white-space: nowrap; }
.hr-btn:hover { background: var(--hr-hover); }
.hr-btn.primary { background: var(--hr-accent); border-color: var(--hr-accent); color: #fff; }
.hr-btn.primary:hover { background: color-mix(in srgb, var(--hr-accent) 85%, #fff); }
.hr-chip button:focus-visible, .hr-btn:focus-visible, .hr-icon:focus-visible, .hr-pg:focus-visible,
#hr-app a:focus-visible, .hr-select:focus-visible { outline: 2px solid var(--hr-accent); outline-offset: 2px; }
#hr-panel { position: fixed; z-index: 2147483646; right: 16px; bottom: 16px; max-width: min(560px, calc(100vw - 96px));
display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 8px;
background: color-mix(in srgb, var(--hr-surface) 88%, transparent); backdrop-filter: blur(14px);
border: 1px solid var(--hr-border); border-radius: 16px; box-shadow: 0 12px 32px var(--hr-shadow);
color: var(--hr-text); font: 14px/1.4 var(--hr-font); color-scheme: var(--hr-scheme); }
#hr-panel[hidden], html.hr-ui #hr-panel { display: none !important; }
#hr-panel .hr-acts { display: flex; gap: 6px; margin-left: auto; }
#hr-fab { position: fixed; z-index: 2147483646; left: 16px; bottom: 16px; width: 44px; height: 44px; padding: 0;
border: 0; border-radius: 12px; background: none; cursor: pointer; opacity: .85;
box-shadow: 0 6px 18px var(--hr-shadow); transition: opacity .15s; }
#hr-fab:hover, #hr-fab:focus-visible { opacity: 1; }
#hr-fab:focus-visible { outline: 2px solid var(--hr-accent); outline-offset: 2px; }
#hr-fab img { display: block; width: 100%; height: 100%; border-radius: 12px; }
#hr-fab .hr-badge { position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px;
box-sizing: border-box; border-radius: 9px; background: var(--hr-neg); color: #fff;
font: 600 11px/18px system-ui, sans-serif; text-align: center; }
#hr-fab .hr-badge[hidden], html.hr-ui #hr-fab { display: none; }
.hr-bl-hide { display: none !important; }
.hr-bl-dim { position: relative; opacity: .3; transition: opacity .15s; }
.hr-bl-dim img { filter: blur(10px) grayscale(1); }
.hr-bl-dim:hover { opacity: 1; }
.hr-bl-dim:hover img { filter: none; }
.hr-bl-dim::before { content: 'Blacklisted: ' attr(data-hr-bl); position: absolute; z-index: 2; left: 6px; top: 6px;
background: var(--hr-neg); color: #fff; font: 12px/1.4 system-ui, sans-serif; padding: 2px 6px; border-radius: 4px; pointer-events: none; }
html.hr-ui, html.hr-ui body { background: var(--hr-bg) !important; }
html.hr-ui body { margin: 0 !important; padding: 0 !important; min-width: 0 !important; }
html.hr-ui body > :not([id^="hr-"]) { display: none !important; }
#hr-app { min-height: 100vh; color: var(--hr-text); font: 14px/1.45 var(--hr-font); color-scheme: var(--hr-scheme);
-webkit-font-smoothing: antialiased; }
#hr-app *, #hr-app *::before, #hr-app *::after { box-sizing: border-box; }
#hr-app a { color: inherit; text-decoration: none; }
.hr-top { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; gap: 10px;
padding: 10px clamp(12px, 3vw, 32px); background: color-mix(in srgb, var(--hr-bg) 82%, transparent);
backdrop-filter: blur(16px); border-bottom: 1px solid var(--hr-border); }
.hr-brand { display: flex; align-items: center; gap: 9px; flex: none; font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
.hr-brand img { width: 30px; height: 30px; border-radius: 8px; display: block; }
.hr-sbox { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-height: 42px;
padding: 6px 8px 6px 12px; background: var(--hr-surface); border: 1px solid var(--hr-border); border-radius: 12px; cursor: text; }
.hr-sbox:focus-within { border-color: var(--hr-accent); box-shadow: 0 0 0 3px var(--hr-accent-soft); }
.hr-sbox > svg { flex: none; color: var(--hr-muted); margin-right: 2px; }
.hr-chips { display: contents; }
.hr-sbox input { flex: 1; min-width: 150px; height: 28px; border: 0; outline: none; background: none; color: inherit;
font: inherit; padding: 0 4px; }
.hr-sbox input::placeholder { color: var(--hr-muted); }
.hr-select { height: 42px; padding: 0 32px 0 12px; border-radius: 12px; border: 1px solid var(--hr-border);
background: var(--hr-surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238e90b0' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 12px center;
color: inherit; font: inherit; appearance: none; -webkit-appearance: none; cursor: pointer; }
.hr-select option { background: var(--hr-surface); color: var(--hr-text); }
.hr-top .hr-btn { height: 42px; border-radius: 12px; padding: 0 18px; }
.hr-icon { flex: none; display: grid; place-items: center; width: 42px; height: 42px; padding: 0; border-radius: 12px;
border: 1px solid var(--hr-border); background: var(--hr-surface); color: var(--hr-muted); cursor: pointer; }
.hr-icon:hover { color: var(--hr-text); }
.hr-main { max-width: 1680px; margin: 0 auto; padding: 28px clamp(12px, 3vw, 32px) 64px; }
.hr-headline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 20px; }
.hr-headline h1 { margin: 0; font-size: 28px; font-weight: 750; letter-spacing: -.02em; line-height: 1.15; overflow-wrap: anywhere; }
.hr-kind { color: var(--hr-muted); font-size: 14px; }
.hr-stat { margin-left: auto; color: var(--hr-muted); font-size: 13px; }
.hr-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--hr-card), 1fr)); gap: 26px 18px; }
.hr-card { position: relative; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.hr-cover { position: relative; display: block; aspect-ratio: 5 / 7; overflow: hidden; border-radius: 10px;
background: var(--hr-surface2); }
.hr-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }
.hr-cover-link { position: absolute; inset: 0; display: block; border-radius: inherit; }
.hr-cover:has(.hr-cover-link:hover) { box-shadow: 0 0 0 2px var(--hr-accent); }
.hr-pill { position: absolute; bottom: 8px; padding: 3px 7px; border-radius: 6px; background: rgba(10,10,16,.68);
color: #fff; font: 600 11px/1.2 var(--hr-font); backdrop-filter: blur(6px); text-transform: capitalize; max-width: 60%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hr-pill.type { left: 8px; }
#hr-app a.hr-pill:hover { background: var(--hr-accent); color: #fff; }
.hr-pill.lang { right: 8px; }
.hr-title { font-weight: 600; font-size: 14px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden; margin-top: 3px; }
.hr-title:hover { color: var(--hr-accent); }
.hr-sub { margin-top: -3px; color: var(--hr-muted); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hr-sub a:hover { color: var(--hr-text); }
.hr-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.hr-tag { padding: 4px 7px; border-radius: 6px; background: var(--hr-surface2); color: var(--hr-muted);
font-size: 11.5px; line-height: 1; white-space: nowrap; }
.hr-tag.f { background: color-mix(in srgb, #df5279 15%, transparent); color: color-mix(in srgb, #df5279 65%, var(--hr-text)); }
.hr-tag.m { background: color-mix(in srgb, #2e8ddf 15%, transparent); color: color-mix(in srgb, #2e8ddf 65%, var(--hr-text)); }
.hr-tag:hover { background: var(--hr-hover); color: var(--hr-text); }
.hr-card.is-dim { opacity: .5; }
.hr-card.is-dim img { filter: blur(14px) grayscale(1); }
.hr-card.is-dim:hover { opacity: 1; }
.hr-card.is-dim:hover img { filter: none; }
.hr-card.is-dim .hr-cover::after { content: 'Blacklisted: ' attr(data-bl); position: absolute; left: 8px; right: 8px; top: 8px;
padding: 3px 7px; border-radius: 6px; background: var(--hr-neg); color: #fff; font-size: 11px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hr-skel .hr-cover { animation: hr-pulse 1.4s ease-in-out infinite; }
.hr-skel i { display: block; height: 12px; border-radius: 6px; background: var(--hr-surface2); }
.hr-skel i + i { width: 60%; }
@keyframes hr-pulse { 50% { opacity: .55; } }
.hr-empty { grid-column: 1 / -1; padding: 96px 0; text-align: center; color: var(--hr-muted); font-size: 15px; }
.hr-pager { display: flex; justify-content: center; flex-wrap: wrap; gap: 6px; margin-top: 44px; }
.hr-pg { display: grid; place-items: center; min-width: 40px; height: 40px; padding: 0 12px; border-radius: 10px;
border: 1px solid var(--hr-border); background: var(--hr-surface); color: var(--hr-text); font: 600 13px/1 var(--hr-font); }
a.hr-pg:hover { border-color: var(--hr-accent); }
.hr-pg.cur { background: var(--hr-accent); border-color: var(--hr-accent); color: #fff; }
.hr-pg.gap { border-color: transparent; background: none; color: var(--hr-muted); }
@media (max-width: 900px) { .hr-brand span { display: none; } }
@media (max-width: 720px) {
.hr-top { flex-wrap: wrap; }
.hr-sbox { order: 10; flex-basis: 100%; }
.hr-brand { margin-right: auto; }
.hr-headline h1 { font-size: 22px; }
.hr-grid { grid-template-columns: repeat(auto-fill, minmax(min(var(--hr-card), 42vw), 1fr)); gap: 20px 12px; }
}
@media (prefers-reduced-motion: reduce) { .hr-skel .hr-cover { animation: none; } #hr-fab { transition: none; } }
#hr-strip { position: fixed; z-index: 2147483645; display: flex; gap: 6px; padding: 8px; box-sizing: border-box;
background: color-mix(in srgb, var(--hr-bg) 94%, transparent); scrollbar-width: thin;
scrollbar-color: var(--hr-border) transparent; font: 14px/1.4 var(--hr-font); color: var(--hr-text); }
#hr-strip[hidden] { display: none; }
html.hr-pos-bottom #hr-strip { left: 0; right: 0; bottom: 0; height: var(--hr-striph);
flex-direction: row; overflow-x: auto; overflow-y: hidden; align-items: center; border-top: 1px solid var(--hr-border); }
html.hr-pos-left #hr-strip, html.hr-pos-right #hr-strip { top: var(--hr-nav); bottom: 0; width: var(--hr-stripw);
flex-direction: column; overflow-y: auto; overflow-x: hidden; align-items: center; }
html.hr-pos-left #hr-strip { left: 0; border-right: 1px solid var(--hr-border); }
html.hr-pos-right #hr-strip { right: 0; border-left: 1px solid var(--hr-border); }
html.hr-reader { --hr-nav: 41px; --hr-striph: 0px; --hr-stripw: 0px; }
html.hr-pos-bottom { --hr-striph: calc(var(--hr-size) + 26px); }
html.hr-pos-left, html.hr-pos-right { --hr-stripw: calc(var(--hr-size) + 26px); }
html.hr-reader #comicImages { height: calc(100vh - var(--hr-nav) - var(--hr-striph)) !important; box-sizing: border-box; }
html.hr-pos-left #comicImages { margin-left: var(--hr-stripw); }
html.hr-pos-right #comicImages { margin-right: var(--hr-stripw); }
html.hr-reader #comicImages.fitVertical img { max-height: calc(100% * var(--hr-zoom)) !important; }
html.hr-reader .spread1 #comicImages.fitHorizontal img { width: calc(100% * var(--hr-zoom)) !important; }
html.hr-reader .spread2 #comicImages.fitHorizontal img { width: calc(50% * var(--hr-zoom)) !important; }
html.hr-reader #comicImages:fullscreen { height: 100vh !important; margin: 0 !important; }
html.hr-reader #comicImages:fullscreen.fitVertical img { max-height: calc(100vh * var(--hr-zoom)) !important; }
.hr-nav-li label { display: block; margin: 0; padding: 10px 12px; color: #999; font-size: 14px; line-height: 20px; font-weight: normal; }
#hr-zoom, #hr-page { width: 52px; height: 20px; margin: 0 2px; padding: 0 4px; background: #2b2b2b; color: #ddd;
border: 1px solid #444; border-radius: 3px; font-size: 14px; vertical-align: baseline; box-sizing: content-box; }
#hr-total { color: #ddd; }
#hr-navgear { display: block; background: none; border: 0; padding: 10px 12px; cursor: pointer; }
#hr-navgear img { display: block; width: 20px; height: 20px; border-radius: 5px; }
#hr-navgear:focus-visible { outline: 2px solid #fff; outline-offset: -2px; }
.hr-tile { flex: 0 0 auto; position: relative; padding: 0; border: 2px solid transparent; border-radius: 6px;
background: var(--hr-surface2); cursor: pointer; overflow: hidden; }
html.hr-pos-bottom .hr-tile { height: var(--hr-size);
width: clamp(calc(var(--hr-size) * .45), calc(var(--hr-size) * var(--r)), calc(var(--hr-size) * 2)); }
html.hr-pos-left .hr-tile, html.hr-pos-right .hr-tile { width: var(--hr-size);
height: clamp(calc(var(--hr-size) * .45), calc(var(--hr-size) / var(--r)), calc(var(--hr-size) * 2)); }
.hr-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.hr-tile:not([data-state="done"]) img { visibility: hidden; }
.hr-tile span { position: absolute; left: 3px; bottom: 3px; font-size: 12px; color: #fff;
background: rgba(0,0,0,.65); padding: 0 5px; border-radius: 4px; }
.hr-tile.cur { border-color: var(--hr-accent); }
.hr-tile:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
`);
let menu = null;
const closeMenu = () => { menu?.remove(); menu = null; };
function openMenu(x, y, head, items) {
closeMenu();
menu = el('div', { id: 'hr-menu' }, head);
menu.setAttribute('role', 'menu');
for (const it of items) {
if (it === '-') { menu.append(el('hr')); continue; }
const b = el('button', { type: 'button', className: 'hr-mi' + (it.danger ? ' danger' : '') },
el('span', { className: 'hr-mi-ic' }, icon(it.icon, 16)),
el('span', { className: 'hr-mi-txt' }, el('span', { textContent: it.label }), ...(it.hint ? [el('small', { textContent: it.hint })] : [])));
b.setAttribute('role', 'menuitem');
b.onclick = () => { closeMenu(); it.fn(); };
menu.append(b);
}
menu.addEventListener('keydown', e => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const bs = [...menu.querySelectorAll('button')];
const i = bs.indexOf(document.activeElement);
bs[(i + (e.key === 'ArrowDown' ? 1 : -1) + bs.length) % bs.length].focus();
});
document.body.append(menu);
const r = menu.getBoundingClientRect();
menu.style.left = Math.max(8, Math.min(x, innerWidth - r.width - 8)) + 'px';
menu.style.top = Math.max(8, Math.min(y, innerHeight - r.height - 8)) + 'px';
menu.querySelector('button').focus();
}
const menuHead = (ns, name, note) => el('div', { className: 'hr-mh' },
el('span', { className: 'hr-ns ' + nsClass(ns), textContent: ns }),
el('strong', { textContent: name, title: name }),
...(note ? [el('small', { textContent: note })] : []));
document.addEventListener('contextmenu', e => {
if (e.shiftKey) return;
const a = e.target instanceof Element && e.target.closest('a[href]');
if (!a || isReader()) return;
const gid = galleryIdFromLink(a);
if (gid && gid !== pageGalleryId) {
e.preventDefault();
const info = a.dataset.hrInfo || a.href;
const name = a.closest('.hr-card')?.querySelector('.hr-title')?.textContent || a.textContent.trim() || `Gallery ${gid}`;
openMenu(e.clientX, e.clientY, menuHead('gallery', name), [
{ label: 'Read', icon: 'book', fn: () => { location.href = readerUrl(gid); } },
{ label: 'Read in new tab', icon: 'external', fn: () => openTab(readerUrl(gid)) },
'-',
{ label: 'Gallery page', icon: 'info', hint: 'Details, tags and every page', fn: () => { location.href = info; } },
{ label: 'Gallery page in new tab', icon: 'external', fn: () => openTab(info) },
]);
return;
}
const term = termFromLink(a);
if (!term) return;
e.preventDefault();
const key = normTerm(term);
const listed = blacklist.includes(key);
const cur = load().find(x => bare(x) === term);
const ns = term.split(':')[0];
const name = term.slice(ns.length + 1).replace(/_/g, ' ');
const note = [cur ? (cur.startsWith('-') ? 'Excluded from this tab’s search' : 'In this tab’s search') : '', listed ? 'Blacklisted' : ''].filter(Boolean).join('. ');
const removeTerm = () => save(load().filter(x => bare(x) !== term));
const searchItems = !cur
? [{ label: 'Add to search', icon: 'plus', hint: 'Show galleries that have it', fn: () => addTerm(term, false) },
{ label: 'Exclude from search', icon: 'minus', hint: 'Hide galleries that have it', fn: () => addTerm(term, true) }]
: [{ label: 'Remove from search', icon: 'x', fn: removeTerm },
cur.startsWith('-')
? { label: 'Include instead', icon: 'plus', fn: () => addTerm(term, false) }
: { label: 'Exclude instead', icon: 'minus', fn: () => addTerm(term, true) }];
openMenu(e.clientX, e.clientY, menuHead(ns, name, note), [
...searchItems,
{ label: 'Search with it now', icon: 'search', hint: 'Adds it and runs the search', fn: () => { addTerm(term, false); location.href = searchUrl(load()); } },
{ label: 'Search only this', icon: 'search', hint: 'Starts a fresh search', fn: () => { location.href = searchUrl([term]); } },
'-',
{ label: 'Open in new tab', icon: 'external', fn: () => openTab(a.href) },
'-',
listed
? { label: 'Remove from blacklist', icon: 'ban', hint: 'Show these galleries again', fn: () => blRemove(key) }
: { label: 'Blacklist', icon: 'ban', hint: 'Hide galleries with it everywhere', danger: true, fn: () => blAdd(key) },
]);
}, true);
document.addEventListener('mousedown', e => { if (menu && !menu.contains(e.target)) closeMenu(); }, true);
document.addEventListener('keydown', e => { if (e.key === 'Escape' && menu) { e.stopImmediatePropagation(); closeMenu(); } }, true);
window.addEventListener('scroll', closeMenu, { passive: true });
function applyTheme() {
const root = document.documentElement;
let t = THEMES[S.theme] || THEMES.midnight;
if (isReader() && t.scheme === 'light') t = THEMES.graphite;
for (const [k, v] of Object.entries(t)) root.style.setProperty('--hr-' + k, v);
root.style.setProperty('--hr-accent', ACCENTS[S.accent] || ACCENTS.indigo);
root.style.setProperty('--hr-card', (CARD_W[S.cardSize] || CARD_W.m) + 'px');
}
applyTheme();
function chipEl(t, i) {
const neg = t.startsWith('-');
const chip = el('span', { className: 'hr-chip' + (neg ? ' neg' : ''), title: neg ? 'Excluded. Click to include' : 'Click to exclude' },
...(neg ? [el('span', { className: 'hr-chip-not', textContent: 'not' })] : []),
el('span', { textContent: neg ? t.slice(1) : t }));
chip.onclick = () => {
const next = load();
next[i] = next[i].startsWith('-') ? next[i].slice(1) : '-' + next[i];
save(next);
};
const x = el('button', { type: 'button', textContent: '×' });
x.setAttribute('aria-label', 'Remove ' + t);
x.onclick = ev => { ev.stopPropagation(); const next = load(); next.splice(i, 1); save(next); };
chip.append(x);
return chip;
}
const runSearch = () => { const t = load(); location.href = t.length ? searchUrl(t) : '/'; };
let panel = null;
function renderPanel() {
const terms = load();
if (panel) {
panel.hidden = terms.length === 0 || isReader();
const go = el('button', { type: 'button', className: 'hr-btn primary', textContent: 'Search' });
go.onclick = runSearch;
const clr = el('button', { type: 'button', className: 'hr-btn', textContent: 'Clear' });
clr.onclick = () => save([]);
panel.replaceChildren(...terms.map(chipEl), el('span', { className: 'hr-acts' }, go, clr));
}
if (appRefs) {
appRefs.chips.replaceChildren(...terms.map(chipEl));
const flat = terms.filter(t => !t.startsWith('-'));
const key = flat.find(t => t.startsWith('orderbykey:'));
appRefs.sort.value = flat.includes('orderby:popular') && key ? key.slice(11) : '';
const lg = (flat.find(t => t.startsWith('language:')) || '').slice(9);
if (lg && ![...appRefs.lang.options].some(o => o.value === lg)) appRefs.lang.append(el('option', { value: lg, textContent: lg }));
appRefs.lang.value = lg;
}
}
GM_addStyle(`
.hr-sbox { position: relative; }
.hr-sugg { position: absolute; left: -1px; right: -1px; top: calc(100% + 6px); z-index: 10; padding: 5px;
background: var(--hr-surface); border: 1px solid var(--hr-border); border-radius: 12px;
box-shadow: 0 16px 40px var(--hr-shadow); max-height: 60vh; overflow: auto; cursor: default; }
.hr-sugg[hidden] { display: none; }
.hr-sugg-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.hr-sugg-item.on, .hr-sugg-item:hover { background: var(--hr-surface2); }
.hr-ns { flex: none; min-width: 58px; text-align: center; padding: 3px 7px; border-radius: 6px; background: var(--hr-surface2);
color: var(--hr-muted); font-size: 11px; font-weight: 600; }
.hr-sugg-item.on .hr-ns, .hr-sugg-item:hover .hr-ns { background: var(--hr-bg); }
.hr-ns.f, .hr-sugg-item .hr-ns.f { background: color-mix(in srgb, #df5279 18%, transparent); color: color-mix(in srgb, #df5279 70%, var(--hr-text)); }
.hr-ns.m, .hr-sugg-item .hr-ns.m { background: color-mix(in srgb, #2e8ddf 18%, transparent); color: color-mix(in srgb, #2e8ddf 70%, var(--hr-text)); }
.hr-sugg-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hr-sugg-count { color: var(--hr-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.hr-cover .hr-prev { position: absolute; inset: 0; opacity: 0; transition: opacity .15s; }
.hr-cover .hr-prev.on { opacity: 1; }
.hr-dots { position: absolute; left: 10px; right: 10px; top: 8px; display: flex; gap: 4px; opacity: 0; pointer-events: none; transition: opacity .15s; }
.hr-cover.peek .hr-dots { opacity: 1; }
.hr-dots i { flex: 1; max-width: 26px; height: 3px; border-radius: 2px; background: rgba(255,255,255,.4); box-shadow: 0 0 3px rgba(0,0,0,.4); }
.hr-dots i.on { background: #fff; }
@media (prefers-reduced-motion: reduce) { .hr-cover .hr-prev, .hr-dots { transition: none; } }
.hr-tagbox { position: relative; }
.hr-tags.open { position: absolute; z-index: 30; top: -8px; left: -8px; right: -8px; padding: 8px; max-height: 320px; overflow: auto;
background: var(--hr-surface); border: 1px solid var(--hr-border); border-radius: 12px; box-shadow: 0 18px 44px var(--hr-shadow); }
.hr-card.tags-open { z-index: 30; }
.hr-tags { max-height: none; overflow: visible; }
.hr-tag.hr-more { border: 0; cursor: pointer; font-family: inherit; font-weight: 600;
background: var(--hr-accent-soft); color: var(--hr-text); }
.hr-tag.hr-more:hover { background: var(--hr-accent); color: #fff; }
#hr-app .hr-btn.primary { color: #fff; }
.hr-hero { display: grid; grid-template-columns: minmax(200px, 300px) minmax(0, 1fr); gap: clamp(20px, 3vw, 44px); align-items: start; }
.hr-hero-cover { position: sticky; top: 86px; display: block; aspect-ratio: 5 / 7; overflow: hidden; border-radius: 14px;
background: var(--hr-surface2); box-shadow: 0 18px 44px var(--hr-shadow); }
.hr-hero-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }
.hr-hero-info h1 { margin: 0; font-size: clamp(22px, 2.6vw, 32px); font-weight: 750; letter-spacing: -.02em; line-height: 1.15; overflow-wrap: anywhere; }
.hr-jp { margin: 8px 0 0; color: var(--hr-muted); font-size: 15px; overflow-wrap: anywhere; }
.hr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 22px 0; }
.hr-btn.hr-btn-lg { height: 44px; padding: 0 20px; border-radius: 12px; display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
.hr-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 10px 22px; margin: 0; padding: 18px 20px;
background: var(--hr-surface); border: 1px solid var(--hr-border); border-radius: 14px; }
.hr-meta dt { color: var(--hr-muted); font-size: 13px; padding-top: 1px; }
.hr-meta dd { margin: 0; overflow-wrap: anywhere; }
#hr-app .hr-meta a { border-bottom: 1px solid var(--hr-border); }
#hr-app .hr-meta a:hover { color: var(--hr-accent); border-color: var(--hr-accent); }
.hr-tagcloud { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 18px; }
.hr-tagcloud .hr-tag { font-size: 13px; padding: 7px 10px; border-radius: 8px; }
.hr-sec { margin-top: 52px; }
.hr-sec[hidden] { display: none; }
.hr-sec-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 16px; }
.hr-sec h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -.01em; }
.hr-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 12px; }
.hr-thumb { position: relative; display: block; aspect-ratio: 5 / 7; overflow: hidden; border-radius: 8px; background: var(--hr-surface2); }
.hr-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; transition: opacity .2s; }
.hr-thumb img:not([data-state="done"]) { opacity: 0; }
.hr-thumb span { position: absolute; left: 6px; bottom: 6px; padding: 2px 6px; border-radius: 5px; background: rgba(10,10,16,.68);
color: #fff; font-size: 11px; font-weight: 600; }
.hr-thumb:hover { box-shadow: 0 0 0 2px var(--hr-accent); }
.hr-skl { display: block; height: 14px; margin: 12px 0; border-radius: 7px; background: var(--hr-surface2); animation: hr-pulse 1.4s ease-in-out infinite; }
.hr-skl.big { height: 34px; width: 70%; }
@media (max-width: 720px) {
.hr-hero { grid-template-columns: 1fr; }
.hr-hero-cover { position: static; width: min(260px, 70vw); }
}
@media (prefers-reduced-motion: reduce) { .hr-skl { animation: none; } .hr-thumb img { transition: none; } }
html.hr-rv { --hr-nav: 0px; }
html.hr-rv.hr-rv-pin { --hr-nav: 56px; }
html.hr-rv .navbar { display: none !important; }
html.hr-rv, html.hr-rv body { background: var(--hr-bg) !important; }
html.hr-rv body { margin: 0 !important; padding: var(--hr-nav) 0 0 !important; }
html.hr-rv #comicImages { background: var(--hr-bg) !important; }
html.hr-rv.hr-idle, html.hr-rv.hr-idle * { cursor: none !important; }
#hr-rbar { position: fixed; z-index: 2147483646; top: 0; left: 0; right: 0; height: 56px; display: flex; align-items: center;
gap: 6px; padding: 0 10px; background: color-mix(in srgb, var(--hr-bg) 82%, transparent); backdrop-filter: blur(16px);
border-bottom: 1px solid var(--hr-border); color: var(--hr-text); font: 14px/1.3 var(--hr-font); color-scheme: dark;
transition: transform .2s ease, opacity .2s ease; box-sizing: border-box; }
#hr-rbar * { box-sizing: border-box; }
html.hr-idle #hr-rbar { transform: translateY(-100%); opacity: 0; pointer-events: none; }
.hr-rb { flex: none; display: inline-grid; place-items: center; height: 38px; min-width: 38px; padding: 0 8px; border: 0;
border-radius: 10px; background: none; color: var(--hr-muted); cursor: pointer; font: 600 13px/1 var(--hr-font); }
.hr-rb:hover { background: var(--hr-hover); color: var(--hr-text); }
.hr-rb[aria-pressed="true"] { color: var(--hr-accent); }
.hr-rb:focus-visible, #hr-rbar input:focus-visible { outline: 2px solid var(--hr-accent); outline-offset: 1px; }
.hr-rtitle { min-width: 0; display: flex; flex-direction: column; margin-left: 4px; }
.hr-rtitle strong { font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hr-rtitle span { color: var(--hr-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hr-rspacer { flex: 1; }
.hr-rgroup { flex: none; display: flex; align-items: center; gap: 2px; padding: 2px; border-radius: 12px;
background: var(--hr-surface); border: 1px solid var(--hr-border); }
.hr-rgroup .hr-rb { height: 34px; min-width: 34px; }
.hr-pagebox { display: flex; align-items: center; gap: 5px; padding: 0 4px; color: var(--hr-muted); font-variant-numeric: tabular-nums; cursor: text; }
#hr-rbar #hr-page { width: 52px; height: 30px; margin: 0; padding: 0 4px; text-align: center; border-radius: 8px;
border: 1px solid transparent; background: var(--hr-bg); color: var(--hr-text); font: 600 14px/1 var(--hr-font); -moz-appearance: textfield; }
#hr-rbar #hr-page:focus { border-color: var(--hr-accent); outline: none; }
#hr-rbar #hr-page::-webkit-inner-spin-button, #hr-rbar #hr-page::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
#hr-rbar #hr-total { color: var(--hr-muted); }
.hr-zoomval { min-width: 54px !important; font-variant-numeric: tabular-nums; }
#hr-progress { position: fixed; z-index: 2147483646; left: 0; right: 0; top: 56px; height: 2px; pointer-events: none; transition: top .2s ease; }
html.hr-idle #hr-progress { top: 0; }
#hr-progress i { display: block; height: 100%; width: 0; background: var(--hr-accent); transition: width .2s ease; }
@media (max-width: 760px) { .hr-rtitle span, .hr-rzoom, #hr-fullbtn { display: none !important; } }
@media (prefers-reduced-motion: reduce) { #hr-rbar, #hr-progress, #hr-progress i { transition: none; } }
:where(html.hr-ct:not(.hr-ui)), :where(html.hr-ct:not(.hr-ui)) body { background-color: var(--hr-bg) !important; color: var(--hr-text) !important; color-scheme: var(--hr-scheme); }
:where(html.hr-ct:not(.hr-ui)) :where(div, table, thead, tbody, tr, td, th, ul, ol, li, section, article, header, footer, main, nav, p, h1, h2, h3):where(:not([id^="hr-"], [id^="hr-"] *)) {
background-color: transparent !important; border-color: var(--hr-border) !important; }
:where(html.hr-ct:not(.hr-ui)) :where(h1, h2, h3, h4, h5, h6, p, td, th, li, span, div, label, dt, dd, b, strong):where(:not([id^="hr-"], [id^="hr-"] *)) { color: var(--hr-text) !important; }
:where(html.hr-ct:not(.hr-ui)) a:where(:not([id^="hr-"] *)) { color: color-mix(in srgb, var(--hr-accent) 45%, var(--hr-text)) !important; }
:where(html.hr-ct:not(.hr-ui)) :where(input, select, textarea):where(:not([id^="hr-"] *)) {
background-color: var(--hr-surface) !important; color: var(--hr-text) !important; border: 1px solid var(--hr-border) !important; border-radius: 6px; }
:where(html.hr-ct:not(.hr-ui)) button:where(:not([id^="hr-"], [id^="hr-"] *)) { background-color: var(--hr-surface2) !important; color: var(--hr-text) !important; border-color: var(--hr-border) !important; }
html.hr-ct:not(.hr-ui) .gallery-content > div { background-color: var(--hr-surface) !important; border: 1px solid var(--hr-border) !important; border-radius: 12px !important; }
html.hr-ct:not(.hr-ui) .gallery-content > div h1 a { color: var(--hr-text) !important; }
html.hr-ct:not(.hr-ui) :is(.relatedtags, .tags, #tags) a { background-color: var(--hr-surface2) !important; color: var(--hr-text) !important; border-color: transparent !important; }
html.hr-ct:not(.hr-ui) .page-container a { color: var(--hr-text) !important; }
`);
let app = null, appRefs = null, gridSig = '', settled = false;
const wantUi = () => S.newUi && !isReader();
const wantReaderUi = () => S.newUi && isReader();
{
const c = document.documentElement.classList;
if (wantUi()) c.add('hr-ui');
if (wantReaderUi()) c.add('hr-rv');
if (S.classicTheme && !isReader()) c.add('hr-ct');
}
const ICONS = {
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>',
sliders: '<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/>',
back: '<path d="M19 12H5M11 18l-6-6 6-6"/>',
prev: '<path d="M15 18l-6-6 6-6"/>',
next: '<path d="M9 18l6-6-6-6"/>',
fitH: '<path d="M12 3v18M8 7l4-4 4 4M8 17l4 4 4-4"/>',
fitW: '<path d="M3 12h18M7 8l-4 4 4 4M17 8l4 4-4 4"/>',
full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
grid: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>',
minus: '<path d="M6 12h12"/>',
plus: '<path d="M12 6v12M6 12h12"/>',
book: '<path d="M4 5.5C6.5 4 9.5 4 12 6c2.5-2 5.5-2 8-.5V19c-2.5-1.5-5.5-1.5-8 .5-2.5-2-5.5-2-8-.5z"/><path d="M12 6v13.5"/>',
external: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>',
x: '<path d="M6 6l12 12M18 6L6 18"/>',
ban: '<circle cx="12" cy="12" r="8"/><path d="M6.5 6.5l11 11"/>',
info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
};
const svg = html => { const t = document.createElement('template'); t.innerHTML = html; return t.content.firstChild; };
const icon = (name, size = 18) => svg(`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`);
const setText = (n, t) => { if (n.textContent !== t) n.textContent = t; };
const nsClass = ns => ns === 'female' ? 'f' : ns === 'male' ? 'm' : '';
const SORTS = [['', 'Newest'], ['today', 'Popular today'], ['week', 'Popular this week'], ['month', 'Popular this month'], ['year', 'Popular this year']];
const LANGS = [['', 'Any language'], ['english', 'English'], ['japanese', 'Japanese'], ['chinese', 'Chinese'], ['korean', 'Korean'],
['spanish', 'Spanish'], ['french', 'French'], ['german', 'German'], ['russian', 'Russian'], ['portuguese', 'Portuguese']];
const KIND = { tag: 'Tag', artist: 'Artist', series: 'Series', character: 'Character', group: 'Group', type: 'Type' };
function replaceTerms(drop, add) {
const t = load().filter(x => !drop(bare(x)));
save([...t, ...add]);
runSearch();
}
const suggestFn = () => typeof W.get_suggestions_for_query === 'function' ? W.get_suggestions_for_query : null;
function attachSuggest(input, box) {
const list = el('div', { className: 'hr-sugg', hidden: true });
list.setAttribute('role', 'listbox');
box.append(list);
let items = [], idx = -1, seq = 0, timer = 0;
const close = () => { list.hidden = true; items = []; idx = -1; };
const collect = (x, out = []) => {
if (!x || out.length > 50) return out;
if (Array.isArray(x)) {
if (typeof x[0] === 'string') out.push({ s: x[0], t: x[1], n: typeof x[3] === 'string' ? x[3] : x[2] });
else for (const y of x) collect(y, out);
} else if (typeof x === 'object') {
if (typeof x.s === 'string') out.push(x);
else for (const y of Object.values(x)) if (y && typeof y === 'object') collect(y, out);
}
return out;
};
const norm = x => {
const name = String(x.s).trim();
if (!name) return null;
const n = typeof x.n === 'string' && /^[a-z]+$/.test(x.n) ? x.n : 'tag';
return { name, count: +x.t || 0, ns: n, term: `${n}:${name.replace(/\s+/g, '_')}` };
};
async function run() {
const raw = (input.value.match(/(\S*)$/) || ['', ''])[1];
const neg = raw.startsWith('-');
const q = raw.replace(/^-/, '');
const fn = suggestFn();
if (!q || !fn) return close();
const my = ++seq;
let res = null;
try { res = await fn.call(W, q); } catch {}
if (my !== seq || document.activeElement !== input) return;
const seen = new Set();
items = collect(res).map(norm).filter(x => x && !seen.has(x.term) && seen.add(x.term)).slice(0, 10).map(x => ({ ...x, neg }));
idx = -1;
draw();
}
function draw() {
if (!items.length) return close();
list.replaceChildren(...items.map((it, i) => {
const row = el('div', { className: 'hr-sugg-item' + (i === idx ? ' on' : '') },
el('span', { className: 'hr-ns ' + nsClass(it.ns), textContent: (it.neg ? 'not ' : '') + it.ns }),
el('span', { className: 'hr-sugg-name', textContent: it.name }),
el('span', { className: 'hr-sugg-count', textContent: it.count ? it.count.toLocaleString() : '' }));
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', String(i === idx));
row.addEventListener('mousedown', e => { e.preventDefault(); pick(it); });
return row;
}));
list.hidden = false;
list.querySelector('.on')?.scrollIntoView({ block: 'nearest' });
}
function pick(it) {
input.value = input.value.replace(/\S*$/, '');
const t = load().filter(x => bare(x) !== it.term);
t.push((it.neg ? '-' : '') + it.term);
save(t);
close();
input.focus();
}
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 120); });
input.addEventListener('blur', () => setTimeout(close, 120));
return {
handleKey(e) {
if (list.hidden || !items.length) return false;
const n = items.length;
if (e.key === 'ArrowDown') idx = idx + 1 >= n ? -1 : idx + 1;
else if (e.key === 'ArrowUp') idx = idx - 1 < -1 ? n - 1 : idx - 1;
else if ((e.key === 'Enter' && idx >= 0) || (e.key === 'Tab' && !e.shiftKey)) { e.preventDefault(); pick(items[Math.max(idx, 0)]); return true; }
else if (e.key === 'Escape') { e.preventDefault(); close(); return true; }
else return false;
e.preventDefault();
draw();
return true;
},
};
}
function buildTopBar() {
const chips = el('span', { className: 'hr-chips' });
const input = el('input', { type: 'text', placeholder: 'Search tags, artists or titles', spellcheck: false, autocomplete: 'off' });
input.setAttribute('aria-label', 'Search');
const sbox = el('div', { className: 'hr-sbox' }, icon('search', 16), chips, input);
sbox.addEventListener('click', e => { if (e.target === sbox) input.focus(); });
const sugg = attachSuggest(input, sbox);
const commit = () => {
const words = input.value.trim().split(/\s+/).filter(Boolean);
if (words.length) { const t = load(); for (const w of words) if (!t.includes(w)) t.push(w); store(t); }
input.value = '';
runSearch();
};
input.addEventListener('keydown', e => {
if (sugg.handleKey(e)) return;
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Backspace' && !input.value) { const t = load(); if (t.length) { t.pop(); save(t); } }
else if (e.key === 'Escape') input.blur();
});
const select = (opts, label) => {
const s = el('select', { className: 'hr-select' });
s.setAttribute('aria-label', label);
for (const [v, text] of opts) s.append(el('option', { value: v, textContent: text }));
return s;
};
const sort = select(SORTS, 'Sort');
sort.onchange = () => replaceTerms(x => /^orderby/.test(x), sort.value ? ['orderby:popular', 'orderbykey:' + sort.value] : []);
const lang = select(LANGS, 'Language');
lang.onchange = () => replaceTerms(x => x.startsWith('language:'), lang.value ? ['language:' + lang.value] : []);
const go = el('button', { type: 'button', className: 'hr-btn primary', textContent: 'Search' });
go.onclick = commit;
const gear = el('button', { type: 'button', className: 'hr-icon', title: 'Settings' }, icon('sliders'));
gear.setAttribute('aria-label', 'Hitomi Revamp settings');
gear.onclick = openSettings;
const brand = el('a', { className: 'hr-brand', href: '/', title: 'Latest galleries' },
el('img', { src: LOGO_URL, alt: '' }), el('span', { textContent: 'Hitomi' }));
return { header: el('header', { className: 'hr-top' }, brand, sbox, sort, lang, go, gear), refs: { chips, input, sort, lang } };
}
function buildApp() {
if (app) return;
const top = buildTopBar();
appRefs = { ...top.refs, mode: pageGalleryId ? 'gallery' : 'list' };
let main;
if (appRefs.mode === 'list') {
const h1 = el('h1'), kind = el('span', { className: 'hr-kind' }), stat = el('span', { className: 'hr-stat' });
const grid = el('div', { className: 'hr-grid' });
const pager = el('nav', { className: 'hr-pager' });
pager.setAttribute('aria-label', 'Pages');
Object.assign(appRefs, { h1, kind, stat, grid, pager });
main = el('main', { className: 'hr-main' }, el('div', { className: 'hr-headline' }, h1, kind, stat), grid, pager);
} else {
main = buildGalleryMain();
}
app = el('div', { id: 'hr-app' }, top.header, main);
document.body.append(app);
setTimeout(() => { settled = true; renderApp(); }, 8000);
renderPanel();
renderApp();
}
function teardownApp() {
app?.remove();
app = appRefs = null;
gridSig = '';
document.documentElement.classList.remove('hr-ui');
}
function syncUi() {
if (!document.body || isReader()) return;
document.documentElement.classList.toggle('hr-ct', !!S.classicTheme);
const ok = wantUi() && (pageGalleryId ? !!W.galleryinfo?.files : !!document.querySelector('.gallery-content'));
if (ok) {
document.documentElement.classList.add('hr-ui');
if (!app) buildApp(); else { gridSig = ''; renderApp(); }
} else teardownApp();
}
function parseCard(card, bl) {
const links = [...card.querySelectorAll('a[href]')];
const ga = links.find(a => galleryIdFromLink(a));
if (!ga) return null;
const c = { href: ga.dataset.hrInfo || ga.href, title: '', covers: [], artists: [], series: [], tags: [], type: null, lang: null };
c.title = (card.querySelector('h1') || ga).textContent.trim() || 'Untitled';
const push = u => {
u = (u || '').trim().split(/\s+/)[0];
if (!u || u.startsWith('data:')) return;
try { u = new URL(u, location.href).href; } catch { return; }
if (!c.covers.includes(u)) c.covers.push(u);
};
const sets = [];
const pics = [...card.querySelectorAll('picture')];
for (const node of pics.length ? pics : [...card.querySelectorAll('img')]) {
c.covers = [];
const img = node.tagName === 'IMG' ? node : node.querySelector('img');
if (img) { push(img.getAttribute('data-src')); push(img.getAttribute('src')); (img.getAttribute('data-srcset') || '').split(',').forEach(push); }
if (node.tagName !== 'IMG') node.querySelectorAll('source').forEach(s => (s.getAttribute('data-srcset') || s.getAttribute('srcset') || '').split(',').forEach(push));
if (c.covers.length && !sets.some(x => x[0] === c.covers[0])) sets.push(c.covers);
}
c.covers = sets[0] || [];
c.previews = sets.slice(1);
for (const a of links) {
const t = termFromLink(a);
if (!t) continue;
const item = { href: a.href, text: a.textContent.trim() || t.replace(/^[a-z]+:/, '').replace(/_/g, ' '), term: t };
if (t.startsWith('artist:') || t.startsWith('group:')) c.artists.push(item);
else if (t.startsWith('series:')) c.series.push(item);
else if (t.startsWith('type:')) c.type = item;
else if (t.startsWith('language:')) c.lang = item;
else if (/^(tag|female|male):/.test(t)) c.tags.push(item);
}
c.hit = cardHit(card, bl);
return c;
}
const tagLink = t => el('a', { className: 'hr-tag ' + nsClass(t.term.split(':')[0]), href: t.href, textContent: t.text, title: t.term });
function cardEl(c) {
const coverLink = el('a', { className: 'hr-cover-link', href: c.href });
coverLink.setAttribute('aria-label', c.title);
const cover = el('div', { className: 'hr-cover' }, coverLink);
if (c.covers.length) {
let k = 0;
const img = el('img', { alt: '', loading: 'lazy', decoding: 'async' });
img.onerror = () => { if (++k < c.covers.length) img.src = c.covers[k]; else img.remove(); };
img.src = c.covers[0];
coverLink.append(img);
}
if (c.previews.length) addPeek(cover, coverLink, c.previews);
if (c.type) cover.append(el('a', { className: 'hr-pill type', href: c.type.href, textContent: c.type.text, title: c.type.term }));
if (c.lang) cover.append(el('a', { className: 'hr-pill lang', href: c.lang.href, textContent: c.lang.text, title: c.lang.term }));
const card = el('article', { className: 'hr-card' }, cover, el('a', { className: 'hr-title', href: c.href, textContent: c.title, title: c.title }));
const people = [...c.artists, ...c.series];
if (people.length) {
const sub = el('div', { className: 'hr-sub' });
people.forEach((p, i) => { if (i) sub.append(', '); sub.append(el('a', { href: p.href, textContent: p.text, title: p.term })); });
card.append(sub);
}
if (S.cardTags !== 'off' && c.tags.length) {
const LIMIT = 8;
const wrap = el('div', { className: 'hr-tags' });
const box = el('div', { className: 'hr-tagbox' }, wrap);
let off = null;
const fill = open => {
off?.();
off = null;
if (open) box.style.height = box.offsetHeight + 'px';
else box.style.height = '';
wrap.classList.toggle('open', open);
card.classList.toggle('tags-open', open);
wrap.replaceChildren(...(open || S.cardTags === 'all' ? c.tags : c.tags.slice(0, LIMIT)).map(tagLink));
if (S.cardTags === 'all' || c.tags.length <= LIMIT) return;
const b = el('button', { type: 'button', className: 'hr-tag hr-more', textContent: open ? 'Less' : `+${c.tags.length - LIMIT}`,
title: open ? 'Show fewer tags' : 'Show all tags' });
b.setAttribute('aria-expanded', String(open));
b.onclick = () => { fill(!open); wrap.querySelector('.hr-more')?.focus(); };
wrap.append(b);
if (open) {
const outside = e => { if (!wrap.contains(e.target) && !menu?.contains(e.target)) fill(false); };
const esc = e => { if (e.key === 'Escape' && !menu) fill(false); };
document.addEventListener('pointerdown', outside, true);
document.addEventListener('keydown', esc);
off = () => { document.removeEventListener('pointerdown', outside, true); document.removeEventListener('keydown', esc); };
}
};
fill(false);
card.append(box);
}
if (c.hit) { card.classList.add('is-dim'); cover.dataset.bl = c.hit; }
return card;
}
function addPeek(cover, link, previews) {
const layers = previews.map(urls => { const im = el('img', { className: 'hr-prev', alt: '', decoding: 'async' }); im._hrUrls = urls; return im; });
const dots = el('div', { className: 'hr-dots' }, ...[0, ...layers].map(() => el('i')));
link.append(...layers);
cover.append(dots);
let cur = 0;
const render = () => {
layers.forEach((im, k) => im.classList.toggle('on', k + 1 === cur && !!im.dataset.ok));
[...dots.children].forEach((d, k) => d.classList.toggle('on', k === cur));
};
const load = im => {
if (im.getAttribute('src')) return;
let k = 0;
im.onload = () => { im.dataset.ok = '1'; render(); };
im.onerror = () => { if (++k < im._hrUrls.length) im.src = im._hrUrls[k]; };
im.src = im._hrUrls[0];
};
const total = layers.length + 1;
cover.addEventListener('pointerenter', e => {
if (e.pointerType === 'touch') return;
layers.forEach(load);
cover.classList.add('peek');
cur = 1;
render();
});
cover.addEventListener('pointermove', e => {
if (total < 3 || e.pointerType === 'touch') return;
const r = cover.getBoundingClientRect();
const n = Math.min(total - 1, Math.max(0, Math.floor((e.clientX - r.left) / r.width * total)));
if (n !== cur) { cur = n; render(); }
});
cover.addEventListener('pointerleave', () => { cover.classList.remove('peek'); cur = 0; render(); });
}
function cardEls(cards) {
const bl = new Set(blacklist);
const out = [];
for (const card of cards) {
const c = parseCard(card, bl);
if (!c || (c.hit && S.blMode === 'hide')) continue;
out.push(cardEl(c));
}
return out;
}
const skeleton = () => el('article', { className: 'hr-card hr-skel' }, el('div', { className: 'hr-cover' }), el('i'), el('i'));
function parsePages() {
const pc = document.querySelector('.page-container');
if (!pc) return [];
return [...pc.querySelectorAll('li')]
.map(li => ({ label: li.textContent.trim(), href: li.querySelector('a')?.href || '' }))
.filter(p => p.label);
}
function renderApp() {
if (!appRefs) return;
const R = appRefs;
const cards = [...document.querySelectorAll(CARD_SEL)];
const cardSig = [cards.map(c => c.querySelector('a[href]')?.getAttribute('href')).join(','), blacklist.join(','), S.blMode, S.cardTags].join('|');
if (R.mode === 'gallery') {
if (!R.relGrid || cardSig === gridSig) return;
gridSig = cardSig;
const out = cardEls(cards);
R.relSection.hidden = !out.length;
R.relGrid.replaceChildren(...out);
return;
}
const title = (document.title || '').replace(/\s*[|\-]\s*hitomi\.la.*$/i, '').trim();
const m = location.pathname.match(/^\/([a-z]+)\//);
setText(R.h1, title && !/^hitomi\.la$/i.test(title) ? title : 'Latest galleries');
setText(R.kind, (m && KIND[m[1]]) || (location.pathname === '/search.html' ? 'Search results' : ''));
const pages = parsePages();
const sig = cardSig + '|' + pages.map(p => p.label + (p.href ? '' : '*')).join(',') + '|' + settled;
if (sig !== gridSig) {
gridSig = sig;
const out = cardEls(cards);
R.shown = out.length;
if (out.length) R.grid.replaceChildren(...out);
else if (!cards.length && !settled) R.grid.replaceChildren(...Array.from({ length: 12 }, skeleton));
else R.grid.replaceChildren(el('p', { className: 'hr-empty', textContent: cards.length ? 'Everything on this page is blacklisted.' : 'No galleries here.' }));
R.pager.replaceChildren(...pages.map(p => {
if (!p.href) return el('span', { className: 'hr-pg ' + (/^\d+$/.test(p.label) ? 'cur' : 'gap'), textContent: p.label });
const a = el('a', { className: 'hr-pg', href: p.href, textContent: p.label });
a.addEventListener('click', e => {
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey) return;
e.preventDefault();
location.href = p.href;
scrollTo(0, 0);
});
return a;
}));
}
const n = R.shown || 0;
let s = cards.length ? `${n} ${n === 1 ? 'gallery' : 'galleries'} on this page` : '';
if (blCount) s += `, ${blCount} ${S.blMode === 'hide' ? 'hidden' : 'dimmed'} by your blacklist`;
setText(R.stat, s);
}
function makeThumbLoader() {
const queue = [];
let active = 0, last = 0, timer = 0;
const io = new IntersectionObserver(entries => {
for (const en of entries) {
const img = en.target;
if (en.isIntersecting) { if (!img.dataset.state && !queue.includes(img)) queue.push(img); }
else { const i = queue.indexOf(img); if (i > -1) queue.splice(i, 1); }
}
pump();
}, { rootMargin: '400px' });
function pump() {
while (active < S.maxParallel && queue.length) {
const wait = last + S.delayMs - Date.now();
if (wait > 0) { if (!timer) timer = setTimeout(() => { timer = 0; pump(); }, wait); return; }
last = Date.now();
const img = queue.shift();
const urls = img._hrUrls;
let k = 0;
active++;
img.dataset.state = 'loading';
img.onload = () => { active--; img.dataset.state = 'done'; io.unobserve(img); pump(); };
img.onerror = () => {
if (++k < urls.length) { img.src = urls[k]; return; }
active--; img.dataset.state = 'err'; io.unobserve(img); pump();
};
img.src = urls[0];
}
}
return { add(img, urls) { img._hrUrls = urls; io.observe(img); } };
}
function fmtDate(s) {
if (!s) return '';
const d = new Date(String(s).replace(' ', 'T').replace(/([+-]\d\d)$/, '$1:00'));
return isNaN(d) ? String(s).slice(0, 10) : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function buildGalleryMain() {
const hero = el('section', { className: 'hr-hero' },
el('div', { className: 'hr-hero-cover' }),
el('div', { className: 'hr-hero-info' }, el('i', { className: 'hr-skl big' }), el('i', { className: 'hr-skl' }), el('i', { className: 'hr-skl' })));
const pagesSec = el('section', { className: 'hr-sec', hidden: true });
const relGrid = el('div', { className: 'hr-grid' });
const relSection = el('section', { className: 'hr-sec', hidden: true },
el('div', { className: 'hr-sec-head' }, el('h2', { textContent: 'Related galleries' })), relGrid);
Object.assign(appRefs, { hero, pagesSec, relGrid, relSection });
waitFor(() => W.galleryinfo?.files, 15000).then(ok => {
if (!appRefs || appRefs.mode !== 'gallery') return;
if (ok) fillGallery(W.galleryinfo);
else teardownApp();
});
return el('main', { className: 'hr-main' }, hero, pagesSec, relSection);
}
function fillGallery(g) {
const R = appRefs;
const id = g.id || pageGalleryId;
const files = g.files || [];
const named = (list, key, path) => (list || []).map(x => ({
text: x[key], href: x.url || `/${path}/${encodeURIComponent(x[key])}-all.html`, term: `${path}:${String(x[key]).replace(/\s+/g, '_')}`,
}));
const links = items => items.length
? el('span', {}, ...items.flatMap((it, i) => [...(i ? [', '] : []), el('a', { href: it.href, textContent: it.text, title: it.term })]))
: null;
const tags = (g.tags || []).map(t => {
const f = +t.female === 1 || t.female === true, m = +t.male === 1 || t.male === true;
const pre = f ? 'female:' : m ? 'male:' : '';
return { text: t.tag + (f ? ' ♀' : m ? ' ♂' : ''), href: t.url || `/tag/${encodeURIComponent(pre + t.tag)}-all.html`,
term: (pre || 'tag:') + String(t.tag).replace(/\s+/g, '_') };
}).sort((a, b) => (a.term.startsWith('tag:') - b.term.startsWith('tag:')));
const rows = [
['Type', g.type ? links([{ text: g.type, href: `/type/${encodeURIComponent(g.type)}-all.html`, term: 'type:' + g.type }]) : null],
['Language', g.language ? links([{ text: g.language_localname || g.language, href: `/index-${g.language}.html`, term: 'language:' + g.language }]) : null],
['Also in', links((g.languages || []).filter(l => String(l.galleryid) !== String(id))
.map(l => ({ text: l.language_localname || l.name, href: `/galleries/${l.galleryid}.html`, term: 'language:' + l.name })))],
['Artists', links(named(g.artists, 'artist', 'artist'))],
['Groups', links(named(g.groups, 'group', 'group'))],
['Series', links(named(g.parodys, 'parody', 'series'))],
['Characters', links(named(g.characters, 'character', 'character'))],
['Pages', String(files.length)],
['Added', fmtDate(g.date)],
].filter(([, v]) => v);
const coverImg = el('img', { alt: '' });
const cover = el('a', { className: 'hr-hero-cover', href: readerUrl(id), title: 'Read' }, coverImg);
const read = el('a', { className: 'hr-btn primary hr-btn-lg', href: readerUrl(id) }, icon('book', 17), el('span', { textContent: 'Read' }));
const readTab = el('button', { type: 'button', className: 'hr-btn hr-btn-lg' }, icon('external', 16), el('span', { textContent: 'Read in new tab' }));
readTab.onclick = () => openTab(readerUrl(id));
R.hero.replaceChildren(cover, el('div', { className: 'hr-hero-info' },
el('h1', { textContent: g.title || 'Untitled' }),
...(g.japanese_title && g.japanese_title !== g.title ? [el('p', { className: 'hr-jp', textContent: g.japanese_title })] : []),
el('div', { className: 'hr-actions' }, read, readTab),
el('dl', { className: 'hr-meta' }, ...rows.flatMap(([k, v]) => [el('dt', { textContent: k }), el('dd', {}, v)])),
...(tags.length ? [el('div', { className: 'hr-tagcloud' }, ...tags.map(tagLink))] : [])));
const covers = [];
const siteImg = document.querySelector('.cover img, .cover-column img');
if (siteImg) for (const u of [siteImg.getAttribute('data-src'), siteImg.getAttribute('src')]) if (u && !u.startsWith('data:')) covers.push(new URL(u, location.href).href);
const thumbs = el('div', { className: 'hr-thumbs' });
R.pagesSec.replaceChildren(el('div', { className: 'hr-sec-head' }, el('h2', { textContent: 'Pages' }), el('span', { className: 'hr-kind', textContent: String(files.length) })), thumbs);
R.pagesSec.hidden = !files.length;
waitFor(() => typeof W.url_from_url_from_hash === 'function' && W.gg, 8000).then(ok => {
const tn = (f, kind) => { try { return W.url_from_url_from_hash(id, f, kind, 'webp', 'tn'); } catch { return null; } };
if (ok && files[0]) covers.push(...[tn(files[0], 'webpbigtn'), tn(files[0], 'webpsmalltn')].filter(Boolean));
if (covers.length) {
let k = 0;
coverImg.onerror = () => { if (++k < covers.length) coverImg.src = covers[k]; };
coverImg.src = covers[0];
}
const loader = makeThumbLoader();
files.forEach((f, i) => {
const img = el('img', { alt: '', decoding: 'async' });
thumbs.append(el('a', { className: 'hr-thumb', href: `/reader/${id}.html#${i + 1}`, title: `Page ${i + 1}` }, img, el('span', { textContent: i + 1 })));
if (!ok) return;
const urls = [tn(f, 'webpsmalltn'), tn(f, 'webpsmallsmalltn')].filter(Boolean);
if (urls.length) loader.add(img, urls);
});
});
gridSig = '';
renderApp();
}
document.addEventListener('keydown', e => {
if (e.key !== '/' || !appRefs || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.target instanceof Element && e.target.closest('input, textarea, select, [contenteditable], #hr-settings-host')) return;
e.preventDefault();
appRefs.input.focus();
});
let rbar = null, idleTimer = 0;
function applyFit() {
if (!wantReaderUi()) return;
const box = document.getElementById('comicImages');
if (box) {
box.classList.toggle('fitVertical', S.fit !== 'width');
box.classList.toggle('fitHorizontal', S.fit === 'width');
}
const b = document.getElementById('hr-fit');
if (b) {
b.replaceChildren(icon(S.fit === 'width' ? 'fitW' : 'fitH'));
b.title = (S.fit === 'width' ? 'Fitting width' : 'Fitting height') + ' (W to switch)';
}
}
const toggleFit = () => { S.fit = S.fit === 'width' ? 'height' : 'width'; saveSettings(); applyFit(); };
const toggleFullscreen = () => {
if (document.fullscreenElement) document.exitFullscreen?.();
else document.documentElement.requestFullscreen?.()?.catch?.(() => {});
};
function readerActivity() {
if (!rbar) return;
const root = document.documentElement;
root.classList.remove('hr-idle');
clearTimeout(idleTimer);
if (!S.readerAutoHide) return;
idleTimer = setTimeout(() => {
if (rbar.matches(':hover') || rbar.contains(document.activeElement) || dlg?.open) return readerActivity();
root.classList.add('hr-idle');
}, 2200);
}
function buildReaderBar() {
const btn = (name, title, fn, id) => {
const b = el('button', { type: 'button', className: 'hr-rb', title }, icon(name));
b.setAttribute('aria-label', title);
if (id) b.id = id;
b.onclick = fn;
return b;
};
const dir = S.rightArrowIsNext ? 1 : -1;
const titleEl = el('strong', { textContent: 'Loading…' }), subEl = el('span');
const zoomVal = el('button', { type: 'button', className: 'hr-rb hr-zoomval', id: 'hr-zoom-out', title: 'Reset size (0)', textContent: S.zoom + '%' });
zoomVal.onclick = () => setZoom(100);
const stripBtn = btn('grid', 'Thumbnails (T)', () => {
if (S.stripPos === 'off') { S.stripPos = 'bottom'; stripHidden = false; saveSettings(); }
else stripHidden = !stripHidden;
applyLayout();
}, 'hr-stripbtn');
rbar = el('div', { id: 'hr-rbar' },
btn('back', 'Back (Esc)', goBack),
el('div', { className: 'hr-rtitle' }, titleEl, subEl),
el('span', { className: 'hr-rspacer' }),
el('div', { className: 'hr-rgroup' },
btn('prev', dir > 0 ? 'Previous page' : 'Next page', () => goToPage(curPage() - dir)),
pageBoxEl(false),
btn('next', dir > 0 ? 'Next page' : 'Previous page', () => goToPage(curPage() + dir))),
el('div', { className: 'hr-rgroup hr-rzoom' },
btn('minus', 'Smaller (-)', () => setZoom(S.zoom - 10)), zoomVal, btn('plus', 'Bigger (+)', () => setZoom(S.zoom + 10))),
btn('fitH', 'Fit', toggleFit, 'hr-fit'),
stripBtn,
btn('full', 'Fullscreen (F)', toggleFullscreen, 'hr-fullbtn'),
btn('sliders', 'Settings', openSettings));
document.body.append(rbar, el('div', { id: 'hr-progress' }, el('i')));
for (const ev of ['pointermove', 'pointerdown']) {
window.addEventListener(ev, e => {
if (e.pointerType === 'touch' && e.type === 'pointerdown' && e.clientY > 90) return;
readerActivity();
}, { passive: true });
}
rbar.addEventListener('focusin', readerActivity);
window.addEventListener('hashchange', applyFit);
waitFor(() => W.galleryinfo?.files?.length, 15000).then(ok => {
if (!ok) { titleEl.textContent = document.title.replace(/\s*[|\-]\s*hitomi\.la.*$/i, '') || 'Reader'; return; }
const g = W.galleryinfo;
titleEl.textContent = g.title || 'Untitled';
titleEl.title = g.title || '';
document.title = (g.title || 'Reader') + ' | Hitomi.la';
subEl.textContent = [(g.artists || []).map(a => a.artist).join(', '), g.language_localname || g.language, `${g.files.length} pages`].filter(Boolean).join(', ');
updatePageBox();
setTimeout(applyFit, 300);
});
updatePageBox();
applyFit();
readerActivity();
}
const TN = ['webpsmallsmalltn', 'webpsmalltn'];
const tnPick = () => (S.thumbSize * (window.devicePixelRatio || 1) > 130 ? 1 : 0);
let strip = null, stripInit = false, stripHidden = false, tiles = [], files = [], gid = null, io = null;
const queue = [];
let active = 0, lastStart = 0, pumpTimer = null;
const waitFor = (fn, ms) => new Promise(res => {
const t0 = Date.now();
(function tick() {
const v = fn();
if (v || Date.now() - t0 > ms) return res(v);
setTimeout(tick, 250);
})();
});
async function initStrip() {
stripInit = true;
const ok = await waitFor(() => W.galleryinfo?.files?.length && typeof W.url_from_url_from_hash === 'function', 15000);
if (!ok) { console.warn('[hitomi-revamp] reader data not found; thumbnail strip disabled'); return; }
await waitFor(() => W.gg, 5000);
files = W.galleryinfo.files;
gid = W.galleryinfo.id || readerGalleryId();
buildStrip();
}
function buildStrip() {
strip = el('div', { id: 'hr-strip' });
strip.setAttribute('aria-label', 'Pages');
tiles = files.map((f, i) => {
const r = f.width && f.height ? f.width / f.height : 0.7;
const b = el('button', { className: 'hr-tile', title: `Page ${i + 1}` },
el('img', { alt: '', decoding: 'async' }), el('span', { textContent: i + 1 }));
b.style.setProperty('--r', r);
b.dataset.i = i;
b.onclick = () => goToPage(i + 1);
strip.append(b);
return b;
});
strip.addEventListener('wheel', e => {
if (S.stripPos === 'bottom' && Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
strip.scrollLeft += e.deltaY; e.preventDefault();
}
}, { passive: false });
io = new IntersectionObserver(entries => {
for (const en of entries) {
const i = +en.target.dataset.i;
if (en.isIntersecting) enqueue(i);
else if (S.thumbLoad === 'visible') { const q = queue.indexOf(i); if (q !== -1) queue.splice(q, 1); }
}
pump();
}, { root: strip, rootMargin: '300px' });
tiles.forEach(t => io.observe(t));
document.body.append(strip);
window.addEventListener('hashchange', () => markCurrent(true));
applyLayout();
markCurrent(false);
}
function enqueue(i) {
const st = tiles[i].dataset.state;
if (st || queue.includes(i)) return;
queue.push(i);
}
function enqueueAll() {
const c = curPage() - 1;
[...tiles.keys()].sort((a, b) => Math.abs(a - c) - Math.abs(b - c)).forEach(enqueue);
pump();
}
function pump() {
while (active < S.maxParallel && queue.length) {
const wait = lastStart + S.delayMs - Date.now();
if (wait > 0) {
if (!pumpTimer) pumpTimer = setTimeout(() => { pumpTimer = null; pump(); }, wait);
return;
}
lastStart = Date.now();
loadTile(queue.shift(), tnPick(), false);
}
}
function loadTile(i, pick, retried) {
const tile = tiles[i];
const img = tile.firstChild;
let url;
try { url = W.url_from_url_from_hash(gid, files[i], TN[pick], 'webp', 'tn'); }
catch { tile.dataset.state = 'err'; return; }
active++;
tile.dataset.state = 'loading';
img.onload = () => { active--; tile.dataset.state = 'done'; pump(); };
img.onerror = () => {
active--;
if (!retried) { loadTile(i, 1 - pick, true); return; }
tile.dataset.state = 'err';
pump();
};
img.src = url;
}
function markCurrent(smooth) {
if (!strip) return;
const t = tiles[curPage() - 1];
strip.querySelector('.hr-tile.cur')?.classList.remove('cur');
if (!t) return;
t.classList.add('cur');
const behavior = smooth && !matchMedia('(prefers-reduced-motion: reduce)').matches ? 'smooth' : 'auto';
if (S.stripPos === 'bottom') strip.scrollTo({ left: t.offsetLeft - strip.clientWidth / 2 + t.offsetWidth / 2, behavior });
else strip.scrollTo({ top: t.offsetTop - strip.clientHeight / 2 + t.offsetHeight / 2, behavior });
}
function applyLayout() {
const root = document.documentElement;
root.style.setProperty('--hr-size', S.thumbSize + 'px');
root.style.setProperty('--hr-zoom', S.zoom / 100);
syncZoomBox();
if (!isReader()) return;
root.classList.add('hr-reader');
root.classList.toggle('hr-rv-pin', wantReaderUi() && !S.readerAutoHide);
root.classList.remove('hr-pos-bottom', 'hr-pos-left', 'hr-pos-right');
document.getElementById('hr-stripbtn')?.setAttribute('aria-pressed', String(S.stripPos !== 'off' && !stripHidden));
if (!document.body) return;
const show = S.stripPos !== 'off' && !stripHidden;
if (!strip) { if (show && !stripInit) initStrip(); return; }
strip.hidden = !show;
if (show) root.classList.add('hr-pos-' + S.stripPos);
if (S.thumbLoad === 'all') enqueueAll();
markCurrent(false);
}
let dlg = null, blListEl = null, blHeadEl = null;
const DLG_CSS = `
:host { all: initial; }
* { box-sizing: border-box; }
dialog { padding: 0; border: 1px solid var(--hr-border); border-radius: 16px; background: var(--hr-bg); color: var(--hr-text);
width: min(480px, calc(100vw - 24px)); max-height: min(760px, calc(100vh - 24px)); overflow: hidden;
font: 14px/1.45 var(--hr-font); color-scheme: var(--hr-scheme); box-shadow: 0 24px 64px var(--hr-shadow); }
dialog[open] { display: flex; flex-direction: column; }
dialog::backdrop { background: rgba(6,6,14,.55); }
header { display: flex; align-items: center; gap: 12px; padding: 14px 14px 14px 18px; border-bottom: 1px solid var(--hr-border); }
header img { width: 36px; height: 36px; border-radius: 9px; flex: none; }
header h2 { margin: 0; font-size: 16px; font-weight: 700; line-height: 1.2; }
header .ver { display: block; font-size: 12px; color: var(--hr-muted); }
header .x { margin-left: auto; width: 32px; height: 32px; border: 0; border-radius: 8px; background: none;
color: var(--hr-muted); font: 20px/1 system-ui, sans-serif; cursor: pointer; }
header .x:hover { background: var(--hr-hover); color: var(--hr-text); }
.body { overflow: auto; padding: 4px 18px 18px; scrollbar-width: thin; scrollbar-color: var(--hr-border) transparent; }
section { margin-top: 18px; }
.sh { display: flex; align-items: baseline; justify-content: space-between; margin: 0 2px 8px; }
h3 { margin: 0; font-size: 13px; font-weight: 600; color: var(--hr-muted); }
.card { background: var(--hr-surface); border: 1px solid var(--hr-border); border-radius: 12px; }
.row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 16px; padding: 10px 14px; min-height: 50px; }
.row + .row, .row + .bl { border-top: 1px solid var(--hr-border); }
.row.off { opacity: .45; cursor: not-allowed; }
.sw:disabled { cursor: not-allowed; }
label.row { cursor: pointer; }
.name { display: block; }
.desc { display: block; font-size: 12px; color: var(--hr-muted); margin-top: 1px; }
.sw { appearance: none; -webkit-appearance: none; width: 38px; height: 22px; margin: 0; border-radius: 11px;
background: color-mix(in srgb, var(--hr-text) 22%, transparent); position: relative; cursor: pointer; transition: background .15s; }
.sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s; }
.sw:checked { background: var(--hr-accent); }
.sw:checked::after { transform: translateX(16px); }
.seg { display: inline-flex; gap: 2px; padding: 2px; background: var(--hr-bg); border: 1px solid var(--hr-border); border-radius: 9px; }
.seg button { border: 0; background: none; color: var(--hr-muted); font: inherit; font-size: 13px; padding: 4px 10px;
border-radius: 7px; cursor: pointer; min-width: 30px; }
.seg button:hover { color: var(--hr-text); background: var(--hr-hover); }
.seg button[aria-pressed="true"] { background: var(--hr-accent); color: #fff; }
.swatches { display: flex; gap: 10px; padding: 0 4px; }
.swatches button { width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%; background: var(--c); cursor: pointer;
box-shadow: 0 0 0 2px var(--hr-surface); }
.swatches button[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--hr-surface), 0 0 0 4px var(--c); }
.num { display: inline-flex; align-items: center; background: var(--hr-bg); border: 1px solid var(--hr-border); border-radius: 9px; padding-right: 10px; }
.num:focus-within { border-color: var(--hr-accent); }
.num input { width: 62px; background: none; border: 0; outline: none; color: inherit; font: inherit;
padding: 5px 4px 5px 10px; text-align: right; -moz-appearance: textfield; }
.num input::-webkit-inner-spin-button, .num input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.num span { color: var(--hr-muted); font-size: 12px; }
.range { display: flex; align-items: center; gap: 10px; }
.range input { width: 130px; margin: 0; accent-color: var(--hr-accent); cursor: pointer; }
.range output { min-width: 46px; text-align: right; color: var(--hr-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
.bl { padding: 12px 14px 14px; }
.add { display: flex; gap: 8px; }
.add input { flex: 1; min-width: 0; background: var(--hr-bg); border: 1px solid var(--hr-border); border-radius: 9px;
color: inherit; font: inherit; padding: 7px 10px; outline: none; }
.add input:focus { border-color: var(--hr-accent); }
.add input::placeholder { color: var(--hr-muted); opacity: .8; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; max-height: 168px; overflow: auto; }
.chip { display: inline-flex; align-items: center; gap: 2px; background: var(--hr-neg-soft); color: var(--hr-text);
border-radius: 999px; padding: 2px 3px 2px 10px; font-size: 13px; }
.chip button { border: 0; background: none; color: var(--hr-muted); cursor: pointer; width: 20px; height: 20px;
border-radius: 50%; font: 14px/1 system-ui, sans-serif; }
.chip button:hover { background: var(--hr-hover); color: var(--hr-text); }
.empty { color: var(--hr-muted); font-size: 13px; margin: 10px 0 0; }
.link { border: 0; background: none; color: var(--hr-neg); font: inherit; font-size: 12px; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
.link:hover { text-decoration: underline; }
.link[hidden] { display: none; }
dl.keys { display: grid; grid-template-columns: auto 1fr; gap: 9px 16px; margin: 0; padding: 12px 14px; font-size: 13px; align-items: center; }
.keys dt { display: flex; gap: 4px; flex-wrap: wrap; }
.keys dd { margin: 0; color: var(--hr-muted); }
kbd { font: 12px/1 var(--hr-font); background: var(--hr-bg); border: 1px solid var(--hr-border); border-bottom-width: 2px;
border-radius: 5px; padding: 3px 6px; color: var(--hr-text); }
.note { font-size: 12px; color: var(--hr-muted); margin: 12px 2px 0; }
footer { display: flex; justify-content: space-between; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--hr-border); background: var(--hr-surface); }
.btn { border: 1px solid var(--hr-border); background: transparent; color: var(--hr-text); font: 600 13px/1 var(--hr-font);
padding: 9px 16px; border-radius: 9px; cursor: pointer; }
.btn:hover { background: var(--hr-hover); }
.btn.primary { background: var(--hr-accent); border-color: var(--hr-accent); color: #fff; }
.btn.primary:hover { background: color-mix(in srgb, var(--hr-accent) 85%, #fff); }
:focus-visible { outline: 2px solid var(--hr-accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .sw, .sw::after { transition: none; } }
`;
function gearButton(id) {
const b = el('button', { id, type: 'button', title: 'Hitomi Revamp settings' }, el('img', { src: LOGO_URL, alt: '' }));
b.setAttribute('aria-label', 'Hitomi Revamp settings');
b.onclick = openSettings;
return b;
}
function renderBlacklist() {
if (blHeadEl) {
blHeadEl.firstChild.textContent = blacklist.length ? `Blacklist (${blacklist.length})` : 'Blacklist';
blHeadEl.lastChild.hidden = !blacklist.length;
}
if (!blListEl) return;
blListEl.textContent = '';
if (!blacklist.length) {
blListEl.append(el('p', { className: 'empty', textContent: 'Nothing blacklisted yet. Right-click any tag and pick "Blacklist everywhere", or type one above.' }));
return;
}
const chips = el('div', { className: 'chips' });
for (const t of blacklist) {
const x = el('button', { type: 'button', textContent: '×', title: 'Remove' });
x.setAttribute('aria-label', 'Remove ' + t);
x.onclick = () => blRemove(t);
chips.append(el('span', { className: 'chip' }, t, x));
}
blListEl.append(chips);
}
function openSettings() {
document.getElementById('hr-settings-host')?.remove();
const host = el('div', { id: 'hr-settings-host' });
const root = host.attachShadow({ mode: 'open' });
root.append(el('style', { textContent: DLG_CSS }));
const sw = (key, after) => {
const c = el('input', { type: 'checkbox', className: 'sw', checked: !!S[key] });
c.setAttribute('role', 'switch');
c.onchange = () => { S[key] = c.checked; changed(); after?.(); };
return c;
};
const seg = (key, opts, label) => {
const g = el('div', { className: 'seg' });
g.setAttribute('role', 'group');
g.setAttribute('aria-label', label);
for (const [v, text] of opts) {
const b = el('button', { type: 'button', textContent: text });
b.setAttribute('aria-pressed', String(String(S[key]) === v));
b.onclick = () => {
S[key] = typeof DEFAULTS[key] === 'number' ? +v : v;
g.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
changed();
};
g.append(b);
}
return g;
};
const num = (key, min, max, step, unit) => {
const n = el('input', { type: 'number', min, max, step, value: S[key] });
n.onchange = () => {
const v = Math.max(min, Math.min(max, Math.round(n.value === '' ? DEFAULTS[key] : +n.value)));
n.value = v; S[key] = v; changed();
};
return el('span', { className: 'num' }, n, el('span', { textContent: unit }));
};
const swatches = () => {
const g = el('div', { className: 'swatches' });
g.setAttribute('role', 'group');
g.setAttribute('aria-label', 'Accent color');
for (const [k, c] of Object.entries(ACCENTS)) {
const b = el('button', { type: 'button', title: k[0].toUpperCase() + k.slice(1) });
b.style.setProperty('--c', c);
b.setAttribute('aria-label', k);
b.setAttribute('aria-pressed', String(S.accent === k));
b.onclick = () => {
S.accent = k;
g.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
changed();
};
g.append(b);
}
return g;
};
const focusSw = sw('newTabFocus');
let focusRow = null;
const syncFocusRow = () => {
focusSw.disabled = !S.newTab;
focusRow?.classList.toggle('off', !S.newTab);
focusRow?.setAttribute('aria-disabled', String(!S.newTab));
};
const row = (name, ctrl, desc, tag = 'label') => el(tag, { className: 'row' },
el('span', {}, el('span', { className: 'name', textContent: name }),
...(desc ? [el('span', { className: 'desc', textContent: desc })] : [])),
ctrl);
focusRow = row('Switch to the new tab', focusSw, 'Off opens it in the background');
syncFocusRow();
const section = (title, ...kids) => el('section', {},
el('div', { className: 'sh' }, el('h3', { textContent: title })),
el('div', { className: 'card' }, ...kids));
const sizeOut = el('output', { textContent: S.thumbSize + ' px' });
const size = el('input', { type: 'range', min: 60, max: 260, step: 10, value: S.thumbSize });
size.setAttribute('aria-label', 'Thumbnail size');
size.oninput = () => { S.thumbSize = +size.value; sizeOut.textContent = size.value + ' px'; changed(); };
const blInput = el('input', { type: 'text', placeholder: 'female:big_breasts, artist:name, full_color' });
blInput.setAttribute('aria-label', 'Tags to blacklist, comma separated');
const addFromInput = () => {
const v = blInput.value.split(',').map(normTerm).filter(Boolean);
if (!v.length) return;
setBlacklist([...blacklist, ...v]);
blInput.value = '';
};
blInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addFromInput(); } });
const addBtn = el('button', { type: 'button', className: 'btn primary', textContent: 'Add' });
addBtn.onclick = addFromInput;
const clearBtn = el('button', { type: 'button', className: 'link', textContent: 'Clear all' });
clearBtn.onclick = () => { if (confirm(`Remove all ${blacklist.length} blacklisted tags?`)) setBlacklist([]); };
blHeadEl = el('div', { className: 'sh' }, el('h3'), clearBtn);
blListEl = el('div');
const keys = (combo, what) => [el('dt', {}, ...combo.map(k => el('kbd', { textContent: k }))), el('dd', { textContent: what })];
const close = el('button', { type: 'button', className: 'x', textContent: '×', title: 'Close' });
close.setAttribute('aria-label', 'Close settings');
const reset = el('button', { type: 'button', className: 'btn', textContent: 'Reset settings' });
reset.onclick = () => { Object.assign(S, DEFAULTS); changed(); openSettings(); };
const done = el('button', { type: 'button', className: 'btn primary', textContent: 'Done' });
dlg = el('dialog', {},
el('header', {},
el('img', { src: LOGO_URL, alt: '' }),
el('div', {}, el('h2', { textContent: 'Hitomi Revamp' }), el('span', { className: 'ver', textContent: VERSION ? 'Version ' + VERSION : 'Settings' })),
close),
el('div', { className: 'body' },
section('Appearance',
row('New UI', sw('newUi'), 'Redesigned lists, gallery pages and reader. An open reader switches on its next load'),
row('Theme', seg('theme', THEME_NAMES, 'Theme'), null, 'div'),
row('Accent', swatches(), null, 'div'),
row('Theme the classic pages', sw('classicTheme'), 'Recolors the site wherever New UI is off'),
row('Card size', seg('cardSize', [['s', 'S'], ['m', 'M'], ['l', 'L']], 'Card size'), null, 'div'),
row('Tags on cards', seg('cardTags', [['off', 'Off'], ['compact', 'Some'], ['all', 'All']], 'Tags on cards'), 'Some shows 8; the rest opens over the card', 'div')),
section('Browsing',
row('Open galleries in the reader', sw('openInReader'), 'Skip the details page'),
row('Open galleries in a new tab', sw('newTab', syncFocusRow), 'When you click a result'),
focusRow),
section('Reader',
row('Right arrow goes forward', sw('rightArrowIsNext'), 'Turn off for right-to-left reading'),
row('Click image halves to turn pages', sw('clickHalves')),
row('Esc leaves the reader', sw('escBack')),
row('Auto-hide the reader bar', sw('readerAutoHide'), 'Move the mouse to bring it back'),
row('Image size', num('zoom', 10, 300, 5, '%'), 'Share of the screen the page fills')),
section('Thumbnail strip',
row('Position', seg('stripPos', [['bottom', 'Bottom'], ['left', 'Left'], ['right', 'Right'], ['off', 'Off']], 'Strip position'), null, 'div'),
row('Thumbnail size', el('span', { className: 'range' }, size, sizeOut), null, 'div'),
row('Load thumbnails', seg('thumbLoad', [['visible', 'On screen'], ['all', 'All']], 'Load thumbnails'), null, 'div'),
row('Downloads at once', seg('maxParallel', [['1', '1'], ['2', '2'], ['3', '3'], ['4', '4']], 'Downloads at once'), null, 'div'),
row('Pause between downloads', num('delayMs', 0, 3000, 50, 'ms'))),
el('section', {}, blHeadEl,
el('div', { className: 'card' },
row('Blacklisted galleries', seg('blMode', [['hide', 'Hide'], ['dim', 'Dim']], 'Blacklisted galleries'), 'Dim blurs them until you hover', 'div'),
el('div', { className: 'bl' }, el('div', { className: 'add' }, blInput, addBtn), blListEl))),
section('Shortcuts',
el('dl', { className: 'keys' },
...keys(['←', '→'], 'Turn pages (A / D work too)'),
...keys(['Home', 'End'], 'First / last page'),
...keys(['T'], 'Hide the thumbnail strip'),
...keys(['W'], 'Fit width or height'),
...keys(['F'], 'Fullscreen'),
...keys(['+', '−', '0'], 'Image size'),
...keys(['/'], 'Jump to the search box'),
...keys(['Esc'], 'Leave the reader'),
...keys(['Shift', 'Right-click'], 'Normal browser menu'))),
el('p', { className: 'note', textContent: 'Right-click a tag to search or blacklist it. The search list belongs to this tab; tabs opened from here start with a copy. Button swaps apply on the next page load.' })),
el('footer', {}, reset, done));
close.onclick = done.onclick = () => dlg.close();
dlg.addEventListener('click', e => { if (e.target === dlg) dlg.close(); });
dlg.addEventListener('close', () => { host.remove(); dlg = blListEl = blHeadEl = null; });
root.append(dlg);
document.body.append(host);
renderBlacklist();
dlg.showModal();
}
function changed() { saveSettings(); applyTheme(); applyLayout(); syncUi(); scanCards(); readerActivity(); }
GM_registerMenuCommand('Settings', () => ready(openSettings));
ready(() => {
if (isReader()) {
patchReader();
const ul = document.querySelector('.navbar .nav.navbar-nav');
if (wantReaderUi()) {
buildReaderBar();
} else if (ul) {
addPageBox(ul);
addZoomBox(ul);
ul.append(el('li', { className: 'hr-nav-li' }, gearButton('hr-navgear')));
} else {
document.body.append(gearButton('hr-fab'));
}
} else {
const fab = gearButton('hr-fab');
fab.append(el('span', { className: 'hr-badge', hidden: true }));
document.body.append(fab);
if (wantUi()) {
buildApp();
if (!pageGalleryId) waitFor(() => document.querySelector('.gallery-content'), 4000).then(ok => { if (!ok) teardownApp(); });
}
new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
scanCards();
}
panel = el('div', { id: 'hr-panel' });
document.body.append(panel);
renderPanel();
applyLayout();
});
})();