Last active
April 27, 2025 23:24
-
-
Save roflsunriz/22077fdfbc0a01e303f2cebce3fae271 to your computer and use it in GitHub Desktop.
mangaViewer : ブック風マンガビューア(nicomanga.com/X.com)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name ブック風マンガビューア | |
// @namespace bookStyleMangaViewer | |
// @version 5.0 | |
// @description ウェブページの画像を見開き形式で表示するビューア(Xにも対応) | |
// @author roflsunriz | |
// @match https://nicomanga.com/read-* | |
// @match https://twitter.com/* | |
// @match https://x.com/* | |
// @grant GM_registerMenuCommand | |
// @require https://unpkg.com/react@18/umd/react.production.min.js | |
// @require https://unpkg.com/react-dom@18/umd/react-dom.production.min.js | |
// @updateURL https://gist.githubusercontent.com/roflsunriz/22077fdfbc0a01e303f2cebce3fae271/raw/mangaViewer.user.js | |
// @downloadURL https://gist.githubusercontent.com/roflsunriz/22077fdfbc0a01e303f2cebce3fae271/raw/mangaViewer.user.js | |
// @icon https://nicomanga.com/favicon.ico | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// スクリプト用のスタイルをページに隔離して追加 | |
const styleEl = document.createElement('style'); | |
styleEl.textContent = ` | |
/* カラーパレット定義 - 和風テーマ */ | |
:root { | |
--mv-primary: #FF6B6B; /* 赤色系アクセント */ | |
--mv-secondary: #4ECDC4; /* ティール系 */ | |
--mv-dark: #292F36; /* 濃い灰色 */ | |
--mv-light: #F7FFF7; /* 明るい背景色 */ | |
--mv-accent: #FFE66D; /* 黄色系アクセント */ | |
--mv-shadow-color: rgba(0, 0, 0, 0.3); | |
--mv-glass-bg: rgba(22, 28, 36, 0.8); | |
--mv-glass-light: rgba(255, 255, 255, 0.1); | |
} | |
/* ビューアボタン専用スタイル */ | |
#manga-viewer-launch-btn { | |
position: fixed; | |
bottom: 30px; | |
right: 30px; | |
padding: 12px 24px; | |
background: var(--mv-glass-bg); | |
color: white; | |
border: none; | |
border-radius: 50px; | |
cursor: pointer; | |
z-index: 10000; | |
box-sizing: content-box !important; | |
margin: 0 !important; | |
font-family: 'Segoe UI', 'Helvetica Neue', sans-serif !important; | |
font-size: 15px !important; | |
font-weight: 500 !important; | |
line-height: 1.5 !important; | |
text-align: center !important; | |
pointer-events: auto !important; | |
transform: translateY(0) !important; | |
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important; | |
box-shadow: 0 4px 20px var(--mv-shadow-color) !important; | |
width: auto !important; | |
height: auto !important; | |
display: flex !important; | |
align-items: center !important; | |
justify-content: center !important; | |
float: none !important; | |
opacity: 0.9 !important; | |
visibility: visible !important; | |
backdrop-filter: blur(10px); | |
border: 1px solid var(--mv-glass-light); | |
} | |
#manga-viewer-launch-btn:hover { | |
background-color: var(--mv-primary); | |
transform: translateY(-5px) !important; | |
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4) !important; | |
opacity: 1 !important; | |
} | |
#manga-viewer-launch-btn:before { | |
content: '📖'; | |
margin-right: 8px; | |
font-size: 18px; | |
} | |
/* ビューア用のスタイル */ | |
#manga-viewer-container { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
z-index: 10001; | |
display: flex; | |
flex-direction: column; | |
background-color: rgba(22, 28, 36, 0.85); | |
backdrop-filter: blur(12px); | |
font-family: 'Segoe UI', 'Helvetica Neue', sans-serif; | |
color: var(--mv-light); | |
transition: all 0.3s ease; | |
} | |
#manga-viewer-container * { | |
box-sizing: border-box; | |
} | |
.mv-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
padding: 0.8rem 1.5rem; | |
background-color: var(--mv-glass-bg); | |
backdrop-filter: blur(15px); | |
height: 60px; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | |
border-bottom: 1px solid var(--mv-glass-light); | |
z-index: 100; | |
} | |
.mv-header-text { | |
color: white; | |
font-weight: 600; | |
font-size: 16px; | |
display: flex; | |
align-items: center; | |
} | |
.mv-header-text:before { | |
content: '📖'; | |
margin-right: 10px; | |
font-size: 20px; | |
} | |
.mv-auto-nav-toggle { | |
background-color: var(--mv-glass-bg); | |
color: white; | |
padding: 8px 14px; | |
border-radius: 50px; | |
font-size: 14px; | |
font-weight: 500; | |
display: inline-flex; | |
align-items: center; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
margin: 0 1rem; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); | |
border: 1px solid var(--mv-glass-light); | |
} | |
.mv-auto-nav-toggle:before { | |
content: '✓'; | |
margin-right: 6px; | |
color: var(--mv-primary); | |
font-weight: bold; | |
} | |
.mv-auto-nav-toggle.off { | |
background-color: rgba(50, 50, 50, 0.4); | |
box-shadow: none; | |
} | |
.mv-auto-nav-toggle.off:before { | |
content: '✗'; | |
color: #9e9e9e; | |
} | |
.mv-auto-nav-toggle:hover { | |
transform: translateY(-2px); | |
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
} | |
.mv-close-button { | |
color: white; | |
background: rgba(255, 107, 107, 0.2); | |
border: 1px solid rgba(255, 107, 107, 0.4); | |
border-radius: 50px; | |
padding: 8px 16px; | |
font-weight: 500; | |
font-size: 14px; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
display: flex; | |
align-items: center; | |
} | |
.mv-close-button:before { | |
content: '×'; | |
margin-right: 6px; | |
font-size: 18px; | |
font-weight: bold; | |
} | |
.mv-close-button:hover { | |
background-color: var(--mv-primary); | |
color: white; | |
transform: translateY(-2px); | |
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3); | |
} | |
.mv-main-viewer { | |
flex: 1; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
overflow: hidden; | |
user-select: none; | |
perspective: 1500px; | |
transform-style: preserve-3d; | |
background: radial-gradient(circle at center, rgba(40, 44, 52, 0.8) 0%, rgba(17, 20, 24, 0.95) 100%); | |
} | |
.mv-page-container { | |
position: relative; | |
max-width: 50vw; | |
height: 100%; | |
perspective: 1200px; | |
transform-style: preserve-3d; | |
transition: all 0.3s ease; | |
} | |
.mv-page { | |
position: relative; | |
max-width: 100%; | |
height: 100%; | |
object-fit: contain; | |
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.25); | |
transform-style: preserve-3d; | |
backface-visibility: hidden; | |
will-change: transform, z-index; | |
cursor: grab; | |
border-radius: 3px; | |
transition: transform 0.2s ease; | |
} | |
.mv-page:hover { | |
transform: scale(1.01) translateZ(10px); | |
} | |
.mv-edge-indicator { | |
position: absolute; | |
top: 50%; | |
transform: translateY(-50%); | |
background-color: var(--mv-glass-bg); | |
color: white; | |
padding: 0.8rem 1.2rem; | |
font-weight: 500; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | |
backdrop-filter: blur(8px); | |
transition: all 0.3s ease; | |
opacity: 0.8; | |
display: flex; | |
align-items: center; | |
} | |
.mv-edge-indicator:hover { | |
opacity: 1; | |
transform: translateY(-50%) scale(1.05); | |
} | |
.mv-right-indicator { | |
right: 15px; | |
border-radius: 50px 0 0 50px; | |
padding-right: 1.5rem; | |
} | |
.mv-right-indicator:before { | |
content: '▶'; | |
margin-right: 8px; | |
font-size: 14px; | |
} | |
.mv-left-indicator { | |
left: 15px; | |
border-radius: 0 50px 50px 0; | |
padding-left: 1.5rem; | |
} | |
.mv-left-indicator:after { | |
content: '◀'; | |
margin-left: 8px; | |
font-size: 14px; | |
} | |
.mv-page-edge { | |
position: absolute; | |
height: 100%; | |
width: 20px; | |
top: 0; | |
background: linear-gradient(to right, rgba(0,0,0,0.2), rgba(0,0,0,0)); | |
z-index: 5; | |
} | |
.mv-page-edge.left { | |
left: 0; | |
border-radius: 3px 0 0 3px; | |
} | |
.mv-page-edge.right { | |
right: 0; | |
transform: scaleX(-1); | |
border-radius: 0 3px 3px 0; | |
} | |
.mv-page-animating { | |
z-index: 10 !important; | |
} | |
/* ショートカットヒント */ | |
.mv-shortcuts-hint { | |
position: absolute; | |
bottom: 20px; | |
left: 50%; | |
transform: translateX(-50%); | |
background-color: var(--mv-glass-bg); | |
color: white; | |
padding: 10px 20px; | |
border-radius: 50px; | |
font-size: 13px; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | |
backdrop-filter: blur(8px); | |
transition: opacity 0.3s ease, transform 0.3s ease; | |
display: flex; | |
align-items: center; | |
border: 1px solid var(--mv-glass-light); | |
z-index: 100; | |
} | |
.mv-shortcuts-hint.visible { | |
opacity: 0.9; | |
transform: translateX(-50%) translateY(0); | |
} | |
.mv-shortcuts-hint.hidden { | |
opacity: 0; | |
transform: translateX(-50%) translateY(20px); | |
pointer-events: none; | |
} | |
.mv-shortcuts-hint:hover { | |
opacity: 1; | |
} | |
.mv-shortcuts-hint span { | |
display: inline-flex; | |
align-items: center; | |
margin: 0 6px; | |
} | |
.mv-key { | |
background-color: rgba(255, 255, 255, 0.2); | |
border-radius: 4px; | |
padding: 2px 6px; | |
margin: 0 3px; | |
font-weight: bold; | |
} | |
/* ズームインジケーター */ | |
.mv-zoom-indicator { | |
position: absolute; | |
top: 80px; | |
right: 20px; | |
background-color: var(--mv-glass-bg); | |
color: white; | |
padding: 6px 14px; | |
border-radius: 50px; | |
font-size: 14px; | |
opacity: 0; | |
transform: translateY(-20px); | |
transition: all 0.3s ease; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | |
backdrop-filter: blur(8px); | |
border: 1px solid var(--mv-glass-light); | |
} | |
.mv-zoom-indicator.visible { | |
opacity: 0.9; | |
transform: translateY(0); | |
} | |
/* 発光エフェクト */ | |
.mv-glow-effect { | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%) scale(0.95); | |
width: 60px; | |
height: 60px; | |
border-radius: 50%; | |
background: radial-gradient(circle, rgba(255,107,107,0.3) 0%, rgba(255,107,107,0) 70%); | |
animation: glow-pulse 1.5s ease-in-out infinite; | |
} | |
@keyframes glow-pulse { | |
0% { | |
box-shadow: 0 0 0 0 rgba(255, 107, 107, 0.7); | |
transform: translate(-50%, -50%) scale(0.95); | |
} | |
70% { | |
box-shadow: 0 0 0 15px rgba(255, 107, 107, 0); | |
transform: translate(-50%, -50%) scale(1); | |
} | |
100% { | |
box-shadow: 0 0 0 0 rgba(255, 107, 107, 0); | |
transform: translate(-50%, -50%) scale(0.95); | |
} | |
} | |
/* ローディングスピナー */ | |
#manga-viewer-loading { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
background-color: rgba(17, 20, 24, 0.9); | |
color: white; | |
z-index: 10000; | |
font-family: 'Segoe UI', 'Helvetica Neue', sans-serif; | |
backdrop-filter: blur(10px); | |
} | |
.mv-spinner { | |
position:absolute; | |
top:10px; | |
left:10px; | |
width: 60px; | |
height: 60px; | |
border: 4px solid rgba(255, 255, 255, 0.1); | |
border-radius: 50%; | |
border-top-color: var(--mv-primary); | |
border-left-color: var(--mv-primary); | |
animation: spin 1s cubic-bezier(0.42, 0, 0.58, 1) infinite; | |
margin-bottom: 20px; | |
box-shadow: 0 0 30px rgba(255, 107, 107, 0.3); | |
} | |
.mv-message { | |
font-size: 18px; | |
margin-top: 15px; | |
background-color: var(--mv-glass-bg); | |
padding: 12px 24px; | |
border-radius: 50px; | |
max-width: 80%; | |
text-align: center; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | |
animation: fadeIn 0.5s ease; | |
backdrop-filter: blur(8px); | |
border: 1px solid var(--mv-glass-light); | |
} | |
/* アニメーション定義 */ | |
@keyframes spin { | |
to { transform: rotate(360deg); } | |
} | |
@keyframes fadeIn { | |
from { opacity: 0; transform: translateY(20px); } | |
to { opacity: 1; transform: translateY(0); } | |
} | |
@keyframes bounceLeft { | |
0% { transform: translateX(0); } | |
25% { transform: translateX(20px); } | |
50% { transform: translateX(0); } | |
75% { transform: translateX(10px); } | |
100% { transform: translateX(0); } | |
} | |
@keyframes bounceRight { | |
0% { transform: translateX(0); } | |
25% { transform: translateX(-20px); } | |
50% { transform: translateX(0); } | |
75% { transform: translateX(-10px); } | |
100% { transform: translateX(0); } | |
} | |
@keyframes pulse { | |
0% { | |
box-shadow: 0 0 0 0 rgba(255, 107, 107, 0.7); | |
transform: scale(0.95); | |
} | |
70% { | |
box-shadow: 0 0 0 15px rgba(255, 107, 107, 0); | |
transform: scale(1); | |
} | |
100% { | |
box-shadow: 0 0 0 0 rgba(255, 107, 107, 0); | |
transform: scale(0.95); | |
} | |
} | |
.mv-btn-hover { | |
animation: btn-pulse 1.5s infinite; | |
} | |
.mv-progress-container { | |
position: absolute; | |
top: 60px; /* ヘッダーの高さに合わせる */ | |
left: 0; | |
width: 100%; | |
height: 3px; /* 薄いバー */ | |
background-color: rgba(255, 255, 255, 0.1); | |
z-index: 100; | |
overflow: hidden; | |
} | |
.mv-progress-bar { | |
height: 100%; | |
background-color: var(--mv-primary, #FF6B6B); | |
transition: width 0.3s ease; | |
} | |
.mv-progress-message { | |
position: absolute; | |
top: 3px; /* バーの下に少しスペース */ | |
left: 0; | |
width: 100%; | |
font-size: 10px; | |
color: rgba(255, 255, 255, 0.7); | |
text-align: center; | |
padding: 2px 0; | |
pointer-events: none; | |
opacity: 0.7; | |
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); | |
} | |
`; | |
document.head.appendChild(styleEl); | |
// React要素を作成するためのヘルパー関数 | |
const e = React.createElement; | |
// ローディングスピナークラス | |
class LoadingSpinner { | |
constructor() { | |
this.element = null; | |
} | |
/** | |
* ローディングスピナーを表示する | |
* @param {string} message - 表示するメッセージ | |
* @returns {HTMLElement} - ローディングスピナーのDOM要素 | |
*/ | |
show(message = '画像を読み込み中...') { | |
// すでに存在する場合は削除 | |
this.hide(); | |
// スピナー要素を作成 | |
this.element = document.createElement('div'); | |
this.element.id = 'manga-viewer-loading'; | |
// スピナーコンテナ | |
const spinnerContainer = document.createElement('div'); | |
spinnerContainer.style.position = 'relative'; | |
spinnerContainer.style.width = '80px'; | |
spinnerContainer.style.height = '80px'; | |
// メインスピナー | |
const spinner = document.createElement('div'); | |
spinner.classList.add('mv-spinner'); | |
// 発光エフェクト | |
const glowEffect = document.createElement('div'); | |
glowEffect.style.position = 'absolute'; | |
glowEffect.style.top = '50%'; | |
glowEffect.style.left = '50%'; | |
glowEffect.style.transform = 'translate(-50%, -50%)'; | |
glowEffect.style.width = '60px'; | |
glowEffect.style.height = '60px'; | |
glowEffect.style.borderRadius = '50%'; | |
glowEffect.style.background = 'radial-gradient(circle, rgba(255,107,107,0.3) 0%, rgba(255,107,107,0) 70%)'; | |
glowEffect.classList.add('mv-glow-effect'); | |
// メッセージ | |
const messageElement = document.createElement('div'); | |
messageElement.textContent = message; | |
messageElement.classList.add('mv-message'); | |
// プログレスバー | |
const progressBarContainer = document.createElement('div'); | |
progressBarContainer.style.width = '200px'; | |
progressBarContainer.style.height = '4px'; | |
progressBarContainer.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; | |
progressBarContainer.style.borderRadius = '2px'; | |
progressBarContainer.style.overflow = 'hidden'; | |
progressBarContainer.style.marginTop = '20px'; | |
const progressBar = document.createElement('div'); | |
progressBar.classList.add('mv-progress-bar'); | |
progressBar.style.width = '0%'; | |
progressBar.style.height = '100%'; | |
progressBar.style.backgroundColor = 'var(--mv-primary, #FF6B6B)'; | |
progressBar.style.borderRadius = '2px'; | |
progressBar.style.transition = 'width 0.3s ease'; | |
progressBarContainer.appendChild(progressBar); | |
// 要素を組み立てる | |
spinnerContainer.appendChild(spinner); | |
spinnerContainer.appendChild(glowEffect); | |
this.element.appendChild(spinnerContainer); | |
this.element.appendChild(messageElement); | |
this.element.appendChild(progressBarContainer); | |
document.body.appendChild(this.element); | |
// プログレスバーの初期アニメーション(一定の動き) | |
this.startProgressAnimation(); | |
return this.element; | |
} | |
/** | |
* プログレスバーのアニメーションを開始する | |
*/ | |
startProgressAnimation() { | |
if (!this.element) return; | |
const progressBar = this.element.querySelector('.mv-progress-bar'); | |
if (!progressBar) return; | |
let width = 0; | |
const maxPreloadWidth = 90; // 最大90%まで自動的に進む | |
this.progressInterval = setInterval(() => { | |
if (width >= maxPreloadWidth) { | |
clearInterval(this.progressInterval); | |
return; | |
} | |
// 徐々に遅くなる進行 | |
const increment = (maxPreloadWidth - width) / 100 * 3; | |
width += Math.max(0.1, increment); | |
if (width > maxPreloadWidth) width = maxPreloadWidth; | |
progressBar.style.width = `${width}%`; | |
}, 100); | |
} | |
/** | |
* プログレスバーの進行状況を設定する | |
* @param {number} percent - 0-100の間の数値 | |
*/ | |
setProgress(percent) { | |
if (!this.element) return; | |
const progressBar = this.element.querySelector('.mv-progress-bar'); | |
if (!progressBar) return; | |
// 既存のインターバルをクリア | |
if (this.progressInterval) { | |
clearInterval(this.progressInterval); | |
this.progressInterval = null; | |
} | |
progressBar.style.width = `${percent}%`; | |
} | |
/** | |
* ローディングが完了したことを表示する | |
*/ | |
setComplete() { | |
this.setProgress(100); | |
if (this.element) { | |
this.element.classList.add('mv-loading-complete'); | |
const spinner = this.element.querySelector('.mv-spinner'); | |
if (spinner) { | |
spinner.classList.add('mv-spinner-complete'); | |
} | |
} | |
} | |
/** | |
* ローディングスピナーを非表示にする | |
*/ | |
hide() { | |
if (this.progressInterval) { | |
clearInterval(this.progressInterval); | |
this.progressInterval = null; | |
} | |
if (this.element && this.element.parentNode) { | |
// フェードアウトアニメーション | |
this.element.style.opacity = '0'; | |
setTimeout(() => { | |
if (this.element && this.element.parentNode) { | |
this.element.parentNode.removeChild(this.element); | |
this.element = null; | |
} | |
}, 300); | |
} | |
} | |
/** | |
* ローディングメッセージを更新する | |
* @param {string} message - 新しいメッセージ | |
* @param {number} progressPercent - 進行状況(0-100) | |
*/ | |
updateMessage(message, progressPercent = null) { | |
if (this.element) { | |
const messageElement = this.element.querySelector('.mv-message'); | |
if (messageElement) { | |
// メッセージの更新効果 | |
messageElement.style.opacity = '0'; | |
setTimeout(() => { | |
messageElement.textContent = message; | |
messageElement.style.opacity = '1'; | |
}, 150); | |
} | |
// 進行状況が指定されている場合は更新 | |
if (progressPercent !== null) { | |
this.setProgress(progressPercent); | |
} | |
} | |
} | |
} | |
// チャプターナビゲーションクラス | |
class ChapterNavigator { | |
constructor() { | |
this.prevChapterSelectors = ['.nav-button.prev', '.rd_sd-button_item.rd_top-left']; | |
this.nextChapterSelectors = ['.nav-button.next', '.rd_sd-button_item.rd_top-right']; | |
this.isNavigating = false; | |
} | |
/** | |
* 前のチャプターへ移動する | |
* @returns {boolean} 移動が成功したかどうか | |
*/ | |
navigatePrevChapter() { | |
for (const selector of this.prevChapterSelectors) { | |
const button = document.querySelector(selector); | |
if (button) { | |
// ページ遷移を記録するフラグをセット | |
this.isNavigating = true; | |
localStorage.setItem('mangaViewer_autoLaunch', 'true'); | |
// 実際のボタンがhref属性を持っているか確認 | |
if (button.hasAttribute('href')) { | |
const href = button.getAttribute('href'); | |
// hrefがあれば、リンク先に移動する | |
window.location.href = href; | |
return true; | |
} else { | |
// hrefがなければ、クリックイベントを発火 | |
button.click(); | |
return true; | |
} | |
} | |
} | |
console.log('前のチャプターが見つかりませんでした'); | |
return false; | |
} | |
/** | |
* 次のチャプターへ移動する | |
* @returns {boolean} 移動が成功したかどうか | |
*/ | |
navigateNextChapter() { | |
for (const selector of this.nextChapterSelectors) { | |
const button = document.querySelector(selector); | |
if (button) { | |
// ページ遷移を記録するフラグをセット | |
this.isNavigating = true; | |
localStorage.setItem('mangaViewer_autoLaunch', 'true'); | |
// 実際のボタンがhref属性を持っているか確認 | |
if (button.hasAttribute('href')) { | |
const href = button.getAttribute('href'); | |
// hrefがあれば、リンク先に移動する | |
window.location.href = href; | |
return true; | |
} else { | |
// hrefがなければ、クリックイベントを発火 | |
button.click(); | |
return true; | |
} | |
} | |
} | |
console.log('次のチャプターが見つかりませんでした'); | |
return false; | |
} | |
/** | |
* チャプター移動中かどうかをチェックし、移動中であればビューアを自動起動する | |
* @returns {boolean} ビューアを自動起動する必要があるかどうか | |
*/ | |
checkAutoLaunch() { | |
const shouldAutoLaunch = localStorage.getItem('mangaViewer_autoLaunch') === 'true'; | |
if (shouldAutoLaunch) { | |
// フラグをリセット | |
localStorage.removeItem('mangaViewer_autoLaunch'); | |
return true; | |
} | |
return false; | |
} | |
} | |
// ビューアコンポーネント | |
const ViewerComponent = ({ images, onClose, initialAutoNav = true }) => { | |
const [currentSpreadIndex, setCurrentSpreadIndex] = React.useState(0); | |
const [scale, setScale] = React.useState(1); | |
const [isDragging, setIsDragging] = React.useState(false); | |
const [startX, setStartX] = React.useState(0); | |
const [isAnimating, setIsAnimating] = React.useState(false); | |
const [turnDirection, setTurnDirection] = React.useState(null); | |
const [bounceDirection, setBounceDirection] = React.useState(null); | |
const [autoChapterNavigation, setAutoChapterNavigation] = React.useState(initialAutoNav); | |
const [animatingPage, setAnimatingPage] = React.useState(null); // 左または右のどちらのページをアニメーションするか | |
const [showZoomIndicator, setShowZoomIndicator] = React.useState(false); | |
const [hintsVisible, setHintsVisible] = React.useState(false); | |
const [hasShownInitialHint, setHasShownInitialHint] = React.useState(false); | |
const [isMouseActive, setIsMouseActive] = React.useState(false); | |
const [chapterTitle, setChapterTitle] = React.useState(''); // チャプタータイトルの状態を追加 | |
// 追加: マウス位置と拡大縮小に関する状態 | |
const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 }); | |
const [transformState, setTransformState] = React.useState({ | |
scale: 1, | |
translateX: 0, | |
translateY: 0 | |
}); | |
// スリムなプログレスバーの状態を追加 | |
const [progressState, setProgressState] = React.useState({ | |
visible: false, | |
percent: 0, | |
message: '', | |
phase: 'init' // 処理フェーズを追加(init, loading, complete) | |
}); | |
// 右側の画像のwidthを追跡するための状態を追加 | |
const [rightImageWidth, setRightImageWidth] = React.useState(null); | |
const viewerRef = React.useRef(null); | |
const mainViewerRef = React.useRef(null); // メインビューア部分への参照 | |
const chapterNavigator = React.useRef(new ChapterNavigator()); | |
const zoomIndicatorTimeout = React.useRef(null); | |
const hintsRef = React.useRef(null); | |
const mouseActivityTimer = React.useRef(null); | |
// グローバルなプログレス更新関数を公開 | |
React.useEffect(() => { | |
// グローバル関数として公開 | |
unsafeWindow.MangaViewer = unsafeWindow.MangaViewer || {}; | |
// バックグラウンド処理の進捗を設定する関数 | |
unsafeWindow.MangaViewer.updateProgress = (percent, message, phase = null) => { | |
setProgressState(prev => { | |
// フェーズが変わった場合は新しいフェーズを設定 | |
const newPhase = phase || prev.phase; | |
// 進捗が減少する更新は無視する(常に増加のみ) | |
// ただし、フェーズが変わった場合は例外 | |
if (percent < prev.percent && newPhase === prev.phase) { | |
return prev; | |
} | |
return { | |
visible: true, | |
percent: percent, | |
message: message || '', | |
phase: newPhase | |
}; | |
}); | |
// 100%になったら2秒後に非表示 | |
if (percent >= 100) { | |
setTimeout(() => { | |
setProgressState(prev => ({...prev, visible: false})); | |
}, 2000); | |
} | |
}; | |
// クリーンアップ | |
return () => { | |
if (unsafeWindow.MangaViewer) { | |
unsafeWindow.MangaViewer.updateProgress = null; | |
} | |
}; | |
}, []); | |
// チャプター情報を取得 | |
React.useEffect(() => { | |
// .breadcrumb-item.activeからチャプター番号を取得 | |
const breadcrumbItem = document.querySelector('.breadcrumb-item.active'); | |
if (breadcrumbItem) { | |
const chapterText = breadcrumbItem.textContent.trim().match(/第話 (\d+)/); | |
console.log('チャプター情報を取得:', chapterText); | |
setChapterTitle(`第${chapterText[1]}話`); | |
} else { | |
console.log('チャプター情報を取得できませんでした。.breadcrumb-item.activeが見つかりません。'); | |
// 代替手段:タイトルからチャプター情報を取得 | |
const titleElem = document.querySelector('title'); | |
if (titleElem) { | |
const titleText = titleElem.textContent.trim(); | |
console.log('代替:タイトルからチャプター情報を取得:', titleText); | |
// タイトルから章番号を抽出する正規表現 | |
const chapterMatch = titleText.match(/第(\d+)話/); | |
if (chapterMatch) { | |
setChapterTitle(`第${chapterMatch[1]}話`); | |
} | |
} | |
} | |
}, []); | |
// マウスアクティビティをリセットする関数 | |
const resetMouseActivity = () => { | |
setIsMouseActive(true); | |
// 既存のタイマーをクリア | |
if (mouseActivityTimer.current) { | |
clearTimeout(mouseActivityTimer.current); | |
} | |
// 新しいタイマーをセット(2秒間動きがなければマウスはインアクティブと判断) | |
mouseActivityTimer.current = setTimeout(() => { | |
setIsMouseActive(false); | |
// マウスがインアクティブになったらヒントも非表示にする | |
setHintsVisible(false); | |
}, 2000); | |
}; | |
// マウスの位置を監視してヒント表示を制御するイベントハンドラ | |
React.useEffect(() => { | |
// 既に初期表示が終わっている場合のみ | |
if (hasShownInitialHint && viewerRef.current) { | |
// マウス移動イベントのハンドラ | |
const handleMouseMove = (e) => { | |
if (!hintsRef.current) return; | |
// マウスアクティビティをリセット | |
resetMouseActivity(); | |
// マウス位置を更新 (追加) | |
if (mainViewerRef.current) { | |
const rect = mainViewerRef.current.getBoundingClientRect(); | |
setMousePosition({ | |
x: e.clientX - rect.left, | |
y: e.clientY - rect.top | |
}); | |
} | |
// ビューアの位置情報を取得 | |
const viewerRect = viewerRef.current.getBoundingClientRect(); | |
// マウスがビューアの下部30%以内にあるかをチェック | |
const bottomThreshold = viewerRect.height * 0.7; // 下部30%の境界線 | |
const mouseY = e.clientY - viewerRect.top; | |
// 下部領域にマウスがある場合はヒントを表示 | |
if (mouseY > bottomThreshold) { | |
setHintsVisible(true); | |
} else { | |
setHintsVisible(false); | |
} | |
}; | |
// マウスがビューア外に出た時のハンドラ | |
const handleMouseLeave = () => { | |
setHintsVisible(false); | |
}; | |
// クリーンアップ関数 | |
return () => { | |
if (mouseActivityTimer.current) { | |
clearTimeout(mouseActivityTimer.current); | |
} | |
}; | |
} | |
}, [hasShownInitialHint]); | |
// 現在の見開きページの画像URLを取得 | |
const getCurrentSpread = () => { | |
const startIdx = currentSpreadIndex * 2; | |
const leftPageIndex = startIdx + 1; | |
const rightPageIndex = startIdx; | |
// 画像が奇数枚の場合は最後にダミーページを追加 | |
const isLastSpread = Math.ceil(images.length / 2) - 1 === currentSpreadIndex; | |
const isOddNumberOfImages = images.length % 2 === 1; | |
// 最後の見開きで奇数枚の場合、ダミーページを左側に表示(空いた部分に配置) | |
if (isLastSpread && isOddNumberOfImages && leftPageIndex === images.length) { | |
return [ | |
null, // 左ページは空(ダミーページを表示) | |
rightPageIndex < images.length ? images[rightPageIndex] : null | |
]; | |
} | |
// 通常の処理 | |
// 範囲外のインデックスの場合はnullを返す | |
return [ | |
leftPageIndex < images.length ? images[leftPageIndex] : null, | |
rightPageIndex < images.length ? images[rightPageIndex] : null | |
]; | |
}; | |
// ズームインジケータを表示する | |
const showZoomLevel = () => { | |
// アクティビティをリセット(ズーム操作もユーザーアクション) | |
resetMouseActivity(); | |
// すでに実行中のタイマーがあればクリア | |
if (zoomIndicatorTimeout.current) { | |
clearTimeout(zoomIndicatorTimeout.current); | |
} | |
setShowZoomIndicator(true); | |
// 1.5秒後に非表示にする | |
zoomIndicatorTimeout.current = setTimeout(() => { | |
setShowZoomIndicator(false); | |
}, 1500); | |
}; | |
// ページめくりアニメーション処理 | |
const animatePageTurn = (direction, pageSide = null) => { | |
// ページめくりもユーザーアクションなのでアクティビティをリセット | |
resetMouseActivity(); | |
if (isAnimating) return; | |
// 範囲外へのめくりを防止 | |
if (direction === 'prev' && currentSpreadIndex <= 0) { | |
// 最初のページより前には戻れない - バウンス効果でフィードバック | |
showBounceAnimation('left'); | |
// 自動チャプター移動が有効な場合は前のチャプターへ移動 | |
if (autoChapterNavigation) { | |
const success = chapterNavigator.current.navigatePrevChapter(); | |
if (success) { | |
onClose(); // ビューアを閉じる(新しいページで再度開く) | |
} | |
} | |
return; | |
} | |
// 最後のページ以降にはめくれないようにする | |
const maxSpreadIndex = Math.ceil(images.length / 2) - 1; | |
if (direction === 'next' && currentSpreadIndex >= maxSpreadIndex) { | |
// 最後のページより後ろにはめくれない - バウンス効果でフィードバック | |
showBounceAnimation('right'); | |
// 自動チャプター移動が有効な場合は次のチャプターへ移動 | |
if (autoChapterNavigation) { | |
const success = chapterNavigator.current.navigateNextChapter(); | |
if (success) { | |
onClose(); // ビューアを閉じる(新しいページで再度開く) | |
} | |
} | |
return; | |
} | |
setIsAnimating(true); | |
setTurnDirection(direction); | |
setAnimatingPage(pageSide); // 左または右のどちらのページをアニメーションするか設定 | |
// アニメーション完了後にページを更新 | |
setTimeout(() => { | |
if (direction === 'prev' && currentSpreadIndex > 0) { | |
setCurrentSpreadIndex(prev => prev - 1); | |
} else if (direction === 'next' && currentSpreadIndex < Math.ceil(images.length / 2) - 1) { | |
setCurrentSpreadIndex(prev => prev + 1); | |
} | |
setIsAnimating(false); | |
setTurnDirection(null); | |
setAnimatingPage(null); | |
}, 200); // アニメーション時間を0.2秒に合わせる | |
}; | |
// 境界到達時のバウンスアニメーション | |
const showBounceAnimation = (direction) => { | |
setBounceDirection(direction); | |
setTimeout(() => setBounceDirection(null), 300); // 300msでアニメーション終了 | |
}; | |
// バウンスアニメーションのスタイルを取得 | |
const getBounceStyle = () => { | |
if (!bounceDirection) return {}; | |
if (bounceDirection === 'left') { | |
return { | |
animation: 'bounceLeft 0.3s ease-in-out' | |
}; | |
} else if (bounceDirection === 'right') { | |
return { | |
animation: 'bounceRight 0.3s ease-in-out' | |
}; | |
} | |
return {}; | |
}; | |
// バウンスアニメーションのスタイルシートを追加 | |
React.useEffect(() => { | |
const style = document.createElement('style'); | |
style.textContent = ` | |
@keyframes bounceLeft { | |
0% { transform: translateX(0); } | |
25% { transform: translateX(20px); } | |
50% { transform: translateX(0); } | |
75% { transform: translateX(10px); } | |
100% { transform: translateX(0); } | |
} | |
@keyframes bounceRight { | |
0% { transform: translateX(0); } | |
25% { transform: translateX(-20px); } | |
50% { transform: translateX(0); } | |
75% { transform: translateX(-10px); } | |
100% { transform: translateX(0); } | |
} | |
`; | |
document.head.appendChild(style); | |
return () => style.remove(); | |
}, []); | |
// ページめくり処理 | |
const handlePageTurn = (direction, pageSide = null) => { | |
if (!isAnimating) { | |
animatePageTurn(direction, pageSide); | |
} | |
}; | |
// チャプター自動移動の切り替え | |
const toggleAutoChapterNavigation = () => { | |
setAutoChapterNavigation(prev => !prev); | |
}; | |
// マウスドラッグ処理 | |
const handleMouseDown = (e) => { | |
setIsDragging(true); | |
setStartX(e.clientX); | |
}; | |
const handleMouseMove = (e) => { | |
// ドラッグ処理の前にマウスアクティビティをリセット(常にマウス移動を検知) | |
resetMouseActivity(); | |
// マウス位置を更新 | |
if (mainViewerRef.current) { | |
const rect = mainViewerRef.current.getBoundingClientRect(); | |
setMousePosition({ | |
x: e.clientX - rect.left, | |
y: e.clientY - rect.top | |
}); | |
} | |
// ヒントの表示制御 | |
if (viewerRef.current && hintsRef.current) { | |
const viewerRect = viewerRef.current.getBoundingClientRect(); | |
const bottomThreshold = viewerRect.height * 0.7; // 下部30%の境界線 | |
const mouseY = e.clientY - viewerRect.top; | |
// 下部領域にマウスがある場合はヒントを表示 | |
if (mouseY > bottomThreshold) { | |
setHintsVisible(true); | |
} else { | |
setHintsVisible(false); | |
} | |
} | |
// 元のドラッグ処理 | |
if (!isDragging) return; | |
const deltaX = e.clientX - startX; | |
const threshold = 100; // ドラッグのしきい値 | |
if (Math.abs(deltaX) > threshold) { | |
// 画像エリアを取得して、左右どちらのエリアでドラッグが発生したかを判定 | |
const imageElements = viewerRef.current.querySelectorAll('img'); | |
if (imageElements.length > 0) { | |
// ドラッグの開始位置がどの画像エリアにあるかを判定 | |
const startPosX = startX; | |
// 右側の画像(左ページ)のエリア内でドラッグ開始 | |
const leftPageRect = imageElements[0] ? imageElements[0].getBoundingClientRect() : null; | |
const rightPageRect = imageElements[1] ? imageElements[1].getBoundingClientRect() : null; | |
if (leftPageRect && rightPageRect) { | |
// 左右の画像エリアを判定して方向を決定 | |
if (startPosX >= leftPageRect.left && startPosX <= leftPageRect.right) { | |
// 右側の画像エリア(左ページ) | |
handlePageTurn('next', 'left'); // 左ページをめくる = 次のページ | |
} else if (startPosX >= rightPageRect.left && startPosX <= rightPageRect.right) { | |
// 左側の画像エリア(右ページ) | |
handlePageTurn('prev', 'right'); // 右ページをめくる = 前のページ | |
} else { | |
// 画像エリア外でのドラッグ - 従来通りの方向判定 | |
if (deltaX > 0) { | |
handlePageTurn('prev', 'right'); | |
} else { | |
handlePageTurn('next', 'left'); | |
} | |
} | |
} else { | |
// 画像が1枚以下の場合は従来通りの判定 | |
if (deltaX > 0) { | |
handlePageTurn('prev', 'right'); | |
} else { | |
handlePageTurn('next', 'left'); | |
} | |
} | |
} else { | |
// 画像がない場合は従来通りの判定 | |
if (deltaX > 0) { | |
handlePageTurn('prev', 'right'); | |
} else { | |
handlePageTurn('next', 'left'); | |
} | |
} | |
setIsDragging(false); | |
} | |
}; | |
const handleMouseUp = () => { | |
setIsDragging(false); | |
}; | |
// マウスクリックでのページめくり | |
const handleClick = (e) => { | |
// エンドページの要素を検索 | |
const endPageElements = viewerRef.current.querySelectorAll('.mv-end-page'); | |
// エンドページがあれば、クリックで次のチャプターへ | |
if (endPageElements.length > 0) { | |
for (const endPage of endPageElements) { | |
const rect = endPage.getBoundingClientRect(); | |
if (e.clientX >= rect.left && e.clientX <= rect.right && | |
e.clientY >= rect.top && e.clientY <= rect.bottom) { | |
// エンドページをクリックした場合、自動チャプター移動が有効なら次のチャプターへ | |
if (autoChapterNavigation) { | |
const success = chapterNavigator.current.navigateNextChapter(); | |
if (success) { | |
onClose(); // ビューアを閉じる(新しいページで再度開く) | |
return; | |
} | |
} | |
return; | |
} | |
} | |
} | |
// 画像要素を取得 | |
const imageElements = viewerRef.current.querySelectorAll('img'); | |
if (imageElements.length === 0) return; | |
// 注意: spread[0]は左ページ(viewerでは右側に表示)、spread[1]は右ページ(viewerでは左側に表示) | |
// 左開きの本(日本の漫画など)の動作に合わせる | |
// - 左側の画像(右ページ)をクリックすると前のページへ | |
// - 右側の画像(左ページ)をクリックすると次のページへ | |
// 左側に表示される画像(右ページ、spread[1]に対応) | |
if (imageElements[1]) { | |
const rightPageRect = imageElements[1].getBoundingClientRect(); | |
// 右ページをクリックした場合は前のページへ | |
if (e.clientX >= rightPageRect.left && e.clientX <= rightPageRect.right) { | |
handlePageTurn('prev', 'right'); | |
return; | |
} | |
} | |
// 右側に表示される画像(左ページ、spread[0]に対応) | |
if (imageElements[0]) { | |
const leftPageRect = imageElements[0].getBoundingClientRect(); | |
// 左ページをクリックした場合は次のページへ | |
if (e.clientX >= leftPageRect.left && e.clientX <= leftPageRect.right) { | |
handlePageTurn('next', 'left'); | |
return; | |
} | |
} | |
}; | |
// キーボードイベントハンドラ | |
React.useEffect(() => { | |
const handleKeyPress = (event) => { | |
event.stopPropagation(); // イベントの伝播を止める | |
// キー入力もユーザーアクションなのでアクティビティをリセット | |
resetMouseActivity(); | |
switch(event.key) { | |
case 'ArrowLeft': | |
case 'a': | |
case 'A': | |
handlePageTurn('next', 'left'); // 左へ移動 = 次のページへ(右から左へ読む場合) | |
break; | |
case 'ArrowRight': | |
case 'd': | |
case 'D': | |
handlePageTurn('prev', 'right'); // 右へ移動 = 前のページへ(右から左へ読む場合) | |
break; | |
case 'w': | |
case 'W': | |
case 'ArrowUp': | |
// 拡大処理 | |
if (mainViewerRef.current) { | |
const viewerRect = mainViewerRef.current.getBoundingClientRect(); | |
// マウスカーソルの位置を拡大の中心点とする | |
const cursorX = mousePosition.x; | |
const cursorY = mousePosition.y; | |
// 拡大前のカーソル位置(現在のスケールとオフセットを考慮) | |
const beforeZoomX = cursorX - transformState.translateX; | |
const beforeZoomY = cursorY - transformState.translateY; | |
// ピンチポイントの相対座標 | |
const pinchX = beforeZoomX / transformState.scale; | |
const pinchY = beforeZoomY / transformState.scale; | |
// 新しいスケール | |
const newScale = Math.min(transformState.scale * 1.1, 3); | |
// 新しいスケールでのカーソル位置 | |
const afterZoomX = pinchX * newScale; | |
const afterZoomY = pinchY * newScale; | |
// 位置の差分を計算 | |
const deltaX = afterZoomX - beforeZoomX; | |
const deltaY = afterZoomY - beforeZoomY; | |
// 新しい変換状態を設定 | |
setTransformState({ | |
scale: newScale, | |
translateX: transformState.translateX - deltaX, | |
translateY: transformState.translateY - deltaY | |
}); | |
// スケール値を別途保存 | |
setScale(newScale); | |
showZoomLevel(); | |
} | |
break; | |
case 's': | |
case 'S': | |
case 'ArrowDown': | |
// 縮小処理 | |
if (mainViewerRef.current) { | |
const viewerRect = mainViewerRef.current.getBoundingClientRect(); | |
// マウスカーソルの位置を縮小の中心点とする | |
const cursorX = mousePosition.x; | |
const cursorY = mousePosition.y; | |
// 縮小前のカーソル位置(現在のスケールとオフセットを考慮) | |
const beforeZoomX = cursorX - transformState.translateX; | |
const beforeZoomY = cursorY - transformState.translateY; | |
// ピンチポイントの相対座標 | |
const pinchX = beforeZoomX / transformState.scale; | |
const pinchY = beforeZoomY / transformState.scale; | |
// 新しいスケール | |
const newScale = Math.max(transformState.scale * 0.9, 0.5); | |
// 新しいスケールでのカーソル位置 | |
const afterZoomX = pinchX * newScale; | |
const afterZoomY = pinchY * newScale; | |
// 位置の差分を計算 | |
const deltaX = afterZoomX - beforeZoomX; | |
const deltaY = afterZoomY - beforeZoomY; | |
// 新しい変換状態を設定 | |
setTransformState({ | |
scale: newScale, | |
translateX: transformState.translateX - deltaX, | |
translateY: transformState.translateY - deltaY | |
}); | |
// スケール値を別途保存 | |
setScale(newScale); | |
showZoomLevel(); | |
} | |
break; | |
case 'q': | |
case 'Q': | |
// ズームリセット | |
setTransformState({ | |
scale: 1, | |
translateX: 0, | |
translateY: 0 | |
}); | |
setScale(1); | |
showZoomLevel(); | |
break; | |
case 'h': | |
case 'H': | |
// Hキーでヒント表示を切り替え(ユーザーが明示的に表示したい場合) | |
setHintsVisible(prev => !prev); | |
break; | |
} | |
}; | |
window.addEventListener('keydown', handleKeyPress); | |
return () => window.removeEventListener('keydown', handleKeyPress); | |
}, [currentSpreadIndex, images.length, transformState, mousePosition]); // mousePositionも依存配列に追加 | |
// マウスホイール処理 | |
const handleWheel = (e) => { | |
e.preventDefault(); | |
e.stopPropagation(); // イベントの伝播を止める | |
// ホイール操作もユーザーアクションなのでアクティビティをリセット | |
resetMouseActivity(); | |
// マウス位置の取得 | |
if (!mainViewerRef.current) return; | |
const viewerRect = mainViewerRef.current.getBoundingClientRect(); | |
const mouseX = e.clientX - viewerRect.left; | |
const mouseY = e.clientY - viewerRect.top; | |
// 拡大縮小前のマウス位置(現在のスケールとオフセットを考慮) | |
const beforeZoomX = mouseX - transformState.translateX; | |
const beforeZoomY = mouseY - transformState.translateY; | |
// ピンチポイントの相対座標(画像上の座標をスケール込みで計算) | |
const pinchX = beforeZoomX / transformState.scale; | |
const pinchY = beforeZoomY / transformState.scale; | |
// 新しいスケールを計算 | |
let newScale; | |
if (e.deltaY < 0) { | |
// 拡大 | |
newScale = Math.min(transformState.scale * 1.1, 3); | |
} else { | |
// 縮小 | |
newScale = Math.max(transformState.scale * 0.9, 0.5); | |
} | |
// 新しいスケールでのマウス位置 | |
const afterZoomX = pinchX * newScale; | |
const afterZoomY = pinchY * newScale; | |
// 位置の差分を計算 | |
const deltaX = afterZoomX - beforeZoomX; | |
const deltaY = afterZoomY - beforeZoomY; | |
// 新しい変換状態を設定 | |
setTransformState({ | |
scale: newScale, | |
translateX: transformState.translateX - deltaX, | |
translateY: transformState.translateY - deltaY | |
}); | |
// スケール値を別途保存(他の処理との互換性のため) | |
setScale(newScale); | |
// ズームレベルを表示 | |
showZoomLevel(); | |
}; | |
// マウス中クリック処理 | |
const handleMiddleClick = (e) => { | |
e.preventDefault(); | |
e.stopPropagation(); // イベントの伝播を止める | |
// ズームリセット | |
setTransformState({ | |
scale: 1, | |
translateX: 0, | |
translateY: 0 | |
}); | |
setScale(1); | |
showZoomLevel(); | |
}; | |
const spread = getCurrentSpread(); | |
// ページごとのスタイルを生成する関数 - 綴じ線を維持する目的で不要になったので削除 | |
// 統合されたスプレッドコンテナ用のスタイルを生成 | |
const getSpreadContainerStyle = () => { | |
// バウンススタイルがあれば適用 | |
const bounceStyle = getBounceStyle(); | |
// 拡大縮小と移動のスタイル | |
const transformStyle = { | |
transform: `scale(${transformState.scale}) translate(${transformState.translateX}px, ${transformState.translateY}px)`, | |
transformOrigin: '0 0', | |
transition: isAnimating ? 'none' : 'transform 0.1s ease-out' | |
}; | |
return { | |
...bounceStyle, | |
...transformStyle, | |
display: 'flex', | |
justifyContent: 'center', | |
alignItems: 'center', | |
height: '100%', | |
width: '100%', | |
perspective: '1200px', | |
position: 'relative', | |
transformStyle: 'preserve-3d' | |
}; | |
}; | |
// ページコンテナ用のスタイルを生成 | |
const getPageContainerStyle = (index) => { | |
return { | |
position: 'relative', | |
maxWidth: '50%', | |
height: '100%', | |
display: 'flex', | |
justifyContent: 'center', | |
alignItems: 'center', | |
perspective: '1200px', | |
transformStyle: 'preserve-3d', | |
zIndex: isAnimating && | |
((turnDirection === 'next' && index === 0) || | |
(turnDirection === 'prev' && index === 1)) ? 10 : 0 | |
}; | |
}; | |
// 個別のページ用のスタイルを生成 | |
const getPageStyle = (index) => { | |
const isLeftPage = index === 0; | |
const isRightPage = index === 1; | |
const pageSide = isLeftPage ? 'left' : 'right'; | |
// アニメーションしないページの基本スタイル | |
const baseStyle = { | |
position: 'relative', | |
maxWidth: '100%', | |
maxHeight: '100%', | |
objectFit: 'contain', | |
cursor: isDragging ? 'grabbing' : 'grab', | |
boxShadow: '0 0 15px rgba(0, 0, 0, 0.2)', | |
transformStyle: 'preserve-3d', | |
borderRadius: '3px' | |
}; | |
// アニメーションしていない、またはこのページがアニメーション対象でない場合 | |
if (!isAnimating || (animatingPage !== pageSide)) { | |
return baseStyle; | |
} | |
// アニメーション中のページのスタイル | |
if (turnDirection === 'next' && isLeftPage) { | |
return { | |
...baseStyle, | |
perspective: '1000px', | |
transformStyle: 'preserve-3d', | |
animation: 'turnPageForward 0.2s cubic-bezier(0.645, 0.045, 0.355, 1.000)', | |
transformOrigin: 'right center', // 真ん中(綴じ目側)を中心に回転 | |
boxShadow: '-10px 0 15px rgba(0, 0, 0, 0.3)' | |
}; | |
} else if (turnDirection === 'prev' && isRightPage) { | |
return { | |
...baseStyle, | |
perspective: '1000px', | |
transformStyle: 'preserve-3d', | |
animation: 'turnPageBackward 0.2s cubic-bezier(0.645, 0.045, 0.355, 1.000)', | |
transformOrigin: 'left center', // 真ん中(綴じ目側)を中心に回転 | |
boxShadow: '10px 0 15px rgba(0, 0, 0, 0.3)' | |
}; | |
} else { | |
// その他のアニメーション中の場合 | |
return { | |
...baseStyle, | |
perspective: '1000px', | |
transformStyle: 'preserve-3d' | |
}; | |
} | |
}; | |
// スタイルシートにアニメーションを追加 | |
React.useEffect(() => { | |
const style = document.createElement('style'); | |
style.textContent = ` | |
@keyframes turnPageForward { | |
0% { | |
transform: rotateY(0) translateZ(0); | |
filter: brightness(1); | |
} | |
20% { | |
transform: rotateY(40deg) translateZ(50px) skewY(5deg); | |
box-shadow: 30px 0 30px rgba(0, 0, 0, 0.4); | |
filter: brightness(1.03); | |
} | |
50% { | |
transform: rotateY(90deg) translateZ(100px) skewY(8deg); | |
box-shadow: 40px 20px 40px rgba(0, 0, 0, 0.5); | |
filter: brightness(1.05); | |
} | |
80% { | |
transform: rotateY(140deg) translateZ(50px) skewY(5deg); | |
box-shadow: 30px 0 30px rgba(0, 0, 0, 0.4); | |
filter: brightness(1.03); | |
} | |
100% { | |
transform: rotateY(180deg) translateZ(0); | |
filter: brightness(1); | |
} | |
} | |
@keyframes turnPageBackward { | |
0% { | |
transform: rotateY(0) translateZ(0); | |
filter: brightness(1); | |
} | |
20% { | |
transform: rotateY(-40deg) translateZ(50px) skewY(-5deg); | |
box-shadow: -30px 0 30px rgba(0, 0, 0, 0.4); | |
filter: brightness(1.03); | |
} | |
50% { | |
transform: rotateY(-90deg) translateZ(100px) skewY(-8deg); | |
box-shadow: -40px 20px 40px rgba(0, 0, 0, 0.5); | |
filter: brightness(1.05); | |
} | |
80% { | |
transform: rotateY(-140deg) translateZ(50px) skewY(-5deg); | |
box-shadow: -30px 0 30px rgba(0, 0, 0, 0.4); | |
filter: brightness(1.03); | |
} | |
100% { | |
transform: rotateY(-180deg) translateZ(0); | |
filter: brightness(1); | |
} | |
} | |
/* ページのエッジ効果用 */ | |
.mv-page-edge { | |
position: absolute; | |
height: 100%; | |
width: 10px; | |
top: 0; | |
background: linear-gradient(to right, rgba(0,0,0,0.1), rgba(0,0,0,0)); | |
} | |
.mv-page-edge.left { | |
left: 0; | |
} | |
.mv-page-edge.right { | |
right: 0; | |
transform: scaleX(-1); | |
} | |
/* 3D変換の強化 */ | |
.mv-spread-container { | |
transform-style: preserve-3d; | |
will-change: transform; | |
} | |
.mv-page-container { | |
transform-style: preserve-3d; | |
will-change: transform; | |
} | |
.mv-page { | |
backface-visibility: hidden; | |
transform-style: preserve-3d; | |
will-change: transform, z-index; | |
} | |
.mv-page-animating { | |
z-index: 10 !important; | |
} | |
`; | |
document.head.appendChild(style); | |
return () => style.remove(); | |
}, []); | |
// スプレッド(現在の見開きページ)を取得 | |
const currentSpreadPages = getCurrentSpread(); | |
// ヘッダーとプログレスバーを含むトップコンポーネント | |
const renderHeader = () => { | |
return e('div', { | |
className: 'mv-top-container', | |
style: { | |
position: 'absolute', // relativeからabsoluteに変更 | |
top: 0, | |
left: 0, | |
width: '100%', | |
// マウス非アクティブ時にヘッダーを上に隠す | |
transform: isMouseActive ? 'translateY(0)' : 'translateY(-100%)', | |
transition: 'transform 0.3s ease', | |
zIndex: 100 | |
} | |
}, [ | |
// ヘッダー | |
e('div', { | |
key: 'header', | |
className: 'mv-header' | |
}, [ | |
e('div', { | |
key: 'page-info', | |
className: 'mv-header-text' | |
}, `${currentSpreadIndex + 1} / ${Math.ceil(images.length / 2)}${chapterTitle ? ` - ${chapterTitle}` : ''}${images.length % 2 === 1 && currentSpreadIndex === Math.ceil(images.length / 2) - 1 ? ' (End of Contents)' : ''}`), | |
e('div', { | |
key: 'auto-navigation-toggle', | |
className: `mv-auto-nav-toggle ${autoChapterNavigation ? '' : 'off'}`, | |
onClick: (e) => { | |
e.stopPropagation(); // クリックイベントの伝播を止める | |
toggleAutoChapterNavigation(); | |
} | |
}, `チャプター自動移動: ${autoChapterNavigation ? 'ON' : 'OFF'}`), | |
e('button', { | |
key: 'close-button', | |
onClick: (e) => { | |
e.stopPropagation(); // クリックイベントの伝播を止める | |
onClose(); | |
}, | |
className: 'mv-close-button' | |
}, '閉じる') | |
]), | |
// プログレスバー(常に存在するが、visibleがfalseなら透明) | |
e('div', { | |
key: 'progress-container', | |
className: 'mv-progress-container', | |
style: { | |
opacity: progressState.visible ? 1 : 0, | |
transition: 'opacity 0.3s ease' | |
} | |
}, [ | |
e('div', { | |
key: 'progress-bar', | |
className: 'mv-progress-bar', | |
style: { | |
width: `${progressState.percent}%` | |
} | |
}), | |
progressState.message ? e('div', { | |
key: 'progress-message', | |
className: 'mv-progress-message' | |
}, progressState.message) : null | |
]) | |
]); | |
}; | |
// 右側の画像のwidthを監視するためのeffect | |
React.useEffect(() => { | |
if (!viewerRef.current) return; | |
const updateRightImageWidth = () => { | |
const rightImg = viewerRef.current.querySelector('.mv-page-container:nth-child(2) img'); | |
if (rightImg) { | |
setRightImageWidth(rightImg.offsetWidth); | |
} | |
}; | |
// 最初に一度実行 | |
updateRightImageWidth(); | |
// 画像読み込み完了時にも実行 | |
const observer = new MutationObserver(updateRightImageWidth); | |
observer.observe(viewerRef.current, { childList: true, subtree: true }); | |
// リサイズ時にも更新 | |
window.addEventListener('resize', updateRightImageWidth); | |
return () => { | |
observer.disconnect(); | |
window.removeEventListener('resize', updateRightImageWidth); | |
}; | |
}, [currentSpreadIndex]); | |
return e('div', { | |
id: 'manga-viewer-container', | |
tabIndex: 0, // フォーカス可能にする | |
onWheel: handleWheel, | |
onMouseDown: handleMouseDown, | |
onMouseMove: handleMouseMove, | |
onMouseUp: handleMouseUp, | |
onMouseLeave: handleMouseUp, | |
onClick: handleClick, | |
onAuxClick: handleMiddleClick, // 中クリックを処理 | |
ref: viewerRef, | |
}, [ | |
// ヘッダーとプログレスバーを含むトップ部分 | |
renderHeader(), | |
// メインビューア | |
e('div', { | |
key: 'viewer', | |
className: 'mv-main-viewer', | |
ref: mainViewerRef, | |
style: { | |
// ヘッダーの有無に関わらず常に100%の高さを持つ | |
height: '100%', | |
transition: 'all 0.3s ease', | |
// paddingTopで調整して、ヘッダーがある時だけ上部に空間を作る | |
paddingTop: isMouseActive ? '60px' : '0' | |
} | |
}, [ | |
// 右端インジケーター (最初のページの時) | |
currentSpreadIndex === 0 ? e('div', { | |
key: 'right-edge-indicator', | |
className: 'mv-edge-indicator mv-right-indicator' | |
}, autoChapterNavigation ? '前のチャプターへ' : '最初のページ') : null, | |
// 見開きページを単一のコンテナで囲む | |
e('div', { | |
key: 'spread-container', | |
className: 'mv-spread-container', | |
style: getSpreadContainerStyle() | |
}, [ | |
// 見開きページ表示 | |
...currentSpreadPages.map((url, index) => | |
url ? e('div', { | |
key: `page-container-${index}`, | |
className: 'mv-page-container', | |
style: getPageContainerStyle(index) | |
}, [ | |
e('img', { | |
key: `page-${index}`, | |
src: url, | |
className: `mv-page ${isAnimating && ((turnDirection === 'next' && index === 0) || (turnDirection === 'prev' && index === 1)) ? 'mv-page-animating' : ''}`, | |
style: getPageStyle(index), | |
draggable: false | |
}), | |
e('div', { | |
key: `page-edge-${index}`, | |
className: `mv-page-edge ${index === 0 ? 'right' : 'left'}` | |
}) | |
]) : e('div', { | |
key: `empty-${index}`, | |
className: 'mv-page-container', | |
style: { | |
...getPageContainerStyle(index), | |
display: 'flex', | |
justifyContent: 'center', | |
alignItems: 'center' | |
} | |
}, | |
// 奇数ページの最後のスプレッドで左側が空の場合、終了ページを表示 | |
index === 0 && images.length % 2 === 1 && currentSpreadIndex === Math.ceil(images.length / 2) - 1 ? | |
e('div', { | |
key: `end-page-${index}`, | |
className: 'mv-end-page', | |
style: { | |
display: 'flex', | |
flexDirection: 'column', | |
justifyContent: 'center', | |
alignItems: 'center', | |
width: rightImageWidth ? `${rightImageWidth}px` : '100%', | |
height: '100%', | |
backgroundColor: '#f5f5f5', | |
border: '1px solid #ddd', | |
borderRadius: '4px', | |
padding: '20px', | |
boxSizing: 'border-box', | |
textAlign: 'center', | |
cursor: 'pointer' // クリック可能であることを示す | |
} | |
}, [ | |
e('div', { | |
style: { | |
fontSize: '24px', | |
fontWeight: 'bold', | |
marginBottom: '20px', | |
color: '#333' | |
} | |
}, 'End of Contents'), | |
e('div', { | |
style: { | |
fontSize: '16px', | |
color: '#666' | |
} | |
}, autoChapterNavigation ? 'クリックして次のチャプターへ' : '最後のページです') | |
]) : null | |
) | |
) | |
]), | |
// 左端インジケーター (最後のページの時) | |
currentSpreadIndex >= Math.ceil(images.length / 2) - 1 ? e('div', { | |
key: 'left-edge-indicator', | |
className: 'mv-edge-indicator mv-left-indicator' | |
}, autoChapterNavigation ? '次のチャプターへ' : '最後のページ') : null, | |
// ズームインジケーター | |
e('div', { | |
key: 'zoom-indicator', | |
className: `mv-zoom-indicator ${showZoomIndicator ? 'visible' : ''}`, | |
}, `ズーム: ${Math.round(scale * 100)}%`), | |
// ショートカットヒント(下部に常に配置されるがマウスが近づくと表示される) | |
e('div', { | |
key: 'shortcuts-hint', | |
className: `mv-shortcuts-hint ${hintsVisible ? 'visible' : 'hidden'}`, | |
ref: hintsRef, | |
}, [ | |
e('span', {key: 'hint-nav'}, [ | |
'移動: ', | |
e('span', {key: 'key-left', className: 'mv-key'}, '←'), | |
e('span', {key: 'key-right', className: 'mv-key'}, '→') | |
]), | |
' | ', | |
e('span', {key: 'hint-zoom'}, [ | |
'ズーム: ', | |
e('span', {key: 'key-up', className: 'mv-key'}, '↑'), | |
e('span', {key: 'key-down', className: 'mv-key'}, '↓'), | |
e('span', {key: 'key-reset', className: 'mv-key'}, 'Q') | |
]), | |
' | ', | |
e('span', {key: 'hint-toggle'}, [ | |
'ヒント表示: ', | |
e('span', {key: 'key-hint', className: 'mv-key'}, 'H') | |
]) | |
]) | |
]) | |
]); | |
}; | |
class UIBuilder { | |
constructor() { | |
this.container = null; | |
this.spinner = null; | |
} | |
/** | |
* LoadingSpinnerを設定する | |
* @param {LoadingSpinner} spinner - スピナーのインスタンス | |
*/ | |
setSpinner(spinner) { | |
this.spinner = spinner; | |
} | |
/** | |
* 画像をプリロードする | |
* @param {string[]} imageUrls - プリロードする画像のURL配列 | |
* @returns {Promise<void>} - プリロード完了時に解決するPromise | |
*/ | |
async preloadImages(imageUrls) { | |
if (!imageUrls || imageUrls.length === 0) return; | |
const total = imageUrls.length; | |
let loaded = 0; | |
if (this.spinner) { | |
this.spinner.updateMessage(`画像をプリロード中... 0/${total} (0%)`, 0); | |
} | |
// バッチで処理して負荷を分散 | |
const batchSize = 5; | |
const batches = Math.ceil(total / batchSize); | |
for (let i = 0; i < batches; i++) { | |
const start = i * batchSize; | |
const end = Math.min(start + batchSize, total); | |
const batchUrls = imageUrls.slice(start, end); | |
await Promise.all(batchUrls.map(url => { | |
return new Promise((resolve) => { | |
const img = new Image(); | |
img.src = url; | |
img.onload = img.onerror = () => { | |
loaded++; | |
// 進捗状況を更新 | |
if (this.spinner) { | |
const percent = Math.round((loaded / total) * 100); | |
this.spinner.updateMessage(`画像をプリロード中... ${loaded}/${total} (${percent}%)`, percent); | |
} | |
resolve(); | |
}; | |
}); | |
})); | |
} | |
if (this.spinner) { | |
this.spinner.updateMessage(`${total}枚の画像をプリロード完了。ビューアを起動中...`, 100); | |
this.spinner.setComplete(); | |
} | |
} | |
/** | |
* ビューアを構築する | |
* @param {string[]} initialImageUrls - 初期表示する画像URL配列 | |
* @param {Object} options - ビューアのオプション | |
* @returns {Promise<HTMLElement>} - ビューアのコンテナ要素 | |
*/ | |
async buildViewer(initialImageUrls, options = {}) { | |
// デフォルトオプション | |
const defaultOptions = { | |
initialAutoNav: true, // チャプター自動移動のデフォルト値 | |
}; | |
// オプションをマージ | |
const viewerOptions = {...defaultOptions, ...options}; | |
// 最初のセットの画像をプリロード | |
if (initialImageUrls && initialImageUrls.length > 0) { | |
await this.preloadImages(initialImageUrls); | |
} else { | |
if (this.spinner) { | |
this.spinner.updateMessage("有効な画像を検索中です..."); | |
} | |
} | |
// コンテナ要素を作成 | |
this.container = document.createElement('div'); | |
this.container.id = 'manga-viewer-container'; | |
document.body.appendChild(this.container); | |
// Reactコンポーネントをレンダリング | |
const root = ReactDOM.createRoot(this.container); | |
// 初期画像セットでビューアをレンダリング | |
let isFirstRender = true; | |
const renderViewer = (images) => { | |
// ビューア表示直後、少し遅れてプログレスバーを表示(初期化) | |
if (unsafeWindow.MangaViewer && images.length > 0 && isFirstRender) { | |
setTimeout(() => { | |
if (unsafeWindow.MangaViewer.updateProgress) { | |
unsafeWindow.MangaViewer.updateProgress(0, "バックグラウンド処理を開始...", 'init'); | |
} | |
}, 500); // ビューア表示から少し遅らせて表示 | |
isFirstRender = false; | |
} | |
root.render(e(ViewerComponent, { | |
images: images, | |
onClose: () => { | |
root.unmount(); | |
this.container.remove(); | |
}, | |
initialAutoNav: viewerOptions.initialAutoNav | |
})); | |
}; | |
// 初期表示 | |
renderViewer(initialImageUrls); | |
// 後から更新する仕組みを提供 | |
this.updateImages = (newImages) => { | |
if (newImages && newImages.length > 0) { | |
renderViewer(newImages); | |
} | |
}; | |
return this.container; | |
} | |
} | |
class DataLoader { | |
constructor() { | |
this.imageUrls = []; | |
this.spinner = null; // LoadingSpinnerへの参照 | |
} | |
/** | |
* LoadingSpinnerを設定する | |
* @param {LoadingSpinner} spinner - スピナーのインスタンス | |
*/ | |
setSpinner(spinner) { | |
this.spinner = spinner; | |
} | |
/** | |
* 現在のサイトに応じて適切な画像収集メソッドを呼び出す | |
* @returns {Promise<{initialUrls: string[], onValidatedCallback: Function}>} | |
*/ | |
async collectImageUrls() { | |
// 現在のURLをチェックしてサイトを判別 | |
const currentUrl = window.location.href; | |
if (currentUrl.includes('twitter.com') || currentUrl.includes('x.com')) { | |
// Xの場合 | |
if (this.spinner) { | |
this.spinner.updateMessage("Xのページをスキャン中..."); | |
} | |
// ツイッターページをスクロールして画像を全て表示させながら収集 | |
const orderedUrls = await this.scrollTwitterPageToCollectImages(); | |
if (this.spinner) { | |
this.spinner.updateMessage(`${orderedUrls.length}枚のツイート画像を見つけました。検証中...`); | |
} | |
// X用の検証メソッドを呼び出し | |
return this.validateTwitterUrls(orderedUrls); | |
} else { | |
// その他のサイト(nicomanga.comなど) | |
return this.collectGenericImages(); | |
} | |
} | |
/** | |
* ツイッターページを自動スクロールして画像を収集する関数 | |
* ページ全体をスクロールすることで、バーチャルスクロールで非表示になっていた画像も表示させる | |
* @returns {Promise<string[]>} 収集した画像URLの配列 | |
*/ | |
async scrollTwitterPageToCollectImages() { | |
// 元のスクロール位置を保存 | |
const originalScrollY = window.scrollY; | |
// スクロール関連のパラメータ | |
const maxScrollAttempts = 25; // 最大スクロール試行回数 | |
const scrollPauseTime = 300; // スクロール間の待機時間(ミリ秒) | |
const scrollStepSize = 800; // 一度にスクロールする量(ピクセル) | |
// 収集した画像URLを保存する配列 | |
const collectedUrls = []; | |
let scrollAttempts = 0; | |
let currentScrollY = 0; | |
let newContentFound = true; | |
let lastImageCount = 0; | |
if (this.spinner) { | |
this.spinner.updateMessage("画像を探すためにページをスクロール中..."); | |
} | |
try { | |
// 最初に画面上の画像要素数を取得 | |
lastImageCount = document.querySelectorAll('img[src*="pbs.twimg.com/media"]').length; | |
// 現在表示されている画像を収集 | |
this.collectCurrentVisibleImages(collectedUrls); | |
// スクロールを繰り返す | |
while (scrollAttempts < maxScrollAttempts && newContentFound) { | |
// 少しずつスクロールする | |
currentScrollY += scrollStepSize; | |
window.scrollTo(0, currentScrollY); | |
// 進捗状況を更新 | |
if (this.spinner) { | |
this.spinner.updateMessage(`画像を探すためにページをスクロール中... (${scrollAttempts + 1}/${maxScrollAttempts}) - ${collectedUrls.length}枚見つかりました`); | |
} | |
// DOM更新を待つ | |
await new Promise(resolve => setTimeout(resolve, scrollPauseTime)); | |
// 現在の表示されている画像を収集 | |
this.collectCurrentVisibleImages(collectedUrls); | |
// 現在の画像数を取得 | |
const currentImageCount = document.querySelectorAll('img[src*="pbs.twimg.com/media"]').length; | |
// 新しい画像が見つかったか確認 | |
if (currentImageCount > lastImageCount) { | |
// 新しい画像が見つかったので続行 | |
lastImageCount = currentImageCount; | |
newContentFound = true; | |
} else { | |
// 前回と同じ画像数の場合、もう少し待って再確認 | |
await new Promise(resolve => setTimeout(resolve, scrollPauseTime * 2)); | |
// 再度現在の表示されている画像を収集 | |
this.collectCurrentVisibleImages(collectedUrls); | |
const recheckImageCount = document.querySelectorAll('img[src*="pbs.twimg.com/media"]').length; | |
if (recheckImageCount > lastImageCount) { | |
// 待機後に新しい画像が見つかった | |
lastImageCount = recheckImageCount; | |
newContentFound = true; | |
} else { | |
// ページの最下部まで到達したか確認 | |
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) { | |
// ページ下部に近いので、スクロールを終了 | |
newContentFound = false; | |
} else { | |
// まだページ下部ではないが、新しい画像が見つからない | |
// 直近3回連続で新しい画像が見つからなければスクロールを終了 | |
if (scrollAttempts >= 3 && !newContentFound) { | |
break; | |
} | |
} | |
} | |
} | |
scrollAttempts++; | |
} | |
// スクロールが完了したら、上から下まで段階的に丁寧にスクロールして | |
// すべての画像を確実に読み込む | |
if (this.spinner) { | |
this.spinner.updateMessage("画像を確認するために再スキャン中..."); | |
} | |
// まず一番上に戻る | |
window.scrollTo(0, 0); | |
await new Promise(resolve => setTimeout(resolve, scrollPauseTime)); | |
// 現在の表示されている画像を収集 | |
this.collectCurrentVisibleImages(collectedUrls); | |
// 画面の高さの半分ずつスクロールして全体をスキャン | |
const viewportHeight = window.innerHeight; | |
const totalHeight = Math.max( | |
document.body.scrollHeight, | |
document.body.offsetHeight, | |
document.documentElement.scrollHeight | |
); | |
// 少しずつスクロールして全ての画像を確保 | |
for (let scrollPos = 0; scrollPos < totalHeight; scrollPos += Math.floor(viewportHeight / 2)) { | |
window.scrollTo(0, scrollPos); | |
await new Promise(resolve => setTimeout(resolve, scrollPauseTime)); | |
// 現在の表示されている画像を収集 | |
this.collectCurrentVisibleImages(collectedUrls); | |
// 進捗更新 | |
if (this.spinner) { | |
const percent = Math.min(100, Math.round((scrollPos / totalHeight) * 100)); | |
this.spinner.updateMessage(`画像を再スキャン中... (${percent}%) - ${collectedUrls.length}枚見つかりました`); | |
} | |
} | |
// 最後に一番下までスクロール | |
window.scrollTo(0, totalHeight); | |
await new Promise(resolve => setTimeout(resolve, scrollPauseTime)); | |
// 最後のスクロール位置でも画像を収集 | |
this.collectCurrentVisibleImages(collectedUrls); | |
// 元のスクロール位置に戻る | |
window.scrollTo(0, originalScrollY); | |
// DOM更新を待つ | |
await new Promise(resolve => setTimeout(resolve, scrollPauseTime)); | |
// 見つかった画像の数を表示 | |
if (this.spinner) { | |
this.spinner.updateMessage(`ページのスキャンが完了しました。${collectedUrls.length}枚の画像候補を見つけました。`); | |
} | |
return collectedUrls; | |
} catch (error) { | |
console.error("スクロール中にエラーが発生しました:", error); | |
// エラーが発生した場合でも元のスクロール位置に戻る | |
window.scrollTo(0, originalScrollY); | |
return collectedUrls; | |
} | |
} | |
/** | |
* 現在表示されている画像を収集する | |
* @param {string[]} collectedUrls - 収集済みのURL配列(参照渡し) | |
*/ | |
collectCurrentVisibleImages(collectedUrls) { | |
// まずはツイートに含まれる画像を収集 | |
const timelineItems = document.querySelectorAll('[data-testid="tweet"], article'); | |
timelineItems.forEach(tweet => { | |
const tweetImages = tweet.querySelectorAll('img[src*="pbs.twimg.com/media"]'); | |
tweetImages.forEach(img => { | |
let imageUrl = img.src; | |
// 元の高解像度画像URLを取得 | |
if (imageUrl.includes('format=')) { | |
const formatMatch = imageUrl.match(/(format=[^&]+)/); | |
const format = formatMatch ? formatMatch[1] : 'format=jpg'; | |
const baseUrl = imageUrl.split('?')[0]; | |
imageUrl = `${baseUrl}?${format}&name=orig`; | |
} | |
if (imageUrl && !collectedUrls.includes(imageUrl)) { | |
collectedUrls.push(imageUrl); | |
} | |
}); | |
}); | |
// タイムラインアイテムで見つからない場合、他の方法でも探す | |
if (timelineItems.length === 0) { | |
// 単一ツイートや詳細ページの場合 | |
const galleryImages = document.querySelectorAll('[data-testid="tweetPhoto"] img, [role="group"] img[src*="pbs.twimg.com/media"]'); | |
galleryImages.forEach(img => { | |
let imageUrl = img.src; | |
// 元の高解像度画像URLを取得 | |
if (imageUrl.includes('format=')) { | |
const formatMatch = imageUrl.match(/(format=[^&]+)/); | |
const format = formatMatch ? formatMatch[1] : 'format=jpg'; | |
const baseUrl = imageUrl.split('?')[0]; | |
imageUrl = `${baseUrl}?${format}&name=orig`; | |
} | |
if (imageUrl && !collectedUrls.includes(imageUrl)) { | |
collectedUrls.push(imageUrl); | |
} | |
}); | |
// それでも見つからない場合、すべての画像から探す | |
if (galleryImages.length === 0) { | |
const allImages = document.querySelectorAll('img[src*="pbs.twimg.com/media"]'); | |
allImages.forEach(img => { | |
let imageUrl = img.src; | |
// 元の高解像度画像URLを取得 | |
if (imageUrl.includes('format=')) { | |
const formatMatch = imageUrl.match(/(format=[^&]+)/); | |
const format = formatMatch ? formatMatch[1] : 'format=jpg'; | |
const baseUrl = imageUrl.split('?')[0]; | |
imageUrl = `${baseUrl}?${format}&name=orig`; | |
} | |
if (imageUrl && !collectedUrls.includes(imageUrl)) { | |
collectedUrls.push(imageUrl); | |
} | |
}); | |
} | |
} | |
} | |
/** | |
* ツイートの順序に沿って画像を収集する | |
* @returns {string[]} ツイート順に整理された画像URLの配列 | |
*/ | |
getOrderedTwitterImages() { | |
const orderedUrls = []; | |
// タイムラインまたはツイート詳細ページを特定 | |
const timelineItems = document.querySelectorAll('[data-testid="tweet"], article'); | |
// タイムラインの各ツイートをループ | |
timelineItems.forEach(tweet => { | |
// このツイート内の画像要素を取得 | |
const tweetImages = tweet.querySelectorAll('img[src*="pbs.twimg.com/media"]'); | |
// このツイート内の画像URLを順番に取得 | |
tweetImages.forEach(img => { | |
let imageUrl = img.src; | |
// 元の高解像度画像URLを取得(サムネイルではなく) | |
if (imageUrl.includes('format=')) { | |
// format=webpやformat=jpgなどのパラメータを保持 | |
const formatMatch = imageUrl.match(/(format=[^&]+)/); | |
const format = formatMatch ? formatMatch[1] : 'format=jpg'; | |
// ベースURLを取得(?より前の部分) | |
const baseUrl = imageUrl.split('?')[0]; | |
// 高解像度バージョンのURLを構築 | |
imageUrl = `${baseUrl}?${format}&name=orig`; | |
} | |
if (imageUrl && !orderedUrls.includes(imageUrl)) { | |
orderedUrls.push(imageUrl); | |
} | |
}); | |
}); | |
// タイムラインアイテムが見つからない場合は、従来の方法でURLを収集する | |
if (orderedUrls.length === 0) { | |
// 単一ツイートの場合や詳細ページの場合 | |
const galleryImages = document.querySelectorAll('[data-testid="tweetPhoto"] img, [role="group"] img[src*="pbs.twimg.com/media"]'); | |
galleryImages.forEach(img => { | |
let imageUrl = img.src; | |
// 元の高解像度画像URLを取得 | |
if (imageUrl.includes('format=')) { | |
const formatMatch = imageUrl.match(/(format=[^&]+)/); | |
const format = formatMatch ? formatMatch[1] : 'format=jpg'; | |
const baseUrl = imageUrl.split('?')[0]; | |
imageUrl = `${baseUrl}?${format}&name=orig`; | |
} | |
if (imageUrl && !orderedUrls.includes(imageUrl)) { | |
orderedUrls.push(imageUrl); | |
} | |
}); | |
} | |
// それでも画像が見つからない場合は、ページ全体から検索 | |
if (orderedUrls.length === 0) { | |
const allImages = document.querySelectorAll('img[src*="pbs.twimg.com/media"]'); | |
allImages.forEach(img => { | |
let imageUrl = img.src; | |
// 元の高解像度画像URLを取得 | |
if (imageUrl.includes('format=')) { | |
const formatMatch = imageUrl.match(/(format=[^&]+)/); | |
const format = formatMatch ? formatMatch[1] : 'format=jpg'; | |
const baseUrl = imageUrl.split('?')[0]; | |
imageUrl = `${baseUrl}?${format}&name=orig`; | |
} | |
if (imageUrl && !orderedUrls.includes(imageUrl)) { | |
orderedUrls.push(imageUrl); | |
} | |
}); | |
} | |
return orderedUrls; | |
} | |
/** | |
* Twitter/X用のURL検証メソッド | |
* @param {string[]} urls | |
* @returns {Promise<{initialUrls: string[], onValidatedCallback: Function}>} | |
*/ | |
async validateTwitterUrls(urls) { | |
// バックグラウンドプリロード用の変数を初期化 | |
const validUrls = [...urls]; | |
let onValidatedCallback = null; | |
const minInitialUrls = 2; // 最初に表示する画像の最小数 | |
// 最初に表示する画像を準備(最初の2枚だけ) | |
const initialUrls = validUrls.length > minInitialUrls ? | |
validUrls.slice(0, minInitialUrls) : validUrls; | |
if (this.spinner) { | |
this.spinner.updateMessage(`最初の${initialUrls.length}枚の画像を表示します。残り${Math.max(0, validUrls.length - initialUrls.length)}枚は引き続き検証中...`); | |
} | |
// コールバック関数を返すオブジェクト | |
const result = { | |
initialUrls: initialUrls, // 最初に表示する画像(2枚) | |
onValidated: function(callback) { | |
onValidatedCallback = callback; | |
// バックグラウンドプリロード完了後にコールバックを呼び出す | |
// 少し遅らせて実行することで、ビューアの初期表示後にバックグラウンド処理が実行されるようにする | |
setTimeout(() => { | |
if (callback) { | |
callback(validUrls); // すべての画像をコールバックで返す | |
} | |
// ビューア表示後のプログレスバーを更新 | |
if (unsafeWindow.MangaViewer && unsafeWindow.MangaViewer.updateProgress) { | |
unsafeWindow.MangaViewer.updateProgress(100, `検証完了: ${validUrls.length}枚のツイート画像を処理しました`, 'complete'); | |
} | |
}, 1000); // 1秒後に実行 | |
} | |
}; | |
return result; | |
} | |
/** | |
* 汎用的な画像URLを収集するメソッド | |
* @returns {Promise<{initialUrls: string[], onValidatedCallback: Function}>} | |
*/ | |
async collectGenericImages() { | |
const images = document.querySelectorAll('img'); | |
const urls = []; | |
images.forEach(img => { | |
// まずはsrcを優先して取得 | |
const src = img.src; | |
if (src && !urls.includes(src)) { | |
urls.push(src); | |
} | |
// 次にdata-srcをチェック | |
const dataSrc = img.getAttribute('data-src'); | |
if (dataSrc && !urls.includes(dataSrc)) { | |
urls.push(dataSrc); | |
} | |
// srcset または data-srcset から画像URLを取得 | |
const srcset = img.dataset.srcset || img.srcset || ''; | |
if (srcset) { | |
// srcsetから最大解像度の画像URLを取得 | |
const srcsetUrls = this.parseSrcset(srcset); | |
if (srcsetUrls.length > 0) { | |
// 元の順序を保つために、srcsetの最初のURLを追加 | |
urls.push(srcsetUrls[0].url); | |
} | |
} | |
}); | |
if (this.spinner) { | |
this.spinner.updateMessage(`${urls.length}枚の画像候補を見つけました。検証中...`); | |
} | |
return this.validateUrls(urls); | |
} | |
/** | |
* srcset文字列をパースする | |
* @param {string} srcset | |
* @returns {{url: string, width: number}[]} | |
*/ | |
parseSrcset(srcset) { | |
return srcset.split(',') | |
.map(src => { | |
const [url, width] = src.trim().split(/\s+/); | |
return { | |
url: url, | |
width: parseInt(width || '0', 10) | |
}; | |
}) | |
.sort((a, b) => a.width - b.width); | |
} | |
/** | |
* URLが有効かどうかを確認する | |
* @param {string[]} urls | |
* @returns {Promise<{initialUrls: string[], onValidatedCallback: Function}>} | |
*/ | |
async validateUrls(urls) { | |
const validUrls = []; | |
let processed = 0; | |
const total = urls.length; | |
let validUrlsFound = 0; | |
const minInitialUrls = 2; // Xの場合は少ない画像でも表示できるようにする(最初は2枚でも表示) | |
let initialLoadComplete = false; | |
let onValidatedCallback = null; | |
// 現在のサイトがTwitter/Xかどうかを判定 | |
const isTwitter = window.location.href.includes('twitter.com') || window.location.href.includes('x.com'); | |
// コールバック関数を返すオブジェクトを作成 | |
const result = { | |
initialUrls: [], // 最初に表示する画像の配列 | |
onValidated: function(callback) { | |
onValidatedCallback = callback; | |
} | |
}; | |
// バックグラウンドで検証を続行するためのタスク | |
const validateInBackground = async () => { | |
// プログレスバーを初期化(ビューア表示後のバックグラウンド処理を可視化) | |
if (unsafeWindow.MangaViewer && unsafeWindow.MangaViewer.updateProgress) { | |
unsafeWindow.MangaViewer.updateProgress(0, `画像検証を開始... (0/${total})`, 'loading'); | |
} | |
for (const url of urls) { | |
// URLが空でないことを確認 | |
if (!url) { | |
processed++; | |
updateProgressBar(); | |
continue; | |
} | |
try { | |
// 有効なURLかどうかを確認 | |
new URL(url, window.location.href); | |
// Twitter/Xの場合はpbs.twimg.comドメインを優先 | |
const isTwitterImage = url.includes('pbs.twimg.com/media'); | |
// 画像ファイルの拡張子かどうかを確認 | |
const extension = url.split('.').pop().toLowerCase().split('?')[0]; | |
const validExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; | |
// TwitterのURLには必ずしも拡張子がないので、ドメインで判断 | |
const isValidByExtension = validExtensions.includes(extension) || isTwitterImage; | |
if (!isValidByExtension) { | |
processed++; | |
updateProgressBar(); | |
continue; | |
} | |
// 画像のサイズを確認 | |
const img = new Image(); | |
img.src = url; | |
await new Promise((resolve) => { | |
img.onload = () => { | |
processed++; | |
// Twitterの画像は通常小さめなので、サイズの閾値を下げる | |
const minWidth = isTwitter ? 200 : 400; | |
const minHeight = isTwitter ? 200 : 400; | |
if (img.width > minWidth && img.height > minHeight) { | |
// Twitterの画像は優先順位を上げる | |
if (isTwitterImage) { | |
// Twitter画像をリストの先頭に追加 | |
validUrls.unshift(url); | |
} else { | |
validUrls.push(url); | |
} | |
validUrlsFound++; | |
// 最初の表示用の画像が十分に集まったらコールバックを呼び出し | |
if (!initialLoadComplete && validUrlsFound >= minInitialUrls) { | |
initialLoadComplete = true; | |
result.initialUrls = [...validUrls]; // 配列のコピーを作成 | |
if (this.spinner) { | |
this.spinner.updateMessage(`最初の${validUrlsFound}枚の画像を表示します。残りは引き続き検証中...`); | |
} | |
} | |
// 検証が進むごとにコールバックで新しい画像を通知 | |
if (initialLoadComplete && onValidatedCallback) { | |
onValidatedCallback([...validUrls]); | |
} | |
} | |
// 進捗状況を更新 | |
if (this.spinner && processed % 5 === 0) { | |
const percent = Math.round((processed / total) * 100); | |
this.spinner.updateMessage(`画像を検証中... ${processed}/${total} (${percent}%) - ${validUrlsFound}枚有効`); | |
} | |
// プログレスバーを更新 | |
updateProgressBar(); | |
resolve(); | |
}; | |
img.onerror = () => { | |
processed++; | |
// 進捗状況を更新 | |
if (this.spinner && processed % 5 === 0) { | |
const percent = Math.round((processed / total) * 100); | |
this.spinner.updateMessage(`画像を検証中... ${processed}/${total} (${percent}%) - ${validUrlsFound}枚有効`); | |
} | |
// プログレスバーを更新 | |
updateProgressBar(); | |
resolve(); | |
}; | |
}); | |
} catch (e) { | |
processed++; | |
// プログレスバーを更新 | |
updateProgressBar(); | |
continue; | |
} | |
} | |
// 検証完了時の処理 | |
if (this.spinner) { | |
this.spinner.updateMessage(`${validUrls.length}枚の有効な画像を見つけました`); | |
} | |
// 完了メッセージで100%に設定 | |
if (unsafeWindow.MangaViewer && unsafeWindow.MangaViewer.updateProgress) { | |
unsafeWindow.MangaViewer.updateProgress(100, `検証完了: ${validUrls.length}枚の有効な画像を見つけました`, 'complete'); | |
} | |
// すべての検証が終わったら最終結果をコールバックで通知 | |
if (onValidatedCallback) { | |
onValidatedCallback([...validUrls]); | |
} | |
}; | |
// 進捗バーを更新する関数 | |
const updateProgressBar = () => { | |
if (unsafeWindow.MangaViewer && unsafeWindow.MangaViewer.updateProgress) { | |
const percent = Math.round((processed / total) * 100); | |
const message = `画像を検証中... ${processed}/${total} (${percent}%) - ${validUrlsFound}枚有効`; | |
unsafeWindow.MangaViewer.updateProgress(percent, message, 'loading'); | |
} | |
}; | |
// バックグラウンドで検証を開始 | |
validateInBackground(); | |
// 最低限の画像が集まるまで待機 | |
if (urls.length > 0) { | |
// 最大5秒まで待機して、少なくとも数枚の画像が見つかるのを待つ | |
let waitTime = 0; | |
const maxWaitTime = 5000; // 最大5秒 | |
const checkInterval = 200; // 200msごとにチェック | |
while (!initialLoadComplete && waitTime < maxWaitTime) { | |
await new Promise(resolve => setTimeout(resolve, checkInterval)); | |
waitTime += checkInterval; | |
// 少なくとも数枚の画像が見つかったら初期表示を開始 | |
if (validUrls.length > 0 && (validUrls.length >= minInitialUrls || processed >= urls.length / 3)) { | |
initialLoadComplete = true; | |
result.initialUrls = [...validUrls]; | |
if (this.spinner) { | |
this.spinner.updateMessage(`最初の${validUrls.length}枚の画像を表示します。残りは引き続き検証中...`); | |
} | |
break; | |
} | |
} | |
// 待機時間が終了しても最低限の画像が見つからなければ、現状の結果を返す | |
if (!initialLoadComplete) { | |
initialLoadComplete = true; | |
result.initialUrls = [...validUrls]; | |
if (this.spinner) { | |
const message = validUrls.length > 0 ? | |
`最初の${validUrls.length}枚の画像を表示します。残りは引き続き検証中...` : | |
`有効な画像を検索中です...`; | |
this.spinner.updateMessage(message); | |
} | |
} | |
} | |
return result; | |
} | |
} | |
class UIManager { | |
constructor(container, imageUrls) { | |
this.container = container; | |
this.imageUrls = imageUrls; | |
this.currentScale = 1; | |
this.isDragging = false; | |
this.startX = 0; | |
} | |
/** | |
* ビューアの状態を更新する | |
* @param {Object} state - 更新する状態 | |
*/ | |
updateState(state) { | |
if (state.scale !== undefined) { | |
this.currentScale = state.scale; | |
} | |
} | |
/** | |
* ビューアを閉じる | |
*/ | |
closeViewer() { | |
if (this.container) { | |
ReactDOM.unmountComponentAtNode(this.container); | |
this.container.remove(); | |
} | |
} | |
} | |
// グローバルスコープに公開(デバッグ用) | |
unsafeWindow.MangaViewer = { | |
DataLoader: DataLoader, | |
UIBuilder: UIBuilder, | |
UIManager: UIManager | |
}; | |
// ビューアを起動する関数 | |
async function launchViewer() { | |
// ローディングスピナーを表示 | |
const spinner = new LoadingSpinner(); | |
spinner.show('画像を検索中...'); | |
try { | |
const loader = new DataLoader(); | |
// スピナーを設定 | |
loader.setSpinner(spinner); | |
// 現在のURLをチェック | |
const isTwitter = window.location.href.includes('twitter.com') || window.location.href.includes('x.com'); | |
// 画像URL収集を開始 | |
spinner.updateMessage('画像を検索中...'); | |
const result = await loader.collectImageUrls(); | |
if (result.initialUrls.length > 0 || true) { // 最初は何も見つからなくても起動 | |
spinner.updateMessage(`${result.initialUrls.length}枚の画像を読み込みました。ビューアを準備中...`); | |
const builder = new UIBuilder(); | |
builder.setSpinner(spinner); | |
// ビューアオプションを設定 | |
const viewerOptions = { | |
initialAutoNav: !isTwitter // Xの場合はチャプター自動移動をオフに | |
}; | |
// ビューアを構築(初期画像のプリロードを含む) | |
await builder.buildViewer(result.initialUrls, viewerOptions); | |
// バックグラウンドで検証された新しい画像を受け取るコールバック | |
result.onValidated((updatedUrls) => { | |
if (updatedUrls.length > 0 && builder.updateImages) { | |
builder.updateImages(updatedUrls); | |
} | |
}); | |
// ビューア表示後にローディングを非表示 | |
spinner.hide(); | |
} else { | |
spinner.hide(); | |
alert('表示可能な画像が見つかりませんでした。'); | |
} | |
} catch (error) { | |
// エラーが発生した場合 | |
spinner.hide(); | |
console.error('ビューア起動中にエラーが発生しました:', error); | |
alert('ビューア起動中にエラーが発生しました。'); | |
} | |
} | |
// 起動ボタンを作成 | |
function createLaunchButton() { | |
const button = document.createElement('button'); | |
button.textContent = 'マンガビューア起動'; | |
button.id = 'manga-viewer-launch-btn'; | |
button.addEventListener('click', launchViewer); | |
// アニメーション効果を追加 | |
button.addEventListener('mouseenter', () => { | |
button.classList.add('mv-btn-hover'); | |
}); | |
button.addEventListener('mouseleave', () => { | |
button.classList.remove('mv-btn-hover'); | |
}); | |
// 既存のボタンを削除してから追加(重複防止) | |
const existingButton = document.getElementById('manga-viewer-launch-btn'); | |
if (existingButton) { | |
existingButton.remove(); | |
} | |
document.body.appendChild(button); | |
// ボタンが追加されたら軽いアニメーションを実行 | |
setTimeout(() => { | |
button.classList.add('mv-btn-appear'); | |
setTimeout(() => button.classList.remove('mv-btn-appear'), 500); | |
}, 100); | |
return button; | |
} | |
// Tampermonkeyメニューに登録 | |
GM_registerMenuCommand('ブック風マンガビューア起動', launchViewer); | |
// Twitter/Xでの画像読み込み監視 | |
let launchButtonVisible = false; | |
let urlChangeTimeout = null; | |
let lastUrl = window.location.href; | |
// ページコンテンツの変更を監視(X用) | |
function setupMutationObserver() { | |
// 現在のURLがTwitter/Xかどうかを確認 | |
const isTwitter = window.location.href.includes('twitter.com') || window.location.href.includes('x.com'); | |
if (!isTwitter) return; | |
// X用のコンテンツ監視 | |
const observer = new MutationObserver((mutations) => { | |
// 画像が追加されたかチェック | |
const hasNewImages = mutations.some(mutation => { | |
return Array.from(mutation.addedNodes).some(node => { | |
if (node.nodeType === Node.ELEMENT_NODE) { | |
return node.querySelector('img[src*="pbs.twimg.com/media"]') !== null || | |
node.tagName === 'IMG' && node.src.includes('pbs.twimg.com/media'); | |
} | |
return false; | |
}); | |
}); | |
// 新しい画像があればボタンを表示 | |
if (hasNewImages && !launchButtonVisible) { | |
createLaunchButton(); | |
launchButtonVisible = true; | |
} | |
// URLの変更を検出 | |
if (window.location.href !== lastUrl) { | |
lastUrl = window.location.href; | |
// 少し待ってからボタンの表示状態を更新(URLの変更後にコンテンツが読み込まれるため) | |
clearTimeout(urlChangeTimeout); | |
urlChangeTimeout = setTimeout(() => { | |
checkForTwitterImages(); | |
}, 1000); | |
} | |
}); | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true, | |
attributes: true, | |
attributeFilter: ['src'] | |
}); | |
} | |
// X上の画像をチェックしてボタンの表示状態を更新 | |
function checkForTwitterImages() { | |
const tweetImages = document.querySelectorAll('img[src*="pbs.twimg.com/media"]'); | |
if (tweetImages.length > 0) { | |
if (!launchButtonVisible) { | |
createLaunchButton(); | |
launchButtonVisible = true; | |
} | |
} else { | |
const existingButton = document.getElementById('manga-viewer-launch-btn'); | |
if (existingButton) { | |
existingButton.remove(); | |
launchButtonVisible = false; | |
} | |
} | |
} | |
// ページロード時の処理 | |
function onPageLoad() { | |
// 現在のURLをチェック | |
const currentUrl = window.location.href; | |
const isTwitter = currentUrl.includes('twitter.com') || currentUrl.includes('x.com'); | |
// Twitter/X用の処理 | |
if (isTwitter) { | |
// 最初のチェック | |
checkForTwitterImages(); | |
// 動的コンテンツの監視を開始 | |
setupMutationObserver(); | |
} else { | |
// 通常サイト用の処理 | |
// チャプター移動後の自動起動チェック | |
const navigator = new ChapterNavigator(); | |
if (navigator.checkAutoLaunch()) { | |
// ページが完全に読み込まれる少し後にビューアを起動 | |
setTimeout(launchViewer, 1000); | |
} | |
// 起動ボタンを常に表示 | |
createLaunchButton(); | |
launchButtonVisible = true; | |
} | |
} | |
// ページ読み込み完了時に処理を実行 | |
if (document.readyState === 'loading') { | |
document.addEventListener('DOMContentLoaded', onPageLoad); | |
} else { | |
onPageLoad(); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
ホイール操作でも拡大縮小可能。
カーソルを中心とした拡大縮小が可能。