E-Hentai Clean Reader

A modern, responsive, and customizable viewer for E-Hentai and ExHentai. Features include SPA-style navigation, advanced zooming, auto-hide cursor, and prefetching for a seamless reading experience.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         E-Hentai Clean Reader
// @namespace    https://github.com/bytecode-larper/
// @version      2.3.0
// @description  A modern, responsive, and customizable viewer for E-Hentai and ExHentai. Features include SPA-style navigation, advanced zooming, auto-hide cursor, and prefetching for a seamless reading experience.
// @author       bytecode-larper
// @icon         https://api.iconify.design/ph/book-open-bold.svg
// @match        https://e-hentai.org/s/*/*
// @match        https://exhentai.org/s/*/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// @license      MIT
// ==/UserScript==

// src/config.ts
const DEFAULT_KEYMAP = {
  next: ["D", "ARROWRIGHT"],
  prev: ["A", "ARROWLEFT"],
  fit: ["F"],
  gallery: ["Q"],
  up: ["W", "ARROWUP"],
  down: ["S", "ARROWDOWN"],
};
const DEFAULT_SETTINGS = {
  fitHeight: true,
  debug: true,
  scrollStep: 220,
  prefetchCount: 2,
  maxNlRetry: 4,
  imgCacheLimit: 20,
  keymap: DEFAULT_KEYMAP,
};
function loadSettings() {
  const settings = {
    ...DEFAULT_SETTINGS,
    fitHeight: GM_getValue("defaultFitHeight", DEFAULT_SETTINGS.fitHeight),
    debug: GM_getValue("debug", DEFAULT_SETTINGS.debug),
    keymap: GM_getValue("keymap", DEFAULT_SETTINGS.keymap),
  };
  if (!GM_getValue("keymap")) {
    GM_setValue("keymap", DEFAULT_KEYMAP);
  }
  return settings;
}
const SETTINGS = loadSettings();
const TAG = "[EH-Reader]";
const log = (...a) => SETTINGS.debug && console.log(TAG, ...a);
const warn = (...a) => SETTINGS.debug && console.warn(TAG, ...a);
function isKey(e, action) {
  const k = e.key.toUpperCase();
  return SETTINGS.keymap[action].includes(k);
}
function registerMenuCommands(onUpdate) {
  GM_registerMenuCommand(
    `Default Mode: ${SETTINGS.fitHeight ? "Fit-Height" : "Natural-Width"}`,
    () => {
      SETTINGS.fitHeight = !SETTINGS.fitHeight;
      GM_setValue("defaultFitHeight", SETTINGS.fitHeight);
      onUpdate(SETTINGS.fitHeight);
      registerMenuCommands(onUpdate);
    }
  );
  GM_registerMenuCommand(`Debug Mode: ${SETTINGS.debug ? "Enabled" : "Disabled"}`, () => {
    SETTINGS.debug = !SETTINGS.debug;
    GM_setValue("debug", SETTINGS.debug);
    location.reload();
  });
  GM_registerMenuCommand("Reset Keymap to Defaults", () => {
    if (confirm("Reset all keys to defaults (WASD/Arrows/F/Q)?")) {
      GM_setValue("keymap", DEFAULT_KEYMAP);
      location.reload();
    }
  });
}

