Skip to content

Instantly share code, notes, and snippets.

@roflsunriz
Last active April 27, 2025 23:24
Show Gist options
  • Save roflsunriz/22077fdfbc0a01e303f2cebce3fae271 to your computer and use it in GitHub Desktop.
Save roflsunriz/22077fdfbc0a01e303f2cebce3fae271 to your computer and use it in GitHub Desktop.
mangaViewer : ブック風マンガビューア(nicomanga.com/X.com)
// ==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();
}
})();
@roflsunriz
Copy link
Author

ホイール操作でも拡大縮小可能。
カーソルを中心とした拡大縮小が可能。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment