マンガ見開きビューア

画像が縦一列表示となっているサイト上で起動後、右上アイコンクリックで見開き表示に切り替える(遅延読み込み対応)

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

// ==UserScript==
// @name         マンガ見開きビューア
// @namespace    http://2chan.net/
// @version      1.2
// @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.
// @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,
    containerMaxWidth: '95vw',
    imageMaxHeight: '90vh',
    enableKeyControls: true,
    enableMouseWheel: true,
    minMangaImageCount: 2,
    defaultBg: '#333333', // Dark 初期
    refreshDebounceMs: 250,
  };

  let currentPage = 0;     // 0-based, 表示は [currentPage, currentPage+1]
  let images = [];         // 検出済み <img> の配列(縦位置順)
  let container = null;
  let imageArea = null;
  let pageInfo = null;
  let bgToggleBtn = null;
  let toggleButton = null; // 見開き表示ボタンの参照を保持
  let io = null;           // IntersectionObserver
  const watched = new WeakSet(); // 監視済みimg
  let refreshTimer = null;
  let currentDomain = window.location.hostname; // 現在のドメイン

  // ---------- ドメイン学習システム ----------
  function getDomainSettings() {
    const saved = localStorage.getItem('mangaViewerDomains');
    return saved ? JSON.parse(saved) : {};
  }
  
  function saveDomainSettings(domains) {
    localStorage.setItem('mangaViewerDomains', JSON.stringify(domains));
  }
  
  function setDomainStatus(domain, status) {
    const domains = getDomainSettings();
    domains[domain] = status;
    saveDomainSettings(domains);
  }
  
  function getDomainStatus(domain) {
    const domains = getDomainSettings();
    return domains[domain] || 'unknown'; // unknown, show, hide
  }
  
  function shouldShowButton() {
    const status = getDomainStatus(currentDomain);
    return status === 'show'; // showのみ表示(ホワイトリスト方式)
  }

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

  // ---------- 画像検出 ----------
  function detectMangaImages() {
    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'
    ];
    
    // 1. 基本条件でフィルタリング
    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;
      const area = img.naturalWidth * img.naturalHeight;
      if (area > 15000000 || area < 50000) return false;
      return true;
    });
    
    if (potential.length < CONFIG.minMangaImageCount) return [];
    
    // 2. 重複除去(同じsrcの画像は除外)
    const uniqueImages = [];
    const seenSrcs = new Set();
    for (const img of potential) {
      if (!seenSrcs.has(img.src)) {
        seenSrcs.add(img.src);
        uniqueImages.push(img);
      }
    }
    
    // 3. 連番ソート(ファイル名に数字が含まれる場合)
    const sortedImages = uniqueImages.sort((a, b) => {
      // まずY座標でソート(基本位置)
      const ra = a.getBoundingClientRect();
      const rb = b.getBoundingClientRect();
      const yDiff = ra.top - rb.top;
      
      // Y座標の差が50px以内なら、ファイル名の数字で比較
      if (Math.abs(yDiff) <= 50) {
        const aNumbers = extractNumbers(a.src);
        const bNumbers = extractNumbers(b.src);
        
        // 両方に数字がある場合、数字順でソート
        if (aNumbers.length > 0 && bNumbers.length > 0) {
          for (let i = 0; i < Math.min(aNumbers.length, bNumbers.length); i++) {
            const diff = aNumbers[i] - bNumbers[i];
            if (diff !== 0) return diff;
          }
        }
      }
      
      // デフォルトはY座標順
      return yDiff;
    });
    
    return sortedImages;
  }
  
  // ファイル名から数字を抽出する関数
  function extractNumbers(url) {
    // URLからファイル名部分を抽出
    const filename = url.split('/').pop().split('?')[0];
    // 数字の連続を全て抽出
    const matches = filename.match(/\d+/g);
    return matches ? matches.map(num => parseInt(num, 10)) : [];
  }

  // 検出リスト更新(デバウンス)
  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();
      }
    }
    // 新しいimgに監視を付与
    newImages.forEach(img => attachWatchers(img));
  }

  // 遅延読み込み対策:intersect/load を監視
  function attachWatchers(img) {
    if (watched.has(img)) return;
    watched.add(img);
    // 画像ロード完了時に更新
    img.addEventListener('load', scheduleRefreshImages, { once: true });
    // まだ読み込まれていない && IO が使えるなら監視
    if (io && !img.complete) {
      io.observe(img);
    }
  }

  // ---------- ビューア生成(1回だけ) ----------
  function createSpreadViewerOnce() {
    if (container) return;

    container = document.createElement('div');
    container.id = 'manga-spread-viewer';
    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 = document.createElement('div');
    imageArea.style.cssText = `
      display:flex; flex-direction:row-reverse; /* 右→左で表示 */
      justify-content:center; align-items:center;
      max-width:${CONFIG.containerMaxWidth}; max-height:${CONFIG.imageMaxHeight};
      gap:2px; margin-top: -15px;
    `;

    // --- ナビゲーション ---
    const nav = document.createElement('div');
    nav.setAttribute('data-mv-ui', '1');
    nav.style.cssText = `
      position:absolute; bottom:20px; 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;
    `;

    const mkBtn = (label, onClick) => {
      const b = document.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;
    };

    // 表記:←次 / 前→(RTLの文脈で「次」は左方向)
    const btnNextSpread = mkBtn('←次', () => nextPage(2));   // 2ページ進む
    const btnNextSingle = mkBtn('←単', () => nextPage(1));   // 1ページ進む
    pageInfo = document.createElement('span');
    pageInfo.setAttribute('data-mv-ui', '1');
    pageInfo.style.cssText = 'min-width: 120px; text-align: center; font-family: monospace;'; // 固定幅フォントで位置安定化
    const btnPrevSingle = mkBtn('単→', () => prevPage(1));   // 1ページ戻る
    const btnPrevSpread = mkBtn('前→', () => prevPage(2));   // 2ページ戻る

    nav.appendChild(btnNextSpread);
    nav.appendChild(btnNextSingle);
    nav.appendChild(pageInfo);
    nav.appendChild(btnPrevSingle);
    nav.appendChild(btnPrevSpread);

    // --- 閉じるボタン ---
    const closeBtn = document.createElement('button');
    closeBtn.type = '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';
    });

    // --- 右上の全画像読み込みボタン ---
    const loadAllBtnTop = document.createElement('button');
    loadAllBtnTop.type = 'button';
    loadAllBtnTop.setAttribute('data-mv-ui', '1');
    loadAllBtnTop.textContent = '🔥全読込';
    loadAllBtnTop.title = 'ページ全体をスクロールして全ての画像を読み込み';
    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);
    });

    // --- 右下の全画像読み込みボタンを削除 ---

    // --- 背景トグル ---
    bgToggleBtn = document.createElement('button');
    bgToggleBtn.type = 'button';
    bgToggleBtn.setAttribute('data-mv-ui', '1');
    bgToggleBtn.style.cssText = `
      position:absolute; bottom:20px; right:20px;
      background: rgba(0,0,0,0.5); color: white;
      border:none; font-size:14px; padding:6px 10px;
      border-radius:6px; cursor:pointer;
    `;
    bgToggleBtn.textContent = modeFromColor(getBgColor());
    bgToggleBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      e.preventDefault();
      toggleBgColor();
    });

    container.appendChild(imageArea);
    container.appendChild(nav);
    container.appendChild(closeBtn);
    container.appendChild(loadAllBtnTop);
    container.appendChild(bgToggleBtn);
    document.body.appendChild(container);

    // --- コンテナ内クリック(画像/背景のみで有効) ---
    container.addEventListener('click', (e) => {
      // UI要素上のクリックは無視
      if (e.target.closest('[data-mv-ui="1"]')) return;

      const rect = container.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const center = rect.width / 2;

      if (x > center) {
        // 右半分:前(2ページ戻る)
        prevPage(2);
      } else {
        // 左半分:次(2ページ進む)
        nextPage(2);
      }
    });

    // マウスホイール
    if (CONFIG.enableMouseWheel) {
      container.addEventListener('wheel', (e) => {
        e.preventDefault();
        if (e.deltaY > 0) nextPage(2); else prevPage(2);
      }, { passive: false });
    }
  }

  // ---------- 全画像読み込み機能 ----------
  function loadAllImages(buttonElement) {
    if (buttonElement) {
      buttonElement.textContent = '🔥読込中...';
      buttonElement.style.opacity = '0.5';
    }
    
    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); // 画面1個分ずつスクロール(高速化)
    
    function scrollAndLoad() {
      // 高速スクロール
      currentScroll += scrollStep;
      window.scrollTo(0, currentScroll);
      
      // 画像検出を更新
      scheduleRefreshImages();
      
      if (currentScroll < documentHeight - viewportHeight) {
        // まだスクロールが必要(間隔を短縮:10msに)
        setTimeout(scrollAndLoad, 10);
      } else {
        // スクロール完了
        setTimeout(() => {
          // 元の位置に戻す
          window.scrollTo(0, originalScrollTop);
          
          // 最終的な画像検出
          refreshImages();
          
          // ボタンを元に戻す
          if (buttonElement) {
            buttonElement.textContent = '🔥全読込';
            buttonElement.style.opacity = '0.8';
          }
          
          // 完了メッセージ
          const msg = document.createElement('div');
          msg.textContent = `${images.length}枚の画像を検出しました`;
          msg.style.cssText = `
            position: fixed; top: 80px; left: 50%; transform: translateX(-50%);
            z-index: 10001; background: rgba(0,150,0,0.8); color: white;
            padding: 8px 12px; border-radius: 4px; font-size: 12px; pointer-events: none;
          `;
          document.body.appendChild(msg);
          setTimeout(() => msg.remove(), 2000);
          
        }, 100); // 少し待ってから元の位置に戻す
      }
    }
    
    scrollAndLoad();
  }
  
  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 = '';

    // row-reverse なので pageNum が右、pageNum+1 が左に並ぶ
    for (let i = 0; i < 2; i++) {
      const idx = pageNum + i;
      if (idx < images.length) {
        const img = document.createElement('img');
        img.src = images[idx].src;
        img.style.cssText = `
          max-height:${CONFIG.imageMaxHeight};
          max-width:50vw;
          object-fit:contain;
        `;
        imageArea.appendChild(img);
      }
    }

    currentPage = pageNum;
    updatePageInfoOnly();
    container.style.display = 'flex';
  }

  function updatePageInfoOnly() {
    if (!pageInfo) return;
    const start = Math.min(currentPage + 1, images.length);
    const end = Math.min(currentPage + 2, images.length);
    // 3桁まで固定幅で表示(例:001-002 / 123)
    const startStr = start.toString().padStart(3, '0');
    const endStr = end.toString().padStart(3, '0');
    const totalStr = images.length.toString().padStart(3, '0');
    pageInfo.textContent = `${startStr}-${endStr} / ${totalStr}`;
  }

  function nextPage(step = 2) {
    const target = currentPage + step;
    if (target < images.length) showSpreadPage(target);
  }

  function prevPage(step = 2) {
    const target = currentPage - step;
    if (target >= 0) showSpreadPage(target);
  }

  // ---------- キー操作 ----------
  if (CONFIG.enableKeyControls) {
    document.addEventListener('keydown', (e) => {
      if (!container || container.style.display !== 'flex') return;
      switch (e.key) {
        case 'ArrowLeft':     // 画面左:次(2ページ進む)
        case ' ':
          e.preventDefault();
          nextPage(2);
          break;
        case 'ArrowRight':    // 画面右:前(2ページ戻る)
          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.type = 'button';
    toggleButton.textContent = '📖'; // 本の絵文字で目立たな
    toggleButton.title = '見開き表示'; // ツールチップで機能説明
    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;
      display: flex; align-items: center; justify-content: center;
    `;
    toggleButton.onmouseenter = () => toggleButton.style.opacity = '1';
    toggleButton.onmouseleave = () => toggleButton.style.opacity = '0.7';
    toggleButton.addEventListener('click', () => {
      // クリック時に改めて画像を検出
      refreshImages();
      if (images.length >= CONFIG.minMangaImageCount) {
        // ビューア起動 = 記憶として、サイトを記憶
        setDomainStatus(currentDomain, 'show');
        showSpreadPage(0);
      } else {
        // ボタンクリック時は簡潔なメッセージ表示(アラートなし)
        const msg = document.createElement('div');
        msg.textContent = '漫画画像が見つかりません';
        msg.style.cssText = `
          position: fixed; top: 60px; right: 40px; z-index: 10000;
          background: rgba(0,0,0,0.8); color: white; padding: 8px 12px;
          border-radius: 4px; font-size: 14px; pointer-events: none;
        `;
        document.body.appendChild(msg);
        setTimeout(() => msg.remove(), 2000);
      }
    });
    document.body.appendChild(toggleButton);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', addToggleButton);
  } else {
    addToggleButton();
  }

  // ---------- Tampermonkey 右クリックメニュー ----------
  if (typeof GM_registerMenuCommand !== 'undefined') {
    // 見開きビューアを手動起動
    GM_registerMenuCommand("📖 見開きビューアを起動", () => {
      // 300ms待機してから画像検出を実行し、遅延読み込みに対応
      setTimeout(() => {
        refreshImages();
        if (images.length >= CONFIG.minMangaImageCount) {
          setDomainStatus(currentDomain, 'show');
          showSpreadPage(0);
        } else {
          // アラートの代わりにメッセージチップを表示
          const msg = document.createElement('div');
          msg.textContent = '漫画画像が見つかりません';
          msg.style.cssText = `
            position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
            z-index: 10001; background: rgba(0,0,0,0.8); color: white;
            padding: 10px 16px; border-radius: 6px; font-size: 14px; pointer-events: none;
          `;
          document.body.appendChild(msg);
          setTimeout(() => msg.remove(), 2500);
        }
      }, 300);
    });
    
    // 現在のドメインでの表示状態に応じてメニューを切り替え
    const status = getDomainStatus(currentDomain);
    GM_registerMenuCommand("👁️ このサイトでボタンを表示", () => {
      setDomainStatus(currentDomain, 'show');
      location.reload(); // ページをリロードして反映 
    });
    
    if (status === 'show') {
      GM_registerMenuCommand("🚫 このサイトでボタンを非表示", () => {
        setDomainStatus(currentDomain, 'hide');
        if (toggleButton) {
          toggleButton.remove();
          toggleButton = null;
        }
        const msg = document.createElement('div');
        msg.textContent = `${currentDomain} で見開きボタンを非表示にしました`;
        msg.style.cssText = `
          position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
          z-index: 10000; background: rgba(0,0,0,0.8); color: white;
          padding: 12px 20px; border-radius: 6px; font-size: 14px;
        `;
        document.body.appendChild(msg);
        setTimeout(() => msg.remove(), 3000);
      });
    }
    
    // --- 設定リセット
    GM_registerMenuCommand("⚠️ 記憶したサイトを全リセット ⚠️", () => {
      if (confirm('記憶したすべてのサイト設定をリセットしますか?\n(現在のページもリロードされます)')) {
        // LocalStorageから設定を削除
        localStorage.removeItem('mangaViewerDomains');
        localStorage.removeItem('mangaViewerBg'); // 背景設定もリセット
        
        // 確認メッセージを表示
        const msg = document.createElement('div');
        msg.textContent = 'すべての設定をリセットしました';
        msg.style.cssText = `
          position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
          z-index: 10000; background: rgba(255,0,0,0.8); color: white;
          padding: 12px 20px; border-radius: 6px; font-size: 14px;
        `;
        document.body.appendChild(msg);
        
        // 1秒後にリロード
        setTimeout(() => {
          location.reload();
        }, 1000);
      }
    });
  }

  // ---------- 動的コンテンツ対応 ----------
  // IO: 画面に入ったら更新(読み込み契機)
  if ('IntersectionObserver' in window) {
    io = new IntersectionObserver((entries) => {
      let hit = false;
      entries.forEach(entry => {
        if (entry.isIntersecting) hit = true;
      });
      if (hit) scheduleRefreshImages();
    }, { root: null, rootMargin: '200px 0px', threshold: 0.01 });
  }

  // Mutation: 新規imgを監視対象に
  const mo = new MutationObserver((mutations) => {
    let foundImg = false;
    for (const m of mutations) {
      m.addedNodes && m.addedNodes.forEach(node => {
        if (node && node.nodeType === 1) {
          if (node.tagName === 'IMG') {
            attachWatchers(node);
            foundImg = true;
          } else {
            // 子孫にIMGがある可能性
            node.querySelectorAll && node.querySelectorAll('img').forEach(attachWatchers);
          }
        }
      });
    }
    if (foundImg) scheduleRefreshImages();
  });
  mo.observe(document.body, { childList: true, subtree: true});

  // 初期検出
  refreshImages();
})();

QingJ © 2025

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