Magazine Comic Viewer Helper

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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();
  }
})();