// src/parser.ts
function parseViewerDoc(doc, viewerUrl) {
  const [, pageHash = "", galleryId = "", rawNum = "1"] =
    viewerUrl.match(/\/s\/([^/]+)\/(\d+)-(\d+)/) ?? [];
  const pageNum = parseInt(rawNum, 10);
  const imgEl = doc.querySelector("#i3 a img, iframe + a img, .sni > a img");
  const imgSrc = imgEl?.getAttribute("src") ?? "";
  const nlToken =
    doc
      .querySelector('a[onclick*="nl("]')
      ?.getAttribute("onclick")
      ?.match(/nl\((\d+)\)/)?.[1] ?? null;
  const anchors = [...doc.querySelectorAll('a[href*="/s/"]')];
  const hrefMatching = (n) =>
    anchors.find((a) => a.href.match(new RegExp(`-(${n})(\\?|$)`)))?.href ?? null;
  const nextHref = (() => {
    const byNum = hrefMatching(pageNum + 1);
    if (byNum) {
      return byNum;
    }
    const i3 = doc.querySelector("#i3 a")?.href;
    return i3 && i3 !== viewerUrl ? i3 : null;
  })();
  const prevHref =
    pageNum <= 1
      ? null
      : (hrefMatching(pageNum - 1) ??
        (pageHash && galleryId
          ? `https://${location.host}/s/${pageHash}/${galleryId}-${pageNum - 1}`
          : null));
  const counterText =
    [...doc.querySelectorAll("div, span, td")]
      .find((el) => /^\d+ \/ \d+$/.test(el.textContent?.trim() ?? ""))
      ?.textContent?.trim() ?? `${pageNum} / ?`;
  const fileInfo = (() => {
    for (const el of doc.querySelector("#i2")?.querySelectorAll("div, span") ?? []) {
      const t = (el.textContent ?? "").trim();
      if (/\d+ x \d+/.test(t) && t.includes("::")) {
        return (
          t.split(`
`)[0] ?? t
        ).trim();
      }
    }
    return "";
  })();
  const galleryHref = doc.querySelector('a[href*="/g/"]')?.href ?? "#";
  const galleryTitleElement = doc.querySelector("h1");
  const galleryTitle = galleryTitleElement?.textContent?.trim() ?? "Untitled";
  const parsedTitle = parseTitle(galleryTitle);
  log("parsedTitle", galleryTitle, parsedTitle);
  return {
    viewerUrl,
    pageNum,
    counterText,
    galleryTitle: parsedTitle,
    imgSrc,
    nextHref,
    prevHref,
    fileInfo,
    galleryHref,
    nlToken,
  };
}
function parseTitle(raw) {
  let text = raw.trim();
  const leading = [];
  const trailing = [];
  const langRegex =
    /\[(English|Japanese|Chinese|Korean|Thai|Vietnamese|French|German|Italian|Portuguese|Russian|Spanish)/i;
  while (true) {
    const match = text.match(/^(\([^)]+\)|\[[^\]]+\])\s*/);
    if (!match || !match[1]) {
      break;
    }
    const m = match[1];
    let type = "artist";
    if (m.startsWith("(")) {
      type = "event";
    } else if (m.toLowerCase().includes("anthology")) {
      type = "anthology";
    }
    leading.push({ text: m, type });
    text = text.slice(match[0].length).trim();
  }
  while (true) {
    const match = text.match(/\s*(\([^)]+\)|\[[^\]]+\])$/);
    if (!match || !match[1]) {
      break;
    }
    const m = match[1];
    let type = "tag";
    if (m.startsWith("(")) {
      type = "parody";
    } else if (langRegex.test(m)) {
      type = "lang";
    }
    trailing.unshift({ text: m, type });
    text = text.slice(0, -match[0].length).trim();
  }
  const parts = text.split(/\s*\|\s*/);
  return {
    leading,
    primary: parts[0] || "Untitled",
    secondary: parts[1] || null,
    trailing,
  };
}

// src/network.ts
const PAGE_CACHE_LIMIT = 40;
const pageCache = new Map();
const imgCache = new Map();
let preloadContainer = null;
function ensurePreloadContainer() {
  if (preloadContainer) {
    return;
  }
  preloadContainer = Object.assign(document.createElement("div"), {
    style: "position:absolute;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none",
  });
  document.body.appendChild(preloadContainer);
}
function preloadImage(src) {
  if (imgCache.has(src)) {
    return imgCache.get(src);
  }
  if (imgCache.size >= SETTINGS.imgCacheLimit) {
    const oldest = imgCache.keys().next().value;
    imgCache.get(oldest)?.remove();
    imgCache.delete(oldest);
    log("evicted preload", oldest);
  }
  ensurePreloadContainer();
  const img = document.createElement("img");
  img.onload = () => {
    img.decode().then(() => log("preload + decode done", src));
  };
  img.onerror = () => warn("preload error", src);
  img.src = src;
  preloadContainer.appendChild(img);
  imgCache.set(src, img);
  log("preloading", src);
  return img;
}
async function fetchViewerPage(viewerUrl) {
  if (pageCache.has(viewerUrl)) {
    log("viewer cache hit", viewerUrl);
    return pageCache.get(viewerUrl);
  }
  log("fetching viewer →", viewerUrl);
  const res = await fetch(viewerUrl, { credentials: "include" });
  const html = await res.text();
  const data = parseViewerDoc(new DOMParser().parseFromString(html, "text/html"), viewerUrl);
  if (pageCache.size >= PAGE_CACHE_LIMIT) {
    pageCache.delete(pageCache.keys().next().value);
  }
  pageCache.set(viewerUrl, data);
  log("cached viewer", viewerUrl, "| img:", data.imgSrc, "| nl:", data.nlToken);
  return data;
}
async function fetchNlRetry(pageData) {
  if (!pageData.nlToken) {
    return null;
  }
  const retryUrl = `${pageData.viewerUrl.split("?")[0]}?nl=${pageData.nlToken}`;
  log("nl retry →", retryUrl);
  try {
    const res = await fetch(retryUrl, { credentials: "include" });
    const newData = parseViewerDoc(
      new DOMParser().parseFromString(await res.text(), "text/html"),
      pageData.viewerUrl
    );
    pageCache.set(pageData.viewerUrl, newData);
    return newData;
  } catch (e) {
    warn("nl retry fetch failed", e);
    return null;
  }
}
async function prefetchDirection(data, getNext) {
  let cur = data;
  for (let i = 0; i < SETTINGS.prefetchCount; i++) {
    const href = getNext(cur);
    if (!href) {
      break;
    }
    const next = await fetchViewerPage(href).catch(() => null);
    if (!next) {
      break;
    }
    if (next.imgSrc) {
      preloadImage(next.imgSrc);
    }
    cur = next;
  }
}
function prefetchBoth(data) {
  prefetchDirection(data, (d) => d.nextHref).catch((e) => warn("prefetch forward error", e));
  prefetchDirection(data, (d) => d.prevHref).catch((e) => warn("prefetch backward error", e));
}

