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