// ==UserScript==
// @name マンガ見開きビューア
// @namespace http://2chan.net/
// @version 1.4
// @description 画像が縦一列表示となっているサイト上で起動後、右上アイコンクリックで見開き表示(iframe系のサイト対応メニュー追加)
// @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,
enableKeyControls: true,
enableMouseWheel: true,
minMangaImageCount: 2,
defaultBg: '#333333',
refreshDebounceMs: 250,
};
let currentPage = 0;
let images = [];
let container = null;
let imageArea = null;
let bgToggleBtn = null;
let toggleButton = null;
let io = null;
const watched = new WeakSet();
let refreshTimer = null;
let navTimer = null;
let isIframeMode = 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] || 'default'; // 'show', 'hide', 'iframe', 'default'
}
function shouldShowButton() {
const status = getCurrentSiteStatus();
return status === 'show' || status === 'iframe';
}
function isCurrentSiteIframeMode() {
const status = getCurrentSiteStatus();
return status === 'iframe';
}
function setSiteIframeMode(hostname, enabled) {
const settings = getSiteSettings();
if (enabled) {
settings[hostname] = 'iframe';
} else {
// iframe解除時は通常の表示モードに変更
settings[hostname] = 'show';
}
setSiteSettings(settings);
// 即座にisIframeModeフラグも更新
isIframeMode = enabled;
}
// ---------- 背景色 ----------
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 detectMangaImages() {
console.log('detectMangaImages called, isIframeMode:', isIframeMode);
if (isIframeMode) {
const result = detectMangaImagesFromIframe();
console.log('iframe images detected:', result.length);
return result;
}
const result = detectMangaImagesFromDocument();
console.log('normal images detected:', result.length);
return result;
}
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 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;
console.log('Image check:', {
src: img.src.substring(0, 50) + '...',
complete: isComplete,
size: `${img.naturalWidth}x${img.naturalHeight}`,
largeEnough: isLargeEnough
});
return isComplete && hasSize && isLargeEnough;
});
console.log('Filtered potential images:', potential.length);
// iframe内の画像のsrcをフルURLに変換(元のオブジェクトを保持)
potential.forEach(img => {
let src = img.src;
// 相対URLの場合、iframe内のベースURLを使用
if (!src.startsWith('http')) {
try {
const iframeUrl = new URL(iframe.src);
src = new URL(src, iframeUrl.origin).href;
// 元の画像オブジェクトのsrcを更新
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 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();
}
if (!isIframeMode) {
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';
}
if (isIframeMode) {
// iframe内でのスクロール処理
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();
if (buttonElement) {
buttonElement.textContent = '🔥全読込';
buttonElement.style.opacity = '0.8';
buttonElement.dataset.loading = '0';
}
showMessage(`${images.length}枚の画像を検出しました`);
}, 100);
}
}
scrollAndLoad();
} catch (e) {
console.log("iframe scroll failed:", e);
if (buttonElement) {
buttonElement.textContent = '🔥全読込';
buttonElement.style.opacity = '0.8';
buttonElement.dataset.loading = '0';
}
}
} else {
// 通常のスクロール処理
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();
if (buttonElement) {
buttonElement.textContent = '🔥全読込';
buttonElement.style.opacity = '0.8';
buttonElement.dataset.loading = '0';
}
showMessage(`${images.length}枚の画像を検出しました`);
}, 100);
}
}
scrollAndLoad();
}
}
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;
// iframe対応時は、親ウィンドウではなくトップレベルウィンドウに作成
const targetDocument = (isIframeMode && window !== window.top) ? window.top.document : document;
const targetWindow = (isIframeMode && window !== window.top) ? window.top : 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; 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);
// --- 背景切替 ---
bgToggleBtn = targetDocument.createElement('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 = (getBgColor() === '#F5F5F5') ? '背景:白' : '背景:黒';
bgToggleBtn.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); toggleBgColor(); });
container.appendChild(bgToggleBtn);
// --- 常時表示のページカウンター ---
const pageCounter = targetDocument.createElement('div');
pageCounter.id = 'mv-page-counter';
pageCounter.setAttribute('data-mv-ui','1');
pageCounter.style.cssText = `
position:absolute; bottom:60px; 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);
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 targetDocument = (isIframeMode && window !== window.top) ? window.top.document : document;
const pageCounter = targetDocument.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;
display: flex; align-items: center; justify-content: center;
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) {
showSpreadPage(0);
} else {
showMessage('漫画画像が見つかりません', 'rgba(0,0,0,0.8)');
}
});
// 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() {
isIframeMode = isCurrentSiteIframeMode();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addToggleButton);
} else {
addToggleButton();
}
// ---------- 動的監視 ----------
if (!isIframeMode && 'IntersectionObserver' in window) {
io = new IntersectionObserver(entries => {
if (entries.some(e => e.isIntersecting)) scheduleRefreshImages();
}, { root:null, rootMargin:'200px 0px', threshold:0.01 });
}
if (!isIframeMode) {
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) {
showSpreadPage(0);
} else {
showMessage('漫画画像が見つかりません', 'rgba(0,0,0,0.8)');
}
}, 300);
});
GM_registerMenuCommand("🔄 iframe対応モード切替", () => {
const hostname = window.location.hostname;
const currentMode = isCurrentSiteIframeMode();
setSiteIframeMode(hostname, !currentMode);
showMessage(`${hostname}: iframe対応 ${!currentMode ? 'ON' : 'OFF'}`, 'rgba(0,100,200,0.8)');
// リロードしない - 即座に反映
if (toggleButton) {
toggleButton.remove();
addToggleButton();
}
});
GM_registerMenuCommand("👁️ このサイトでボタンを表示", () => {
const settings = getSiteSettings();
const hostname = window.location.hostname;
const currentMode = isCurrentSiteIframeMode();
settings[hostname] = currentMode ? 'iframe' : 'show';
setSiteSettings(settings);
showMessage(`${hostname} でボタンを表示します`, 'rgba(0,150,0,0.8)');
setTimeout(() => location.reload(), 1500);
});
GM_registerMenuCommand("🚫 このサイトでボタンを非表示", () => {
const settings = getSiteSettings();
const hostname = window.location.hostname;
settings[hostname] = 'hide';
setSiteSettings(settings);
if (toggleButton) toggleButton.remove();
showMessage(`${hostname} で見開きボタンを非表示にしました`, 'rgba(200,100,0,0.8)');
});
GM_registerMenuCommand("⚠️ 記憶したサイト設定をリセット ⚠️", () => {
if (confirm('記憶したサイト設定をリセットしますか?\n(現在のページもリロードされます)')) {
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: 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();
})();