// src/shell.html
const shell_default = `<div id="reader">
  <span id="img-wrap"><img id="main-img" src="" alt="" /></span>
  <div id="hud">
    <a id="hud-gallery" href="" title="Back to gallery">&#8617;</a>
    <div class="hud-spacer"></div>
    <div id="hud-title"></div>
  </div>
  <div id="page-info">
    <div id="hud-toast"></div>
    <div id="hud-counter"></div>
    <div id="file-info"></div>
  </div>
</div>
`;

// src/style.css
const style_default = `*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
html,
body {
  background: #111;
  color: #ccc;
  font:
    13px/1 system-ui,
    sans-serif;
  height: 100%;
}

body.no-cursor, 
body.no-cursor * {
  cursor: none !important;
}

body.fit-h {
  overflow: hidden;
}
body.fit-h #reader {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}
body.fit-h #img-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
}
body.fit-h #main-img {
  max-height: 100vh;
  max-width: 100vw;
  width: auto;
  height: 100vh;
  object-fit: contain;
}

body.fit-w {
  overflow-y: auto;
  overflow-x: hidden;
}
body.fit-w #reader {
  position: relative;
  min-height: 100vh;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  width: 100%;
}
body.fit-w #img-wrap {
  display: block;
}
body.fit-w #main-img {
  display: block;
  max-width: 100vw;
  height: auto;
}

#reader {
  cursor: pointer;
}

#main-img {
  user-select: none;
  -webkit-user-drag: none;
  transition: transform 0.1s ease-out;
}
#main-img.no-transition {
  transition: none !important;
}

#hud-toast {
  font-size: 11px;
  color: #ffaa00;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
  pointer-events: none;
  opacity: 0;
  transform: translateY(5px);
  transition: all 0.2s ease-out;
}
#hud-toast.show {
  opacity: 1;
  transform: translateY(0);
}

#hud {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: flex-start;
  padding: 8px 14px;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
  opacity: 0;
  transition: opacity 0.2s;
  z-index: 10;
}
body:hover #hud {
  opacity: 1;
}
#hud-gallery {
  color: #aaa;
  text-decoration: none;
  font-size: 18px;
  line-height: 1.2;
}
#hud-gallery:hover {
  color: #fff;
}
.hud-spacer {
  flex: 1;
}
#hud-title {
  font-size: 11px;
  color: #777;
  text-align: right;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
  line-height: 1.35;
  font-family: "Segoe UI", Tahoma, sans-serif;
}
.title-main {
  margin: 2px 0;
}
.title-primary {
  font-size: 16px;
  color: #eee;
  font-weight: 600;
}
.title-secondary {
  font-size: 15px;
  color: #aaa;
  font-weight: 400;
}
.title-sep {
  color: #555;
  margin: 0 2px;
}
.meta-item {
  display: inline-block;
  margin-left: 4px;
}
.meta-artist {
  color: #5588aa;
  font-weight: 600;
}
.meta-artist span {
  color: #77aadd;
  font-weight: 400;
}
.meta-event {
  color: #aa8855;
}
.meta-anthology {
  color: #cc6699;
  font-weight: 600;
}
.meta-parody {
  color: #55aa66;
}
.meta-tag {
  color: #888;
}
.meta-lang {
  color: #8877aa;
  font-weight: 600;
}

#page-info {
  position: fixed;
  bottom: 12px;
  right: 14px;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 4px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s;
  z-index: 10;
}
body:hover #page-info {
  opacity: 1;
}

#hud-counter {
  font-size: 14px;
  color: #eee;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}

#file-info {
  font-size: 11px;
  color: rgba(255, 255, 255, 0.4);
  white-space: nowrap;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
`;

