キー操作によって画像/動画の位置へスクロール移動する
// ==UserScript==
// @name FANBOX Scroll Viewer
// @namespace https://github.com/bookyakuno/fanbox-scroll-viewer
// @version 1.0.5
// @description キー操作によって画像/動画の位置へスクロール移動する
// @author Bookyakuno
// @match https://*.fanbox.cc/posts/*
// @match https://fantia.jp/posts/*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ==========================================
// ユーザー設定
// ==========================================
const SCROLL_BEHAVIOR = 'instant'; // 'instant': 即座にスクロール, 'smooth': スムーズスクロール
const HEADER_OFFSET = 80; // ヘッダーに被らないように空ける上部マージン(px単位)
let mediaElements = [];
// サイト公式の拡大ビューアーが起動中かどうかを判定
function isNativeViewerOpen() {
if (location.hostname.includes('fantia.jp')) {
return document.body.classList.contains('modal-open') || !!document.querySelector('.glightbox-container');
} else if (location.hostname.includes('fanbox.cc')) {
return document.body.style.overflow === 'hidden' || !!document.querySelector('div[role="dialog"]');
}
return false;
}
// メディア要素の検出(レイアウト変更は一切行わない)
function initMediaElements() {
if (isNativeViewerOpen()) return;
let combined = [];
if (location.hostname.includes('fanbox.cc')) {
const imgs = Array.from(document.querySelectorAll('article img'));
const vids = Array.from(document.querySelectorAll('article video'));
combined = [...imgs, ...vids];
} else if (location.hostname.includes('fantia.jp')) {
// Fantiaの画像・動画を広域スキャン
const blocks = document.querySelectorAll('.image-text-block, .post-content-inner, .ghost-image');
blocks.forEach(block => {
const imgs = Array.from(block.querySelectorAll('img'));
const vids = Array.from(block.querySelectorAll('video'));
combined.push(...imgs, ...vids);
});
const independentImgs = document.querySelectorAll('.post-body img, .post-content img');
independentImgs.forEach(img => {
if (!combined.includes(img)) combined.push(img);
});
}
// ページ上からの配置順にソート
combined.sort((a, b) => {
return a.getBoundingClientRect().top - b.getBoundingClientRect().top;
});
// サムネイルなどの不要な極小アイコンを除外
mediaElements = combined.filter(el => {
if (el.closest('a[href*="/users/"]')) return false;
if (el.closest('.nav, .header, .footer')) return false;
if (el.closest('.glightbox-container, div[role="dialog"]')) return false;
const rect = el.getBoundingClientRect();
return (rect.width > 150 || rect.height > 150 || (rect.width === 0 && rect.height === 0));
});
}
// 現在の画面中央から最も近いメディアのインデックスを取得
function getClosestMediaIndex() {
if (mediaElements.length === 0) return -1;
const viewportCenter = HEADER_OFFSET + ((window.innerHeight - HEADER_OFFSET) / 2);
let closestIndex = 0;
let minDistance = Infinity;
mediaElements.forEach((el, index) => {
const rect = el.getBoundingClientRect();
const targetRect = (rect.width === 0 && el.parentElement) ? el.parentElement.getBoundingClientRect() : rect;
const elementCenter = targetRect.top + (targetRect.height / 2);
const distance = Math.abs(viewportCenter - elementCenter);
if (distance < minDistance) {
minDistance = distance;
closestIndex = index;
}
});
return closestIndex;
}
// 指定位置へ正確にスクロール
function scrollToMedia(index) {
if (index < 0 || index >= mediaElements.length) return;
const target = mediaElements[index];
const rect = target.getBoundingClientRect();
const targetRect = (rect.width === 0 && target.parentElement) ? target.parentElement.getBoundingClientRect() : rect;
const absoluteElementTop = targetRect.top + window.pageYOffset;
const middleOffset = absoluteElementTop - HEADER_OFFSET - ((window.innerHeight - HEADER_OFFSET) / 2) + (targetRect.height / 2);
window.scrollTo({
top: middleOffset,
behavior: SCROLL_BEHAVIOR
});
}
// キーボードイベント
window.addEventListener('keydown', function(e) {
if (isNativeViewerOpen()) return;
initMediaElements();
if (mediaElements.length === 0) return;
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) {
return;
}
const currentClosestIndex = getClosestMediaIndex();
if (e.key === ' ' && e.shiftKey) {
e.preventDefault();
if (currentClosestIndex > 0) {
scrollToMedia(currentClosestIndex - 1);
}
return;
}
if (e.key === ' ' || e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault();
if (currentClosestIndex < mediaElements.length - 1) {
scrollToMedia(currentClosestIndex + 1);
}
} else if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault();
if (currentClosestIndex > 0) {
scrollToMedia(currentClosestIndex - 1);
}
}
}, { passive: false });
const observer = new MutationObserver(() => {
initMediaElements();
});
setTimeout(() => {
initMediaElements();
const body = document.querySelector('body');
if (body) {
observer.observe(body, { childList: true, subtree: true });
}
}, 1000);
})();