Magazine Comic Viewer Helper

A Tampermonkey script for specific comic sites that fits images to the viewport and enables precise image-by-image scrolling.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

You will need to install an extension such as Tampermonkey to install this script.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name            Magazine Comic Viewer Helper
// @name:ja         マガジン・コミック・ビューア・ヘルパー
// @author          kuchida1981
// @namespace       https://github.com/kuchida1981/comic-viewer-helper
// @version      1.3.0-unstable.5e7f525
// @description     A Tampermonkey script for specific comic sites that fits images to the viewport and enables precise image-by-image scrolling.
// @description:ja  特定の漫画サイトで画像をビューポートに合わせ、画像単位のスクロールを可能にするユーザースクリプトです。
// @license         ISC
// @match           https://something/magazine/*
// @match           https://something/fanzine/*
// @run-at          document-idle
// @grant           none
// ==/UserScript==

/**
 * ⚠️ DO NOT EDIT THIS FILE DIRECTLY ⚠️
 * This file is automatically generated by the build process.
 * Please edit files in the `src/` directory instead and run `npm run build`.
 */
(function() {
  "use strict";
  const STORAGE_KEYS = {
    DUAL_VIEW: "comic-viewer-helper-dual-view",
    GUI_POS: "comic-viewer-helper-gui-pos",
    ENABLED: "comic-viewer-helper-enabled"
  };
  class Store {
    constructor() {
      this.state = {
        enabled: localStorage.getItem(STORAGE_KEYS.ENABLED) !== "false",
        isDualViewEnabled: localStorage.getItem(STORAGE_KEYS.DUAL_VIEW) === "true",
        spreadOffset: 0,
        currentVisibleIndex: 0,
        guiPos: this._loadGuiPos(),
        metadata: {
          title: "",
          tags: [],
          relatedWorks: []
        },
        isMetadataModalOpen: false,
        isHelpModalOpen: false
      };
      this.listeners = [];
    }
    /**
     * @returns {StoreState}
     */
    getState() {
      return { ...this.state };
    }
    /**
     * @param {Partial<StoreState>} patch 
     */
    setState(patch) {
      let changed = false;
      for (const key in patch) {
        const k = (
          /** @type {keyof StoreState} */
          key
        );
        if (this.state[k] !== patch[k]) {
          this.state[k] = patch[k];
          changed = true;
        }
      }
      if (!changed) return;
      if ("enabled" in patch) {
        localStorage.setItem(STORAGE_KEYS.ENABLED, String(patch.enabled));
      }
      if ("isDualViewEnabled" in patch) {
        localStorage.setItem(STORAGE_KEYS.DUAL_VIEW, String(patch.isDualViewEnabled));
      }
      if ("guiPos" in patch) {
        localStorage.setItem(STORAGE_KEYS.GUI_POS, JSON.stringify(patch.guiPos));
      }
      this._notify();
    }
    /**
     * @param {Function} callback 
     */
    subscribe(callback) {
      this.listeners.push(callback);
      return () => {
        this.listeners = this.listeners.filter((l) => l !== callback);
      };
    }
    _notify() {
      this.listeners.forEach((callback) => callback(this.getState()));
    }
    _loadGuiPos() {
      try {
        const saved = localStorage.getItem(STORAGE_KEYS.GUI_POS);
        if (!saved) return null;
        const pos = JSON.parse(saved);
        const buffer = 50;
        if (pos.left < -buffer || pos.left > window.innerWidth + buffer || pos.top < -buffer || pos.top > window.innerHeight + buffer) {
          return null;
        }
        return pos;
      } catch {
        return null;
      }
    }
  }
  const CONTAINER_SELECTOR = "#post-comic";
  const DefaultAdapter = {
    // Always match as a fallback (should be checked last)
    match: () => true,
    getContainer: () => (
      /** @type {HTMLElement | null} */
      document.querySelector(CONTAINER_SELECTOR)
    ),
    getImages: () => (
      /** @type {HTMLImageElement[]} */
      Array.from(document.querySelectorAll(`${CONTAINER_SELECTOR} img`))
    ),
    getMetadata: () => {
      const title = document.querySelector("h1")?.textContent?.trim() || "Unknown Title";
      const tags = Array.from(document.querySelectorAll("#post-tag a")).map((a) => ({
        text: a.textContent?.trim() || "",
        href: (
          /** @type {HTMLAnchorElement} */
          a.href
        )
      }));
      const relatedWorks = Array.from(document.querySelectorAll(".post-list-image")).map((el) => {
        const anchor = el.closest("a");
        const img = el.querySelector("img");
        const titleEl = el.querySelector("span") || anchor?.querySelector("span");
        return {
          title: titleEl?.textContent?.trim() || "Untitled",
          href: anchor?.href || "",
          thumb: img?.src || ""
        };
      });
      return { title, tags, relatedWorks };
    }
  };
  function calculateVisibleHeight(rect, windowHeight) {
    const visibleTop = Math.max(0, rect.top);
    const visibleBottom = Math.min(windowHeight, rect.bottom);
    return Math.max(0, visibleBottom - visibleTop);
  }
  function shouldPairWithNext(current, next, isDualViewEnabled) {
    if (!isDualViewEnabled) return false;
    if (current.isLandscape) return false;
    if (!next) return false;
    if (next.isLandscape) return false;
    return true;
  }
  function getPrimaryVisibleImageIndex(imgs, windowHeight) {
    if (imgs.length === 0) return -1;
    let maxVisibleHeight = 0;
    let minDistanceToCenter = Infinity;
    let primaryIndex = -1;
    const viewportCenter = windowHeight / 2;
    imgs.forEach((img, index) => {
      const rect = img.getBoundingClientRect();
      const visibleHeight = calculateVisibleHeight(rect, windowHeight);
      if (visibleHeight > 0) {
        const elementCenter = (rect.top + rect.bottom) / 2;
        const distanceToCenter = Math.abs(viewportCenter - elementCenter);
        if (visibleHeight > maxVisibleHeight || visibleHeight === maxVisibleHeight && distanceToCenter < minDistanceToCenter) {
          maxVisibleHeight = visibleHeight;
          minDistanceToCenter = distanceToCenter;
          primaryIndex = index;
        }
      }
    });
    return primaryIndex;
  }
  function getImageElementByIndex(imgs, index) {
    if (index < 0 || index >= imgs.length) return null;
    return imgs[index];
  }
  function cleanupDOM(container) {
    const allImages = (
      /** @type {HTMLImageElement[]} */
      Array.from(container.querySelectorAll("img"))
    );
    const wrappers = container.querySelectorAll(".comic-row-wrapper");
    wrappers.forEach((w) => w.remove());
    allImages.forEach((img) => {
      img.style.cssText = "";
    });
    return allImages;
  }
  function fitImagesToViewport(container, spreadOffset = 0, isDualViewEnabled = false) {
    if (!container) return;
    const allImages = cleanupDOM(container);
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    Object.assign(container.style, {
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      padding: "0",
      margin: "0",
      width: "100%",
      maxWidth: "none"
    });
    for (let i = 0; i < allImages.length; i++) {
      const img = allImages[i];
      const isLandscape = img.naturalWidth > img.naturalHeight;
      let pairWithNext = false;
      const effectiveIndex = i - spreadOffset;
      const isPairingPosition = effectiveIndex >= 0 && effectiveIndex % 2 === 0;
      if (isDualViewEnabled && isPairingPosition && i + 1 < allImages.length) {
        const nextImg = allImages[i + 1];
        const nextIsLandscape = nextImg.naturalWidth > nextImg.naturalHeight;
        if (shouldPairWithNext({ isLandscape }, { isLandscape: nextIsLandscape }, isDualViewEnabled)) {
          pairWithNext = true;
        }
      }
      const row = document.createElement("div");
      row.className = "comic-row-wrapper";
      Object.assign(row.style, {
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        width: "100vw",
        maxWidth: "100vw",
        marginLeft: "calc(50% - 50vw)",
        marginRight: "calc(50% - 50vw)",
        height: "100vh",
        marginBottom: "0",
        position: "relative",
        boxSizing: "border-box"
      });
      if (pairWithNext) {
        const nextImg = allImages[i + 1];
        row.style.flexDirection = "row-reverse";
        [img, nextImg].forEach((im) => {
          Object.assign(im.style, {
            maxWidth: "50%",
            maxHeight: "100%",
            width: "auto",
            height: "auto",
            objectFit: "contain",
            margin: "0",
            display: "block"
          });
        });
        row.appendChild(img);
        row.appendChild(nextImg);
        container.appendChild(row);
        i++;
      } else {
        Object.assign(img.style, {
          maxWidth: `${vw}px`,
          maxHeight: `${vh}px`,
          width: "auto",
          height: "auto",
          display: "block",
          margin: "0 auto",
          flexShrink: "0",
          objectFit: "contain"
        });
        row.appendChild(img);
        container.appendChild(row);
      }
    }
  }
  function revertToOriginal(originalImages, container) {
    if (!container) return;
    container.style.cssText = "";
    originalImages.forEach((img) => {
      img.style.cssText = "";
      container.appendChild(img);
    });
    const wrappers = container.querySelectorAll(".comic-row-wrapper");
    wrappers.forEach((w) => w.remove());
  }
  function getNavigationDirection(event, threshold = 50) {
    if (Math.abs(event.deltaY) < threshold) {
      return "none";
    }
    return event.deltaY > 0 ? "next" : "prev";
  }
  class Navigator {
    /**
     * @param {import('../global').SiteAdapter} adapter 
     * @param {import('../store.js').Store} store 
     */
    constructor(adapter, store) {
      this.adapter = adapter;
      this.store = store;
      this.originalImages = [];
      this.getImages = this.getImages.bind(this);
      this.jumpToPage = this.jumpToPage.bind(this);
      this.scrollToImage = this.scrollToImage.bind(this);
      this.scrollToEdge = this.scrollToEdge.bind(this);
      this.applyLayout = this.applyLayout.bind(this);
      this.updatePageCounter = this.updatePageCounter.bind(this);
      this.init = this.init.bind(this);
      this._lastEnabled = void 0;
      this._lastDualView = void 0;
      this._lastSpreadOffset = void 0;
    }
    init() {
      this.store.subscribe((state) => {
        const layoutChanged = state.enabled !== this._lastEnabled || state.isDualViewEnabled !== this._lastDualView || state.spreadOffset !== this._lastSpreadOffset;
        if (layoutChanged) {
          this.applyLayout();
          this._lastEnabled = state.enabled;
          this._lastDualView = state.isDualViewEnabled;
          this._lastSpreadOffset = state.spreadOffset;
        }
      });
      const initialState = this.store.getState();
      this._lastEnabled = initialState.enabled;
      this._lastDualView = initialState.isDualViewEnabled;
      this._lastSpreadOffset = initialState.spreadOffset;
      const imgs = this.getImages();
      imgs.forEach((img) => {
        if (!img.complete) {
          img.addEventListener("load", () => {
            requestAnimationFrame(() => this.applyLayout());
          });
        }
      });
      if (initialState.enabled) {
        this.applyLayout();
      }
    }
    /**
     * @returns {HTMLImageElement[]}
     */
    getImages() {
      if (this.originalImages.length > 0) return this.originalImages;
      this.originalImages = this.adapter.getImages();
      return this.originalImages;
    }
    updatePageCounter() {
      const state = this.store.getState();
      const { enabled } = state;
      if (!enabled) return;
      const imgs = this.getImages();
      const currentIndex = getPrimaryVisibleImageIndex(imgs, window.innerHeight);
      if (currentIndex !== -1) {
        this.store.setState({ currentVisibleIndex: currentIndex });
      }
    }
    /**
     * @param {string | number} pageNumber 
     * @returns {boolean}
     */
    jumpToPage(pageNumber) {
      const imgs = this.getImages();
      const index = typeof pageNumber === "string" ? parseInt(pageNumber, 10) - 1 : pageNumber - 1;
      const targetImg = getImageElementByIndex(imgs, index);
      if (targetImg) {
        targetImg.scrollIntoView({ behavior: "smooth", block: "center" });
        return true;
      } else {
        this.updatePageCounter();
        return false;
      }
    }
    /**
     * @param {number} direction 
     */
    scrollToImage(direction) {
      const imgs = this.getImages();
      if (imgs.length === 0) return;
      const { isDualViewEnabled } = this.store.getState();
      const currentIndex = getPrimaryVisibleImageIndex(imgs, window.innerHeight);
      let targetIndex = currentIndex + direction;
      if (targetIndex < 0) targetIndex = 0;
      if (targetIndex >= imgs.length) targetIndex = imgs.length - 1;
      const prospectiveTargetImg = imgs[targetIndex];
      if (isDualViewEnabled && direction !== 0 && currentIndex !== -1) {
        const currentImg = imgs[currentIndex];
        if (currentImg && prospectiveTargetImg && prospectiveTargetImg.parentElement === currentImg.parentElement && prospectiveTargetImg.parentElement?.classList.contains("comic-row-wrapper")) {
          targetIndex += direction;
        }
      }
      const finalIndex = Math.max(0, Math.min(targetIndex, imgs.length - 1));
      const finalTarget = imgs[finalIndex];
      if (finalTarget) {
        finalTarget.scrollIntoView({ behavior: "smooth", block: "center" });
      }
    }
    /**
     * @param {'start' | 'end'} position 
     */
    scrollToEdge(position) {
      const imgs = this.getImages();
      if (imgs.length === 0) return;
      const target = position === "start" ? imgs[0] : imgs[imgs.length - 1];
      target.scrollIntoView({ behavior: "smooth", block: "center" });
    }
    /**
     * @param {number} [forcedIndex] 
     */
    applyLayout(forcedIndex) {
      const { enabled, isDualViewEnabled, spreadOffset } = this.store.getState();
      const container = this.adapter.getContainer();
      if (!container) return;
      if (!enabled) {
        revertToOriginal(this.getImages(), container);
        return;
      }
      const imgs = this.getImages();
      const currentIndex = forcedIndex !== void 0 ? forcedIndex : getPrimaryVisibleImageIndex(imgs, window.innerHeight);
      fitImagesToViewport(container, spreadOffset, isDualViewEnabled);
      this.updatePageCounter();
      if (currentIndex !== -1) {
        const targetImg = imgs[currentIndex];
        if (targetImg) targetImg.scrollIntoView({ block: "center" });
      }
    }
  }
  const styles = `
  #comic-helper-ui {
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 10000;
    display: flex;
    gap: 8px;
    background-color: rgba(0, 0, 0, 0.7);
    padding: 8px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
    cursor: move;
    user-select: none;
    touch-action: none;
    align-items: center;
    white-space: nowrap;
    width: max-content;
    opacity: 0.3;
    transition: opacity 0.3s;
  }

  #comic-helper-ui:hover {
    opacity: 1.0;
  }

  .comic-helper-button {
    cursor: pointer;
    padding: 6px 12px;
    border: none;
    background: #fff;
    color: #333;
    border-radius: 4px;
    font-size: 12px;
    font-weight: bold;
    min-width: 50px;
    transition: background 0.2s;
  }
  .comic-helper-button:hover {
    background: #eee;
  }

  .comic-helper-power-btn {
    cursor: pointer;
    border: none;
    background: transparent;
    font-size: 16px;
    padding: 0 4px;
    font-weight: bold;
    transition: color 0.2s;
  }
  .comic-helper-power-btn.enabled { color: #4CAF50; }
  .comic-helper-power-btn.disabled { color: #888; }

  .comic-helper-counter-wrapper {
    color: #fff;
    font-size: 14px;
    font-weight: bold;
    padding: 0 8px;
    display: flex;
    align-items: center;
    user-select: none;
  }

  .comic-helper-page-input {
    width: 45px;
    background: transparent;
    border: 1px solid transparent;
    color: #fff;
    font-size: 14px;
    font-weight: bold;
    text-align: right;
    padding: 2px;
    outline: none;
    margin: 0;
    transition: border 0.2s, background 0.2s;
  }
  .comic-helper-page-input:focus {
    border: 1px solid #fff;
    background: rgba(255, 255, 255, 0.1);
  }
  /* Hide spin buttons */
  .comic-helper-page-input::-webkit-outer-spin-button,
  .comic-helper-page-input::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
  }
  .comic-helper-page-input[type=number] {
    -moz-appearance: textfield;
  }

  .comic-helper-label {
    display: flex;
    align-items: center;
    color: #fff;
    font-size: 12px;
    cursor: pointer;
    user-select: none;
    margin-right: 8px;
  }
  .comic-helper-label input {
    margin-right: 4px;
  }

  .comic-helper-adjust-btn {
    cursor: pointer;
    padding: 2px 6px;
    border: 1px solid #fff;
    background: transparent;
    color: #fff;
    border-radius: 4px;
    font-size: 10px;
    margin-left: 4px;
    font-weight: normal;
    transition: background 0.2s;
  }
  .comic-helper-adjust-btn:hover {
    background: rgba(255, 255, 255, 0.2);
  }

  /* Metadata Modal Styles */
  .comic-helper-modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    background: rgba(0, 0, 0, 0.6);
    backdrop-filter: blur(4px);
    z-index: 20000;
    display: flex;
    justify-content: center;
    align-items: center;
  }

  .comic-helper-modal-content {
    background: #1a1a1a;
    color: #eee;
    width: 80%;
    max-width: 800px;
    max-height: 80%;
    padding: 24px;
    border-radius: 12px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
    overflow-y: auto;
    position: relative;
    border: 1px solid #333;
  }

  .comic-helper-modal-close {
    position: absolute;
    top: 16px;
    right: 16px;
    background: transparent;
    border: none;
    color: #888;
    font-size: 24px;
    cursor: pointer;
    line-height: 1;
  }
  .comic-helper-modal-close:hover {
    color: #fff;
  }

  .comic-helper-modal-title {
    margin-top: 0;
    margin-bottom: 20px;
    font-size: 20px;
    border-bottom: 1px solid #333;
    padding-bottom: 10px;
  }

  .comic-helper-section-title {
    font-size: 14px;
    color: #888;
    margin: 20px 0 10px;
    text-transform: uppercase;
    letter-spacing: 1px;
  }

  .comic-helper-tag-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
  }

  .comic-helper-tag-chip {
    background: #333;
    color: #ccc;
    padding: 4px 12px;
    border-radius: 16px;
    font-size: 12px;
    text-decoration: none;
    transition: background 0.2s, color 0.2s;
  }
  .comic-helper-tag-chip:hover {
    background: #444;
    color: #fff;
  }

  .comic-helper-related-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
    gap: 16px;
    margin-top: 10px;
  }

  .comic-helper-related-item {
    text-decoration: none;
    color: #ccc;
    font-size: 11px;
    transition: transform 0.2s;
  }
  .comic-helper-related-item:hover {
    transform: translateY(-4px);
  }

  .comic-helper-related-thumb {
    width: 100%;
    aspect-ratio: 3 / 4;
    object-fit: cover;
    border-radius: 4px;
    background: #222;
    margin-bottom: 6px;
  }

  .comic-helper-related-title {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    line-height: 1.4;
  }

  /* Help Modal Styles */
  .comic-helper-shortcut-list {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }

  .comic-helper-shortcut-row {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 8px 0;
    border-bottom: 1px solid #222;
  }

  .comic-helper-shortcut-keys {
    display: flex;
    gap: 4px;
    flex-wrap: wrap;
    max-width: 40%;
  }

  .comic-helper-kbd {
    background: #444;
    border: 1px solid #555;
    border-radius: 4px;
    box-shadow: 0 1px 0 rgba(0,0,0,0.2), 0 0 0 2px #333 inset;
    color: #eee;
    display: inline-block;
    font-size: 11px;
    font-family: monospace;
    line-height: 1.4;
    margin: 0 2px;
    padding: 2px 6px;
    white-space: nowrap;
  }

  .comic-helper-shortcut-label {
    color: #eee;
    font-size: 13px;
    font-weight: bold;
    flex: 1;
    margin: 0 12px;
  }

  .comic-helper-shortcut-desc {
    color: #bbb;
    font-size: 13px;
    flex: 1;
  }
`;
  function injectStyles() {
    const id = "comic-helper-style";
    if (document.getElementById(id)) return;
    const style = document.createElement("style");
    style.id = id;
    style.textContent = styles;
    document.head.appendChild(style);
  }
  function createElement(tag, options = {}, children = []) {
    const el = document.createElement(tag);
    if (options.id) el.id = options.id;
    if (options.className) el.className = options.className;
    if (options.textContent) el.textContent = options.textContent;
    if (options.title) el.title = options.title;
    if (el instanceof HTMLInputElement) {
      if (options.type) el.type = options.type;
      if (options.checked !== void 0) el.checked = options.checked;
    }
    if (options.style) {
      Object.assign(el.style, options.style);
    }
    if (options.attributes) {
      for (const [key, value] of Object.entries(options.attributes)) {
        el.setAttribute(key, String(value));
      }
    }
    if (options.events) {
      for (const [type, listener] of Object.entries(options.events)) {
        el.addEventListener(type, listener);
      }
    }
    children.forEach((child) => {
      if (typeof child === "string") {
        el.appendChild(document.createTextNode(child));
      } else if (child instanceof HTMLElement) {
        el.appendChild(child);
      }
    });
    return el;
  }
  const MESSAGES = {
    en: {
      ui: {
        spread: "Spread",
        offset: "Offset",
        info: "Info",
        help: "Help",
        close: "Close",
        tags: "Tags",
        related: "Related Works",
        version: "Version",
        stable: "stable",
        unstable: "unstable",
        keyboardShortcuts: "Keyboard Shortcuts",
        goFirst: "Go to First",
        goPrev: "Go to Previous",
        goNext: "Go to Next",
        goLast: "Go to Last",
        showMetadata: "Show Metadata",
        showHelp: "Show Help",
        shiftOffset: "Shift spread pairing by 1 page (Offset)",
        space: "Space",
        enable: "Enable Comic Viewer Helper",
        disable: "Disable Comic Viewer Helper"
      },
      shortcuts: {
        nextPage: { label: "Next Page", desc: "Move to next page" },
        prevPage: { label: "Prev Page", desc: "Move to previous page" },
        dualView: { label: "Dual View", desc: "Toggle Dual View" },
        spreadOffset: { label: "Spread Offset", desc: "Toggle Offset (0 ↔ 1)", cond: "Dual View only" },
        metadata: { label: "Metadata", desc: "Show metadata" },
        help: { label: "Help", desc: "Show this help" },
        closeModal: { label: "Close Modal", desc: "Close modal" }
      }
    },
    ja: {
      ui: {
        spread: "見開き",
        offset: "オフセット",
        info: "作品情報",
        help: "ヘルプ",
        close: "閉じる",
        tags: "タグ",
        related: "関連作品",
        version: "バージョン",
        stable: "安定版",
        unstable: "開発版",
        keyboardShortcuts: "キーボードショートカット",
        goFirst: "最初へ",
        goPrev: "前へ",
        goNext: "次へ",
        goLast: "最後へ",
        showMetadata: "作品情報を表示",
        showHelp: "ヘルプを表示",
        shiftOffset: "見開きペアを1ページ分ずらす(オフセット)",
        space: "スペース",
        enable: "スクリプトを有効にする",
        disable: "スクリプトを無効にする"
      },
      shortcuts: {
        nextPage: { label: "次ページ", desc: "次のページへ移動" },
        prevPage: { label: "前ページ", desc: "前のページへ移動" },
        dualView: { label: "見開き", desc: "見開きモードのON/OFF" },
        spreadOffset: { label: "見開きオフセット", desc: "見開きオフセットの切替 (0 ↔ 1)", cond: "見開きモード中のみ" },
        metadata: { label: "作品情報", desc: "作品情報(メタデータ)の表示" },
        help: { label: "ヘルプ", desc: "このヘルプの表示" },
        closeModal: { label: "閉じる", desc: "モーダルを閉じる" }
      }
    }
  };
  const getLanguage = () => {
    const lang = (navigator.language || "en").split("-")[0];
    return MESSAGES[lang] ? lang : "en";
  };
  const currentLang = getLanguage();
  function t(path) {
    const keys = path.split(".");
    let result = MESSAGES[currentLang];
    let fallback = MESSAGES["en"];
    for (const key of keys) {
      result = result ? result[key] : void 0;
      fallback = fallback ? fallback[key] : void 0;
    }
    return result ?? fallback ?? path;
  }
  function createPowerButton({ isEnabled, onClick }) {
    const el = createElement("button", {
      className: `comic-helper-power-btn ${isEnabled ? "enabled" : "disabled"}`,
      title: isEnabled ? t("ui.disable") : t("ui.enable"),
      textContent: "⚡",
      style: {
        marginRight: isEnabled ? "8px" : "0"
      },
      events: {
        click: (e) => {
          e.preventDefault();
          e.stopPropagation();
          onClick();
        }
      }
    });
    return {
      el,
      /** @param {boolean} enabled */
      update: (enabled) => {
        el.className = `comic-helper-power-btn ${enabled ? "enabled" : "disabled"}`;
        el.title = enabled ? t("ui.disable") : t("ui.enable");
        el.style.marginRight = enabled ? "8px" : "0";
      }
    };
  }
  function createPageCounter({ current, total, onJump }) {
    const input = (
      /** @type {HTMLInputElement} */
      createElement("input", {
        type: "number",
        className: "comic-helper-page-input",
        attributes: { min: 1 },
        events: {
          keydown: (e) => {
            if (e instanceof KeyboardEvent && e.key === "Enter") {
              e.preventDefault();
              onJump(input.value);
            }
          },
          focus: () => {
            input.select();
          }
        }
      })
    );
    input.value = String(current);
    const totalLabel = createElement("span", {
      id: "comic-total-counter",
      textContent: ` / ${total}`
    });
    const el = createElement("span", {
      className: "comic-helper-counter-wrapper"
    }, [input, totalLabel]);
    return {
      el,
      input,
      /** 
       * @param {number} current 
       * @param {number} total 
       */
      update: (current2, total2) => {
        if (document.activeElement !== input) {
          input.value = String(current2);
        }
        totalLabel.textContent = ` / ${total2}`;
      }
    };
  }
  function createSpreadControls({ isDualViewEnabled, onToggle, onAdjust }) {
    const checkbox = (
      /** @type {HTMLInputElement} */
      createElement("input", {
        type: "checkbox",
        checked: isDualViewEnabled,
        events: {
          change: (e) => {
            if (e.target instanceof HTMLInputElement) {
              onToggle(e.target.checked);
              e.target.blur();
            }
          }
        }
      })
    );
    const label = createElement("label", {
      className: "comic-helper-label"
    }, [checkbox, "Spread"]);
    const createAdjustBtn = () => createElement("button", {
      className: "comic-helper-adjust-btn",
      textContent: "Offset",
      title: t("ui.shiftOffset"),
      events: {
        click: (e) => {
          e.preventDefault();
          e.stopPropagation();
          onAdjust();
        }
      }
    });
    let adjustBtn = isDualViewEnabled ? createAdjustBtn() : null;
    const el = createElement("div", {
      style: { display: "flex", alignItems: "center" }
    }, [label]);
    if (adjustBtn) el.appendChild(adjustBtn);
    return {
      el,
      /** @param {boolean} enabled */
      update: (enabled) => {
        checkbox.checked = enabled;
        if (enabled) {
          if (!adjustBtn) {
            adjustBtn = createAdjustBtn();
            el.appendChild(adjustBtn);
          }
        } else {
          if (adjustBtn) {
            adjustBtn.remove();
            adjustBtn = null;
          }
        }
      }
    };
  }
  function createNavigationButtons({ onFirst, onPrev, onNext, onLast, onInfo, onHelp }) {
    const configs = [
      { text: "<<", title: t("ui.goFirst"), action: onFirst },
      { text: "<", title: t("ui.goPrev"), action: onPrev },
      { text: ">", title: t("ui.goNext"), action: onNext },
      { text: ">>", title: t("ui.goLast"), action: onLast },
      { text: "Info", title: t("ui.showMetadata"), action: onInfo },
      { text: "?", title: t("ui.showHelp"), action: onHelp }
    ];
    const elements = configs.map((cfg) => createElement("button", {
      className: "comic-helper-button",
      textContent: cfg.text,
      title: cfg.title,
      events: {
        click: (e) => {
          e.preventDefault();
          e.stopPropagation();
          cfg.action();
          if (e.target instanceof HTMLElement) e.target.blur();
        }
      }
    }));
    return {
      elements,
      update: () => {
      }
      // No dynamic state for these buttons yet
    };
  }
  function createMetadataModal({ metadata, onClose }) {
    const { title, tags, relatedWorks } = metadata;
    const closeBtn = createElement("button", {
      className: "comic-helper-modal-close",
      textContent: "×",
      title: t("ui.close"),
      events: {
        click: (e) => {
          e.preventDefault();
          onClose();
        }
      }
    });
    const titleEl = createElement("h2", {
      className: "comic-helper-modal-title",
      textContent: title
    });
    const tagChips = tags.map((tag) => createElement("a", {
      className: "comic-helper-tag-chip",
      textContent: tag.text,
      attributes: { href: tag.href, target: "_blank" },
      events: {
        click: (e) => e.stopPropagation()
      }
    }));
    const tagSection = createElement("div", {}, [
      createElement("div", { className: "comic-helper-section-title", textContent: t("ui.tags") }),
      createElement("div", { className: "comic-helper-tag-list" }, tagChips)
    ]);
    const relatedItems = relatedWorks.map((work) => {
      const thumb = createElement("img", {
        className: "comic-helper-related-thumb",
        attributes: { src: work.thumb, loading: "lazy" }
      });
      const workTitle = createElement("div", {
        className: "comic-helper-related-title",
        textContent: work.title
      });
      return createElement("a", {
        className: "comic-helper-related-item",
        attributes: { href: work.href, target: "_blank" },
        events: {
          click: (e) => e.stopPropagation()
        }
      }, [thumb, workTitle]);
    });
    const relatedSection = createElement("div", {}, [
      createElement("div", { className: "comic-helper-section-title", textContent: t("ui.related") }),
      createElement("div", { className: "comic-helper-related-grid" }, relatedItems)
    ]);
    const versionTag = createElement("div", {
      className: "comic-helper-modal-version",
      style: {
        fontSize: "11px",
        color: "#888",
        marginTop: "15px",
        textAlign: "right",
        borderTop: "1px solid #eee",
        paddingTop: "5px"
      },
      textContent: `${t("ui.version")}: v${"1.3.0-unstable.5e7f525"} (${t("ui.unstable")})`
    });
    const content = createElement("div", {
      className: "comic-helper-modal-content",
      events: {
        click: (e) => e.stopPropagation()
      }
    }, [closeBtn, titleEl, tagSection, relatedSection, versionTag]);
    const overlay = createElement("div", {
      className: "comic-helper-modal-overlay",
      events: {
        click: (e) => {
          e.preventDefault();
          onClose();
        }
      }
    }, [content]);
    return {
      el: overlay,
      update: () => {
      }
      // No dynamic update needed once opened
    };
  }
  const SHORTCUTS = [
    {
      id: "nextPage",
      label: t("shortcuts.nextPage.label"),
      keys: ["j", "ArrowDown", "PageDown", "ArrowRight", "Space"],
      description: t("shortcuts.nextPage.desc")
    },
    {
      id: "prevPage",
      label: t("shortcuts.prevPage.label"),
      keys: ["k", "ArrowUp", "PageUp", "ArrowLeft", "Shift+Space"],
      description: t("shortcuts.prevPage.desc")
    },
    {
      id: "dualView",
      label: t("shortcuts.dualView.label"),
      keys: ["d"],
      description: t("shortcuts.dualView.desc")
    },
    {
      id: "spreadOffset",
      label: t("shortcuts.spreadOffset.label"),
      keys: ["o"],
      description: t("shortcuts.spreadOffset.desc"),
      condition: t("shortcuts.spreadOffset.cond")
    },
    {
      id: "metadata",
      label: t("shortcuts.metadata.label"),
      keys: ["i"],
      description: t("shortcuts.metadata.desc")
    },
    {
      id: "help",
      label: t("shortcuts.help.label"),
      keys: ["?"],
      description: t("shortcuts.help.desc")
    },
    {
      id: "closeModal",
      label: t("shortcuts.closeModal.label"),
      keys: ["Escape"],
      description: t("shortcuts.closeModal.desc")
    }
  ];
  function createHelpModal({ onClose }) {
    const closeBtn = createElement("button", {
      className: "comic-helper-modal-close",
      textContent: "×",
      title: t("ui.close"),
      events: {
        click: (e) => {
          e.preventDefault();
          onClose();
        }
      }
    });
    const titleEl = createElement("h2", {
      className: "comic-helper-modal-title",
      textContent: t("ui.keyboardShortcuts")
    });
    const shortcutRows = SHORTCUTS.map((sc) => {
      const keyLabels = sc.keys.map((k) => {
        const label = k === " " ? t("ui.space") : k;
        return createElement("kbd", { className: "comic-helper-kbd", textContent: label });
      });
      const keyContainer = createElement("div", { className: "comic-helper-shortcut-keys" }, keyLabels);
      const labelEl = createElement("div", { className: "comic-helper-shortcut-label", textContent: sc.label });
      const descText = sc.condition ? `${sc.description} (${sc.condition})` : sc.description;
      const descEl = createElement("div", { className: "comic-helper-shortcut-desc", textContent: descText });
      return createElement("div", { className: "comic-helper-shortcut-row" }, [keyContainer, labelEl, descEl]);
    });
    const shortcutList = createElement("div", { className: "comic-helper-shortcut-list" }, shortcutRows);
    const content = createElement("div", {
      className: "comic-helper-modal-content",
      events: {
        click: (e) => e.stopPropagation()
      }
    }, [closeBtn, titleEl, shortcutList]);
    const overlay = createElement("div", {
      className: "comic-helper-modal-overlay",
      events: {
        click: (e) => {
          e.preventDefault();
          onClose();
        }
      }
    }, [content]);
    return {
      el: overlay,
      update: () => {
      }
    };
  }
  class Draggable {
    /**
     * @param {HTMLElement} element 
     * @param {Object} options
     * @param {Function} [options.onDragEnd]
     */
    constructor(element, options = {}) {
      this.element = element;
      this.onDragEnd = options.onDragEnd || (() => {
      });
      this.isDragging = false;
      this.dragStartX = 0;
      this.dragStartY = 0;
      this.initialTop = 0;
      this.initialLeft = 0;
      this._onMouseDown = this._onMouseDown.bind(this);
      this._onMouseMove = this._onMouseMove.bind(this);
      this._onMouseUp = this._onMouseUp.bind(this);
      this.element.addEventListener("mousedown", this._onMouseDown);
    }
    /**
     * @param {MouseEvent} e 
     */
    _onMouseDown(e) {
      if (e.button !== 0 || !(e.target instanceof HTMLElement)) return;
      if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT") return;
      this.isDragging = true;
      const rect = this.element.getBoundingClientRect();
      this.initialTop = rect.top;
      this.initialLeft = rect.left;
      this.dragStartX = e.clientX;
      this.dragStartY = e.clientY;
      Object.assign(this.element.style, {
        top: `${this.initialTop}px`,
        left: `${this.initialLeft}px`,
        bottom: "auto",
        right: "auto"
      });
      document.addEventListener("mousemove", this._onMouseMove);
      document.addEventListener("mouseup", this._onMouseUp);
      e.preventDefault();
    }
    /**
     * Clamp the element's position to keep it within the viewport
     * @returns {{top: number, left: number}} The clamped position
     */
    clampToViewport() {
      const rect = this.element.getBoundingClientRect();
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      const padding = 10;
      let top = rect.top;
      let left = rect.left;
      const maxTop = vh - rect.height - padding;
      const maxLeft = vw - rect.width - padding;
      top = Math.max(padding, Math.min(top, maxTop));
      left = Math.max(padding, Math.min(left, maxLeft));
      Object.assign(this.element.style, {
        top: `${top}px`,
        left: `${left}px`,
        bottom: "auto",
        right: "auto"
      });
      return { top, left };
    }
    /**
     * @param {MouseEvent} e 
     */
    _onMouseMove(e) {
      if (!this.isDragging) return;
      const deltaX = e.clientX - this.dragStartX;
      const deltaY = e.clientY - this.dragStartY;
      this.element.style.top = `${this.initialTop + deltaY}px`;
      this.element.style.left = `${this.initialLeft + deltaX}px`;
      this.clampToViewport();
    }
    _onMouseUp() {
      if (!this.isDragging) return;
      this.isDragging = false;
      document.removeEventListener("mousemove", this._onMouseMove);
      document.removeEventListener("mouseup", this._onMouseUp);
      const { top, left } = this.clampToViewport();
      this.onDragEnd(top, left);
    }
    destroy() {
      this.element.removeEventListener("mousedown", this._onMouseDown);
      document.removeEventListener("mousemove", this._onMouseMove);
      document.removeEventListener("mouseup", this._onMouseUp);
    }
  }
  class UIManager {
    /**
     * @param {import('../global').SiteAdapter} adapter 
     * @param {import('../store.js').Store} store 
     * @param {import('./Navigator.js').Navigator} navigator 
     */
    constructor(adapter, store, navigator2) {
      this.adapter = adapter;
      this.store = store;
      this.navigator = navigator2;
      this.powerComp = null;
      this.counterComp = null;
      this.spreadComp = null;
      this.draggable = null;
      this.modalEl = null;
      this.helpModalEl = null;
      this.updateUI = this.updateUI.bind(this);
      this.init = this.init.bind(this);
    }
    init() {
      injectStyles();
      this.updateUI();
      this.store.subscribe(this.updateUI);
      window.addEventListener("resize", () => {
        if (this.draggable) {
          const { top, left } = this.draggable.clampToViewport();
          this.store.setState({ guiPos: { top, left } });
        }
      });
    }
    updateUI() {
      const state = this.store.getState();
      const { enabled, isDualViewEnabled, guiPos, currentVisibleIndex } = state;
      let container = document.getElementById("comic-helper-ui");
      if (!container) {
        container = createElement("div", { id: "comic-helper-ui" });
        if (guiPos) {
          Object.assign(container.style, {
            top: `${guiPos.top}px`,
            left: `${guiPos.left}px`,
            bottom: "auto",
            right: "auto"
          });
        }
        this.draggable = new Draggable(container, {
          onDragEnd: (top, left) => this.store.setState({ guiPos: { top, left } })
        });
        document.body.appendChild(container);
      }
      if (!this.powerComp) {
        this.powerComp = createPowerButton({
          isEnabled: enabled,
          onClick: () => {
            const newState = !this.store.getState().enabled;
            this.store.setState({ enabled: newState });
          }
        });
        container.appendChild(this.powerComp.el);
      }
      const imgs = this.navigator.getImages();
      if (!this.counterComp) {
        this.counterComp = createPageCounter({
          current: currentVisibleIndex + 1,
          total: imgs.length,
          onJump: (val) => {
            const success = this.navigator.jumpToPage(val);
            if (this.counterComp) {
              this.counterComp.input.blur();
              if (!success) {
                this.counterComp.input.style.backgroundColor = "rgba(255, 0, 0, 0.3)";
                setTimeout(() => {
                  if (this.counterComp) this.counterComp.input.style.backgroundColor = "";
                }, 500);
              }
            }
          }
        });
        container.appendChild(this.counterComp.el);
      }
      if (!this.spreadComp) {
        this.spreadComp = createSpreadControls({
          isDualViewEnabled,
          onToggle: (val) => this.store.setState({ isDualViewEnabled: val }),
          onAdjust: () => {
            const { spreadOffset } = this.store.getState();
            this.store.setState({ spreadOffset: spreadOffset === 0 ? 1 : 0 });
          }
        });
        container.appendChild(this.spreadComp.el);
      }
      if (container.querySelectorAll(".comic-helper-button").length === 0) {
        const navBtns = createNavigationButtons({
          onFirst: () => this.navigator.scrollToEdge("start"),
          onPrev: () => this.navigator.scrollToImage(-1),
          onNext: () => this.navigator.scrollToImage(1),
          onLast: () => this.navigator.scrollToEdge("end"),
          onInfo: () => this.store.setState({ isMetadataModalOpen: true }),
          onHelp: () => this.store.setState({ isHelpModalOpen: true })
        });
        navBtns.elements.forEach((btn) => container.appendChild(btn));
      }
      const { isMetadataModalOpen, isHelpModalOpen, metadata } = state;
      if (isHelpModalOpen) {
        if (!this.helpModalEl) {
          const modal = createHelpModal({
            onClose: () => this.store.setState({ isHelpModalOpen: false })
          });
          this.helpModalEl = modal.el;
          document.body.appendChild(this.helpModalEl);
        }
      } else {
        if (this.helpModalEl) {
          this.helpModalEl.remove();
          this.helpModalEl = null;
        }
      }
      if (isMetadataModalOpen) {
        if (!this.modalEl) {
          const modal = createMetadataModal({
            metadata,
            onClose: () => this.store.setState({ isMetadataModalOpen: false })
          });
          this.modalEl = modal.el;
          document.body.appendChild(this.modalEl);
        }
      } else {
        if (this.modalEl) {
          this.modalEl.remove();
          this.modalEl = null;
        }
      }
      this.powerComp.update(enabled);
      if (!enabled) {
        container.style.padding = "4px 8px";
        this.counterComp.el.style.display = "none";
        this.spreadComp.el.style.display = "none";
        container.querySelectorAll(".comic-helper-button").forEach((btn) => {
          btn.style.display = "none";
        });
        return;
      }
      container.style.padding = "8px";
      this.counterComp.el.style.display = "flex";
      this.spreadComp.el.style.display = "flex";
      container.querySelectorAll(".comic-helper-button").forEach((btn) => {
        btn.style.display = "inline-block";
      });
      this.counterComp.update(currentVisibleIndex + 1, imgs.length);
      this.spreadComp.update(isDualViewEnabled);
    }
  }
  class InputManager {
    /**
     * @param {import('../store.js').Store} store 
     * @param {import('./Navigator.js').Navigator} navigator 
     */
    constructor(store, navigator2) {
      this.store = store;
      this.navigator = navigator2;
      this.lastWheelTime = 0;
      this.WHEEL_THROTTLE_MS = 500;
      this.WHEEL_THRESHOLD = 1;
      this.resizeReq = void 0;
      this.scrollReq = void 0;
      this.handleWheel = this.handleWheel.bind(this);
      this.onKeyDown = this.onKeyDown.bind(this);
      this.handleResize = this.handleResize.bind(this);
      this.handleScroll = this.handleScroll.bind(this);
    }
    init() {
      window.addEventListener("wheel", this.handleWheel, { passive: false });
      document.addEventListener("keydown", this.onKeyDown, true);
      window.addEventListener("resize", this.handleResize);
      window.addEventListener("scroll", this.handleScroll);
    }
    /**
     * @param {EventTarget | null} target 
     * @returns {boolean}
     */
    isInputField(target) {
      if (!(target instanceof HTMLElement)) return false;
      return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || !!target.isContentEditable;
    }
    /**
     * @param {WheelEvent} e 
     */
    handleWheel(e) {
      const { enabled, isDualViewEnabled, currentVisibleIndex, isMetadataModalOpen, isHelpModalOpen } = this.store.getState();
      if (!enabled) return;
      if (isMetadataModalOpen || isHelpModalOpen) {
        const modalContent = document.querySelector(".comic-helper-modal-content");
        if (modalContent && modalContent.contains(
          /** @type {Node} */
          e.target
        )) {
          return;
        }
        e.preventDefault();
        return;
      }
      e.preventDefault();
      const now = Date.now();
      if (now - this.lastWheelTime < this.WHEEL_THROTTLE_MS) return;
      const direction = getNavigationDirection(e, this.WHEEL_THRESHOLD);
      if (direction === "none") return;
      const imgs = this.navigator.getImages();
      if (imgs.length === 0) return;
      this.lastWheelTime = now;
      const step = isDualViewEnabled ? 2 : 1;
      const nextIndex = direction === "next" ? Math.min(currentVisibleIndex + step, imgs.length - 1) : Math.max(currentVisibleIndex - step, 0);
      this.navigator.jumpToPage(nextIndex + 1);
    }
    /**
     * @param {KeyboardEvent} e 
     */
    onKeyDown(e) {
      if (this.isInputField(e.target) || e.ctrlKey || e.metaKey || e.altKey) return;
      const { enabled, isDualViewEnabled, isMetadataModalOpen, isHelpModalOpen } = this.store.getState();
      if (e.key === "Escape") {
        if (isMetadataModalOpen || isHelpModalOpen) {
          e.preventDefault();
          this.store.setState({ isMetadataModalOpen: false, isHelpModalOpen: false });
          return;
        }
      }
      const isKey = (id) => {
        const sc = SHORTCUTS.find((s) => s.id === id);
        if (!sc) return false;
        return sc.keys.some((k) => {
          if (k.startsWith("Shift+")) {
            const baseKey = k.replace("Shift+", "");
            return e.shiftKey && e.key === (baseKey === "Space" ? " " : baseKey);
          }
          return !e.shiftKey && e.key === (k === "Space" ? " " : k);
        });
      };
      if (isKey("help") && isHelpModalOpen) {
        e.preventDefault();
        this.store.setState({ isHelpModalOpen: false });
        return;
      }
      if (isMetadataModalOpen || isHelpModalOpen || !enabled) return;
      if (isKey("nextPage")) {
        e.preventDefault();
        this.navigator.scrollToImage(1);
      } else if (isKey("prevPage")) {
        e.preventDefault();
        this.navigator.scrollToImage(-1);
      } else if (isKey("dualView")) {
        e.preventDefault();
        this.store.setState({ isDualViewEnabled: !isDualViewEnabled });
      } else if (isKey("spreadOffset") && isDualViewEnabled) {
        e.preventDefault();
        const { spreadOffset } = this.store.getState();
        this.store.setState({ spreadOffset: spreadOffset === 0 ? 1 : 0 });
      } else if (isKey("metadata")) {
        e.preventDefault();
        this.store.setState({ isMetadataModalOpen: !isMetadataModalOpen });
      } else if (isKey("help")) {
        e.preventDefault();
        this.store.setState({ isHelpModalOpen: !isHelpModalOpen });
      }
    }
    handleResize() {
      const { enabled, currentVisibleIndex } = this.store.getState();
      if (!enabled) return;
      if (this.resizeReq) cancelAnimationFrame(this.resizeReq);
      this.resizeReq = requestAnimationFrame(() => this.navigator.applyLayout(currentVisibleIndex));
    }
    handleScroll() {
      if (!this.store.getState().enabled) return;
      if (this.scrollReq) cancelAnimationFrame(this.scrollReq);
      this.scrollReq = requestAnimationFrame(() => this.navigator.updatePageCounter());
    }
  }
  class App {
    constructor() {
      this.store = new Store();
      const adapters = [DefaultAdapter];
      this.adapter = adapters.find((a) => a.match(window.location.href)) || DefaultAdapter;
      this.navigator = new Navigator(this.adapter, this.store);
      this.uiManager = new UIManager(this.adapter, this.store, this.navigator);
      this.inputManager = new InputManager(this.store, this.navigator);
      this.init = this.init.bind(this);
    }
    init() {
      const container = this.adapter.getContainer();
      if (!container) return;
      const metadata = this.adapter.getMetadata?.() ?? { title: "Unknown Title", tags: [], relatedWorks: [] };
      this.store.setState({ metadata });
      this.navigator.init();
      this.uiManager.init();
      this.inputManager.init();
    }
  }
  const app = new App();
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", app.init);
  } else {
    app.init();
  }
})();