// src/ui.ts
function injectShell(initData) {
  document.body.innerHTML = shell_default.replace('href=""', `href="${initData.galleryHref}"`);
  GM_addStyle(style_default);
  const ui = {
    elImg: document.getElementById("main-img"),
    elTitle: document.getElementById("hud-title"),
    elCounter: document.getElementById("hud-counter"),
    elFileInfo: document.getElementById("file-info"),
    elGallery: document.getElementById("hud-gallery"),
  };
  const updateTitleWidth = () => {
    const imgWidth = ui.elImg.clientWidth;
    const viewportWidth = window.innerWidth;
    const gutter = (viewportWidth - imgWidth) / 2;
    ui.elTitle.style.maxWidth = `${Math.max(200, gutter - 30)}px`;
  };
  new ResizeObserver(updateTitleWidth).observe(document.body);
  new ResizeObserver(updateTitleWidth).observe(ui.elImg);
  return ui;
}
function applyMode(fitHeight) {
  document.body.classList.toggle("fit-h", fitHeight);
  document.body.classList.toggle("fit-w", !fitHeight);
}
let toastTimer = null;
function showToast(text) {
  const el = document.getElementById("hud-toast");
  if (!el) {
    return;
  }
  el.textContent = text;
  el.classList.add("show");
  if (toastTimer) {
    clearTimeout(toastTimer);
  }
  toastTimer = window.setTimeout(() => {
    el.classList.remove("show");
  }, 1200);
}
function displayImage(elImg, pageData, retryCount = 0) {
  elImg.onload = null;
  elImg.onerror = null;
  const preloaded = imgCache.get(pageData.imgSrc);
  if (preloaded?.complete && preloaded.naturalWidth > 0) {
    log("instant from cache", pageData.imgSrc);
    elImg.src = pageData.imgSrc;
    return;
  }
  elImg.onload = () => log("image displayed", pageData.imgSrc);
  elImg.onerror = async () => {
    warn(`image failed (attempt ${retryCount + 1})`, pageData.imgSrc);
    if (retryCount >= SETTINGS.maxNlRetry) {
      warn("giving up");
      return;
    }
    const newData = await fetchNlRetry(pageData);
    if (newData) {
      displayImage(elImg, newData, retryCount + 1);
    }
  };
  elImg.src = pageData.imgSrc;
}
function renderTitle(title) {
  const formatMeta = (m) => {
    let content = m.text;
    if (m.type === "artist") {
      content = content.replace(/\(([^)]+)\)/, "<span>($1)</span>");
    }
    return `<span class="meta-item meta-${m.type}">${content}</span>`;
  };
  const leading = title.leading.map(formatMeta).join("");
  const trailing = title.trailing.map(formatMeta).join("");
  const main = `<span class="title-primary">${title.primary}</span>`;
  const sub = title.secondary
    ? `<span class="title-sep"> | </span><span class="title-secondary">${title.secondary}</span>`
    : "";
  return `
    <div class="title-meta-wrap leading">${leading}</div>
    <div class="title-main">${main}${sub}</div>
    <div class="title-meta-wrap trailing">${trailing}</div>
  `.trim();
}
function renderPage(ui, data, fitHeight, isInitial = false) {
  ui.elTitle.innerHTML = renderTitle(data.galleryTitle);
  ui.elCounter.textContent = data.counterText;
  ui.elFileInfo.textContent = data.fileInfo;
  ui.elGallery.href = data.galleryHref;
  const reader = document.getElementById("reader");
  if (reader) {
    reader.dataset.prev = data.prevHref ?? "";
    reader.dataset.next = data.nextHref ?? "";
  }
  displayImage(ui.elImg, data);
  if (!isInitial) {
    history.pushState({ viewerUrl: data.viewerUrl }, "", data.viewerUrl);
  }
  if (!fitHeight) {
    window.scrollTo(0, 0);
  }
  log("rendered", data.counterText);
}

