マンガ見開きビューア(自動判別版)

画像が縦一列表示となっているサイト上で起動後、右上アイコンクリックで見開き表示。

目前為 2025-08-20 提交的版本,檢視 最新版本

// ==UserScript==
// @name         マンガ見開きビューア(自動判別版)
// @namespace    http://2chan.net/
// @version      2.1
// @description  画像が縦一列表示となっているサイト上で起動後、右上アイコンクリックで見開き表示。
// @description  When launched on a website where images are displayed in a single vertical column, click the icon in the upper right corner to switch to a double-page display with auto-detection.
// @author       futaba
// @match        *://*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=2chan.net
// @license      MIT
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
  'use strict';

  const CONFIG = {
    minImageHeight: 400,
    minImageWidth: 200,
    enableKeyControls: true,
    enableMouseWheel: true,
    minMangaImageCount: 2,
    defaultBg: '#333333',
    refreshDebounceMs: 250,
  };

  // 検出モード設定
  const DETECTION_MODES = {
    'default': {
      name: '自動判別',
      description: 'ページ全体から画像を自動検出'
    },
    'normal': {
      name: '通常検出',
      description: 'ページ全体から画像を自動検出'
    },
    'iframe': {
      name: 'iframeモード', 
      description: 'iframe内の画像を検出'
    },
    'selector:reading-content': {
      name: 'reading-contentセレクタ',
      description: '.reading-content img で検出',
      selector: '.reading-content img',
      dataSrcSupport: true
    },
    'selector:chapter-content': {
      name: 'chapter-contentセレクタ',
      description: '.chapter-content img で検出', 
      selector: '.chapter-content img',
      dataSrcSupport: true
    },
    'selector:manga-reader': {
      name: 'manga-readerセレクタ',
      description: '.manga-reader img で検出',
      selector: '.manga-reader img', 
      dataSrcSupport: false
    }
  };

  let currentPage = 0;
  let images = [];
  let container = null;
  let imageArea = null;
  let bgToggleBtn = null;
  let fullscreenBtn = null;
  let toggleButton = null;
  let io = null;
  const watched = new WeakSet();
  let refreshTimer = null;
  let navTimer = null;
  let detectedMode = null; // 自動判別で検出されたモード
  let isFullscreen = false;

  // ---------- 設定管理 ----------
  function getSiteSettings() {
    try {
      return JSON.parse(localStorage.getItem('mangaViewerDomains') || '{}');
    } catch {
      return {};
    }
  }

  function setSiteSettings(settings) {
    localStorage.setItem('mangaViewerDomains', JSON.stringify(settings));
  }

  function getCurrentSiteStatus() {
    const settings = getSiteSettings();
    const hostname = window.location.hostname;
    return settings[hostname] || 'hide'; // デフォルトは非表示
  }

  function shouldShowButton() {
    return getCurrentSiteStatus() === 'show';
  }

  function getCurrentModeDisplay() {
    const status = getCurrentSiteStatus();
    if (status === 'hide') return '非表示';
    if (status === 'show') return '表示中';
    return 'デフォルト';
  }

  function setSiteMode(hostname, mode) {
    const settings = getSiteSettings();
    settings[hostname] = mode;
    setSiteSettings(settings);
    
    const statusText = mode === 'show' ? 'ボタン表示' : 'ボタン非表示';
    showMessage(`${hostname}: ${statusText} に設定しました`, 'rgba(0,100,200,0.8)');
    
    // UI更新(リロードなし)
    if (mode === 'hide' && toggleButton) {
      toggleButton.remove();
      toggleButton = null;
    } else if (mode === 'show' && !toggleButton) {
      setTimeout(addToggleButton, 100);
    }
  }

  // ---------- 検出モード設定 ----------
  function getDetectionMode() {
    const hostname = window.location.hostname;
    const key = `mangaDetectionMode_${hostname}`;
    return localStorage.getItem(key) || 'default';
  }

  function setDetectionMode(hostname, mode) {
    const key = `mangaDetectionMode_${hostname}`;
    localStorage.setItem(key, mode);
    
    const modeInfo = DETECTION_MODES[mode] || { name: mode };
    showMessage(`${hostname}: ${modeInfo.name} に設定しました`, 'rgba(0,150,0,0.8)');
  }

  function getCurrentDetectionResultDisplay() {
    const mode = getDetectionMode();
    if (mode === 'default') {
      // 自動判別モードの場合、実際に検出されたモードを表示
      if (detectedMode) {
        const modeInfo = DETECTION_MODES[detectedMode];
        return modeInfo ? modeInfo.name : detectedMode;
      } else {
        return '[不明]';
      }
    } else {
      const modeInfo = DETECTION_MODES[mode];
      return modeInfo ? modeInfo.name : mode;
    }
  }

  // ---------- 背景色 ----------
  function getBgColor() {
    return localStorage.getItem('mangaViewerBg') || CONFIG.defaultBg;
  }
  
  function toggleBgColor() {
    const newColor = getBgColor() === '#333333' ? '#F5F5F5' : '#333333';
    localStorage.setItem('mangaViewerBg', newColor);
    if (container) container.style.background = newColor;
    if (bgToggleBtn) bgToggleBtn.textContent = (newColor === '#F5F5F5') ? '背景:白' : '背景:黒';
  }

  // ---------- 全画面機能 ----------
  function toggleFullscreen() {
    if (!container) return;
    
    if (!isFullscreen) {
      // 全画面に入る
      if (container.requestFullscreen) {
        container.requestFullscreen();
      } else if (container.webkitRequestFullscreen) {
        container.webkitRequestFullscreen();
      } else if (container.mozRequestFullScreen) {
        container.mozRequestFullScreen();
      } else if (container.msRequestFullscreen) {
        container.msRequestFullscreen();
      }
    } else {
      // 全画面を出る
      if (document.exitFullscreen) {
        document.exitFullscreen();
      } else if (document.webkitExitFullscreen) {
        document.webkitExitFullscreen();
      } else if (document.mozCancelFullScreen) {
        document.mozCancelFullScreen();
      } else if (document.msExitFullscreen) {
        document.msExitFullscreen();
      }
    }
  }

  // 全画面状態の監視
  function setupFullscreenListeners() {
    const fullscreenEvents = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'];
    
    fullscreenEvents.forEach(event => {
      document.addEventListener(event, () => {
        isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
        // ボタンのアイコンは常に⛶で固定
      });
    });
  }

  // ---------- 画像検出(統合版) ----------
  function detectMangaImages() {
    const detectionMode = getDetectionMode();
    console.log('Detection mode:', detectionMode);
    
    // 設定済みの場合は、その方法を使用
    if (detectionMode !== 'default') {
      return detectByMode(detectionMode);
    }
    
    // デフォルト(自動判別)の場合:自動判別を試行
    return detectWithAutoFallback();
  }

  function detectByMode(mode) {
    if (mode === 'default') return detectWithAutoFallback();
    if (mode === 'normal') return detectMangaImagesFromDocument();
    if (mode === 'iframe') return detectMangaImagesFromIframe();
    if (mode.startsWith('selector:')) return detectBySelectorMode(mode);
    
    // フォールバック
    return detectMangaImagesFromDocument();
  }

  function detectWithAutoFallback() {
    console.log('Starting auto-detection...');
    
    // 1. 通常検出を試行
    let images = detectMangaImagesFromDocument();
    if (images.length >= CONFIG.minMangaImageCount) {
      console.log('Auto-detected: normal mode');
      detectedMode = 'normal';
      return images;
    }
    
    // 2. 各セレクタパターンを試行
    const selectorModes = ['reading-content', 'chapter-content', 'manga-reader'];
    for (const selectorName of selectorModes) {
      images = detectBySelectorConfig(selectorName);
      if (images.length >= CONFIG.minMangaImageCount) {
        console.log(`Auto-detected: selector:${selectorName}`);
        detectedMode = `selector:${selectorName}`;
        return images;
      }
    }
    
    // 3. iframe検出を試行
    if (document.querySelector('iframe')) {
      images = detectMangaImagesFromIframe();
      if (images.length >= CONFIG.minMangaImageCount) {
        console.log('Auto-detected: iframe mode');
        detectedMode = 'iframe';
        return images;
      }
    }
    
    // すべて失敗
    console.log('Auto-detection failed');
    detectedMode = null;
    return [];
  }

  function detectMangaImagesFromDocument() {
    const allImages = document.querySelectorAll('img');
    const excludePatterns = ['icon','logo','avatar','banner','header','footer','thumb','thumbnail','profile','menu','button','bg','background','nav','sidebar','ad','advertisement','favicon','sprite'];

    const potential = Array.from(allImages).filter(img => {
      if (!img.complete || img.naturalHeight === 0 || img.naturalWidth === 0) return false;
      if (img.naturalHeight < CONFIG.minImageHeight || img.naturalWidth < CONFIG.minImageWidth) return false;
      const src = (img.src || '').toLowerCase();
      if (excludePatterns.some(p => src.includes(p))) return false;
      return true;
    });

    if (potential.length < CONFIG.minMangaImageCount) return [];

    const seenSrcs = new Set();
    return potential.filter(img => {
      if (seenSrcs.has(img.src)) return false;
      seenSrcs.add(img.src);
      return true;
    }).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
  }

  function detectBySelectorMode(mode) {
    const selectorName = mode.replace('selector:', '');
    return detectBySelectorConfig(selectorName);
  }

  function detectBySelectorConfig(configName) {
    const config = DETECTION_MODES[`selector:${configName}`];
    if (!config || !config.selector) {
      console.warn(`Unknown selector config: ${configName}`);
      return [];
    }
    
    const images = Array.from(document.querySelectorAll(config.selector));
    console.log(`Selector ${config.selector} found ${images.length} images`);
    
    // data-src対応
    if (config.dataSrcSupport) {
      images.forEach(img => {
        if (!img.src && img.dataset.src) {
          console.log('Converting data-src to src:', img.dataset.src);
          img.src = img.dataset.src;
        }
      });
    }
    
    // 基本フィルタリング
    return images.filter(img => {
      if (!img.complete || img.naturalHeight === 0 || img.naturalWidth === 0) return false;
      return img.naturalHeight >= CONFIG.minImageHeight && 
             img.naturalWidth >= CONFIG.minImageWidth;
    });
  }

  function detectMangaImagesFromIframe() {
    console.log('detectMangaImagesFromIframe called');
    let iframe = document.querySelector("iframe");
    if (!iframe) {
      console.log('No iframe found');
      return [];
    }
    
    let doc;
    try {
      doc = iframe.contentDocument || iframe.contentWindow.document;
    } catch (e) {
      console.log("iframe access denied:", e);
      return [];
    }
    
    if (!doc) {
      console.log('No iframe document found');
      return [];
    }

    const allImages = doc.querySelectorAll('img');
    console.log('Total images in iframe:', allImages.length);
    
    const potential = Array.from(allImages).filter(img => {
      const isComplete = img.complete;
      const hasSize = img.naturalHeight > 0 && img.naturalWidth > 0;
      const isLargeEnough = img.naturalWidth >= 500;
      
      return isComplete && hasSize && isLargeEnough;
    });

    console.log('Filtered potential images:', potential.length);

    // iframe内の画像のsrcをフルURLに変換
    potential.forEach(img => {
      let src = img.src;
      if (!src.startsWith('http')) {
        try {
          const iframeUrl = new URL(iframe.src);
          src = new URL(src, iframeUrl.origin).href;
          Object.defineProperty(img, 'src', { value: src, writable: false });
        } catch (e) {
          console.log("URL conversion failed:", e);
        }
      }
    });

    return potential.sort((a, b) => {
      const rectA = a.getBoundingClientRect();
      const rectB = b.getBoundingClientRect();
      return rectA.top - rectB.top;
    });
  }

  // ---------- 検出方法選択ダイアログ ----------
  function showDetectionMethodDialog() {
    const dialog = document.createElement('div');
    dialog.style.cssText = `
      position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
      background: white; padding: 20px; border-radius: 8px; z-index: 10002;
      box-shadow: 0 4px 20px rgba(0,0,0,0.5); max-width: 400px;
      color: black; font-family: sans-serif;
    `;
    
    dialog.innerHTML = `
      <h3 style="margin-top:0;">画像検出設定</h3>
      <p>検出方法を選択してください:</p>
      <div style="display:flex; flex-direction:column; gap:8px;">
        <button data-mode="default" style="padding:8px; cursor:pointer;">🤖 自動判別</button>
        <button data-mode="selector:reading-content" style="padding:8px; cursor:pointer;">📖 reading-content セレクタ</button>
        <button data-mode="selector:chapter-content" style="padding:8px; cursor:pointer;">📖 chapter-content セレクタ</button>
        <button data-mode="selector:manga-reader" style="padding:8px; cursor:pointer;">📖 manga-reader セレクタ</button>
        <button data-mode="iframe" style="padding:8px; cursor:pointer;">📄 iframe内を検索</button>
        <button data-mode="normal" style="padding:8px; cursor:pointer;">🔍 通常検出(再試行)</button>
        <button data-close="true" style="padding:8px; cursor:pointer; background:#ddd;">✖ キャンセル</button>
      </div>
    `;
    
    dialog.addEventListener('click', (e) => {
      const mode = e.target.dataset.mode;
      const close = e.target.dataset.close;
      
      if (mode) {
        // 選択された方法で再試行 + サイト設定に保存
        setDetectionMode(window.location.hostname, mode);
        dialog.remove();
        
        // 即座に再実行
        setTimeout(() => {
          refreshImages();
          if (images.length >= CONFIG.minMangaImageCount) {
            showSpreadPage(0);
          } else {
            showMessage('この方法でも画像が見つかりませんでした', 'rgba(200,0,0,0.8)');
          }
        }, 100);
      } else if (close) {
        dialog.remove();
      }
    });
    
    document.body.appendChild(dialog);
  }

  // ---------- 画像更新処理 ----------
  function scheduleRefreshImages() {
    if (refreshTimer) clearTimeout(refreshTimer);
    refreshTimer = setTimeout(refreshImages, CONFIG.refreshDebounceMs);
  }
  
  function refreshImages() {
    refreshTimer = null;
    const newImages = detectMangaImages();
    if (newImages.length !== images.length || newImages.some((img, i) => images[i] !== img)) {
      images = newImages;
      if (container && container.style.display === 'flex') updatePageInfoOnly();
    }
    
    // 通常モードでのみ監視を設定
    const currentMode = getDetectionMode();
    if (currentMode === 'default' || currentMode === 'normal') {
      newImages.forEach(img => attachWatchers(img));
    }
  }
  
  function attachWatchers(img) {
    if (watched.has(img)) return;
    watched.add(img);
    img.addEventListener('load', scheduleRefreshImages, { once: true });
    if (io && !img.complete) io.observe(img);
  }

  // ---------- 全画像読み込み機能 ----------
  function loadAllImages(buttonElement) {
    if (buttonElement) {
      if (buttonElement.dataset.loading === '1') return;
      buttonElement.dataset.loading = '1';
      buttonElement.textContent = '🔥読込中...';
      buttonElement.style.opacity = '0.5';
    }
    
    const currentMode = getDetectionMode();
    const isIframeMode = (currentMode === 'iframe');
    
    if (isIframeMode) {
      loadAllImagesFromIframe(buttonElement);
    } else {
      loadAllImagesFromDocument(buttonElement);
    }
  }

  function loadAllImagesFromIframe(buttonElement) {
    let iframe = document.querySelector("iframe");
    if (!iframe) return;
    
    try {
      const iframeWindow = iframe.contentWindow;
      const iframeDoc = iframe.contentDocument || iframeWindow.document;
      const originalScrollTop = iframeWindow.pageYOffset || iframeDoc.documentElement.scrollTop;
      
      let currentScroll = 0;
      const documentHeight = Math.max(
        iframeDoc.body.scrollHeight,
        iframeDoc.body.offsetHeight,
        iframeDoc.documentElement.clientHeight,
        iframeDoc.documentElement.scrollHeight,
        iframeDoc.documentElement.offsetHeight
      );
      const viewportHeight = iframeWindow.innerHeight;
      const scrollStep = Math.max(500, viewportHeight);

      function scrollAndLoad() {
        currentScroll += scrollStep;
        iframeWindow.scrollTo(0, currentScroll);
        scheduleRefreshImages();
        
        if (currentScroll < documentHeight - viewportHeight) {
          setTimeout(scrollAndLoad, 10);
        } else {
          setTimeout(() => {
            iframeWindow.scrollTo(0, originalScrollTop);
            refreshImages();
            finishLoadAll(buttonElement);
          }, 100);
        }
      }
      scrollAndLoad();
    } catch (e) {
      console.log("iframe scroll failed:", e);
      finishLoadAll(buttonElement);
    }
  }

  function loadAllImagesFromDocument(buttonElement) {
    const originalScrollTop = window.pageYOffset;
    let currentScroll = 0;
    const documentHeight = Math.max(
      document.body.scrollHeight,
      document.body.offsetHeight,
      document.documentElement.clientHeight,
      document.documentElement.scrollHeight,
      document.documentElement.offsetHeight
    );
    const viewportHeight = window.innerHeight;
    const scrollStep = Math.max(500, viewportHeight);

    function scrollAndLoad() {
      currentScroll += scrollStep;
      window.scrollTo(0, currentScroll);
      scheduleRefreshImages();
      if (currentScroll < documentHeight - viewportHeight) {
        setTimeout(scrollAndLoad, 10);
      } else {
        setTimeout(() => {
          window.scrollTo(0, originalScrollTop);
          refreshImages();
          finishLoadAll(buttonElement);
        }, 100);
      }
    }
    scrollAndLoad();
  }

  function finishLoadAll(buttonElement) {
    if (buttonElement) {
      buttonElement.textContent = '🔥全読込';
      buttonElement.style.opacity = '0.8';
      buttonElement.dataset.loading = '0';
    }
    showMessage(`${images.length}枚の画像を検出しました`);
  }

  function showMessage(text, color = 'rgba(0,150,0,0.8)') {
    const msg = document.createElement('div');
    msg.textContent = text;
    msg.style.cssText = `
      position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
      z-index: 10001; background: ${color}; color: white;
      padding: 8px 12px; border-radius: 4px; font-size: 12px; pointer-events: none;
    `;
    document.body.appendChild(msg);
    setTimeout(() => msg.remove(), 2500);
  }

  // ---------- ビューア生成 ----------
  function createSpreadViewerOnce() {
    if (container) return;
    
    const targetDocument = document;
    const targetWindow = window;
    
    container = targetDocument.createElement('div');
    container.style.cssText = `
      position: fixed; top:0; left:0; width:100vw; height:100vh;
      background:${getBgColor()}; z-index:10000; display:none;
      justify-content:center; align-items:center; flex-direction:column;
    `;

    imageArea = targetDocument.createElement('div');
    imageArea.style.cssText = `
      display:flex; flex-direction:row-reverse;
      justify-content:center; align-items:center;
      max-width:calc(100vw - 10px); max-height:calc(100vh - 10px);
      gap:2px; padding:5px; box-sizing:border-box;
    `;

    // --- ナビゲーション ---
    const nav = targetDocument.createElement('div');
    nav.setAttribute('data-mv-ui', '1');
    nav.style.cssText = `
      position:absolute; bottom:20px; left: 50%; transform: translateX(-50%);
      color:white; font-size:16px; background:rgba(0,0,0,0.7);
      padding:10px 20px; border-radius:20px;
      display:flex; align-items:center; gap:12px;
      opacity:1; transition:opacity 0.5s;
      pointer-events:auto;
    `;
    
    const mkBtn = (label, onClick) => {
      const b = targetDocument.createElement('button');
      b.type = 'button'; b.textContent = label;
      b.setAttribute('data-mv-ui', '1');
      b.style.cssText = `
        background: rgba(255,255,255,0.2); color: white;
        border:none; padding:6px 10px; border-radius:4px; cursor:pointer;
      `;
      b.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); onClick(); });
      return b;
    };
    
    const btnNextSpread = mkBtn('←次', () => nextPage(2));
    const btnNextSingle = mkBtn('←単', () => nextPage(1));
    const btnPrevSingle = mkBtn('単→', () => prevPage(1));
    const btnPrevSpread = mkBtn('前→', () => prevPage(2));

    // 中央は進捗バー
    const progress = targetDocument.createElement('progress');
    progress.setAttribute('data-mv-ui', '1');
    progress.max = 100;
    progress.value = 0;
    progress.style.cssText = `
      width:160px; height:8px;
      appearance:none; -webkit-appearance:none;
      direction: rtl;
    `;

    nav.append(btnNextSpread, btnNextSingle, progress, btnPrevSingle, btnPrevSpread);
    container.appendChild(nav);

    // --- ナビフェードアウト ---
    function scheduleNavFade() {
      clearTimeout(navTimer);
      navTimer = setTimeout(() => nav.style.opacity = '0', 3000);
    }
    nav.addEventListener('mouseenter', () => { nav.style.opacity = '1'; clearTimeout(navTimer); });
    nav.addEventListener('mouseleave', scheduleNavFade);
    scheduleNavFade();

    // --- 閉じるボタン ---
    const closeBtn = targetDocument.createElement('button');
    closeBtn.setAttribute('data-mv-ui','1');
    closeBtn.textContent = '×';
    closeBtn.style.cssText = `
      position:absolute; top:20px; right:20px;
      background:rgba(0,0,0,0.5); color:white;
      border:none; font-size:24px; width:40px; height:40px;
      border-radius:50%; cursor:pointer;
    `;
    closeBtn.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); container.style.display = 'none'; });
    container.appendChild(closeBtn);

    // --- 全読込ボタン ---
    const loadAllBtnTop = targetDocument.createElement('button');
    loadAllBtnTop.setAttribute('data-mv-ui','1');
    loadAllBtnTop.textContent = '🔥全読込';
    loadAllBtnTop.style.cssText = `
      position:absolute; top:70px; right:20px;
      background:rgba(0,0,0,0.5); color:white;
      border:none; font-size:12px; padding:6px 8px;
      border-radius:4px; cursor:pointer; opacity:0.8;
    `;
    loadAllBtnTop.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); loadAllImages(loadAllBtnTop); });
    container.appendChild(loadAllBtnTop);

    // --- 全画面ボタン(右下上) ---
    fullscreenBtn = targetDocument.createElement('button');
    fullscreenBtn.setAttribute('data-mv-ui','1');
    fullscreenBtn.textContent = '⛶';
    fullscreenBtn.style.cssText = `
      position:absolute; bottom:80px; right:20px;
      background:rgba(0,0,0,0.5); color:white;
      border:none; font-size:14px; padding:4px 8px;
      border-radius:6px; cursor:pointer; font-family:monospace;
    `;
    fullscreenBtn.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); toggleFullscreen(); });
    container.appendChild(fullscreenBtn);

    // --- ページカウンター(右下下) ---
    const pageCounter = targetDocument.createElement('div');
    pageCounter.id = 'mv-page-counter';
    pageCounter.setAttribute('data-mv-ui','1');
    pageCounter.style.cssText = `
      position:absolute; bottom:40px; right:20px;
      background:rgba(0,0,0,0.5); color:white;
      font-size:14px; padding:4px 8px;
      border-radius:6px; font-family:monospace;
      pointer-events:none;
    `;
    container.appendChild(pageCounter);

    // --- 背景切替(左下) ---
    bgToggleBtn = targetDocument.createElement('button');
    bgToggleBtn.setAttribute('data-mv-ui','1');
    bgToggleBtn.style.cssText = `
      position:absolute; bottom:40px; left:20px;
      background:rgba(0,0,0,0.5); color:white;
      border:none; font-size:14px; padding:4px 8px;
      border-radius:6px; cursor:pointer; font-family:monospace;
    `;
    bgToggleBtn.textContent = (getBgColor() === '#F5F5F5') ? '背景:白' : '背景:黒';
    bgToggleBtn.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); toggleBgColor(); });
    container.appendChild(bgToggleBtn);

    container.appendChild(imageArea);
    targetDocument.body.appendChild(container);

    container.addEventListener('click', e => {
      if (e.target.closest('[data-mv-ui="1"]')) return;
      const rect = container.getBoundingClientRect();
      if ((e.clientX - rect.left) > rect.width/2) prevPage(2); else nextPage(2);
    });

    if (CONFIG.enableMouseWheel) {
      container.addEventListener('wheel', e => {
        e.preventDefault();
        if (e.deltaY > 0) nextPage(2); else prevPage(2);
      }, { passive: false });
    }
  }

  function showSpreadPage(pageNum) {
    if (!images.length) return;
    createSpreadViewerOnce();
    if (pageNum < 0) pageNum = 0;
    if (pageNum >= images.length) pageNum = Math.max(0, images.length - 1);
    imageArea.innerHTML = '';

    for (let i = 0; i < 2; i++) {
      const idx = pageNum + i;
      if (idx < images.length) {
        const wrapper = document.createElement('div');
        wrapper.className = 'image-wrapper';
        wrapper.style.cssText = 'pointer-events:none;';
        const img = document.createElement('img');
        img.src = images[idx].src;
        img.style.cssText = `
          max-height:calc(100vh - 10px);
          max-width:calc(50vw - 10px);
          object-fit:contain;
          display:block;
        `;
        wrapper.appendChild(img);
        imageArea.appendChild(wrapper);
      }
    }
    currentPage = pageNum;
    updatePageInfoOnly();
    container.style.display = 'flex';
  }

  function updatePageInfoOnly() {
    const pageCounter = document.getElementById('mv-page-counter');
    const progress = container ? container.querySelector('progress[data-mv-ui]') : null;
    if (!pageCounter || !progress) return;

    const current = currentPage + 1;
    const total = images.length;
    pageCounter.textContent = `${String(current).padStart(3,'0')}/${String(total).padStart(3,'0')}`;
    progress.value = Math.floor((current / total) * 100);
  }
  
  function nextPage(step=2){ const t=currentPage+step; if(t<images.length) showSpreadPage(t);}
  function prevPage(step=2){ const t=currentPage-step; if(t>=0) showSpreadPage(t);}

  // ---------- キー操作 ----------
  if (CONFIG.enableKeyControls) {
    document.addEventListener('keydown', e => {
      if (!container || container.style.display !== 'flex') return;
      switch(e.key){
        case 'ArrowLeft': case ' ': e.preventDefault(); nextPage(2); break;
        case 'ArrowRight': e.preventDefault(); prevPage(2); break;
        case 'ArrowDown': e.preventDefault(); nextPage(1); break;
        case 'ArrowUp': e.preventDefault(); prevPage(1); break;
        case 'Escape': e.preventDefault(); container.style.display='none'; break;
      }
    });
  }

  // ---------- 起動ボタン ----------
  function addToggleButton() {
    if (!shouldShowButton()) return;
    
    toggleButton = document.createElement('button');
    toggleButton.textContent = '📖';
    toggleButton.title = '見開き表示';
    
    // PageExpand拡張機能対策
    toggleButton.setAttribute('data-pageexpand-ignore', 'true');
    toggleButton.setAttribute('data-no-zoom', 'true');
    toggleButton.setAttribute('data-skip-pageexpand', 'true');
    toggleButton.setAttribute('data-manga-viewer-button', 'true');
    toggleButton.className = 'pageexpand-ignore no-zoom manga-viewer-btn';
    
    toggleButton.style.cssText = `
      position:fixed; top:50px; right:40px; z-index:9999;
      background:rgba(0,0,0,0.6); color:white; border:none;
      width:32px; height:32px; border-radius:6px; cursor:pointer;
      font-size:16px; opacity:0.7; transition:opacity 0.2s;
      text-align: center; line-height: 32px; vertical-align: middle;
      pointer-events: auto;
      transform: none !important;
      zoom: 1 !important;
      scale: 1 !important;
    `;
    
    // CSS でも拡大を防ぐ
    toggleButton.style.setProperty('transform', 'none', 'important');
    toggleButton.style.setProperty('zoom', '1', 'important');
    toggleButton.style.setProperty('scale', '1', 'important');
    
    toggleButton.onmouseenter = () => toggleButton.style.opacity='1';
    toggleButton.onmouseleave = () => toggleButton.style.opacity='0.7';
    
    toggleButton.addEventListener('click', () => {
      refreshImages();
      if (images.length >= CONFIG.minMangaImageCount) {
        // 成功:そのまま表示
        // 自動判別で検出された場合は、設定を保存
        if (detectedMode && getDetectionMode() === 'default') {
          setDetectionMode(window.location.hostname, detectedMode);
        }
        showSpreadPage(0);
      } else {
        // 失敗:検出方法選択ダイアログを表示
        showDetectionMethodDialog();
      }
    });
    
    // PageExpand拡張機能の動的変更を阻止
    toggleButton.addEventListener('mouseenter', (e) => {
      e.stopImmediatePropagation();
      toggleButton.style.opacity = '1';
      toggleButton.style.transform = 'none';
      toggleButton.style.zoom = '1';
    });
    
    toggleButton.addEventListener('mouseleave', (e) => {
      e.stopImmediatePropagation();
      toggleButton.style.opacity = '0.7';
      toggleButton.style.transform = 'none';
      toggleButton.style.zoom = '1';
    });
    
    document.body.appendChild(toggleButton);
    
    // ボタンが追加された後に、PageExpandによる変更を監視・阻止
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.target === toggleButton && mutation.type === 'attributes') {
          if (mutation.attributeName === 'style') {
            if (toggleButton.style.transform !== 'none') {
              toggleButton.style.transform = 'none';
            }
            if (toggleButton.style.zoom !== '1' && toggleButton.style.zoom !== '') {
              toggleButton.style.zoom = '1';
            }
          }
        }
      });
    });
    
    observer.observe(toggleButton, { attributes: true, attributeFilter: ['style', 'class'] });
  }

  // ---------- 初期化 ----------
  function initialize() {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', addToggleButton);
    } else {
      addToggleButton();
    }

    // 全画面監視の設定
    setupFullscreenListeners();

    // ---------- 動的監視(通常モードのみ) ----------
    const detectionMode = getDetectionMode();
    if ((detectionMode === 'default' || detectionMode === 'normal') && 'IntersectionObserver' in window) {
      io = new IntersectionObserver(entries => {
        if (entries.some(e => e.isIntersecting)) scheduleRefreshImages();
      }, { root:null, rootMargin:'200px 0px', threshold:0.01 });
    }
    
    if (detectionMode === 'default' || detectionMode === 'normal') {
      const mo = new MutationObserver(mutations => {
        let found=false;
        for(const m of mutations){
          m.addedNodes && m.addedNodes.forEach(node=>{
            if(node.nodeType===1){
              if(node.tagName==='IMG'){ attachWatchers(node); found=true;}
              else node.querySelectorAll && node.querySelectorAll('img').forEach(attachWatchers);
            }
          });
        }
        if(found) scheduleRefreshImages();
      });
      mo.observe(document.body,{childList:true,subtree:true});
    }
    
    refreshImages();
  }

  // ---------- Tampermonkey 右クリックメニュー ----------
  if (typeof GM_registerMenuCommand !== 'undefined') {
    // 見開きビューアを起動
    GM_registerMenuCommand("📖 見開きビューアを起動", () => {
      setTimeout(() => {
        refreshImages();
        if (images.length >= CONFIG.minMangaImageCount) {
          // 自動判別で検出された場合は、設定を保存
          if (detectedMode && getDetectionMode() === 'default') {
            setDetectionMode(window.location.hostname, detectedMode);
          }
          showSpreadPage(0);
        } else {
          showDetectionMethodDialog();
        }
      }, 300);
    });

    GM_registerMenuCommand("─── 検出モード設定 ───", () => {});

    // 現在の検出結果表示
    GM_registerMenuCommand(`🔍 判定: ${getCurrentDetectionResultDisplay()}`, () => {
      const mode = getDetectionMode();
      if (mode === 'default') {
        if (detectedMode) {
          const modeInfo = DETECTION_MODES[detectedMode];
          const description = modeInfo ? modeInfo.description : detectedMode;
          showMessage(`検出結果: ${description}`, 'rgba(100,100,100,0.8)');
        } else {
          showMessage('検出結果: 不明(画像が見つかりませんでした)', 'rgba(200,100,0,0.8)');
        }
      } else {
        const modeInfo = DETECTION_MODES[mode];
        const description = modeInfo ? modeInfo.description : mode;
        showMessage(`設定済み: ${description}`, 'rgba(100,100,100,0.8)');
      }
    });

    GM_registerMenuCommand("⚙️ 画像検出設定...", () => {
      showDetectionMethodDialog();
    });

    GM_registerMenuCommand("─── サイト設定 ───", () => {});

    GM_registerMenuCommand("👁️ このサイトでボタンを表示", () => {
      setSiteMode(window.location.hostname, 'show');
    });

    GM_registerMenuCommand("🚫 このサイトでボタンを非表示", () => {
      setSiteMode(window.location.hostname, 'hide');
    });

    GM_registerMenuCommand("⚠️ 記憶したサイト設定をリセット ⚠️", () => {
      if (confirm('記憶したサイト設定をリセットしますか?\n(現在のページもリロードされます)')) {
        localStorage.removeItem('mangaViewerDomains');
        localStorage.removeItem('mangaViewerBg');
        // 検出モード設定もリセット
        const hostname = window.location.hostname;
        const detectionKey = `mangaDetectionMode_${hostname}`;
        localStorage.removeItem(detectionKey);
        
        const msg = document.createElement('div');
        msg.textContent = 'すべての設定をリセットしました';
        msg.style.cssText = `
          position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
          z-index: 10001; background: rgba(255,0,0,0.8); color: white;
          padding: 12px 20px; border-radius: 6px; font-size: 14px; pointer-events: none;
        `;
        document.body.appendChild(msg);
        setTimeout(() => location.reload(), 1000);
      }
    });
  }

  initialize();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址