// src/zoom.ts
class ZoomController {
  container;
  img;
  getFitHeight;
  zoomLevel = 1;
  zoomSnapTimer = null;
  panX = 0;
  panY = 0;
  isDragging = false;
  startX = 0;
  startY = 0;
  hasMoved = false;
  constructor(container, img, getFitHeight) {
    this.container = container;
    this.img = img;
    this.getFitHeight = getFitHeight;
    this.initEvents();
  }
  initEvents() {
    this.container.addEventListener("mousedown", (e) => this.onMouseDown(e));
    window.addEventListener("mousemove", (e) => this.onMouseMove(e));
    window.addEventListener("mouseup", () => this.onMouseUp());
    window.addEventListener(
      "wheel",
      (e) => {
        if (e.ctrlKey) {
          e.preventDefault();
          this.updateZoom(e.deltaY < 0 ? 0.1 : -0.1, this.getFitHeight());
        }
      },
      { passive: false }
    );
  }
  updateTransform() {
    const imgWidth = this.img.clientWidth;
    const imgHeight = this.img.clientHeight;
    const scaledWidth = imgWidth * this.zoomLevel;
    const scaledHeight = imgHeight * this.zoomLevel;
    const limitX = Math.max(0, (scaledWidth - window.innerWidth) / 2);
    const limitY = Math.max(0, (scaledHeight - window.innerHeight) / 2);
    this.panX = Math.max(-limitX, Math.min(this.panX, limitX));
    this.panY = Math.max(-limitY, Math.min(this.panY, limitY));
    this.img.style.transform =
      this.zoomLevel === 1 && this.panX === 0 && this.panY === 0
        ? ""
        : `translate(${this.panX}px, ${this.panY}px) scale(${this.zoomLevel})`;
  }
  updateZoom(delta, isFitHeight) {
    if (delta !== null && !isFitHeight) {
      return;
    }
    if (delta === null) {
      this.reset();
    } else {
      this.zoomLevel = Math.max(0.7, Math.min(5, this.zoomLevel + delta));
      this.updateTransform();
      showToast(`ZOOM: ${Math.round(this.zoomLevel * 100)}%`);
    }
    if (this.zoomSnapTimer) {
      window.clearTimeout(this.zoomSnapTimer);
    }
    if (this.zoomLevel < 1) {
      this.zoomSnapTimer = window.setTimeout(() => {
        this.reset();
        showToast("ZOOM: 100%");
      }, 200);
    }
  }
  reset() {
    this.zoomLevel = 1;
    this.panX = 0;
    this.panY = 0;
    this.updateTransform();
  }
  onMouseDown(e) {
    if (e.target.closest("#hud, #page-info")) {
      return;
    }
    if (!this.getFitHeight()) {
      return;
    }
    e.preventDefault();
    if (this.zoomLevel > 1) {
      this.isDragging = true;
      this.hasMoved = false;
      this.startX = e.clientX - this.panX;
      this.startY = e.clientY - this.panY;
      this.container.style.cursor = "grabbing";
      this.img.classList.add("no-transition");
    }
  }
  onMouseMove(e) {
    if (this.isDragging) {
      const newPanX = e.clientX - this.startX;
      const newPanY = e.clientY - this.startY;
      if (Math.abs(newPanX - this.panX) > 2 || Math.abs(newPanY - this.panY) > 2) {
        this.hasMoved = true;
      }
      this.panX = newPanX;
      this.panY = newPanY;
      this.updateTransform();
    }
  }
  onMouseUp() {
    if (this.isDragging) {
      this.isDragging = false;
      this.container.style.cursor = "pointer";
      this.img.classList.remove("no-transition");
    }
  }
  handleKey(e, isFitHeight) {
    if (isFitHeight && this.zoomLevel > 1) {
      if (isKey(e, "up")) {
        this.panY += SETTINGS.scrollStep;
        this.updateTransform();
        return true;
      }
      if (isKey(e, "down")) {
        this.panY -= SETTINGS.scrollStep;
        this.updateTransform();
        return true;
      }
    }
    return false;
  }
  get isZoomed() {
    return this.zoomLevel > 1;
  }
  get wasPanned() {
    return this.hasMoved;
  }
}

// src/main.ts
document.documentElement.style.cssText = "visibility:hidden!important;background:#111!important";
let pendingNav = null;
let isNavigating = false;
let ui;
let zoom;
let currentFitHeight = SETTINGS.fitHeight;
async function navigateTo(url) {
  if (!url) {
    return;
  }
  pendingNav = url;
  if (isNavigating) {
    return;
  }
  while (pendingNav) {
    const target = pendingNav;
    pendingNav = null;
    isNavigating = true;
    try {
      const data = await fetchViewerPage(target);
      zoom?.reset();
      renderPage(ui, data, currentFitHeight);
      prefetchBoth(data);
    } catch (e) {
      warn("navigation failed", target, e);
    }
    isNavigating = false;
  }
}
function init() {
  log("init");
  const initData = parseViewerDoc(document, location.href);
  pageCache.set(location.href, initData);
  ui = injectShell(initData);
  zoom = new ZoomController(document.getElementById("reader"), ui.elImg, () => currentFitHeight);
  applyMode(currentFitHeight);
  renderPage(ui, initData, currentFitHeight, true);
  prefetchBoth(initData);
  registerMenuCommands((newFit) => {
    currentFitHeight = newFit;
    zoom.updateZoom(null, currentFitHeight);
    applyMode(currentFitHeight);
  });
  document.documentElement.style.cssText = "";
  log("SPA ready");
  window.addEventListener("popstate", (e) => {
    const url = e.state?.viewerUrl ?? location.href;
    const data = pageCache.get(url);
    if (data) {
      zoom.reset();
      renderPage(ui, data, currentFitHeight);
      prefetchBoth(data);
    } else {
      navigateTo(url);
    }
  });
  const reader = document.getElementById("reader");
  reader?.addEventListener("click", (e) => {
    if (e.target.closest("#hud, #page-info")) {
      return;
    }
    if (zoom.wasPanned) {
      return;
    }
    const x = e.clientX;
    const width = window.innerWidth;
    if (x < width * 0.4) {
      navigateTo(reader.dataset.prev);
    } else {
      navigateTo(reader.dataset.next);
    }
  });
  let mouseTimer = null;
  const hideCursor = () => document.body.classList.add("no-cursor");
  const showCursor = () => {
    document.body.classList.remove("no-cursor");
    if (mouseTimer) {
      window.clearTimeout(mouseTimer);
    }
    mouseTimer = window.setTimeout(hideCursor, 3000);
  };
  window.addEventListener("mousemove", showCursor);
  window.addEventListener("mousedown", showCursor);
  showCursor();
  document.addEventListener("keydown", (e) => {
    if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
      return;
    }
    if (e.ctrlKey) {
      const k = e.key.toUpperCase();
      if (k === "=" || k === "+" || e.key === "+") {
        e.preventDefault();
        zoom.updateZoom(0.1, currentFitHeight);
        return;
      }
      if (k === "-" || k === "_" || e.key === "-") {
        e.preventDefault();
        zoom.updateZoom(-0.1, currentFitHeight);
        return;
      }
      if (k === "0") {
        e.preventDefault();
        zoom.updateZoom(null, currentFitHeight);
        return;
      }
    }
    if (mouseTimer) {
      window.clearTimeout(mouseTimer);
    }
    mouseTimer = window.setTimeout(hideCursor, 3000);
    if (zoom.handleKey(e, currentFitHeight)) {
      return;
    }
    if (isKey(e, "fit")) {
      currentFitHeight = !currentFitHeight;
      zoom.updateZoom(null, currentFitHeight);
      applyMode(currentFitHeight);
      showToast(`MODE: ${currentFitHeight ? "FIT HEIGHT" : "NATURAL WIDTH"}`);
    } else if (isKey(e, "gallery")) {
      location.href = ui.elGallery.href;
    } else if (isKey(e, "up")) {
      if (!currentFitHeight) {
        e.preventDefault();
        window.scrollBy(0, -SETTINGS.scrollStep);
      }
    } else if (isKey(e, "down")) {
      if (!currentFitHeight) {
        e.preventDefault();
        window.scrollBy(0, SETTINGS.scrollStep);
      }
    } else if (isKey(e, "next")) {
      e.preventDefault();
      navigateTo(reader?.dataset.next);
    } else if (isKey(e, "prev")) {
      e.preventDefault();
      navigateTo(reader?.dataset.prev);
    }
  });
}
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
  init();
}