NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name BetterPixiv
// @name:en BetterPixiv
// @namespace http://tampermonkey.net/
// @version 1.0.3
// @description This is a user script that makes browsing Pixiv easier.
// @description:en This is a user script that makes browsing Pixiv easier.
// @match *://www.pixiv.net/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @noframes
// @grant none
// @run-at document-start
// @license MIT
// @downloadURL https://update.greasyfork.org/scripts/591644/BetterPixiv.user.js
// @updateURL https://update.greasyfork.org/scripts/591644/BetterPixiv.meta.js
// ==/UserScript==
// 情報取得用(タイトル/本文/タグ/ブクマ数等)。omit=ノーカウントでAPI制限を消費しない。
// 全カードで多用するためこちら優先。ただしomitだと一部作品(年齢制限等)で画像URLが欠落する。
const pxvIllustDataCache = new Map();
function pxvGetIllustData(id) {
if (pxvIllustDataCache.has(id)) return pxvIllustDataCache.get(id);
const p = fetch(`https://www.pixiv.net/ajax/illust/${id}`, { credentials: 'omit' })
.then(r => r.json()).then(d => d?.body || null)
.catch(e => { console.debug('[pxv] getIllustData failed', id, e); return null; });
pxvIllustDataCache.set(id, p);
return p;
}
// フル画像URL取得用(モーダル表示専用)。same-origin=ログイン扱いで画像欠落を回避できるが
// API制限が厳しい→数ページで制限に掛かる。呼び出しはモーダルを開いた時のみに限定する。
const pxvIllustDataAuthCache = new Map();
function pxvGetIllustDataAuth(id) {
if (pxvIllustDataAuthCache.has(id)) return pxvIllustDataAuthCache.get(id);
const p = fetch(`https://www.pixiv.net/ajax/illust/${id}`, { credentials: 'same-origin' })
.then(r => r.json()).then(d => d?.body || null)
.catch(e => { console.debug('[pxv] getIllustDataAuth failed', id, e); return null; });
pxvIllustDataAuthCache.set(id, p);
return p;
}
/* ============================================================
[1/3] Pixiv Contents Filter & Settings Panel (元v5.20)
============================================================ */
(function () {
'use strict';
/* ============================== 設定 ==============================
全項目、外部/console/localStorage経由で変更する想定(GUIパネルなし)。
例: localStorage.setItem('pxv_unified_settings', JSON.stringify({...現状, hideRanking:false}))
その後 window.dispatchEvent(new CustomEvent('pxv:settings-changed', {detail:{hideRanking:false}})) */
const STORAGE_KEY = 'pxv_unified_settings';
const EVENT_NAME = 'pxv:settings-changed';
// UI表示言語。フッター言語切替の固定値判定(loadSettings)でも使うため、DEFAULTSより前に定義する。
const LANG = (navigator.language || 'en').toLowerCase().startsWith('ja') ? 'ja' : 'en';
// 広告枠マーカー(kyujinbox等)。hideAd設定の常時非表示、および空コンテナ畳み込み時の「広告残存」判定の両方で使う。
// buildAlwaysHideSelector()がdocument-start直後に即時実行されるため、他の定数より前(ファイル冒頭)に置く必要がある。
const AD_MARKER_SELECTOR = '.ad-frame-kyujinbox, [class*="overlay"][class*="_overray"], [class*="ad-frame"], iframe[src*="ads"], iframe[name*="appeal"], div.w-full.flex.flex-col.bg-surface1:has(a[href*="pixon.ads-pixiv.net"]), .__top_side_menu_body div.w-full.flex.flex-col:has(> div > iframe[data-uid]), nav div[class*="premium-"][class*="-t-footer"], div[class$="_header"]:has(.ad-frame-headerbidding)';
const DEFAULTS = {
// ---- 設定パネルUI専用項目(フィルタ処理には無関係) ----
ratingFontSize: 10, dateFontSize: 10,
showRatingCount: true, showDate: true,
preview: true, hoverPreview: true,
panelOpacity: 100, // 設定パネル自体の背景透過度(%, 40〜100)
// ---- 自動検索除外 ( 検索ページで除外パラメーター付けてリダイレクト ) ----
autoSearchExclude: false,
autoSearchExcludeTags: [], // 自動除外パラメーター
// ---- カードフィルター ----
minBookmarkCount: 1000, // ブクマ数フィルター閾値(オン/オフに関わらず値そのものは保持する)
bookmarkFilterEnabled: false, // ブクマ数フィルター 有効/無効(オフにしてもminBookmarkCountの値は0に戻さない)
hideAiGenerated: false, // AIタグフィルター
hideNovelCards: true, // 小説作品の投稿カード
hideCollectionCards: true, // コレクションの投稿カード
muteUsersEnabled: true, // ユーザーミュート機能 有効/無効
mutedAuthors: [], // ミュート投稿者リスト [{id,name}]
muteTagsEnabled: true, // タグミュート機能 有効/無効
mutedTags: [], // ミュートタグリスト
mutedFeedPosters: [], // ミュートフィード送信者リスト [{account,name}]
muteFeedUsersEnabled: true, // フィードユーザーミュート機能 有効/無効(フィードページのみUI表示)
filterBeforeReveal: true, // フィルターするまで投稿カードを一時的に隠す(チラつき防止)
// ---- 不要な要素を非表示 ----
// 全体(PC/モバイル共通)
hideNotice: true, // お知らせ
hideLeadContent: true, // ホームページのPixivプレミアム宣伝カード(UI非表示、常時true)
// トップページ(ホーム/イラスト/マンガ共通)
hideHomeCommentPreview: true, // コメントのプレビュー
hideRequestSection: false, // リクエストのプレビュー(該当セレクタ未特定、未実装のプレースホルダー。UIからは削除済み)
hidePixivisionSection: true, // Pixivisionの新着記事(ホーム側の実装 + サイドメニューのバナーカルーセル。UIではhidePixivisionArticlesと1トグルにまとめている)
hidePixivisionArticles: true, // Pixivisionの新着記事(イラスト/マンガ側の実装)
hideContestSections: true, // コンテスト
hideFeaturedProjects: true, // 注目の企画目録
hideEditorPicks: true, // 編集部のおすすめ
hideComicRecommendations: true, // Pixivコミックの人気作品
hidePartnerLabel: true, // 提携レーベル
// その他
hideNovelSection: true, // 小説作品セクション(リクエストページ)
hideSideMenuNovelSection: false, // サイドメニュー「小説」セクション
hidePopularNovelTags: true, // 人気の小説タグ(検索ポップアップ)
hideArtworkComments: false, // コメント欄(作品ページ)セクション全体(セレクタ未検証、要動作確認)
hideTagNovelSection: true, // サイドメニューの「#〇〇の小説作品」セクション
// ---- UIには出していないが動作は続けている項目 ----
// パネルに戻したい場合はSECTION_TOGGLE_GROUPSのitemsに1行足すだけで復活する。値は変えていないのでそのまま使える。
hidePcTagList: false, // タグリスト(PC)
hideUserPageTagList: false, // タグリスト(ユーザーページ)
hideTrendingTags: true, // トレンド
hideRanking: true, // ランキング
hideMangaRequestWorks: false, // マンガリクエスト作品(PC)
hideTagContent: true, // 人気のタグ(モバイル)
hideSideMenuPopularTags: true, // 人気のタグ(サイドメニュー/PC)
hideSideMenuRecommendedUser: true, // おすすめユーザー(サイドメニュー/PC)
hideGenreTags: false, // ジャンルタグ(セレクタ未特定、未実装のプレースホルダー)
// ---- 固定値化(UIからは削除。値を変えたい場合はこの初期値を直接書き換える) ----
hidePremiumBanner: false, // 広告 / Pixivプレミアムの宣伝(UIから削除。機能自体も無効化)
hideFooterExtra: true, // フッター追加ブロック(モバイル)。
hideFooterLanguageSelect: true, // フッター言語切替(モバイル)
hideForeignLanguage: false, // 外国語フィルター
collapseEmptyContainers: true, // 空枠を自動で畳む。
// ---- 廃止済み設定(不明な要素、現在Pixiv上に存在しない/実体不明。参考記録として残置) ----
// hideAd : 広告単独トグル(hidePremiumBannerへ統合したため廃止)
// hideRequestTagList : サイドメニューの作品リクエストタグ?
// hideSidebarAdIframe : ホームページ上部の宣伝カード?
};
function loadSettings() {
let s;
try { s = { ...DEFAULTS, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') }; }
catch (e) { s = { ...DEFAULTS }; }
s.hideFooterLanguageSelect = (LANG === 'ja');
return s;
}
function saveSettings() {
const merged = { ...loadSettings(), ...settings };
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: settings }));
}
let settings = loadSettings();
window.addEventListener(EVENT_NAME, e => {
settings = { ...settings, ...e.detail };
updateAlwaysHideStyle();
if (typeof applyPanelOpacity === 'function') applyPanelOpacity();
// MutationObserver発火時と同じフルカスケードを、同じ間引き機構(scheduleUpdate/
// runUpdateThrottled, MIN_UPDATE_INTERVAL_MS)に乗せて流す。以前はrunUpdate()を
// 直接呼んでおり、スライダー連打等で設定変更が連続すると間引きされず毎回フルカスケードが
// 走って重かった。scheduleUpdate()(引数無し=フル要求)経由にすることで最大500msに1回に制限する
// (関数宣言のためhoistingにより本イベントリスナー発火時点では常に定義済み)
scheduleUpdate();
// 設定パネルUI側の表示同期(パネル未生成の初回document-start時点では関数未定義のため存在チェック)
if (typeof refreshBlacklistUI === 'function') refreshBlacklistUI();
if (typeof syncToggleGroupCheckbox === 'function') {
Object.keys(e.detail || {}).forEach(key => syncToggleGroupCheckbox(key, settings[key]));
}
});
/* ============================== 常時非表示(フラッシュ防止) ==============================
条件判定不要な要素をdocument-start時点でCSS注入し、描画直後に一瞬表示されて消える
チラつきを防ぐ。React/Vueの初回描画より前に注入する。各項目は設定でON/OFF可能。 */
function buildAlwaysHideSelector() {
const parts = [];
if (settings.hideTrendingTags) parts.push('[data-ga4-label="trend_tag_section_content"]');
if (settings.hideRanking) parts.push('[data-ga4-label="ranking_content"]');
if (settings.hidePixivisionSection) parts.push('[data-ga4-label="pixivision_content"]', '.__top_side_menu_body [data-ga4-label="banner_content"]');
if (settings.hideLeadContent) parts.push('[data-ga4-label="lead_content"]');
if (settings.hideTagContent) parts.push('[data-ga4-label="tag_content"]');
if (settings.hideSideMenuPopularTags) parts.push('[data-ga4-label="popular_tags_content"]');
if (settings.hideSideMenuRecommendedUser) parts.push('[data-ga4-label="recommended_user_content"]');
if (settings.hidePremiumBanner) {
parts.push('#__premium_banner', 'a[href*="/premium/lead/"]', 'section:has(a[href*="/premium/campaign/"])');
parts.push(AD_MARKER_SELECTOR); // 広告枠。「広告」も「Pixivプレミアムの宣伝」も同じトグルに統合。セレクタが増えたらAD_MARKER_SELECTORにカンマ区切りで追記する
}
if (settings.hidePcTagList) parts.push('div.sc-3ad027a5-0'); // イラスト/マンガページのタグリスト。ホームページ分(旧hidePromoBlock)は構造セレクタのためhidePcTagListMisc()側で処理
if (settings.hideFooterExtra) parts.push('div.c-flex.c-flex-col.c-items-center.c-my-64[data-v-1229f4d8]');
if (settings.hideFooterLanguageSelect) parts.push('footer nav ul.language-select', '.__top_side_menu_body footer > ul');
return parts.join(', ');
}
let alwaysHideStyleEl = null;
function updateAlwaysHideStyle() {
const selector = buildAlwaysHideSelector();
if (!alwaysHideStyleEl) {
alwaysHideStyleEl = document.createElement('style');
alwaysHideStyleEl.id = 'pxv-pre-hide-style';
(document.head || document.documentElement).appendChild(alwaysHideStyleEl);
}
alwaysHideStyleEl.textContent = selector ? `${selector} { display: none !important; }` : '';
}
updateAlwaysHideStyle();
/* ============================== 読み込み無限化対策(15秒タイムアウト) ==============================
まれにページ読み込みが完了せずタブ/リロードアイコンが回り続けることがある。
document-start時点からタイマーを張り、15秒経ってもload完了しなければwindow.stop()で強制停止する。 */
(function setupLoadTimeout() {
const TIMEOUT_MS = 15000;
if (document.readyState === 'complete') return;
const timer = setTimeout(() => {
try {
window.stop();
console.warn('[Pixiv Contents Filter] 読み込み15秒超過、強制停止しました');
} catch (e) {}
}, TIMEOUT_MS);
window.addEventListener('load', () => clearTimeout(timer), { once: true });
})();
/* ============================== bundle読み込み失敗時の自動リロード ==============================
デプロイ直後、古いHTMLが新bundle名を参照して404/MIMEエラー→白画面化する現象を検知し、
クールダウン付きで自動リロードする。 */
(function setupBundleErrorReload() {
const RELOAD_FLAG_KEY = 'pxv_bundle_error_reload_at';
const COOLDOWN_MS = 15000; // 短時間の再発はCDN障害等の可能性→無限リロード回避
function tryReload(reason) {
let last = 0;
try { last = Number(sessionStorage.getItem(RELOAD_FLAG_KEY) || 0); } catch (e) {}
const now = Date.now();
if (now - last < COOLDOWN_MS) return;
try { sessionStorage.setItem(RELOAD_FLAG_KEY, String(now)); } catch (e) {}
console.warn('[Pixiv Contents Filter] bundle読み込み失敗検知、自動リロードします:', reason);
location.reload();
}
document.addEventListener('error', (e) => {
const el = e.target;
if (!(el instanceof HTMLScriptElement)) return;
const src = el.src || '';
if (/s\.pximg\.net\/.+\/bundle\//.test(src)) tryReload(src);
}, true); // errorイベントはbubbleしないためcaptureフェーズで拾う
})();
const AI_TAGS = ['AI生成', 'AIイラスト', 'AI art', 'AI generated', 'NovelAI', 'Stable Diffusion', 'LoRA',
'Midjourney', 'ComfyUI', '呪文公開', 'AI Generated', 'StableDiffusion', 'AIの絵師さんと繋がりたい',
'Illustrious', 'Pony Diffusion', 'SDXL'];
/* ============================== セレクタ / 基礎ヘルパー ============================== */
// 作品カードとみなすDOM構造。Pixiv側の自動生成クラス名(sc-xxxx)はデプロイ毎に変わりうるため
// URL/data属性ベースの判定を優先し、崩れた場合の構造フォールバックも列挙している。
const CARD_SELECTORS = [
'.col-span-2',
'div[type="illust"]:has(a[href*="/artworks/"])', // 関連作品等の横並びリスト(1 li内に複数作品が同居するケース)
'div.sc-no5f3t-0:has(a[href*="/artworks/"])', // 「#タグのおすすめ作品」セクション
'div.sc-4c03132a-0:has(a[href*="/artworks/"])', // 検索モーダル内「#タグ」おすすめ作品(旧クラス名。ビルドでハッシュ変更されうるため下の構造セレクタも併用)
'div:has(> div > a[data-ga4-label="thumbnail_link"]):not([data-ga4-label] *)', // サムネ直下2階層の共通コンポーネント(検索モーダル/おすすめユーザー等で使い回し)。ハッシュクラス非依存。data-ga4-label付き祖先配下は既存セレクタと競合するため除外
'[data-ga4-label="thumbnail"]',
'[data-ga4-label="work_content"]',
'div.sc-4f0aae32-0 > div:has(a[href*="/artworks/"])', // おすすめユーザー作品
'ul > div:has(a[data-ga4-label="thumbnail_link"]):not(:has(div.sc-4f0aae32-0))',
'ul > div:has(a[href*="/artworks/"]):not(:has(div.sc-4f0aae32-0))',
'li:has(a[data-ga4-label="thumbnail_link"])',
'div.sc-90442766-1 > div:has(a[href*="/artworks/"])',
'div.sc-a93fbab8-2:has(a[href*="/artworks/"])',
'div.sc-c19193ba-0 > div:has(a[data-ga4-label="thumbnail_link"])',
'nav div:has(a[data-ga4-label="thumbnail_link"])',
'li:has(a[href*="/artworks/"])',
'div.sc-6342e1fd-9:has(a[href*="/artworks/"])',
'.stacc_status_summary:has(a[href*="illust_id="])', // モバイル: 通知フィード
'.works-item-illust:has(a[href*="/artworks/"])', // モバイル: 作品一覧
'.list-item:has(a[href*="/artworks/"])', // モバイル: グリッド1マス
'[class*="list-item"]:has(a[href*="/artworks/"])',
'.pickup-carousel a.carousel-item', // ピックアップカルーセル
'.user-latest-works > a[href*="/artworks/"]',
'li.illust:has(a[href*="illust_id="])', // 旧URL形式
'div[data-tx]:has(a[href*="/artworks/"])', // ディスカバリーフィード新レイアウト(style_*ハッシュクラス、data-tx=illustID)
];
const CARD_SELECTOR = CARD_SELECTORS.join(', ');
// 新URL(/artworks/12345)・旧URL(illust_id=12345)の両対応
const ARTWORK_LINK_SELECTOR = 'a[href*="/artworks/"], a[href*="illust_id="]';
// 同一作品が入れ子でマッチした場合は最内側のみ残す(外側/内側の二重マッチ対策)。
// 祖先方向に辿って「マッチ済み祖先=除外対象」とマークするO(n*深さ)方式。
function dedupeNestedCards(all) {
const matched = new Set(all);
const excluded = new Set();
all.forEach(el => {
let p = el.parentElement;
while (p) {
if (matched.has(p)) excluded.add(p); // elを内包する祖先はすべて「外側」なので除外対象
p = p.parentElement;
}
});
return all.filter(el => !excluded.has(el));
}
function getCards() {
return dedupeNestedCards(Array.from(document.querySelectorAll(CARD_SELECTOR)));
}
// 差分更新用: 指定root集合(追加/属性変化ノード)配下だけを対象にCARD_SELECTORマッチを集める。
// 無限スクロールは「空ラッパー追加→中身は後から差し込み」の2段階レンダリングがあり、
// mutation対象がカード内部要素になることがあるため、子孫方向だけでなく祖先方向(closest)も見る。
function getCardsWithin(roots) {
const seen = new Set();
const all = [];
roots.forEach(root => {
if (!root.isConnected) return;
const owner = root.closest?.(CARD_SELECTOR); // root自身がマッチする場合もclosestが拾う
if (owner && !seen.has(owner)) { seen.add(owner); all.push(owner); }
root.querySelectorAll?.(CARD_SELECTOR).forEach(el => {
if (!seen.has(el)) { seen.add(el); all.push(el); }
});
});
return dedupeNestedCards(all);
}
function getIllustIdFromCard(card) {
const a = card.matches?.(ARTWORK_LINK_SELECTOR) ? card : card.querySelector(ARTWORK_LINK_SELECTOR);
if (a) {
const m = a.href.match(/artworks\/(\d+)/) || a.href.match(/[?&]illust_id=(\d+)/);
if (m) return m[1];
}
// ピックアップカルーセル等、hrefが無くbackground-imageしか手掛かりが無い場合のフォールバック
const bgEl = card.matches?.('[style*="background-image"]') ? card : card.querySelector('[style*="background-image"]');
if (bgEl) {
const bg = bgEl.style.backgroundImage || '';
const m = bg.match(/\/(\d+)_p\d+/);
if (m) return m[1];
}
return null;
}
// staccフィードの「投稿者」=作品のミュート対象とは別人(例: ブックマーク通知の張本人)
const FEED_USER_LINK_SELECTOR = '.stacc_post_user_name a[href*="/stacc/"], .stacc_post_user_img_50 a[href*="/stacc/"]';
const MOBILE_FEED_USER_LINK_SELECTOR = '.staccedby a.user[href*="/users/"], .staccedby a.user[href*="bookmark.php"]';
function getFeedUserFromCard(card) {
const a = card.querySelector(FEED_USER_LINK_SELECTOR);
if (a) {
const m = a.getAttribute('href').match(/\/stacc\/([^/?#]+)/);
if (m) {
const nameEl = card.querySelector('.stacc_post_user_name');
const name = nameEl ? nameEl.textContent.trim() : '';
return { account: decodeURIComponent(m[1]), name };
}
}
const mobA = card.querySelector(MOBILE_FEED_USER_LINK_SELECTOR);
if (mobA) {
const href = mobA.getAttribute('href');
const mm = href.match(/\/users\/(\d+)/) || href.match(/[?&]id=(\d+)/);
if (mm) return { account: mm[1], name: '' };
}
return null;
}
// イラストデータ取得はBetterPixiv共通のpxvGetIllustData()に一本化(Overlay/Previewと共有)。
// 旧: このスクリプト単独のillustCache/getIllustData → 1カード最大3回fetchされていた。
const getIllustData = pxvGetIllustData;
/* ============================== 表示/非表示ヘルパー(一本化) ==============================
React/VueがDOMノードを使い回すため、非表示状態は dataset.pxvHidden を正として一元管理する。
以前は箇所ごとに style.display 直接参照と dataset 参照が混在し、状態不整合バグの温床だった。 */
function isHidden(el) {
return !!el && (el.dataset.pxvHidden === '1' || el.style.display === 'none');
}
// リンクの祖先を辿り、自身または途中の祖先いずれかが非表示化済みかを判定(空セクション折り畳み用)
function isHiddenOrAncestorHidden(el) {
return isHidden(el) || el.closest('[style*="display: none"], [data-pxv-hidden="1"]') !== null;
}
// filterBeforeRevealによる「判定未確定の仮非表示」フラグ。isHidden(確定済み)とは意味が違い、
// 区別せず畳み判定すると fetch未着の一時的な全隠れを「空」と誤判定してしまうため、
// pending中のカードを含むスコープでは畳む/戻すの判断自体を保留する。
function isPending(el) {
if (!el) return false;
if (el.dataset && el.dataset.pxvPending === '1') return true;
return !!(el.querySelector && el.querySelector('[data-pxv-pending="1"]'));
}
// モバイルのマソンリーグリッドは外側.list-itemがマス自体を占有するため、隠す際は併せて隠す。
// precomputedInfoSibs: hide/show側で既に計算済みのgetInfoSiblings(el)結果があれば渡し、
// 初回イテレーション分の再計算を省略する(重複計算の解消)。
function getListItemWrapper(el, precomputedInfoSibs) {
let cur = el;
let wrapper = null;
// depth上限4: サイドメニュー投稿カードはli到達に4階層必要なため。安全装置で打ち切られるため副作用なし。
for (let depth = 0; depth < 4; depth++) {
const parent = cur.parentElement;
if (!parent) break;
if (parent.matches('.list-item, li.col-span-2, [class*="w-1/"]')) { wrapper = parent; break; }
// 安全装置: nav/aside/header/footer/main等のランドマークやサイドメニュー本体まで
// 這い上がると無関係な共有コンテナごと非表示化してしまうため、そこで打ち切る。
if (parent.matches('.__top_side_menu_body, nav, aside, header, footer, main') ||
parent.parentElement?.matches('.__top_side_menu_body')) break;
// 汎用フォールバック: 子要素がカード自身1つだけ→グリッドマス専有ラッパーとみなしさらに上を確認。
// 付随情報兄弟(getInfoSiblings)は別途非表示化されるため実質子要素数から除外してカウントする。
const rawInfoSibs = (cur === el && precomputedInfoSibs) ? precomputedInfoSibs : getInfoSiblings(cur);
const infoSibs = rawInfoSibs.filter(s => s.parentElement === parent);
const effectiveChildCount = parent.children.length - infoSibs.length;
if (effectiveChildCount === 1 && parent.parentElement) { wrapper = parent; cur = parent; continue; }
break;
}
return wrapper;
}
// カード本体(data-pxv-filter-injected付与済み)は、サムネとタイトル/投稿者名等の付随情報が
// 兄弟divに分離している構造がある。カード本体だけ隠すと付随情報が残るため、サムネを持たず
// artworks/usersリンクのみ含む兄弟divをすべて検出し、合わせて隠す対象にする(0〜複数)。
function getInfoSiblings(el) {
if (!el?.dataset || el.dataset.pxvFilterInjected !== '1') return [];
const parent = el.parentElement;
if (!parent) return [];
return Array.from(parent.children).filter(c => {
if (c === el) return false;
// サムネ持ちは別要素(作品サムネ限定。投稿者アバターimg(usersリンク内)は情報側なので除外しない)
if (c.querySelector('a[href*="/artworks/"] img, svg, [style*="background-image"]')) return false;
if (!c.querySelector('a[href*="/artworks/"], a[href*="/users/"]')) return false;
return true;
});
}
function hide(el) {
if (!el) return;
// 一部要素は !c-flex 等Tailwindの!importantクラスでdisplayが強制されており、
// importantなしのインラインstyleでは負けて非表示にならないためsetPropertyで上書きする
el.style.setProperty('display', 'none', 'important');
el.dataset.pxvHidden = '1';
const infoSibs = getInfoSiblings(el); // getListItemWrapper内の初回イテレーション分と共有し二重計算を避ける
const wrapper = getListItemWrapper(el, infoSibs);
if (wrapper) { wrapper.style.setProperty('display', 'none', 'important'); wrapper.dataset.pxvHidden = '1'; }
infoSibs.forEach(sib => {
sib.style.setProperty('display', 'none', 'important');
sib.dataset.pxvHidden = '1';
});
}
function show(el) {
if (!el) return;
el.style.display = '';
if (el.style.visibility === 'hidden') el.style.visibility = '';
delete el.dataset.pxvHidden;
const infoSibs = getInfoSiblings(el); // getListItemWrapper内の初回イテレーション分と共有し二重計算を避ける
const wrapper = getListItemWrapper(el, infoSibs);
if (wrapper) {
wrapper.style.display = '';
if (wrapper.style.visibility === 'hidden') wrapper.style.visibility = '';
delete wrapper.dataset.pxvHidden;
}
infoSibs.forEach(sib => {
sib.style.display = '';
if (sib.style.visibility === 'hidden') sib.style.visibility = '';
delete sib.dataset.pxvHidden;
});
}
function unhideAllTracked() {
document.querySelectorAll('[data-pxv-hidden="1"]').forEach(el => {
el.style.display = '';
if (el.style.visibility === 'hidden') el.style.visibility = '';
delete el.dataset.pxvHidden;
});
}
// 「中身が全部非表示ならブロックごと非表示、そうでなければ復帰」共通処理。
// 従来5箇所に分散していた同型ロジックをここに集約する。
function collapseIfAllHidden(block, items, hiddenCheck = isHidden) {
if (!block || !items.length) return;
// pending中(fetch待ち)のカードが混ざっている間は畳む/戻すの判断自体を保留。
// 仮非表示を「全部隠れてる」と誤読して親を畳んでしまうと、後でカード側だけshow()されても
// 親が畳まれたまま残る(仮非表示が解除されないように見える)バグの元。
if (items.some(isPending)) return;
if (items.every(hiddenCheck)) hide(block);
else if (isHidden(block)) show(block);
}
/* ============================== カード単位のデータ・フィルタ ============================== */
const tagsMap = new WeakMap();
// コレクションフィルターの適用ページ。'all'で全ページ、'path:xxx'で任意pathname部分一致
const COLLECTION_FILTER_PAGES = ['home'];
const PAGE_TYPE_PATTERNS = {
home: /^\/(?:[a-z]{2}\/)?$/,
search: /^\/(?:[a-z]{2}\/)?tags\//,
ranking: /^\/(?:[a-z]{2}\/)?ranking\.php/,
discovery: /^\/(?:[a-z]{2}\/)?discovery/,
newest: /^\/(?:[a-z]{2}\/)?bookmark_new_illust/,
user: /^\/(?:[a-z]{2}\/)?users\//,
};
function isCollectionFilterActivePage() {
if (COLLECTION_FILTER_PAGES.includes('all')) return true;
return COLLECTION_FILTER_PAGES.some(key =>
key.startsWith('path:') ? location.pathname.includes(key.slice(5)) : PAGE_TYPE_PATTERNS[key]?.test(location.pathname)
);
}
// fetchしたデータが無いと判定できないフィルタが1つでも有効かどうか
function needsAsyncData() {
return (settings.bookmarkFilterEnabled && settings.minBookmarkCount > 0) || settings.hideForeignLanguage || settings.hideAiGenerated ||
(settings.muteUsersEnabled && settings.mutedAuthors && settings.mutedAuthors.length > 0) ||
(settings.muteTagsEnabled && settings.mutedTags && settings.mutedTags.length > 0);
}
// リクエスト依頼文のtitle属性を「文」判定に合流させるためのセレクタ
const CARD_TEXT_TITLE_SELECTOR = 'a > div > div.charcoal-text-ellipsis[title]';
// 中国語のみで使われる字体(日本語の常用漢字には無いもの)
const CN_ONLY = '们这你还就呢吧呀哦吗么为说现语时个从对谁很哪啊什喂嘛呗啦谢请显关兴觉让认识讨论议应该卖买车东书长门问间队边达过进远运连选变汉欢权汇传网络线组织级纸纪约给绝继图报场块坏药亿优伟儿处备币产准苏预战钱铁银错爱师转发佬了' +
'幫畫與劍妳們這說從對哪啥顯關覺讓賣傳圖兒處產戰錢鐵變歡權匯壞藥絕繼應邊' +
'无绘术插设计';
const CN_ONLY_RE = new RegExp('[' + CN_ONLY + ']');
const HANGUL_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]/;
const KANA_RE = /[\u3040-\u309F\u30A0-\u30FF\uFF66-\uFF9F]/;
// 外国語フィルター(hideForeignLanguage)専用の定番タグ判定リスト(UI非公開、内部処理のみ)。
// 英語圏/中国語圏の投稿に頻出するタグを持つ作品は、他の判定材料が乏しくても外国語扱いにする。
const FOREIGN_PRESET_TAGS = ['doodle', 'crossover', 'nsfw', 'sketch', 'background', '摸鱼', '原创', '二创', '约稿', '涂鸦', '草稿', '练习', '厚涂', '性转', '自设'];
function hasForeignPresetTag(tagsArr) {
if (!settings.hideForeignLanguage || !tagsArr || !tagsArr.length) return false;
return tagsArr.some(t => FOREIGN_PRESET_TAGS.includes(t.trim().toLowerCase()));
}
// 外国語判定(3軸、いずれか該当で外国語):
// ルール1: 題名/文/作者名/タグのいずれかに中国語専用字体・ハングル・全角カンマを含む
// ルール2: 題名/文/作者名にかなが無く、かつ短文除外の対象外(=判断材料あり)なら外国語扱い
// ルール3: タグに外国語定番タグ(FOREIGN_PRESET_TAGS)を含む
// タグ名がミュートタグ一致か判定。「ツルマルツヨシ(ウマ娘)」のように()付きタグは、
// 全体一致に加え()内の文字列("ウマ娘")単体でもミュートタグ一致とみなす。
function matchesMutedTag(name) {
if (!name || !settings.muteTagsEnabled || !settings.mutedTags?.length) return false;
const trimmed = name.trim();
if (settings.mutedTags.includes(trimmed)) return true;
const m = trimmed.match(/[((]([^))]+)[))]/);
return !!(m && settings.mutedTags.includes(m[1].trim()));
}
// タグ一覧フィルタ用の外国語タグ名判定。isForeignText()の「かな無し→外国語」ルールは
// 短い単語タグ(漢字のみの日本語タグは珍しくない)には誤爆しやすいため使わない。
// 中国語専用字体・ハングルを含むか、定番外国語タグそのものかの2条件のみで判定する。
function isForeignTagName(name) {
if (!name) return false;
const t = name.trim();
if (!t) return false;
if (CN_ONLY_RE.test(t) || HANGUL_RE.test(t)) return true;
return FOREIGN_PRESET_TAGS.includes(t.toLowerCase());
}
// タグ一覧フィルタで個々のタグを隠すかどうかの統合判定(ミュートタグ一致 or 外国語フィルターON時の外国語タグ)
function shouldHideTagName(name) {
return matchesMutedTag(name) || (settings.hideForeignLanguage && isForeignTagName(name));
}
function isForeignText({ title = '', body = '', author = '', tags = '', tagsArr = [] }) {
const combinedAll = [title, body, author, tags].join(' ');
const hasChinese = CN_ONLY_RE.test(combinedAll);
const hasHangul = HANGUL_RE.test(combinedAll);
const hasChineseComma = /,/.test(combinedAll);
const rule1 = hasChinese || hasHangul || hasChineseComma;
// .lengthはサロゲートペアを2文字と数えるため、Array.fromでコードポイント単位に数える
const titleTooShort = Array.from(title.trim()).length <= 8;
const bodyTooShort = Array.from(body.trim()).length <= 5;
const anyKanaText = [title, body, author].join(' ');
const hasAnyKana = KANA_RE.test(anyKanaText);
const hasEnoughMaterial = !titleTooShort || !bodyTooShort;
const rule2 = !hasAnyKana && hasEnoughMaterial;
const rule3 = hasForeignPresetTag(tagsArr);
const hasKana = !rule2; // dataset.jpFlag用: 「日本語らしい」を意味するフラグ
return { hasChinese, hasHangul, hasChineseComma, hasKana, rule1, rule2, rule3, isForeign: rule1 || rule2 || rule3 };
}
function injectCardInfo(card) {
const id = getIllustIdFromCard(card);
if (!id) return;
// ReactがDOMノードを使い回すため、IDが変わっていたら判定情報を作り直す(古い判定の残留防止)
if (card.dataset.pxvFilterInjected) {
if (card.dataset.pxvIllustId === id) return;
delete card.dataset.pxvBmCount;
delete card.dataset.pxvJpFlag;
delete card.dataset.pxvHasChinese;
delete card.dataset.pxvHasHangul;
delete card.dataset.pxvForeignFlag;
delete card.dataset.pxvUserId;
delete card.dataset.pxvUserName;
tagsMap.delete(card);
}
card.dataset.pxvFilterInjected = '1';
card.dataset.pxvIllustId = id;
// 非同期データが必要なフィルタが有効な場合、判定確定まで先に隠す(チラつき防止)。
// pxvPendingは「まだ結論が出ていない仮非表示」の目印。collapseEmptyXXX系はこれを見て
// 畳む/戻すの判断を保留する(isHiddenだけでは確定済みhideと区別できないため)。
const wasTempHidden = settings.filterBeforeReveal && needsAsyncData();
if (wasTempHidden) { hide(card); card.dataset.pxvPending = '1'; }
return getIllustData(id).then(body => {
delete card.dataset.pxvPending; // 結論(表示/フィルタ確定)が出たのでpending解除
if (!body) {
if (wasTempHidden && isHidden(card)) show(card);
return;
}
card.dataset.pxvBmCount = body.bookmarkCount;
const tags = (body.tags?.tags || []).map(t => t.tag);
tagsMap.set(card, tags);
const descPlain = (body.description || '')
.replace(/<a\b[^>]*>[\s\S]*?<\/a>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '');
const descNoUrl = descPlain
.split('\n')
.filter(line => !/https?:\/\//.test(line))
.map(line => line.replace(/FANBOX|Patreon|Twitter|X\(旧Twitter\)/gi, '').trim())
.filter(Boolean)
.join('\n');
const domTexts = Array.from(card.querySelectorAll(CARD_TEXT_TITLE_SELECTOR))
.map(d => (d.getAttribute('title') || '').trim())
.filter(Boolean);
const bodyText = [descNoUrl, ...domTexts].join('\n');
const result = isForeignText({
title: body.title || '',
body: bodyText,
author: body.userName || '',
tags: tags.join(' '),
tagsArr: tags,
});
card.dataset.pxvJpFlag = result.hasKana ? '1' : '0';
card.dataset.pxvHasChinese = result.hasChinese ? '1' : '0';
card.dataset.pxvHasHangul = result.hasHangul ? '1' : '0';
card.dataset.pxvForeignFlag = result.isForeign ? '1' : '0';
card.dataset.pxvUserId = body.userId;
card.dataset.pxvUserName = body.userName;
// ここではapplyFilters()を呼ばない。fetch完了タイミングがバラバラなため、
// 個別反映すると1件ずつ順に表示される見た目になる。反映はattachAll側で一括で行う。
}).catch(() => {
// fetch失敗時も.then()が走らずpxvPendingが残り続け、そのカードだけ永久に仮非表示のまま
// (かつ親コンテナのcollapse判断も永久保留)になるのを防ぐ。ここで解除・表示に戻す。
delete card.dataset.pxvPending;
if (wasTempHidden && isHidden(card)) show(card);
});
}
/* ============================== フィルタ本体(パイプライン化) ==============================
各フィルタは「該当すれば非表示」の真偽を返す関数。配列なので追加・一時無効化・順序変更が容易。 */
const FILTERS = [
// 小説/コレクション
card => {
if (!settings.hideNovelCards && !settings.hideCollectionCards) return false;
const a = card.querySelector('a[href*="/novel/"], a[href*="/collections/"]');
if (!a) return false;
const href = a.getAttribute('href');
if (/\/novel\//.test(href)) return settings.hideNovelCards;
if (/\/collections\//.test(href)) return settings.hideCollectionCards && isCollectionFilterActivePage();
return false;
},
// 非同期データ未着なら判定確定まで非表示のまま(作品IDを持たないカードは対象外)
card => {
if (!settings.filterBeforeReveal || !needsAsyncData()) return false;
const hasArtworkId = !!getIllustIdFromCard(card);
const dataReady = card.dataset.pxvBmCount !== undefined || !hasArtworkId;
return !dataReady;
},
// ブックマーク数しきい値
card => settings.bookmarkFilterEnabled && settings.minBookmarkCount > 0 && card.dataset.pxvBmCount !== undefined &&
parseInt(card.dataset.pxvBmCount, 10) < settings.minBookmarkCount,
// 外国語
card => settings.hideForeignLanguage && card.dataset.pxvForeignFlag === '1',
// AI生成
card => {
if (!settings.hideAiGenerated) return false;
const tags = tagsMap.get(card);
return !!tags && tags.some(t => AI_TAGS.includes(t));
},
// ミュートユーザー(投稿者)
card => !!settings.muteUsersEnabled && !!settings.mutedAuthors?.length && card.dataset.pxvUserId !== undefined &&
settings.mutedAuthors.some(u => String(u.id) === card.dataset.pxvUserId),
// ミュートユーザー(フィード表示者。投稿者とは別概念)
card => {
if (!settings.muteFeedUsersEnabled || !settings.mutedFeedPosters?.length) return false;
const feedUser = getFeedUserFromCard(card);
return !!feedUser && settings.mutedFeedPosters.some(u => u.account === feedUser.account);
},
// ミュートタグ
card => {
if (!settings.mutedTags?.length) return false;
const tags = tagsMap.get(card);
return !!tags && tags.some(t => matchesMutedTag(t));
},
];
function applyFilters(cards) {
(cards || getCards()).forEach(card => {
if (FILTERS.some(test => test(card))) hide(card); else show(card);
});
// 空枠の畳み込み判定はcollapseEmptyContainersCascade側に一本化(呼び出し元で実行)。
// ここでは「カード単位の表示/非表示」だけに責務を絞る。
}
/* ============================== 空コンテナの折り畳み群 ==============================
いずれも「配下の作品カードが全部非表示ならブロックごと隠す」という同型パターン。
共通処理は collapseIfAllHidden に集約し、各関数はブロック/カードの拾い方だけを担う。 */
// 「コミッション・イラストポータル」枠
function collapseEmptyCommissionBlocks() {
document.querySelectorAll('#gtm-commission-portal-new-view ul > div, #gtm-commission-portal-follow-view ul > div').forEach(block => {
const cards = block.querySelectorAll('div.sc-4f0aae32-0 > div:has(a[href*="/artworks/"])');
if (cards.length) collapseIfAllHidden(block, Array.from(cards));
});
}
// 「おすすめユーザーの作品」枠
function collapseEmptyUserBlocks() {
document.querySelectorAll('div.sc-c19193ba-0').forEach(row => {
const items = Array.from(row.children).filter(el => el.querySelector('a[data-ga4-label="thumbnail_link"]'));
const userBlock = row.closest('ul > div');
if (userBlock && items.length) collapseIfAllHidden(userBlock, items);
});
}
// サイドメニュー等「ユーザーli > ... > 作品カード群」構成向け
function collapseEmptyUserLi() {
document.querySelectorAll('li:has(div.sc-6b27d003-4)').forEach(li => {
const links = Array.from(li.querySelectorAll('div.sc-6b27d003-4 a[href*="/artworks/"]'));
if (!links.length) return;
const cards = links.map(a => a.closest('[data-pxv-filter-injected]') || a.closest('li') || a.parentElement);
collapseIfAllHidden(li, cards, isHiddenOrAncestorHidden);
});
}
// #user-recommender「おすすめユーザー」ウィジェット向け。カード = .user-latest-works > a
// 作品側がミュート等で全部非表示なら、ユーザー情報ごとsuggest-user-itemを畳む
function collapseEmptyUserRecommenderItems() {
document.querySelectorAll('#user-recommender .suggest-user-item').forEach(item => {
const cards = Array.from(item.querySelectorAll('.user-latest-works > a[href*="/artworks/"]'));
if (cards.length) collapseIfAllHidden(item, cards, isHiddenOrAncestorHidden);
});
}
// 作品リスト+フォローボタン付きユーザー情報が同居するブロック(クラス名ハッシュ変動のため構造で判定)。
// フォローボタンから祖先を辿り、data-pxv-filter-injectedカード群を含む最初のブロックを対象にする
function collapseEmptyFollowUserBlocks() {
document.querySelectorAll('[data-ga4-label="follow_button"]').forEach(btn => {
let block = btn.parentElement;
for (let i = 0; i < 6 && block; i++) {
const cards = block.querySelectorAll('[data-pxv-filter-injected]');
if (cards.length) {
collapseIfAllHidden(block, Array.from(cards), isHiddenOrAncestorHidden);
return;
}
block = block.parentElement;
}
});
}
// 「#タグのおすすめ作品」セクション。見出し+リストの外側ラッパーごと、全作品非表示なら畳む
function collapseEmptyTagRecommendSections() {
// 旧UI固定クラス
document.querySelectorAll('div.sc-no5f3t-1').forEach(list => {
const cards = Array.from(list.querySelectorAll('div.sc-no5f3t-0'));
if (!cards.length) return;
collapseIfAllHidden(list.parentElement || list, cards, isHiddenOrAncestorHidden);
});
// 新UI: クラス名がビルド毎に変動するため構造で判定。カード(div[type=illust/novel])の
// 3階層上=リスト全体。親が見出し等含む外側ラッパー(子要素2つ以上)ならそこごと畳み、
// 単独ラッパーならリスト自体を畳み対象にする。
// 1グループ=1カードの構成では複数グループがtarget(ul)を共有するため、グループ単位で
// collapseIfAllHiddenを個別に呼ぶと後発グループの判定が先発を上書きしてしまう
// (作品を1つ隠しただけでリスト全体が消えるバグの原因)。target毎に全カードを集約してから判定する。
const targetCards = new Map();
document.querySelectorAll('div[type="illust"][data-pxv-filter-injected], div[type="novel"][data-pxv-filter-injected]').forEach(card => {
const list = card.parentElement?.parentElement?.parentElement;
if (!list) return;
const outer = list.parentElement;
const target = (outer && outer.children.length > 1) ? outer : list;
if (!targetCards.has(target)) targetCards.set(target, []);
targetCards.get(target).push(card);
});
targetCards.forEach((cards, target) => {
collapseIfAllHidden(target, cards, isHiddenOrAncestorHidden);
});
}
// サイドメニュー新UI「#タグのおすすめ」。見出しとリストが別枝の兄弟に分かれる構造のため、
// 既存の折り畳み処理では拾いきれず見出しだけ取り残されるバグがあった。
// nth-childチェーン指定は描画構成の変化で壊れやすいため使わず、見出しとリストを両方
// 内包する最も浅い共通祖先ブロックを構造的に探して畳む(他構造向けの処理には影響しない)。
function collapseEmptyHeadingWorksGridSections() {
document.querySelectorAll('.style_heading-area__uDuur').forEach(heading => {
let block = heading.parentElement;
for (let depth = 0; depth < 6 && block; depth++) {
// ランドマーク/サイドメニュー本体まで這い上がったら無関係な共有コンテナごと
// 巻き添えにする危険があるため打ち切る(既存の安全装置と同じ考え方)
if (block.matches('.__top_side_menu_body, nav, aside, header, footer, main')) return;
const list = block.querySelector('.style_works-grid-list__J1aSO');
if (list) {
const cards = Array.from(list.querySelectorAll('[data-pxv-filter-injected]'));
if (cards.length) collapseIfAllHidden(block, cards, isHiddenOrAncestorHidden);
return;
}
block = block.parentElement;
}
});
}
// 「イラストリクエスト作品」等の行(div.sc-a93fbab8-2)。説明文/アバター/サムネ/投稿者名の
// 4つが兄弟divとして並ぶ構成で、getInfoSiblings/getListItemWrapper(直近の親を辿るだけの
// 汎用ロジック)では階層が深すぎて拾いきれず、サムネだけ隠れて他が残る不具合があったため、
// 行全体を明示的に対象として畳む(内側カードとの入れ子除外により他カード構造には影響しない)。
function collapseEmptyRequestWorkCards() {
document.querySelectorAll('div.sc-a93fbab8-2').forEach(row => {
const cards = Array.from(row.querySelectorAll('[data-pxv-filter-injected]'));
if (cards.length) collapseIfAllHidden(row, cards, isHiddenOrAncestorHidden);
});
}
// 「関連作品」等の横並びリスト(1 li内に複数作品が同居)向け。全作品非表示ならli自体畳む
function collapseEmptyRelatedWorksLi() {
document.querySelectorAll('li:has(div[type="illust"] a[href*="/artworks/"])').forEach(li => {
const cards = Array.from(li.querySelectorAll('div[type="illust"]'));
if (cards.length) collapseIfAllHidden(li, cards, isHiddenOrAncestorHidden);
});
}
// 検索モーダル(discovery zone)の各グループ(見出し+w-1/2カード群)。
// グループ配下のカードが全部非表示なら見出しごと畳む
function collapseEmptyRecommendZoneGroups() {
document.querySelectorAll('div.gtm-illust-recommend-zone > div').forEach(group => {
const cards = Array.from(group.querySelectorAll('[data-pxv-filter-injected]'));
if (cards.length) collapseIfAllHidden(group, cards, isHiddenOrAncestorHidden);
});
}
// 左サイドメニューの作品リスト枠(ul > li構成、クラス名が不安定なため構造で判定)。
// liが作品カードを含むリストのみ対象にし、全滅していたら ul の2階層上(セクション全体)を畳む
function collapseEmptySideMenuArtworkLists() {
document.querySelectorAll('.__top_side_menu_body ul').forEach(ul => {
const items = Array.from(ul.children).filter(li => li.matches('li'));
if (!items.length) return;
const hasArtwork = items.some(li => li.querySelector('a[href*="/artworks/"], a[href*="/novel/"], a[data-ga4-label="thumbnail_link"]'));
if (!hasArtwork) return;
const block = ul.parentElement?.parentElement;
if (!block) return;
// 安全装置: 「ulの2階層上=このリスト専用のセクション」という前提が崩れ、block が
// 複数の独立セクションを同居させた共有コンテナだった場合、無関係な兄弟セクションまで
// 巻き添えでhideしてしまう(モバイルでサイドメニューがほぼ全消えするバグの原因)。
// block直下にdata-ga4-label付き子要素が複数あれば専用ラッパーではないとみなし対象外にする。
const siblingSectionCount = block.querySelectorAll(':scope > [data-ga4-label]').length;
if (siblingSectionCount > 1) return;
collapseIfAllHidden(block, items, isHiddenOrAncestorHidden);
});
}
// 左サイドメニューのh2セクション向け
function collapseEmptyTagSections() {
document.querySelectorAll('h2.sc-51741bbe-3').forEach(h2 => {
const section = h2.closest('section') || h2.parentElement?.parentElement;
if (!section) return;
const links = Array.from(section.querySelectorAll('a[data-ga4-label="thumbnail_link"], a[href*="/artworks/"]'));
if (!links.length) return;
const cards = links.map(a => a.closest('.col-span-2') || a.closest('li') || a.closest('[data-pxv-filter-injected]') || a);
collapseIfAllHidden(section, cards, isHiddenOrAncestorHidden);
});
}
// 旧Vue UI「#タグのおすすめ作品」等。.list-itemコンテナが全滅したら畳み、
// さらに親の唯一の可視要素だった場合は上位ブロックへ連鎖して畳み込む
function collapseEmptyListItemContainers() {
const items = document.querySelectorAll('.list-item.column-2, .list-item');
const containers = new Set();
items.forEach(item => item.parentElement && containers.add(item.parentElement));
containers.forEach(container => {
const kids = Array.from(container.children).filter(k => k.classList.contains('list-item'));
if (!kids.length) return;
if (kids.some(isPending)) return; // pending中は畳む/戻す判断を保留(理由はcollapseIfAllHidden参照)
if (kids.every(isHidden)) { hide(container); bubbleHideIfOnlyVisible(container); }
else if (isHidden(container)) show(container);
});
}
// elが隠れた直後、親の「可視な作品を含む子要素」がel以外に無ければ親も畳み込む(意味のある境界で停止)。
// 広告枠は「作品」判定に含まれないため、親に広告マーカーが残っている場合は畳み込みを止める。
function bubbleHideIfOnlyVisible(el, depth = 0) {
if (depth > 8) return;
const parent = el.parentElement;
if (!parent || parent.id === 'spa-contents' || parent === document.body) return;
const hasVisibleAd = Array.from(parent.children).some(c =>
c !== el && !isHidden(c) && (c.matches?.(AD_MARKER_SELECTOR) || c.querySelector(AD_MARKER_SELECTOR)));
if (hasVisibleAd) return;
const visibleWorkSiblings = Array.from(parent.children).filter(c => {
if (c === el || isHidden(c)) return false;
return c.querySelector('a[href*="/artworks/"], .list-item, [data-pxv-illust-id]') || c.matches?.('.list-item, [data-pxv-illust-id]');
});
if (visibleWorkSiblings.length) return;
hide(parent);
bubbleHideIfOnlyVisible(parent, depth + 1);
}
/* ============================== タグ一覧フィルタ ============================== */
const TAG_LIST_SELECTOR = 'a[data-ga4-label="colorful_tag"], a[class*="gtm-toppage-tag-recommend"], a[class*="gtm-search-box-popular-artwork"], ul > div > a[href^="/tags/"], div.sc-bc391d0d-2 > a[href^="/tags/"], ul.tag-list li.tag-li a.tag-item[href^="/tags/"], a.charcoal-tag-item';
const SIDE_MENU_TAG_SELECTOR = 'div.sc-eba38bcb-3 div.charcoal-text-ellipsis[title^="#"], .__top_side_menu_body div.charcoal-text-ellipsis[title^="#"]'; // 左サイドメニュー(hrefなし)
const USER_ILLUST_TAG_SELECTOR = 'ul.tag-list-container li.tag-li a.tag-item, .tag-list-container a.tag-item, a[href*="/illustrations/"][href*="%"], a[href*="/illustrations/"]:has(.charcoal-text-ellipsis[title^="#"])'; // ユーザーページの絞り込みタグ
function filterTagList() {
if (settings.hideUserPageTagList) {
document.querySelectorAll('.tag-list-container, div.sc-13ee5c42-0').forEach(hide);
} else {
document.querySelectorAll('.tag-list-container, div.sc-13ee5c42-0').forEach(el => { if (isHidden(el)) show(el); });
}
// ミュートタグ機能・外国語フィルターのどちらも無効ならタグ単位の判定は不要
if (!(settings.muteTagsEnabled && settings.mutedTags?.length) && !settings.hideForeignLanguage) return;
document.querySelectorAll(TAG_LIST_SELECTOR).forEach(a => {
let name = a.textContent.trim().replace(/^#/, '');
const m = a.getAttribute('href')?.match(/\/tags\/([^/?]+)/);
if (m) name = decodeURIComponent(m[1]);
if (!shouldHideTagName(name)) return;
// charcoal-tag-item構造はaを隠すだけで見た目上崩れないため、liまで遡らない
if (a.classList.contains('charcoal-tag-item')) { hide(a); return; }
hide(a.closest('li.tag-li') || a.closest('li') || a);
});
document.querySelectorAll('a[href^="/tags/"] div[title^="#"]').forEach(div => {
const name = (div.getAttribute('title') || '').replace(/^#/, '');
if (!shouldHideTagName(name)) return;
hide(div.closest('a') || div);
});
document.querySelectorAll(SIDE_MENU_TAG_SELECTOR).forEach(div => {
const name = (div.getAttribute('title') || div.textContent).trim().replace(/^#/, '');
if (!shouldHideTagName(name)) return;
// 横並びタグは1個ずつセルdivに包まれており、aだけ隠すとセル分の空白が残るため
// aの直接の親要素まで含めて隠す
const a = div.closest('a');
hide((a && a.parentElement) || a || div);
});
document.querySelectorAll(USER_ILLUST_TAG_SELECTOR).forEach(a => {
const m = a.getAttribute('href')?.match(/\/illustrations\/([^/?]+)/);
let name = m ? decodeURIComponent(m[1]) : null;
if (!name) {
const titleEl = a.querySelector('.charcoal-text-ellipsis[title^="#"]');
name = titleEl ? titleEl.getAttribute('title').replace(/^#/, '') : a.querySelector('.tag')?.textContent.trim();
}
if (shouldHideTagName(name)) hide(a.closest('li') || a.closest('div.sc-cf5c2739-2') || a);
});
// 検索モーダルのタグ候補(横スクロールリスト)。div.tagのテキストは"Arknights:E…"のように
// 省略表示されるため使えず、span側のdata-gtm-tag-name属性から実名を取る
document.querySelectorAll('#vue-search-modal span.sideScrollTag[data-gtm-tag-name]').forEach(span => {
const name = span.getAttribute('data-gtm-tag-name');
if (shouldHideTagName(name)) hide(span);
});
}
/* ============================== 検索クエリへの自動除外語追加 ============================== */
let lastHref = '';
function appendExcludeTokens(q, tags) {
const existing = q.split(/\s+/).filter(Boolean);
let changed = false;
tags.forEach(t => {
const excl = `-${t}`;
if (!existing.includes(excl)) { existing.push(excl); changed = true; }
});
return { result: existing.join(' '), changed };
}
function applyAutoSearchExclude() {
if (!settings.autoSearchExclude) return;
const tags = (settings.autoSearchExcludeTags || []).filter(Boolean);
if (!tags.length) return;
const path = location.pathname;
// /search?q=... 形式
if (/^\/(?:[a-z]{2}\/)?search(?:\/|$)/.test(path)) {
const url = new URL(location.href);
const q = (url.searchParams.get('q') || '').trim();
if (!q) return;
const { result, changed } = appendExcludeTokens(q, tags);
if (!changed) return;
url.searchParams.set('q', result);
lastHref = url.toString();
location.replace(lastHref);
return;
}
// /tags/タグ名/type 形式 → /search にリダイレクト統一
const m = path.match(/^\/(?:([a-z]{2})\/)?tags\/([^/]+)(?:\/([a-z_]+))?\/?$/);
if (m) {
const [, , encodedTag, typeSeg] = m;
const tagPart = decodeURIComponent(encodedTag);
const { result } = appendExcludeTokens(tagPart, tags);
const TYPE_MAP = { illustrations: 'illust_and_ugoira', manga: 'manga', novels: 'novel', artworks: 'artwork' };
const type = TYPE_MAP[typeSeg] || 'artwork';
const url = new URL('https://www.pixiv.net/search');
url.searchParams.set('q', result);
url.searchParams.set('s_mode', 'tag');
url.searchParams.set('type', type);
lastHref = url.toString();
location.replace(lastHref);
}
}
/* ============================== コメント欄フィルタ ============================== */
// ホーム/フィード等に出る「コメントプレビュー」枠。旧版は常時ON固定だったが設定でOFFにできるようにした。
function removeCommentPreview() {
if (!settings.hideHomeCommentPreview) return;
document.querySelectorAll('[data-ga4-label="comment"]').forEach(hide);
}
// 作品ページ下部の「コメント」セクション全体(見出し+一覧)を丸ごと隠す(要動作検証)。
function hideArtworkCommentsSection() {
if (!settings.hideArtworkComments) return;
document.querySelectorAll(COMMENT_ITEM_SELECTOR).forEach(li => {
const section = li.closest('section') || li.closest('ul')?.parentElement;
if (section) hide(section);
});
// モバイル版(Vue)は見出し+フォーム+一覧を含む専用コンテナが直接存在するため、closest探索せず直接隠す
document.querySelectorAll(MOBILE_COMMENT_CONTAINER_SELECTOR).forEach(hide);
// 新デザイン(CSS Modules、末尾ハッシュはビルド毎変動のため[class*=]で前方一致)。
// CommentList_wrapperは0件時も常に存在するため件数に依存せず検出できる。共通親ごと隠す。
document.querySelectorAll(NEW_COMMENT_LIST_SELECTOR).forEach(list => {
hide(list.parentElement || list);
});
}
// 作品ページ下部のコメント一覧の判定に使う共通セレクタ(hideArtworkCommentsSectionが使用)
const COMMENT_ITEM_SELECTOR = 'li:has(.Comment_commentContainer__zbGIG)';
// モバイル版(Vue)のコメント欄コンテナ。新UIはCSS Modulesでハッシュ付きクラス名になったため
// [class*=]の部分一致も追加し、旧/新どちらの構成も拾えるようにしてある。
const MOBILE_COMMENT_CONTAINER_SELECTOR = '.work-details-comments-container, [class*="work-details-comments-container__"]';
// 新デザイン(PC)のコメント一覧wrapper
const NEW_COMMENT_LIST_SELECTOR = '[class*="CommentList_wrapper"]';
// 旧filterComments(): コメントの外国語判定による個別非表示機能は削除済み。
// 復元する場合はgit履歴等から関数本体を戻し、下記の呼び出し箇所(scheduleUpdate/init)にも追加すること。
/* ============================== 追加セクション除去群 ==============================
旧removeExtraSections()は1関数に無関係な処理が詰め込まれた「神関数」だったため、
責務ごとに分割し、末尾のオーケストレーターから順に呼び出す構成にした。 */
// お知らせ関連ブロック(統合): モバイル=マイメニュー「お知らせ」項目(React版/旧Vue版両対応)、PC=画面下部固定バー
function hideNotice() {
if (!settings.hideNotice) return;
document.querySelectorAll('a[href="/info.php"]').forEach(a => {
const label = a.querySelector('div');
if (label && label.textContent.trim() === 'お知らせ') {
// remove()はReact管理DOMと矛盾しうるためhide()に統一
hide(a.closest('div.flex.flex-col') || a.parentElement);
}
});
document.querySelectorAll('#modal-mymenu .c-text-text2').forEach(div => {
if (div.textContent.trim() === 'お知らせ') hide(div.closest('li') || div.closest('a') || div);
});
// PC: 画面下部固定「お知らせ」バー
document.querySelectorAll('div.mt-8.sticky.bottom-0').forEach(el => {
if (el.querySelector('p')?.textContent.includes('お知らせ')) hide(el);
});
// フィードページ上部の「お知らせ」ウィジェット(見出しp + リンク一覧 + 「すべて見る」)。
// この構造は必ずa[href="/info.php"]を内包するため、存在しないページでは全<p>走査自体を省略する
// (全文書'p'走査は該当ウィジェットが無いページでも毎回発生し無駄だったため修正)
if (document.querySelector('a[href="/info.php"]')) {
document.querySelectorAll('p').forEach(p => {
if (p.textContent.trim() !== 'お知らせ') return;
const inner = p.closest('div'); // 見出し+本文の内側ラッパー(例: sc-1l2xk0u-0)
if (!inner || !inner.querySelector('a[href="/info.php"]')) return;
hide(inner.parentElement || inner); // 外側ラッパー(例: sc-1j6j1zx-3)ごと畳む
});
}
// サイドメニュー内トップページ上部の「お知らせ」バナー(illusttop_appeal iframe + 閉じるボタン)
document.querySelectorAll('iframe[name="illusttop_appeal"]').forEach(iframe => {
hide(iframe.closest('section') || iframe.parentElement);
});
}
// buildAlwaysHideSelector()と同一集合。CSSでも隠しているが、再描画に備えJS側でも確実に隠す。
function hideAlwaysHiddenElements() {
const selector = buildAlwaysHideSelector();
if (selector) document.querySelectorAll(selector).forEach(hide);
}
// サイドメニュー「制作中のリクエスト」記事一覧: タグ/外国語判定でフィルタ
function filterRequestArticles() {
// 対象articleは必ずa[href^="/requests/"]を内包するため、無いページでは全<article>走査を省略する
if (!document.querySelector('a[href^="/requests/"]')) return;
document.querySelectorAll('article').forEach(article => {
if (!article.querySelector('a[href^="/requests/"]')) return;
const tags = Array.from(article.querySelectorAll('ul a[href^="/tags/"]')).map(a => a.textContent.trim());
let shouldHide = tags.some(t => matchesMutedTag(t))
|| (settings.hideAiGenerated && tags.some(t => AI_TAGS.includes(t)));
if (!shouldHide && settings.hideForeignLanguage) {
const descEl = article.querySelector('[data-line-limit]');
const descText = descEl ? (descEl.getAttribute('title') || descEl.textContent || '') : '';
const headText = article.querySelector('h1')?.textContent || '';
// この一覧には作者名が無い(=ルール2の作者名判定は対象外)
shouldHide = isForeignText({ title: headText, body: descText, author: '', tags: tags.join(' '), tagsArr: tags }).isForeign;
}
if (shouldHide) hide(article); else show(article);
});
}
// タグリスト(PC限定、統合): ホームページの固定タグリスト枠(旧hidePromoBlock)。
// イラスト/マンガページ分(div.sc-3ad027a5-0)はセレクタが安定しているためCSS常時非表示(buildAlwaysHideSelector)側で処理。
function hidePcTagListMisc() {
if (!settings.hidePcTagList) return;
document.querySelectorAll('div.bg-background1 > div.pt-24.pb-40').forEach(hide);
}
// 見出し文言の一致で非表示にするルール群(旧: querySelectorAll('div')等の全文書走査が散在していたものを集約)
// settingKey: このルールを有効にするか判定する設定項目名
// h2見出しから実際のセクションブロックを特定する。<section>タグが無いReact/Vue構成では
// 階層が浅いとタイトルだけ消えてコンテンツ本体が残るため、複数パターンを順に試す。
function findSectionBlockFromHeading(h2) {
return h2.closest('section')
|| h2.closest('div[class*="sc-51741bbe-0"]')?.parentElement
// React版の典型パターン: h2 → div → div → div(見出し行) → div(セクション全体) の4階層上
|| h2.parentElement?.parentElement?.parentElement?.parentElement
|| h2.parentElement?.parentElement;
}
const TEXT_HIDE_RULES = [
{
settingKey: 'hidePixivisionArticles',
selector: 'h2',
match: t => t.includes('pixivisionの新着記事'),
getBlock: findSectionBlockFromHeading,
debugLabel: 'section',
},
{
settingKey: 'hideContestSections',
selector: 'h2',
match: t => t.includes('募集中のコンテスト') || ['公式コンテスト', 'コンテスト結果発表'].includes(t),
getBlock: findSectionBlockFromHeading,
debugLabel: 'section',
},
{
settingKey: 'hideFeaturedProjects',
selector: 'h2',
match: t => t.includes('注目の企画目録') || t.includes('注目のイラスト・マンガ企画目録'),
getBlock: findSectionBlockFromHeading,
debugLabel: 'section',
},
{
settingKey: 'hideComicRecommendations',
selector: 'h2',
match: t => ['pixivコミックの人気作品', 'pixivコミックの注目マンガ'].some(k => t.includes(k)),
getBlock: findSectionBlockFromHeading,
debugLabel: 'section',
},
{
settingKey: 'hidePartnerLabel',
selector: 'h2',
match: t => t.includes('提携レーベル'),
getBlock: findSectionBlockFromHeading,
debugLabel: 'section',
},
{
settingKey: 'hideEditorPicks',
selector: 'h2',
match: t => t.includes('編集部のおすすめ作品') || t === '編集部のおすすめ',
getBlock: h2 => h2.closest('section')
|| h2.closest('div[class*="sc-51741bbe-0"]')?.parentElement
// 旧Vue UI: data-v-*(scoped styleマーカー)を目印にもう2階層上まで辿る
|| (h2.hasAttribute('data-v-533567ca') ? h2.parentElement?.parentElement?.parentElement?.parentElement : null)
// モバイル新UI: 従来の2階層上指定だと見出しラベルだけ消えて作品一覧本体が
// 残ってしまうバグがあったため、見出しラッパーの2階層上を辿る専用分岐を追加。
|| h2.closest('.style_layout-section-heading__UGaCL')?.parentElement?.parentElement
|| h2.parentElement?.parentElement,
debugLabel: 'section',
},
{
settingKey: 'hideMangaRequestWorks',
selector: 'h2',
match: t => t.includes('マンガリクエスト作品'),
getBlock: h2 => h2.closest('section') || h2.parentElement?.parentElement,
debugLabel: 'section',
},
{
settingKey: 'hideNovelSection',
selector: 'h2',
match: t => t === '小説作品',
getBlock: h2 => h2.closest('section') || h2.parentElement?.parentElement,
debugLabel: 'section',
},
{
// サイドメニューの「小説」セクション(h3見出し)
settingKey: 'hideSideMenuNovelSection',
selector: 'h3.sc-51741bbe-3',
match: t => t === '小説',
getBlock: h3 => h3.closest('section') || h3.parentElement?.parentElement,
debugLabel: 'section',
},
{
// サイドメニューの「小説」セクション(モバイル版, h3.f-title-s.c-gray-60見出し)
settingKey: 'hideSideMenuNovelSection',
selector: 'h3.f-title-s.c-gray-60',
match: t => t === '小説',
getBlock: h3 => h3.parentElement?.parentElement?.parentElement || h3.parentElement?.parentElement,
debugLabel: 'section',
},
{
// 左サイドメニューの「#〇〇の小説作品」枠。見出しから4階層上がセクション全体
settingKey: 'hideTagNovelSection',
selector: '.__top_side_menu_body div.text-text1.font-bold.text-16',
match: t => /^#.+の小説作品$/.test(t),
getBlock: div => div.parentElement?.parentElement?.parentElement?.parentElement,
debugLabel: 'section',
},
{
settingKey: 'hideContestSections',
selector: '[data-ga4-label="content_header"]',
match: t => t.includes('募集中のコンテスト'),
getBlock: h => h.parentElement,
},
{
// 「人気の小説タグ」は安定したセレクタが無いため構造(単一子要素)+文言一致で拾う。
// 検索ポップアップはReact Portalで安定した親コンテナが無いため(過去に誤ってスコープ
// 限定し機能停止させた経緯あり)、全文書'div'走査のまま据え置く。
settingKey: 'hidePopularNovelTags',
selector: 'div',
match: (t, el) => el.children.length === 1 && t === '人気の小説タグ',
getBlock: div => div.parentElement,
},
];
// 同一selectorのルールをまとめ、querySelectorAllをselector種別ごとに1回だけ実行する
// (旧実装はルール単位でクエリしており、selector:'h2'だけで8回重複実行していた)
const TEXT_HIDE_RULES_BY_SELECTOR = new Map();
TEXT_HIDE_RULES.forEach(rule => {
if (!TEXT_HIDE_RULES_BY_SELECTOR.has(rule.selector)) TEXT_HIDE_RULES_BY_SELECTOR.set(rule.selector, []);
TEXT_HIDE_RULES_BY_SELECTOR.get(rule.selector).push(rule);
});
function applyTextHideRules() {
TEXT_HIDE_RULES_BY_SELECTOR.forEach((rules, selector) => {
if (!rules.some(r => settings[r.settingKey])) return; // このselector配下の全ルールが無効なら丸ごとスキップ
document.querySelectorAll(selector).forEach(el => {
const t = el.textContent.trim();
rules.forEach(({ settingKey, match, getBlock, debugLabel }) => {
if (!settings[settingKey]) return;
if (!match(t, el)) return;
const block = getBlock(el);
if (block) hide(block);
else if (debugLabel) console.log(`[PxvFilter] ${debugLabel} not found for:`, t);
});
});
});
}
// 見出しから#タグ名を抽出し、ミュートタグ一致時のみセクションを隠すルール群
const TAG_SECTION_RULES = [
{ selector: 'div.sc-f0af4f6d-3', pattern: /^#(.+)のおすすめ作品$/, getBlock: div => div.parentElement?.parentElement },
{ selector: 'h2.sc-51741bbe-3', pattern: /^#(.+)のおすすめイラスト作品$/, getBlock: h2 => h2.closest('section') || h2.parentElement?.parentElement },
{
// 旧Vue UI(#spa-contents配下)の「#〇〇のおすすめ」ブロック
selector: 'div.f-title-s.c-black-fade-60:not(.heading-title)',
pattern: /^#(.+)のおすすめ$/,
getBlock: div => div.parentElement?.parentElement?.parentElement?.parentElement?.parentElement || div.parentElement?.parentElement,
},
];
function applyTagSectionRules() {
if (!settings.muteTagsEnabled || !settings.mutedTags?.length) return;
TAG_SECTION_RULES.forEach(({ selector, pattern, getBlock }) => {
document.querySelectorAll(selector).forEach(el => {
const m = el.textContent.trim().match(pattern);
if (m && matchesMutedTag(m[1])) hide(getBlock(el));
});
});
}
// bg-background2直下が「空の作品行」だけで構成されるブロックを畳む
function hideEmptyBackgroundPairs() {
if (!settings.collapseEmptyContainers) return;
document.querySelectorAll('div.bg-background2.w-full').forEach(el => {
const kids = [...el.children];
if (kids.length && kids.every(k =>
(k.matches('div.bg-background1') && /(^|\s)h-\d+(\s|$)/.test(k.className)) ||
(k.matches('div.bg-background2.m-0') || (k.matches('div.bg-background2') && /m-0/.test(k.className)))
)) {
hide(el);
}
});
}
// 新規出現セクションの検知・非表示化のみを担当(全文書querySelectorAll群)。
// 追加ノードが無い(属性変化のみの)差分バッチでは新規セクションは発生し得ないため、
// 呼び出し元(runUpdate)でhasAddedNodes判定によりスキップされる。
function removeExtraSections() {
hideNotice();
hideArtworkCommentsSection();
hideAlwaysHiddenElements();
filterRequestArticles();
hidePcTagListMisc();
applyTextHideRules();
applyTagSectionRules();
}
// 空枠の畳み込み判定を1関数に一本化(旧Group A: 元applyFilters内9関数 / 旧Group B: 元removeExtraSections内5関数)。
// 分裂していた頃はcollapseEmptyHeadingWorksGridSectionsが両groupに重複登録されており、
// 1サイクルで最大4回呼ばれる無駄があった。ここに集約後は各関数ちょうど1回だけ呼ばれる。
// 新規セクション検知(removeExtraSections)とは別物で、既存要素の表示状態
// (display/data-pxv-hidden)に基づく判定のため、ノード追加を伴わないclass/style/hidden/
// aria-selected属性のみの変化(タブ切替等)でも再評価が必要。そのため呼び出し元(runUpdate)
// ではhasAddedNodesの判定に関わらず毎回呼ぶ。
const HAS_CARD_LINK_SELECTOR = 'a[href*="/artworks/"], a[href*="/novel/"], a[data-ga4-label="thumbnail_link"]';
function collapseEmptyContainersCascade() {
// 高速パス: 作品/小説へのリンクが1件も無いページ(メッセージ/設定/フォロー管理等の
// 非リスティング系ページ)では、以下12関数が最終的に依存する対象
// (作品/小説リンク・thumbnail_link・[data-pxv-filter-injected]はいずれもこのリンクの
// 存在から派生する)が存在し得ないため、13関数分の全文書走査自体を丸ごとスキップする。
// hideEmptyBackgroundPairsのみ構造ベース(リンク非依存の判定)のため対象外、常時実行する。
if (settings.collapseEmptyContainers && document.querySelector(HAS_CARD_LINK_SELECTOR)) {
collapseEmptyUserBlocks();
collapseEmptyUserLi();
collapseEmptyRelatedWorksLi();
collapseEmptyUserRecommenderItems();
collapseEmptyTagRecommendSections();
collapseEmptyHeadingWorksGridSections();
collapseEmptyCommissionBlocks();
collapseEmptyFollowUserBlocks();
collapseEmptyRequestWorkCards();
collapseEmptyTagSections();
collapseEmptyListItemContainers();
collapseEmptySideMenuArtworkLists();
collapseEmptyRecommendZoneGroups();
}
hideEmptyBackgroundPairs();
}
/* ============================== bootstrap / MutationObserver制御 ==============================
hide()/show()等でのstyle・dataset変更をMutationObserverに自己検知させない仕組み(withMutationsSuppressed)と、
ページ側のDOM変更を検知してフィルタを再適用する仕組み(scheduleUpdate)の2本柱。 */
// フラグ方式を採る理由: disconnect()→reconnect()だと同期処理中しか自己ミューテーションを
// 隠せず、fetch完了コールバック等の非同期style変更を見逃して無限ループになりうる。
// フラグなら同期/非同期どちらの経路からもON/OFFでき、発生源を問わず抑制できる。
let suppressMutations = false;
function withMutationsSuppressed(fn) {
suppressMutations = true;
try { fn(); }
finally {
// フラグを戻すだけでは不十分: MutationObserverのcallback配信は非同期(microtask)のため、
// fn()内のmutation recordがまだobserverの内部キューに残っている。何もせず解除すると
// 自己発火分がすり抜けて誤発火ループになるため、takeRecords()でキューを空にしてから解除する。
if (mutationObserver) mutationObserver.takeRecords();
suppressMutations = false;
}
}
function attachAll(cards) {
const promises = (cards || getCards()).map(injectCardInfo).filter(Boolean);
// fetch完了タイミングがバラバラでも、このバッチ分は揃ってから一括で表示反映する。
// 反映は直接カスケードを呼ばず、scheduleUpdate()経由でMIN_UPDATE_INTERVAL_MSの
// 既存スロットルに合流させる。直接呼びだとスクロール等で大量カードのfetchが短時間に
// 連続解決した際、スロットルの外側で全カードカスケードが無制限に積み重なり重くなるため。
// scheduleUpdate()を引数無しで呼ぶとpendingMutationsが空のままrunUpdate(null)扱いになり、
// 通常の設定変更時と同じフルカスケード経路(unhideAllTracked→attachAll→applyFilters→
// collapseEmptyContainersCascade→removeExtraSections)を辿る。既にinjected済みのカードは
// injectCardInfoが早期returnするため再帰的なfetch多発は起きない。
if (promises.length) Promise.allSettled(promises).then(() => { scheduleUpdate(); });
}
let mutationObserver = null; // init()内で生成・observe開始
const OBSERVE_OPTIONS = {
childList: true, subtree: true,
attributes: true, attributeFilter: ['class', 'style', 'hidden', 'aria-selected'],
};
// 1回のフルカスケードは軽くなく、subtree監視のためカードと無関係な変化(タブ切替・アニメーション等)
// でもscheduleUpdateは呼ばれる。rAFのフレームまとめだけでは連続する変化中に毎フレーム
// フルカスケードが走り続けて重くなるため、実行間隔に下限を設けて間引く。
const MIN_UPDATE_INTERVAL_MS = 500;
let pendingUpdate = false;
let lastUpdateAt = 0;
let pendingMutations = []; // rAF/間引き待ちの間に溜まったmutationRecordを蓄積(差分スコープ計算用)
// scheduleUpdate()を引数無しで呼ぶ側(attachAllのfetch完了コールバック等)は「フルカスケード必須」の
// 要求。だが差分更新(mutations有り)側とpendingUpdateフラグを共有しているため、両者が同時にpending化すると
// 後着のフル要求がpendingUpdate=trueにより早期returnで握り潰され、実行されるのは先に積まれた
// pendingMutations由来の差分カスケードのみ、という取りこぼしが起きていた(filterBeforeRevealの仮非表示が
// fetch完了後も一生表示に戻らないバグの原因)。フル要求だけは専用フラグで独立して持ち越す。
let forceFullUpdate = false;
function scheduleUpdate(mutations) {
if (suppressMutations) return; // 自己発火分は無視
// 設定パネル内部だけで完結するミューテーションは既に反映済みのためフルカスケード不要
// (バッチ内が全てパネル内部のケースのみスキップ。ページ側の変更が混在する場合は通常通り処理)。
if (mutations && mutations.length) {
const panelWrap = document.getElementById('pxv-panel-wrap');
if (panelWrap && mutations.every(m => panelWrap.contains(m.target))) return;
}
// 追加ノードも属性変化も無い(削除のみ)ミューテーションはスキップ。ただし属性変化は
// ノード追加を伴わないVueのタブ切替等もありうるためスキップ対象から除外する。
if (mutations && mutations.every(m => m.addedNodes.length === 0 && m.type !== 'attributes')) return;
if (!mutations) forceFullUpdate = true; // 引数無し呼び出し=フル要求。pendingUpdate競合時も消さず持ち越す
if (mutations && mutations.length) pendingMutations.push(...mutations);
if (pendingUpdate) return;
pendingUpdate = true;
runUpdateThrottled();
}
// rAFで次描画のタイミングまで待ち、かつ前回実行からMIN_UPDATE_INTERVAL_MS未満なら
// 残り時間分さらに遅らせてから実行する。単発の変更には(ほぼ)1フレームで応答しつつ、
// 連続する変更はまとめて間引く。
function runUpdateThrottled() {
requestAnimationFrame(() => {
const wait = MIN_UPDATE_INTERVAL_MS - (performance.now() - lastUpdateAt);
if (wait > 0) { setTimeout(runUpdateThrottled, wait); return; }
pendingUpdate = false;
lastUpdateAt = performance.now();
const muts = pendingMutations;
pendingMutations = [];
const runFull = forceFullUpdate;
forceFullUpdate = false;
runUpdate(runFull ? null : (muts.length ? muts : null));
});
}
// mutationsが無い(=設定変更/初回ロード安全策タイマー等からの直接呼び出し)場合はフルカスケード、
// MutationObserver経由(mutations有り)の場合は追加/変化があったノード配下だけを対象にした差分カスケードにする。
// SPA遷移(ページ全体差し替え)も新ページツリーがaddedNodesとして丸ごと入るため、差分スコープで漏れなく拾える。
function runUpdate(mutations) {
withMutationsSuppressed(() => {
let cards;
let hasAddedNodes = true; // フル実行(mutations無し)時は常に全処理する
if (mutations) {
const roots = collectAddedRoots(mutations);
cards = roots.length ? getCardsWithin(roots) : [];
hasAddedNodes = mutations.some(m => m.type === 'childList' && m.addedNodes.length > 0);
} else {
unhideAllTracked();
// attachAll/applyFiltersは元々それぞれ内部でgetCards()を呼びクエリが2回走っていたが、
// attachAll()自体はdataset付与のみでDOM構造を変えないため同一スナップショットを使い回す。
cards = getCards();
}
attachAll(cards); applyFilters(cards);
// 空枠の畳み込み/復帰再評価は属性のみの変化(タブ切替等)でも必要なため常に実行する。
collapseEmptyContainersCascade();
// filterTagList/removeCommentPreview/removeExtraSectionsは新規出現セクションを検知する
// 全文書querySelectorAll群。追加ノードが無い(属性変化のみの)差分バッチでは新規セクションは
// 発生し得ないため、呼び出し自体をスキップしてコストを削る。
if (hasAddedNodes) {
filterTagList(); removeCommentPreview(); removeExtraSections();
}
if (location.href !== lastHref) { lastHref = location.href; applyAutoSearchExclude(); }
});
}
// mutationRecordの配列から「新規追加ノード」「属性が変化したノード」を集め、差分処理の走査起点(root)にする。
// 祖先が既にroots内にある子孫は二重処理防止のため除外する(祖先側のquerySelectorAllで拾えるため)。
function collectAddedRoots(mutations) {
const panelWrap = document.getElementById('pxv-panel-wrap');
const roots = [];
mutations.forEach(m => {
if (m.type === 'childList') {
m.addedNodes.forEach(n => {
if (n.nodeType === 1 && n.isConnected && !(panelWrap && panelWrap.contains(n))) roots.push(n);
});
} else if (m.type === 'attributes' && m.target.nodeType === 1 && m.target.isConnected) {
if (!(panelWrap && panelWrap.contains(m.target))) roots.push(m.target);
}
});
const rootSet = new Set(roots);
return roots.filter(el => {
let p = el.parentElement;
while (p) {
if (rootSet.has(p)) return false;
p = p.parentElement;
}
return true;
});
}
/* ============================== 設定パネル i18n文字列 ============================== */
const STR = {
ja: {
panel: '⚙ 設定',
sInfo: '情報オーバーレイ', sizeLabel: 'サイズ:',
showRatingCount: '評価数を表示', showDate: '日時を表示',
sPreview: 'プレビュー', preview: 'プレビュー', hoverPreview: 'ホバープレビュー',
sFilter: 'コンテンツフィルター', muteList: 'ミュートリスト', userBl: 'ユーザー', tagBl: 'タグ', feedUserBl: 'フィードユーザー',
muteUsersEnabled: 'ユーザーミュート', muteTagsEnabled: 'タグミュート',
tagAddPlaceholder: 'NGタグを追加', add: '追加', tagAddSectionLabel: 'NGタグを追加',
showListBtn: 'リストを表示', hideListBtn: 'リストを隠す',
bmFilter: 'ブクマ数フィルター',
hideAiGenerated: 'AIイラストフィルター', hideForeignLanguage: '外国語フィルター',
filterBeforeReveal: 'フィルタリングが終わるまで表示しない',
autoSearchExclude: '自動検索パラメーター', autoSearchExcludePlaceholder: 'パラメーターを追加(-を付けるとマイナス検索)',
howToUseLabel: '使い方',
autoSearchExcludeHelp: '<ul>' +
'<li>検索ページを開くと、自動的にパラメーター付きで再検索します。</li>' +
'<li>パラメーターに「-」を付けると、そのワードを除外するマイナス検索になります。</li>' +
'<li>マイナス検索はサーバー側で処理されるため、検索結果が多く返ってくるようになります。</li>' +
'<li>検索には文字数制限があるため、一部分だけ登録するのがおすすめです。(例:「ポケモン」→「ポケ」)</li>' +
'</ul>',
registerMutedTagsBtn: 'ミュートタグを除外パラメーターに登録する',
sFixedSections: '不要な要素を非表示',
del: '削除', edit: '編集', empty: '(なし)',
apply: '適用',
panelOpacityLabel: 'パネルの透過度',
backupLabel: 'バックアップ', exportBtn: 'エクスポート', importBtn: 'インポート',
exportDone: 'クリップボードにコピーした', importDone: '設定を反映した。反映されない項目がある場合はページを更新してください', importFail: '不正な設定データ',
bugReportLabel: '不具合報告・改善要望', reviewLinkLabel: 'このスクリプトが役に立ったら、ぜひレビューをお願いします',
},
en: {
panel: '⚙ Settings',
sInfo: 'Info Overlay', sizeLabel: 'Size:',
showRatingCount: 'Show rating count', showDate: 'Show date',
sPreview: 'Preview', preview: 'Preview', hoverPreview: 'Hover preview',
sFilter: 'Content Filter', muteList: 'Mute List', userBl: 'Users', tagBl: 'Tags', feedUserBl: 'Feed Users',
muteUsersEnabled: 'User mute', muteTagsEnabled: 'Tag mute',
tagAddPlaceholder: 'Add a muted tag', add: 'Add', tagAddSectionLabel: 'Add NG tag',
showListBtn: 'Show list', hideListBtn: 'Hide list',
bmFilter: 'Bookmark count filter',
hideAiGenerated: 'AI illustration filter', hideForeignLanguage: 'Foreign language filter',
filterBeforeReveal: 'Temporarily hide post cards until filtered (anti-flicker)',
autoSearchExclude: 'Auto-search parameters', autoSearchExcludePlaceholder: 'Add a parameter (prefix with - for negative search)',
howToUseLabel: 'How it works',
autoSearchExcludeHelp: '<ul>' +
'<li>Opening a search page automatically redirects with these parameters appended.</li>' +
'<li>Prefixing a tag with "-" turns it into a negative (exclude) search.</li>' +
'<li>Because negative search is processed server-side, more search results come back.</li>' +
'<li>Search parameters have a character limit, so it\'s best to register just a fragment (e.g. "Pokemon" → "Poke").</li>' +
'</ul>',
registerMutedTagsBtn: 'Register muted tags as exclude parameters',
sFixedSections: 'Hide Unwanted Elements',
del: 'Delete', edit: 'Edit', empty: '(none)',
apply: 'Apply',
panelOpacityLabel: 'Panel opacity',
backupLabel: 'Backup', exportBtn: 'Export', importBtn: 'Import',
exportDone: 'Copied to clipboard', importDone: 'Settings applied. Please reload the page if some items don\'t take effect', importFail: 'Invalid settings data',
bugReportLabel: 'Report an issue / request a feature', reviewLinkLabel: 'If this script has been useful, please leave a review',
},
}[LANG];
/* ============================== 設定パネルUI ============================== */
/* ============================== style ============================== */
// document-start直後、実機ではdocument.head未生成のケースあり(PC/devtools再現不可)。
// headなし時はdocumentElementへ、両方nullなら次フレームまで待って再試行する。
function injectPanelStyle() {
const target = document.head || document.documentElement;
if (!target) { requestAnimationFrame(injectPanelStyle); return; }
target.insertAdjacentHTML('beforeend', `<style id="pxv-panel-style">
#pxv-panel-wrap, #pxv-panel-wrap * { box-sizing: border-box; }
#pxv-panel-wrap {
--pxv-bg: #ffffff; --pxv-fg: #23272f; --pxv-sub: #6b7280; --pxv-border: #e5e7eb;
--pxv-accent: #0096fa; --pxv-danger: #ff5a7a; --pxv-track: #f3f4f6; --pxv-shadow: rgba(0,0,0,.18);
--pxv-panel-opacity: 100%;
position: fixed !important; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 2147483647 !important; isolation: isolate;
display: flex; flex-direction: column-reverse; align-items: flex-end;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
font-size: 15px; line-height: 1.55; color: var(--pxv-fg);
user-select: none; -webkit-user-select: none;
}
#pxv-panel-wrap input, #pxv-panel-wrap textarea {
user-select: text; -webkit-user-select: text;
}
#pxv-panel-wrap.pxv-dark {
--pxv-bg: #1f2229; --pxv-fg: #e8e9ec; --pxv-sub: #9aa0ac; --pxv-border: #33363f;
--pxv-accent: #4db2ff; --pxv-danger: #ff7d95; --pxv-track: #2a2d35; --pxv-shadow: rgba(0,0,0,.5);
}
#pxv-panel-toggle {
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
padding: 11px 18px; background: var(--pxv-accent); color: #fff; border: none;
border-radius: 22px; cursor: pointer; box-shadow: 0 3px 10px var(--pxv-shadow);
font-size: 15px; font-weight: 600; transition: transform .15s ease, box-shadow .15s ease;
}
#pxv-panel-toggle:hover { box-shadow: 0 4px 14px var(--pxv-shadow); transform: translateY(-1px); }
#pxv-panel-toggle:active { transform: translateY(0); }
#pxv-panel {
display: none; margin-bottom: 10px; width: 340px; max-height: 78vh; overflow-y: auto;
background: color-mix(in srgb, var(--pxv-bg) var(--pxv-panel-opacity), transparent);
color: var(--pxv-fg); border-radius: 14px;
box-shadow: 0 8px 28px var(--pxv-shadow); padding: 18px;
scrollbar-width: thin; scrollbar-color: var(--pxv-border) transparent;
overscroll-behavior: contain; /* パネル端まで スクロールしてもページ側へ伝播しないようにする */
}
#pxv-panel::-webkit-scrollbar { width: 6px; }
#pxv-panel::-webkit-scrollbar-thumb { background: var(--pxv-border); border-radius: 3px; }
#pxv-panel h4 {
margin: 18px 0 12px; font-size: 14px; font-weight: 700; letter-spacing: .02em;
color: var(--pxv-accent); border-bottom: 1px solid var(--pxv-border); padding-bottom: 7px;
}
#pxv-panel h4:first-child { margin-top: 0; }
.pxv-row { margin-bottom: 14px; padding-left: 2px; }
.pxv-row > .pxv-label { margin-bottom: 6px; color: var(--pxv-sub); font-size: 14px; }
.pxv-slider-line { display: flex; align-items: center; gap: 10px; }
.pxv-slider-line input[type=range] { width: 100%; accent-color: var(--pxv-accent); height: 24px; }
.pxv-slider-line span { color: var(--pxv-accent); font-weight: 700; min-width: 32px; text-align: right; font-size: 14px; }
.pxv-check-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 11px; min-height: 34px; padding-left: 2px; }
.pxv-check-row label { font-size: 14.5px; }
.pxv-check-row input[type=checkbox] { width: 20px; height: 20px; accent-color: var(--pxv-accent); flex-shrink: 0; }
.pxv-check-row input[type=number] {
width: 70px; padding: 6px 7px; border: 1px solid var(--pxv-border); border-radius: 6px;
font-size: 14px; margin-left: 6px; background: var(--pxv-track); color: var(--pxv-fg);
}
.pxv-check-row input[type=text] {
width: 150px; padding: 6px 7px; border: 1px solid var(--pxv-border); border-radius: 6px;
font-size: 14px; margin-left: 6px; background: var(--pxv-track); color: var(--pxv-fg);
}
.pxv-check-slider-row { display: flex; align-items: center; gap: 6px; margin-bottom: 11px; min-height: 34px; padding-left: 2px; }
.pxv-check-slider-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 14.5px; flex-shrink: 0; white-space: nowrap; }
.pxv-check-slider-label input[type=checkbox] { width: 20px; height: 20px; accent-color: var(--pxv-accent); flex-shrink: 0; }
.pxv-check-slider-row .pxv-size-label { color: var(--pxv-sub); font-size: 11px; font-weight: 400; flex-shrink: 0; white-space: nowrap; }
.pxv-check-slider-row .pxv-slider-line { flex: 1; min-width: 0; gap: 3px; }
.pxv-check-slider-row .pxv-slider-line span { min-width: 22px; font-size: 12.5px; }
.pxv-bl-list { max-height: 220px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--pxv-border); border-radius: 8px; padding: 5px; margin-top: 5px; margin-left: 6px; }
.pxv-bl-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 7px; font-size: 14px; border-radius: 6px; gap: 6px; }
.pxv-bl-item > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pxv-bl-item .pxv-bl-item-btns { display: flex; gap: 4px; flex-shrink: 0; }
.pxv-bl-item:hover { background: var(--pxv-track); }
.pxv-bl-empty { color: var(--pxv-sub); font-size: 13px; padding: 6px; }
.pxv-bl-item button {
border: none; background: var(--pxv-danger); color: #fff; border-radius: 6px;
cursor: pointer; font-size: 12.5px; padding: 5px 10px; flex-shrink: 0; margin-left: 8px;
}
.pxv-bl-item .pxv-bl-item-btns button { margin-left: 0; }
.pxv-bl-item .pxv-edit-btn { background: var(--pxv-track); color: var(--pxv-sub); border: 1px solid var(--pxv-border); }
.pxv-bl-item .pxv-edit-btn:hover { color: var(--pxv-accent); border-color: var(--pxv-accent); }
#pxv-panel details {
margin-bottom: 11px; border: 1px solid var(--pxv-border); border-radius: 8px; padding: 9px 11px;
}
#pxv-panel details summary { cursor: pointer; color: var(--pxv-sub); font-size: 14px; outline: none; }
#pxv-panel details > .pxv-details-body { margin-top: 11px; padding-left: 8px; }
#pxv-panel details summary::marker { color: var(--pxv-accent); }
.pxv-sub-label { color: var(--pxv-sub); font-size: 12.5px; margin: 9px 0 4px; padding-left: 6px; }
.pxv-exclude-input, .pxv-tag-add-input {
width: 100%; box-sizing: border-box; padding: 8px 9px; border: 1px solid var(--pxv-border);
border-radius: 7px; font-size: 14px; margin-top: 6px; background: var(--pxv-track); color: var(--pxv-fg);
}
.pxv-tag-add-row { display: flex; gap: 6px; margin-top: 6px; padding-left: 6px; }
.pxv-tag-add-row input { flex: 1; margin-top: 0; }
.pxv-btn {
border: none; border-radius: 7px; cursor: pointer; font-size: 13.5px; font-weight: 600;
padding: 8px 13px; background: var(--pxv-accent); color: #fff; flex-shrink: 0; white-space: nowrap;
}
.pxv-btn.pxv-btn-ghost { background: var(--pxv-track); color: var(--pxv-fg); }
.pxv-btn-row { display: flex; gap: 6px; }
.pxv-btn-row .pxv-btn { flex: 1; }
.pxv-btn-sm { padding: 4px 8px; font-size: 11px; flex: 0 0 auto; white-space: nowrap; }
.pxv-btn-row .pxv-btn-sm { flex: 0 0 auto; }
#pxv-panel h4.pxv-h4-muted {
font-size: 11.5px; font-weight: 600; color: var(--pxv-sub); border-bottom: none;
margin: 14px 0 6px; padding-bottom: 0; letter-spacing: .01em;
}
.pxv-other-row { padding-left: 2px; margin-bottom: 12px; }
.pxv-other-row > .pxv-label { margin-bottom: 6px; color: var(--pxv-sub); font-size: 14px; }
.pxv-backup-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding-left: 2px; margin-bottom: 8px;
}
.pxv-backup-row > .pxv-label { color: var(--pxv-sub); font-size: 12px; white-space: nowrap; }
.pxv-backup-row .pxv-btn-row { flex-shrink: 0; }
.pxv-opacity-slider { width: 90px; accent-color: var(--pxv-accent); flex-shrink: 0; }
.pxv-opacity-value { color: var(--pxv-sub); font-size: 12px; min-width: 32px; text-align: right; flex-shrink: 0; }
.pxv-external-link {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
text-align: left; padding: 6px 2px; margin-bottom: 4px;
background: transparent; color: var(--pxv-sub); text-decoration: none;
font-size: 12px; font-weight: 400; border: none;
transition: color .15s ease;
}
.pxv-external-link:last-child { margin-bottom: 0; }
.pxv-external-link:hover { color: var(--pxv-accent); }
.pxv-external-link .pxv-ext-icon { flex-shrink: 0; font-size: 11px; opacity: .75; }
.pxv-divider {
border: none; border-top: 1px solid var(--pxv-border); margin: 18px 0 12px;
}
.pxv-list-toggle-btn {
border: 1px solid var(--pxv-border); background: transparent; color: var(--pxv-sub);
border-radius: 6px; cursor: pointer; font-size: 11.5px; padding: 3px 8px;
margin-left: 8px; flex-shrink: 0;
}
.pxv-list-toggle-btn:hover { color: var(--pxv-accent); border-color: var(--pxv-accent); }
.pxv-check-extra-btns { display: flex; align-items: center; flex-shrink: 0; }
.pxv-help-icon-btn {
width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--pxv-border);
background: transparent; color: var(--pxv-sub); font-size: 12px; font-weight: 700;
cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; padding: 0;
}
.pxv-help-icon-btn:hover { color: var(--pxv-accent); border-color: var(--pxv-accent); }
.pxv-sub-label-row { display: flex; align-items: center; justify-content: space-between; margin: 9px 0 4px; padding-left: 6px; }
.pxv-sub-label-row > .pxv-sub-label { margin: 0; padding-left: 0; }
.pxv-help-box {
background: var(--pxv-track); border: 1px solid var(--pxv-border); border-radius: 8px;
padding: 9px 11px; margin: 8px 0 4px; font-size: 12.5px; color: var(--pxv-sub); line-height: 1.6;
}
.pxv-help-box ul { margin: 0; padding-left: 18px; }
.pxv-help-box li { margin-bottom: 3px; }
.pxv-help-box li:last-child { margin-bottom: 0; }
.pxv-help-popover {
border-left: 3px solid var(--pxv-accent); animation: pxv-help-fade-in .18s ease-out;
}
@keyframes pxv-help-fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 767px) {
#pxv-panel-wrap { right: 10px; bottom: calc(10px + env(safe-area-inset-bottom, 0px)); font-size: 16px; }
#pxv-panel-toggle {
padding: 0; width: 44px; height: 44px; font-size: 18px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
}
#pxv-panel-toggle .pxv-toggle-label { display: none; }
#pxv-panel { width: calc(100vw - 20px); max-width: 380px; max-height: 75vh; padding: 16px; }
#pxv-panel h4 { font-size: 15px; }
.pxv-check-row { min-height: 40px; margin-bottom: 13px; }
.pxv-check-row label { font-size: 15.5px; }
.pxv-check-row input[type=checkbox] { width: 22px; height: 22px; }
.pxv-btn { padding: 10px 14px; font-size: 14.5px; }
.pxv-bl-list { max-height: 240px; }
}
</style>`);
}
injectPanelStyle();
/* ============================== panel UI helpers ============================== */
// フィード(/stacc、フォロー中ユーザーの新着一覧)ページのみmutedFeedPosters UIを出す
function isFeedPage() {
return /^\/(?:[a-z]{2}\/)?stacc(?:$|\/)/.test(location.pathname);
}
// pixiv側body背景色の明度からダーク/ライト判定(data-theme属性名の変更に依存しない)
function isDarkMode() {
try {
// bodyが透過な場合、実背景を持つ要素まで辿る(直下の子から探索)
let el = document.body;
let bg = getComputedStyle(el).backgroundColor;
let m = bg.match(/[\d.]+/g);
// alpha=0 (透過) なら子要素を順に確認、または既知のルート候補を試す
if (!m || m.length < 4 || Number(m[3]) === 0) {
const candidates = [
document.documentElement,
...document.body.children,
];
for (const c of candidates) {
const cbg = getComputedStyle(c).backgroundColor;
const cm = cbg.match(/[\d.]+/g);
if (cm && cm.length >= 3 && (cm.length < 4 || Number(cm[3]) > 0)) {
m = cm;
break;
}
}
}
if (!m || m.length < 3) return false;
const [r, g, b] = m.map(Number);
return (0.299 * r + 0.587 * g + 0.114 * b) < 128;
} catch (e) { return false; }
}
function applyTheme() {
const wrap = document.getElementById('pxv-panel-wrap');
if (wrap) wrap.classList.toggle('pxv-dark', isDarkMode());
}
// 設定パネル自体の背景透過度を反映する(バックアップ機能とは無関係、UI専用設定)
function applyPanelOpacity() {
const wrap = document.getElementById('pxv-panel-wrap');
if (wrap) wrap.style.setProperty('--pxv-panel-opacity', (settings.panelOpacity ?? 100) + '%');
}
function refreshBlacklistUI() {
const ul = document.getElementById('pxv-bl-users-list');
const tl = document.getElementById('pxv-bl-tags-list');
const el = document.getElementById('pxv-bl-exclude-list');
const fl = document.getElementById('pxv-bl-feedusers-list');
const flWrap = document.getElementById('pxv-bl-feedusers-wrap');
if (flWrap) flWrap.style.display = isFeedPage() ? '' : 'none';
if (fl) {
fl.replaceChildren();
const feedUsers = settings.mutedFeedPosters || [];
if (feedUsers.length === 0) {
fl.appendChild(Object.assign(document.createElement('div'), { className: 'pxv-bl-empty', textContent: STR.empty }));
} else feedUsers.forEach(u => {
const row = document.createElement('div'); row.className = 'pxv-bl-item';
row.appendChild(Object.assign(document.createElement('span'), { textContent: u.name || u.account }));
const btn = Object.assign(document.createElement('button'), { textContent: STR.del });
btn.onclick = () => { settings.mutedFeedPosters = feedUsers.filter(x => x.account !== u.account); saveSettings(); refreshBlacklistUI(); };
row.appendChild(btn); fl.appendChild(row);
});
}
if (ul) {
ul.replaceChildren();
if (settings.mutedAuthors.length === 0) {
ul.appendChild(Object.assign(document.createElement('div'), { className: 'pxv-bl-empty', textContent: STR.empty }));
} else settings.mutedAuthors.forEach(u => {
const row = document.createElement('div'); row.className = 'pxv-bl-item';
row.appendChild(Object.assign(document.createElement('span'), { textContent: u.name || u.id }));
const btn = Object.assign(document.createElement('button'), { textContent: STR.del });
btn.onclick = () => { settings.mutedAuthors = settings.mutedAuthors.filter(x => x.id !== u.id); saveSettings(); refreshBlacklistUI(); };
row.appendChild(btn); ul.appendChild(row);
});
}
if (tl) {
tl.replaceChildren();
if (settings.mutedTags.length === 0) {
tl.appendChild(Object.assign(document.createElement('div'), { className: 'pxv-bl-empty', textContent: STR.empty }));
} else settings.mutedTags.forEach(t => {
const row = document.createElement('div'); row.className = 'pxv-bl-item';
row.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
const btn = Object.assign(document.createElement('button'), { textContent: STR.del });
btn.onclick = () => { settings.mutedTags = settings.mutedTags.filter(x => x !== t); saveSettings(); refreshBlacklistUI(); };
row.appendChild(btn); tl.appendChild(row);
});
}
if (el) {
el.replaceChildren();
const excludeTags = settings.autoSearchExcludeTags || [];
if (excludeTags.length === 0) {
el.appendChild(Object.assign(document.createElement('div'), { className: 'pxv-bl-empty', textContent: STR.empty }));
} else excludeTags.forEach(t => {
const row = document.createElement('div'); row.className = 'pxv-bl-item';
row.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
const btns = document.createElement('div'); btns.className = 'pxv-bl-item-btns';
const editBtn = Object.assign(document.createElement('button'), { textContent: STR.edit, className: 'pxv-edit-btn' });
editBtn.onclick = () => {
const next = window.prompt(STR.edit, t);
if (next === null) return;
const trimmed = next.trim();
if (!trimmed) return;
const idx = excludeTags.indexOf(t);
if (idx === -1) return;
const updated = [...excludeTags]; updated[idx] = trimmed;
settings.autoSearchExcludeTags = Array.from(new Set(updated));
saveSettings(); refreshBlacklistUI();
};
const delBtn = Object.assign(document.createElement('button'), { textContent: STR.del });
delBtn.onclick = () => { settings.autoSearchExcludeTags = excludeTags.filter(x => x !== t); saveSettings(); refreshBlacklistUI(); };
btns.appendChild(editBtn); btns.appendChild(delBtn);
row.appendChild(btns); el.appendChild(row);
});
}
}
// 「✅表示 サイズ:スライダー」を1行にまとめた行(情報オーバーレイ用)。チェックボックスが先頭、
// 「サイズ:」ラベルは薄く小さい文字にしてスライダーの直前に置く。
function makeCheckSliderRow(labelText, checked, onCheckChange, sizeLabelText, sliderValue, min, max, step, onSliderChange) {
const row = document.createElement('div'); row.className = 'pxv-check-slider-row';
const label = document.createElement('label');
label.className = 'pxv-check-slider-label';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = checked;
cb.onchange = () => onCheckChange(cb.checked);
label.appendChild(cb); label.appendChild(document.createTextNode(labelText));
row.appendChild(label);
const sizeLabel = document.createElement('span'); sizeLabel.className = 'pxv-size-label'; sizeLabel.textContent = sizeLabelText;
row.appendChild(sizeLabel);
const line = document.createElement('div'); line.className = 'pxv-slider-line';
const input = document.createElement('input');
Object.assign(input, { type: 'range', min, max, step, value: sliderValue });
const span = document.createElement('span'); span.textContent = sliderValue;
let sliderDebounceTimer = null;
input.addEventListener('input', () => {
const v = Number(input.value);
span.textContent = v; // 数値表示だけは即時反映
// onSliderChange内のsaveSettings()はpxv:settings-changed経由でrunUpdate()
// (全カード再評価)を走らせるため、ドラッグ中に毎tick呼ぶと重くなる。
// ドラッグが止まってから150ms後にまとめて反映する。
clearTimeout(sliderDebounceTimer);
sliderDebounceTimer = setTimeout(() => onSliderChange(v), 150);
});
line.appendChild(input); line.appendChild(span);
row.appendChild(line);
return row;
}
function makeCheckRow(labelText, checked, onChange, extraInput) {
const row = document.createElement('div'); row.className = 'pxv-check-row';
const label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = checked;
cb.onchange = () => onChange(cb.checked);
label.appendChild(cb); label.appendChild(document.createTextNode(labelText));
row.appendChild(label);
if (extraInput) row.appendChild(extraInput);
return row;
}
// 「リストを表示」ボタン。makeCheckRowのextraInputとして渡し、押すとlistElの表示/非表示を切り替える(初期状態は非表示)。
function makeListShowButton(listEl, { startHidden = true } = {}) {
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'pxv-list-toggle-btn';
const sync = () => { btn.textContent = listEl.style.display === 'none' ? STR.showListBtn : STR.hideListBtn; };
if (startHidden) listEl.style.display = 'none';
sync();
btn.onclick = () => { listEl.style.display = listEl.style.display === 'none' ? '' : 'none'; sync(); };
return btn;
}
// サブラベル + 「リストを表示」ボタンの行(チェックボックスを伴わない単独リスト用、自動検索除外など)。
function makeListToggleLabel(labelText, listEl, opts) {
const row = document.createElement('div'); row.className = 'pxv-sub-label-row';
row.appendChild(Object.assign(document.createElement('div'), { className: 'pxv-sub-label', textContent: labelText }));
row.appendChild(makeListShowButton(listEl, opts));
return row;
}
function makeThresholdInput(value, onApply) {
const input = document.createElement('input');
input.type = 'number'; input.min = '0'; input.value = value;
input.addEventListener('keydown', e => { if (e.key === 'Enter') onApply(input.value); });
input.addEventListener('change', () => onApply(input.value));
return input;
}
function makeDetails(summaryText) {
const details = document.createElement('details');
const summary = document.createElement('summary'); summary.textContent = summaryText;
const body = document.createElement('div'); body.className = 'pxv-details-body';
details.appendChild(summary); details.appendChild(body);
return { details, body };
}
// 不要な要素を非表示にするトグル群。全体/トップページ(ホーム・イラスト・マンガ共通)/その他 の3グループ。
// key: 単一の設定キー。keys: 複数の設定キーを1つのトグルでまとめて切り替える場合に使う。
// 同じkeyが複数グループに出現する項目は、toggleGroupCheckboxes同期で表示を揃えている。
//
// ここに載っていない設定項目はDEFAULTS内にそのまま残っていて動作もし続ける。
// パネルに戻したい場合はitemsに { key: '設定キー名', ja: '表示名', en: '...' } を1行足すだけで復活する。
const SECTION_TOGGLE_GROUPS = [
{ title: { ja: 'Pixiv全体', en: 'Global (all pages)' }, items: [
{ key: 'hideNotice', ja: 'お知らせ', en: 'Notice' },
{
keys: ['hideNovelCards', 'hideNovelSection', 'hideSideMenuNovelSection', 'hidePopularNovelTags', 'hideTagNovelSection'],
ja: '小説関連の要素', en: 'Novel-related elements',
},
] },
{ title: { ja: 'トップページ(ホーム/イラスト/マンガ)', en: 'Top pages (Home / Illustrations / Manga)' }, items: [
{ key: 'hideCollectionCards', ja: 'コレクション', en: 'Collections' },
{ key: 'hideHomeCommentPreview', ja: 'コメントのプレビュー', en: 'Comment preview' },
{ key: 'hideRanking', ja: 'デイリーランキング', en: 'Daily ranking' },
{ keys: ['hidePixivisionSection', 'hidePixivisionArticles'], ja: 'Pixivisionの新着記事', en: 'pixivision new articles' },
{ key: 'hideContestSections', ja: 'コンテスト', en: 'Contests' },
{ key: 'hideFeaturedProjects', ja: '注目の企画目録', en: 'Featured projects' },
{ key: 'hideEditorPicks', ja: '編集部のおすすめ', en: "Editor's picks" },
{ key: 'hideComicRecommendations', ja: 'Pixivコミックの人気作品', en: 'Pixiv Comic popular works' },
{ key: 'hidePartnerLabel', ja: '提携レーベル', en: 'Partner labels' },
] },
{ title: { ja: 'その他のページ', en: 'Other pages' }, items: [
{ key: 'hideArtworkComments', ja: 'コメント欄(作品ページ)', en: 'Comment section (artwork page)' },
] },
];
// 同一設定キーが複数ページグループに重複登場する場合(例: タグリストPCがホーム/イラスト/マンガ全てに出る)、
// 片方のトグルを変更したらもう片方の表示も追従させるための登録簿。
const toggleGroupCheckboxes = {};
function syncToggleGroupCheckbox(key, value) {
(toggleGroupCheckboxes[key] || []).forEach(cb => { cb.checked = value; });
}
function makeToggleGroup(group) {
const d = makeDetails(LANG === 'ja' ? group.title.ja : group.title.en);
group.items.forEach(({ key, keys, ja, en }) => {
const label = LANG === 'ja' ? ja : en;
const keyList = keys || [key];
const row = makeCheckRow(label, settings[keyList[0]], v => {
keyList.forEach(k => { settings[k] = v; syncToggleGroupCheckbox(k, v); });
saveSettings();
});
const cb = row.querySelector('input[type=checkbox]');
if (cb) keyList.forEach(k => (toggleGroupCheckboxes[k] || (toggleGroupCheckboxes[k] = [])).push(cb));
d.body.appendChild(row);
});
return d.details;
}
function createPanel() {
// @noframesが効かない実行環境向け保険: トップフレーム以外ではパネル自体を作らない
if (window.top !== window.self) return;
const wrap = document.createElement('div');
wrap.id = 'pxv-panel-wrap';
const toggleBtn = document.createElement('button');
toggleBtn.id = 'pxv-panel-toggle';
const [toggleIcon, ...toggleLabelParts] = STR.panel.split(' ');
const toggleIconEl = Object.assign(document.createElement('span'), { className: 'pxv-toggle-icon', textContent: toggleIcon });
const toggleLabelEl = Object.assign(document.createElement('span'), { className: 'pxv-toggle-label', textContent: ' ' + toggleLabelParts.join(' ') });
toggleBtn.appendChild(toggleIconEl);
toggleBtn.appendChild(toggleLabelEl);
const panel = document.createElement('div');
panel.id = 'pxv-panel';
// CSS側は display:none だが、初期状態でinline styleにも明示しておかないと
// panel.style.display が空文字のままになり、toggleBtnのクリック判定('none'かどうかの比較)と
// ズレて初回クリックが空振りし、2回目のクリックでようやく開く不具合が起きるため揃えておく。
panel.style.display = 'none';
/* ---- 情報オーバーレイ ---- */
panel.appendChild(Object.assign(document.createElement('h4'), { textContent: STR.sInfo }));
panel.appendChild(makeCheckSliderRow(
STR.showRatingCount, settings.showRatingCount, v => { settings.showRatingCount = v; saveSettings(); },
STR.sizeLabel, settings.ratingFontSize, 8, 20, 1, v => { settings.ratingFontSize = v; saveSettings(); },
));
panel.appendChild(makeCheckSliderRow(
STR.showDate, settings.showDate, v => { settings.showDate = v; saveSettings(); },
STR.sizeLabel, settings.dateFontSize, 8, 20, 1, v => { settings.dateFontSize = v; saveSettings(); },
));
/* ---- プレビュー ---- */
panel.appendChild(Object.assign(document.createElement('h4'), { textContent: STR.sPreview }));
panel.appendChild(makeCheckRow(STR.preview, settings.preview, v => { settings.preview = v; saveSettings(); }));
panel.appendChild(makeCheckRow(STR.hoverPreview, settings.hoverPreview, v => { settings.hoverPreview = v; saveSettings(); }));
/* ---- コンテンツフィルター ---- */
panel.appendChild(Object.assign(document.createElement('h4'), { textContent: STR.sFilter }));
const userList = document.createElement('div'); userList.id = 'pxv-bl-users-list'; userList.className = 'pxv-bl-list';
panel.appendChild(makeCheckRow(STR.muteUsersEnabled, settings.muteUsersEnabled, v => { settings.muteUsersEnabled = v; saveSettings(); }, makeListShowButton(userList)));
panel.appendChild(userList);
const tagAddRow = document.createElement('div'); tagAddRow.className = 'pxv-tag-add-row';
const tagAddInput = document.createElement('input');
tagAddInput.type = 'text'; tagAddInput.className = 'pxv-tag-add-input'; tagAddInput.placeholder = STR.tagAddPlaceholder;
const tagAddBtn = document.createElement('button');
tagAddBtn.className = 'pxv-btn'; tagAddBtn.textContent = STR.add;
const addTag = () => {
const t = tagAddInput.value.trim();
if (!t) return;
if (!settings.mutedTags.includes(t)) { settings.mutedTags = [...settings.mutedTags, t]; saveSettings(); refreshBlacklistUI(); }
tagAddInput.value = '';
};
tagAddBtn.onclick = addTag;
tagAddInput.addEventListener('keydown', e => { if (e.key === 'Enter') addTag(); });
tagAddRow.appendChild(tagAddInput); tagAddRow.appendChild(tagAddBtn);
// タグミュートの「リストを表示」ボタンで、タグリストと追加入力欄を両方まとめて開閉する
const tagList = document.createElement('div'); tagList.id = 'pxv-bl-tags-list'; tagList.className = 'pxv-bl-list';
const tagListWrap = document.createElement('div');
tagListWrap.appendChild(tagList); tagListWrap.appendChild(tagAddRow);
panel.appendChild(makeCheckRow(STR.muteTagsEnabled, settings.muteTagsEnabled, v => { settings.muteTagsEnabled = v; saveSettings(); }, makeListShowButton(tagListWrap)));
panel.appendChild(tagListWrap);
const feedUserWrap = document.createElement('div');
feedUserWrap.id = 'pxv-bl-feedusers-wrap';
const feedUserList = document.createElement('div'); feedUserList.id = 'pxv-bl-feedusers-list'; feedUserList.className = 'pxv-bl-list';
feedUserWrap.appendChild(makeCheckRow(STR.feedUserBl, settings.muteFeedUsersEnabled, v => { settings.muteFeedUsersEnabled = v; saveSettings(); }, makeListShowButton(feedUserList)));
feedUserWrap.appendChild(feedUserList);
panel.appendChild(feedUserWrap);
const bmInput = makeThresholdInput(settings.minBookmarkCount, v => { settings.minBookmarkCount = parseInt(v, 10) || 0; saveSettings(); });
panel.appendChild(makeCheckRow(STR.bmFilter, settings.bookmarkFilterEnabled, v => {
// フィルターのオン/オフだけを切り替える。閾値0は「常に表示(フィルター無効相当)」を
// 意図した有効な値なので、ここでminBookmarkCountを勝手に書き換えない。
settings.bookmarkFilterEnabled = v;
saveSettings();
}, bmInput));
panel.appendChild(makeCheckRow(STR.hideAiGenerated, settings.hideAiGenerated, v => { settings.hideAiGenerated = v; saveSettings(); }));
if (LANG === 'ja') {
panel.appendChild(makeCheckRow(STR.hideForeignLanguage, settings.hideForeignLanguage, v => { settings.hideForeignLanguage = v; saveSettings(); }));
}
// filterBeforeRevealのUIチェックボックスは撤去(設定・挙動そのものは残す。デフォルトtrueで内部的に有効のまま)。
// 復活させたい場合は下記1行を戻すだけでよい:
// panel.appendChild(makeCheckRow(STR.filterBeforeReveal, settings.filterBeforeReveal, v => { settings.filterBeforeReveal = v; saveSettings(); }));
/* ---- 自動検索パラメーター ---- */
const excludeRow = document.createElement('div'); excludeRow.className = 'pxv-row';
const excludeList = document.createElement('div'); excludeList.id = 'pxv-bl-exclude-list'; excludeList.className = 'pxv-bl-list';
const excludeAddRow = document.createElement('div'); excludeAddRow.className = 'pxv-tag-add-row';
const excludeAddInput = document.createElement('input');
excludeAddInput.type = 'text'; excludeAddInput.className = 'pxv-tag-add-input';
excludeAddInput.placeholder = STR.autoSearchExcludePlaceholder;
excludeAddInput.disabled = !settings.autoSearchExclude;
const excludeAddBtn = document.createElement('button');
excludeAddBtn.className = 'pxv-btn'; excludeAddBtn.textContent = STR.add;
excludeAddBtn.disabled = !settings.autoSearchExclude;
const addExcludeTag = () => {
const t = excludeAddInput.value.trim();
if (!t) return;
const cur = settings.autoSearchExcludeTags || [];
if (!cur.includes(t)) { settings.autoSearchExcludeTags = [...cur, t]; saveSettings(); refreshBlacklistUI(); }
excludeAddInput.value = '';
};
excludeAddBtn.onclick = addExcludeTag;
excludeAddInput.addEventListener('keydown', e => { if (e.key === 'Enter') addExcludeTag(); });
excludeAddRow.appendChild(excludeAddInput); excludeAddRow.appendChild(excludeAddBtn);
const registerMutedBtn = document.createElement('button');
registerMutedBtn.type = 'button'; registerMutedBtn.className = 'pxv-btn pxv-btn-ghost pxv-btn-sm';
registerMutedBtn.style.cssText = 'margin-top:8px;width:100%;';
registerMutedBtn.textContent = STR.registerMutedTagsBtn;
registerMutedBtn.disabled = !settings.autoSearchExclude;
registerMutedBtn.onclick = () => {
const cur = settings.autoSearchExcludeTags || [];
const negated = (settings.mutedTags || []).map(t => (t.startsWith('-') ? t : `-${t}`));
const merged = Array.from(new Set([...cur, ...negated]));
settings.autoSearchExcludeTags = merged;
saveSettings(); refreshBlacklistUI();
};
// 「リストを表示」ボタンでリスト/入力欄/登録ボタンをまとめて開閉する(使い方は含まない)
const excludeWrap = document.createElement('div');
excludeWrap.appendChild(excludeList);
excludeWrap.appendChild(excludeAddRow);
excludeWrap.appendChild(registerMutedBtn);
// 「?」アイコン: リストの開閉とは独立して、使い方だけをその場でふわっと開閉する
const helpBox = document.createElement('div');
helpBox.className = 'pxv-help-box pxv-help-popover'; helpBox.style.display = 'none';
helpBox.innerHTML = STR.autoSearchExcludeHelp;
const helpIconBtn = document.createElement('button');
helpIconBtn.type = 'button'; helpIconBtn.className = 'pxv-help-icon-btn'; helpIconBtn.textContent = '?';
helpIconBtn.title = STR.howToUseLabel;
helpIconBtn.onclick = () => { helpBox.style.display = helpBox.style.display === 'none' ? '' : 'none'; };
const excludeExtraBtns = document.createElement('div'); excludeExtraBtns.className = 'pxv-check-extra-btns';
excludeExtraBtns.appendChild(helpIconBtn); excludeExtraBtns.appendChild(makeListShowButton(excludeWrap));
excludeRow.appendChild(makeCheckRow(STR.autoSearchExclude, settings.autoSearchExclude, v => {
settings.autoSearchExclude = v; saveSettings();
excludeAddInput.disabled = !v; excludeAddBtn.disabled = !v; registerMutedBtn.disabled = !v;
}, excludeExtraBtns));
excludeRow.appendChild(helpBox);
excludeRow.appendChild(excludeWrap);
panel.appendChild(excludeRow);
/* ---- 不要な要素を非表示 ---- */
panel.appendChild(Object.assign(document.createElement('h4'), { textContent: STR.sFixedSections }));
SECTION_TOGGLE_GROUPS.forEach(group => panel.appendChild(makeToggleGroup(group)));
/* ---- その他(バックアップ / レビュー) ---- */
const REVIEW_URL = 'https://greasyfork.org/ja/scripts/591644-betterpixiv';
const BUG_REPORT_URL = 'https://forms.gle/U42CY4whyJpmQ1NN7';
panel.appendChild(Object.assign(document.createElement('hr'), { className: 'pxv-divider' }));
/* ---- 設定パネル透過設定(バックアップと同じ見た目、その直上に配置) ---- */
const opacityWrap = document.createElement('div'); opacityWrap.className = 'pxv-backup-row';
opacityWrap.appendChild(Object.assign(document.createElement('div'), { className: 'pxv-label', textContent: STR.panelOpacityLabel }));
const opacityCtrl = document.createElement('div'); opacityCtrl.className = 'pxv-btn-row';
const opacityInput = document.createElement('input');
Object.assign(opacityInput, { type: 'range', min: '40', max: '100', step: '5', value: settings.panelOpacity ?? 100 });
opacityInput.className = 'pxv-opacity-slider';
const opacityValue = document.createElement('span'); opacityValue.className = 'pxv-opacity-value';
opacityValue.textContent = (settings.panelOpacity ?? 100) + '%';
let opacityDebounceTimer = null;
opacityInput.addEventListener('input', () => {
const v = Number(opacityInput.value);
opacityValue.textContent = v + '%';
settings.panelOpacity = v;
applyPanelOpacity(); // 軽量(CSS変数のみ)なので即時反映のまま
// saveSettings()はrunUpdate()を伴う重い処理なのでドラッグ停止後にまとめて実行
clearTimeout(opacityDebounceTimer);
opacityDebounceTimer = setTimeout(saveSettings, 150);
});
opacityCtrl.appendChild(opacityInput); opacityCtrl.appendChild(opacityValue);
opacityWrap.appendChild(opacityCtrl);
panel.appendChild(opacityWrap);
const backupWrap = document.createElement('div'); backupWrap.className = 'pxv-backup-row';
backupWrap.appendChild(Object.assign(document.createElement('div'), { className: 'pxv-label', textContent: STR.backupLabel }));
const backupRow = document.createElement('div'); backupRow.className = 'pxv-btn-row';
const exportBtn = document.createElement('button'); exportBtn.className = 'pxv-btn pxv-btn-ghost pxv-btn-sm'; exportBtn.textContent = STR.exportBtn;
const importBtn = document.createElement('button'); importBtn.className = 'pxv-btn pxv-btn-ghost pxv-btn-sm'; importBtn.textContent = STR.importBtn;
exportBtn.onclick = () => {
const json = JSON.stringify(settings, null, 2);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(json).then(() => alert(STR.exportDone)).catch(() => window.prompt(STR.exportBtn, json));
} else {
window.prompt(STR.exportBtn, json);
}
};
importBtn.onclick = () => {
const raw = window.prompt(STR.importBtn, '');
if (!raw) return;
try {
const parsed = JSON.parse(raw);
settings = { ...DEFAULTS, ...parsed };
saveSettings(); refreshBlacklistUI();
alert(STR.importDone);
} catch (e) { alert(STR.importFail); }
};
backupRow.appendChild(exportBtn); backupRow.appendChild(importBtn);
backupWrap.appendChild(backupRow);
panel.appendChild(backupWrap);
const bugReportLink = Object.assign(document.createElement('a'), {
href: BUG_REPORT_URL, target: '_blank', rel: 'noopener noreferrer',
className: 'pxv-external-link',
});
bugReportLink.innerHTML = `<span>${STR.bugReportLabel}</span><span class="pxv-ext-icon">${LANG === 'ja' ? '外部サイト ↗️' : 'External ↗️'}</span>`;
panel.appendChild(bugReportLink);
const reviewLink = Object.assign(document.createElement('a'), {
href: REVIEW_URL, target: '_blank', rel: 'noopener noreferrer',
className: 'pxv-external-link',
});
reviewLink.innerHTML = `<span>${STR.reviewLinkLabel}</span><span class="pxv-ext-icon">${LANG === 'ja' ? '外部サイト ↗️' : 'External ↗️'}</span>`;
panel.appendChild(reviewLink);
toggleBtn.addEventListener('click', e => {
e.stopPropagation();
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
if (panel.style.display === 'block') {
refreshBlacklistUI();
applyTheme();
}
});
// iOS Safari対策: 対象要素に:hoverスタイルが付いていると、1回目のタップがhover状態の付与に
// 消費され、clickイベントが発火するのは2回目のタップからになる仕様がある(トグルボタンの
// #pxv-panel-toggle:hoverが該当)。documentかbodyにtouchstartリスナーが1つでも登録されていると
// この挙動が回避されるため、no-opのリスナーを登録しておく。
if (!window.__pxvTouchstartPatch) {
window.__pxvTouchstartPatch = true;
document.body.addEventListener('touchstart', () => {}, { passive: true });
}
// パネル内クリックはdocumentまでbubbleさせない(外側クリック判定と衝突するため)
panel.addEventListener('click', e => e.stopPropagation());
// パネル以外をクリックしたら閉じる
document.addEventListener('click', () => {
if (panel.style.display === 'block') panel.style.display = 'none';
});
wrap.appendChild(toggleBtn);
wrap.appendChild(panel);
document.body.appendChild(wrap);
refreshBlacklistUI();
applyTheme();
applyPanelOpacity();
// SPA遷移でURLだけ変わるケースに追従(フィードページ判定を再評価) + ダークテーマ切替追従。
// applyTheme()はパネル表示中のみ実行する(閉時はgetComputedStyle()を毎秒呼ぶ意味が無いため)。
// パネルを開く瞬間は別途toggleBtnのclickハンドラでapplyTheme()を呼んでいるため反映は遅れない。
let lastPath = location.pathname;
setInterval(() => {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
refreshBlacklistUI();
}
if (panel.style.display === 'block') applyTheme();
}, 1000);
}
/* ============================== bootstrap ============================== */
function init() {
// unhideAllTrackedは必ずattachAllより先に呼ぶ。逆順だとattachAll内のfilterBeforeReveal
// (未確定カードの仮hide+pending付与)を直後にunhideAllTrackedが全解除してしまい、
// SSR済みカードがある場合に初回のチラつき防止が効かなくなる(runUpdateのelse分岐と同じ順序に揃える)。
unhideAllTracked();
attachAll();
filterTagList();
removeCommentPreview();
removeExtraSections();
collapseEmptyContainersCascade();
lastHref = location.href;
applyAutoSearchExclude();
createPanel();
mutationObserver = new MutationObserver(scheduleUpdate);
// class/style/hidden/aria-selectedはタブ切替等のノード追加を伴わない表示切替を検知するため監視。
// dataset(data-*)はhide()/show()自身の変更で無限ループ化するため対象外。
// style自体もhide()/show()が書き換えるため、自己発火はsuppressMutationsフラグ(withMutationsSuppressed)で防ぐ。
mutationObserver.observe(document.body, OBSERVE_OPTIONS);
//for (let i = 1; i <= 5; i++) setTimeout(runUpdate, i * 2000);
//setInterval(() => runUpdate(), 5000);
}
// document.bodyの出現を検知し次第即init()する(固定待ちだと環境差で無防備/待ちすぎが起きるため)
function startWhenReady() {
if (document.body) { init(); return; }
const bodyWatcher = new MutationObserver(() => {
if (document.body) { bodyWatcher.disconnect(); init(); }
});
bodyWatcher.observe(document.documentElement, { childList: true });
}
startWhenReady();
})();
/* ============================================================
[2/3] Pixiv Overlay (元v1.1.0)
============================================================ */
(function () {
'use strict';
const LANG = (navigator.language || 'en').toLowerCase().startsWith('ja') ? 'ja' : 'en';
/* ============================== i18n ============================== */
const I18N = {
ja: {
mute: 'ミュート',
muteUser: 'このユーザーをミュート',
muteTag: 'タグをミュート',
muteFeedPoster: 'フィードの投稿者をミュート',
ngTagTitle: 'NGタグ登録',
cancel: 'キャンセル',
save: '保存',
},
en: {
mute: 'Mute',
muteUser: 'Mute this user',
muteTag: 'Mute tag',
muteFeedPoster: 'Mute feed poster',
ngTagTitle: 'Register NG tag',
cancel: 'Cancel',
save: 'Save',
},
};
const T = I18N[LANG];
/* ============================== dataset keys ============================== */
const DS = {
done: 'pxvOverlayDone',
illustId: 'pxvIllustId',
shifted: 'pxvShifted',
};
const DS_ATTR = {
done: 'data-pxv-overlay-done',
illustId: 'data-pxv-illust-id',
shifted: 'data-pxv-shifted',
};
/* ============================== cache ==============================
イラストデータ取得はBetterPixiv共通のpxvGetIllustData()に一本化(Previewと共有)。
旧: このスクリプト単独のillustCache/getIllustData → 1カード最大2回fetchされていた。 */
const getIllustData = pxvGetIllustData;
/* ============================== helpers ============================== */
function getIdFromAnchor(a) {
if (!a) return null;
let m = a.href.match(/artworks\/(\d+)/);
if (m) return m[1];
m = a.href.match(/\/i\/(\d+)/);
if (m) return m[1];
m = a.href.match(/[?&]illust_id=(\d+)/);
if (m) return m[1];
// ピックアップカルーセル: hrefが無くbackground-imageのURLしか手掛かりが無い場合
const bgEl = a.matches('[style*="background-image"]') ? a : a.querySelector('[style*="background-image"]');
if (bgEl) {
const bg = bgEl.style.backgroundImage || '';
m = bg.match(/\/(\d+)_p\d+/);
if (m) return m[1];
}
return null;
}
// stacc(通知フィード)の「投稿者」= 作品の投稿者とは別人(例:ブックマークした人)。
// /stacc/{アカウント名} 形式のリンクから同期的に取得する。
function getFeedUserFromScope(scope) {
if (!scope) return null;
const a = scope.querySelector('.stacc_post_user_name a[href*="/stacc/"], .stacc_post_user_img_50 a[href*="/stacc/"]');
if (a) {
const m = a.getAttribute('href').match(/\/stacc\/([^/?#]+)/);
if (m) {
const nameEl = scope.querySelector('.stacc_post_user_name');
const name = nameEl ? nameEl.textContent.trim() : '';
return { account: decodeURIComponent(m[1]), name };
}
}
// モバイルフィード(li.illust): .staccedby がフィードに流した人(ブクマ者等)へのリンク。
// アカウント名取得不可→userIdをaccountキーに代用。
// href形式は /users/{id} と旧形式 /bookmark.php?id={id} の両方がある。
const mobA = scope.querySelector('.staccedby a.user[href*="/users/"], .staccedby a.user[href*="bookmark.php"]');
if (mobA) {
const href = mobA.getAttribute('href');
const mm = href.match(/\/users\/(\d+)/) || href.match(/[?&]id=(\d+)/);
if (mm) return { account: mm[1], name: '' };
}
return null;
}
function formatNum(n) {
if (n === null || n === undefined) return '-';
return Number(n).toLocaleString(LANG === 'ja' ? 'ja-JP' : 'en-US');
}
function timeAgo(iso) {
if (!iso) return '';
const diffMs = Date.now() - new Date(iso).getTime();
if (isNaN(diffMs)) return '';
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return LANG === 'ja' ? `${sec}秒前` : `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return LANG === 'ja' ? `${min}分前` : `${min}m ago`;
const hour = Math.floor(min / 60);
if (hour < 24) return LANG === 'ja' ? `${hour}時間前` : `${hour}h ago`;
const day = Math.floor(hour / 24);
if (day < 30) return LANG === 'ja' ? `${day}日前` : `${day}d ago`;
const month = Math.floor(day / 30);
if (month < 12) return LANG === 'ja' ? `${month}ヶ月前` : `${month}mo ago`;
const year = Math.floor(month / 12);
return LANG === 'ja' ? `${year}年前` : `${year}y ago`;
}
/* ============================== mute lists (unified settings) ============================== */
const STORAGE_KEY = 'pxv_unified_settings';
const EVENT_NAME = 'pxv:settings-changed';
function loadUnifiedSettings() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); }
catch (e) { console.debug('[pxv] loadUnifiedSettings failed', e); return {}; }
}
function saveUnifiedSettings(partial) {
const current = loadUnifiedSettings();
const next = { ...current, ...partial };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: next }));
return next;
}
function addMuteUser(userId, userName) {
if (!userId) return;
const current = loadUnifiedSettings();
const list = Array.isArray(current.mutedAuthors) ? current.mutedAuthors.slice() : [];
if (!list.some(u => u.id === userId)) {
list.push({ id: userId, name: userName || '' });
saveUnifiedSettings({ mutedAuthors: list });
}
}
function addMuteTags(tags) {
const current = loadUnifiedSettings();
const list = Array.isArray(current.mutedTags) ? current.mutedTags.slice() : [];
let changed = false;
tags.forEach(t => {
t = (t || '').trim();
if (t && !list.includes(t)) { list.push(t); changed = true; }
});
if (changed) saveUnifiedSettings({ mutedTags: list });
}
// フィード(stacc)の投稿者ミュート。作品の投稿者(mutedAuthors)とは別リストで管理。
// アカウント名(/stacc/{account})をキーにする。
function addMuteFeedUser(account, name) {
if (!account) return;
const current = loadUnifiedSettings();
const list = Array.isArray(current.mutedFeedPosters) ? current.mutedFeedPosters.slice() : [];
if (!list.some(u => u.account === account)) {
list.push({ account, name: name || '' });
saveUnifiedSettings({ mutedFeedPosters: list });
}
}
/* ============================== 評価数/日時オーバーレイの表示設定 ==============================
showRatingCount/showDate/ratingFontSize/dateFontSizeはSettings Panel側にUIはあったが
Overlay側で一切参照しておらず、ON/OFF・サイズ変更のどちらも実際には反映されていなかった。
ここで接続する。isStaccLayoutによる旧固定サイズ(11px/10px)はユーザー設定を優先するため廃止。 */
let uiSettings = loadUnifiedSettings();
function applyRatingRowStyle(el) {
if (!el) return;
el.style.display = uiSettings.showRatingCount === false ? 'none' : '';
el.style.fontSize = (uiSettings.ratingFontSize || 10) + 'px';
}
function applyDateRowStyle(el) {
if (!el) return;
el.style.display = uiSettings.showDate === false ? 'none' : '';
el.style.fontSize = (uiSettings.dateFontSize || 10) + 'px';
}
function applyOverlayTextSettingsToAll() {
document.querySelectorAll('.pxv-wi-count-row').forEach(applyRatingRowStyle);
document.querySelectorAll('.pxv-wi-time-row').forEach(applyDateRowStyle);
}
window.addEventListener(EVENT_NAME, e => {
uiSettings = { ...uiSettings, ...(e.detail || {}) };
applyOverlayTextSettingsToAll();
});
/* ============================== style ============================== */
(document.head || document.documentElement).insertAdjacentHTML('beforeend', `<style id="pxv-wi-overlay-style">
.square-thumbnail-container.illust-thumbnail,
.square-thumbnail-container {
position: relative;
}
.pxv-wi-overlay {
position: absolute; right: 6px; bottom: 6px; z-index: 2147483000;
display: flex; flex-direction: row; align-items: center; justify-content: flex-end;
gap: 2px;
pointer-events: none;
box-sizing: border-box;
max-width: calc(100% - 8px);
max-height: calc(100% - 8px);
overflow: visible;
}
.pxv-wi-mute-btn {
pointer-events: auto; cursor: pointer; flex-shrink: 0;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: none; border: none; box-shadow: none;
font-size: 18px;
filter: grayscale(100%);
text-shadow: 0 1px 2px rgba(0,0,0,.8);
opacity: .8;
position: relative;
z-index: 2147483001;
}
.pxv-wi-mute-btn::before {
content: '';
position: absolute;
inset: -8px;
}
.pxv-wi-count-time {
display: flex; align-items: flex-end; gap: 4px; pointer-events: none; flex-shrink: 0;
max-width: 100%; flex-wrap: wrap; justify-content: flex-end;
}
.pxv-wi-count-time .pxv-wi-row {
display: flex; align-items: center; gap: 5px;
color: #fff; line-height: 1;
background: rgba(0,0,0,.3);
border-radius: 8px;
padding: clamp(2px, 2cqw, 4px) clamp(4px, 3.5cqw, 8px);
white-space: nowrap;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
}
.pxv-wi-count-time .pxv-wi-row.pxv-wi-count-row {
font-size: clamp(9px, 7cqw, 15px); font-weight: 700;
}
.pxv-wi-count-time .pxv-wi-row.pxv-wi-time-row {
font-size: clamp(9px, 7cqw, 15px); font-weight: 400;
}
.pxv-wi-count-time .pxv-wi-heart {
color: #ff5c7a; font-size: clamp(9px, 7.5cqw, 16px); line-height: 1;
}
.pxv-mute-popup {
position: fixed !important; z-index: 2147483647 !important;
background: #2a2a35; color: #fff; border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,.5); padding: 6px;
display: flex; flex-direction: column; gap: 4px; font-size: 13px;
min-width: 170px;
}
.pxv-mute-popup button {
background: rgba(255,255,255,.08); border: none; color: #fff;
padding: 8px 10px; border-radius: 5px; cursor: pointer; text-align: left;
font-size: 13px;
}
.pxv-mute-popup button:hover { background: rgba(120,100,255,.4); }
.pxv-ng-tag-modal-backdrop {
position: fixed !important; z-index: 2147483647 !important;
top:0; left:0; width:100vw; height:100vh; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center;
}
.pxv-ng-tag-modal {
background: #20202a; color: #fff; border-radius: 10px; padding: 20px;
width: 320px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,.5);
display: flex; flex-direction: column; gap: 10px;
}
.pxv-ng-tag-modal h3 { margin: 0; font-size: 15px; }
.pxv-ng-tag-modal input {
width: 100%; box-sizing: border-box; padding: 8px 10px;
border-radius: 6px; border: 1px solid rgba(255,255,255,.2);
background: rgba(255,255,255,.06); color: #fff; font-size: 13px;
}
.pxv-ng-tag-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.pxv-ng-tag-chip {
background: rgba(120,100,255,.25); border: 1px solid rgba(160,130,255,.4);
border-radius: 12px; padding: 3px 10px; font-size: 12px; cursor: pointer;
user-select: none;
}
.pxv-ng-tag-chip.selected { background: rgba(120,100,255,.7); }
.pxv-ng-tag-actions { display: flex; justify-content: flex-end; gap: 8px; }
.pxv-ng-tag-modal button.pxv-btn-primary {
background: #7864ff; border: none; color: #fff; padding: 7px 14px;
border-radius: 6px; cursor: pointer; font-size: 13px;
}
.pxv-ng-tag-modal button.pxv-btn-secondary {
background: rgba(255,255,255,.08); border: none; color: #fff; padding: 7px 14px;
border-radius: 6px; cursor: pointer; font-size: 13px;
}
</style>`);
/* ============================== mute popup / ng tag modal ============================== */
let activeMutePopup = null;
function closeMutePopup() {
activeMutePopup?.remove();
activeMutePopup = null;
document.removeEventListener('click', onDocClickClosePopup, true);
window.removeEventListener('scroll', onScrollClosePopup, true);
}
function onDocClickClosePopup(e) {
if (activeMutePopup && !activeMutePopup.contains(e.target)) closeMutePopup();
}
function onScrollClosePopup() {
if (activeMutePopup) closeMutePopup();
}
function showMutePopup(anchorEl, illustId, getUserInfo, getFeedUser) {
closeMutePopup();
const popup = document.createElement('div');
popup.className = 'pxv-mute-popup';
popup.style.visibility = 'hidden';
document.body.appendChild(popup);
const userBtn = document.createElement('button');
userBtn.textContent = T.muteUser;
userBtn.addEventListener('click', e => {
e.stopPropagation();
const { userId, userName } = getUserInfo();
addMuteUser(userId, userName);
closeMutePopup();
});
const tagBtn = document.createElement('button');
tagBtn.textContent = T.muteTag;
tagBtn.addEventListener('click', e => {
e.stopPropagation();
closeMutePopup();
openNgTagModal(illustId);
});
popup.appendChild(userBtn);
popup.appendChild(tagBtn);
// stacc(ブックマーク通知等)フィードの場合のみ、作品投稿者とは別の
// 「フィードに流した人」をミュートするボタンを追加表示する。
const feedUser = getFeedUser ? getFeedUser() : null;
if (feedUser && feedUser.account) {
const feedUserBtn = document.createElement('button');
feedUserBtn.textContent = T.muteFeedPoster;
feedUserBtn.addEventListener('click', e => {
e.stopPropagation();
addMuteFeedUser(feedUser.account, feedUser.name);
closeMutePopup();
});
popup.appendChild(feedUserBtn);
}
const rect = anchorEl.getBoundingClientRect();
const pw = popup.offsetWidth;
const ph = popup.offsetHeight;
let top = rect.bottom + 4;
let left = rect.left - 80;
if (top + ph > window.innerHeight - 4) top = rect.top - ph - 4;
if (top < 4) top = 4;
if (left + pw > window.innerWidth - 4) left = window.innerWidth - pw - 4;
if (left < 4) left = 4;
popup.style.top = `${top}px`;
popup.style.left = `${left}px`;
popup.style.visibility = '';
activeMutePopup = popup;
setTimeout(() => {
document.addEventListener('click', onDocClickClosePopup, true);
window.addEventListener('scroll', onScrollClosePopup, true);
}, 0);
}
function openNgTagModal(illustId) {
const backdrop = document.createElement('div');
backdrop.className = 'pxv-ng-tag-modal-backdrop';
const modalEl = document.createElement('div');
modalEl.className = 'pxv-ng-tag-modal';
const heading = document.createElement('h3');
heading.textContent = T.ngTagTitle;
const chipsWrap = document.createElement('div');
chipsWrap.className = 'pxv-ng-tag-chips';
const selectedFromChips = new Set();
getIllustData(illustId).then(body => {
const tags = (body?.tags?.tags || []).map(t => t.tag);
tags.forEach(tag => {
const chip = document.createElement('div');
chip.className = 'pxv-ng-tag-chip';
chip.textContent = tag;
chip.addEventListener('click', () => {
chip.classList.toggle('selected');
if (chip.classList.contains('selected')) selectedFromChips.add(tag);
else selectedFromChips.delete(tag);
});
chipsWrap.appendChild(chip);
});
});
const actions = document.createElement('div');
actions.className = 'pxv-ng-tag-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'pxv-btn-secondary';
cancelBtn.textContent = T.cancel;
cancelBtn.addEventListener('click', () => backdrop.remove());
const saveBtn = document.createElement('button');
saveBtn.className = 'pxv-btn-primary';
saveBtn.textContent = T.save;
saveBtn.addEventListener('click', () => {
const all = [...selectedFromChips];
if (all.length) addMuteTags(all);
backdrop.remove();
});
actions.appendChild(cancelBtn);
actions.appendChild(saveBtn);
modalEl.appendChild(heading);
modalEl.appendChild(chipsWrap);
modalEl.appendChild(actions);
backdrop.appendChild(modalEl);
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
document.body.appendChild(backdrop);
}
/* ============================== thumbnail overlay ============================== */
const processedWorksItems = new WeakSet();
const muteBtnRegistry = new WeakMap();
// 実タップ座標がミュートボタンに重なっていてもhit-testが別要素(裏の画像等)を指す
// 端末向け救済。document capture最上位で、通常のe.target判定より先に処理。
document.addEventListener('click', e => {
if (e.target.closest && e.target.closest('.pxv-wi-mute-btn')) return; // 正常ヒット時は通常フローに任せる
const btns = document.querySelectorAll('.pxv-wi-mute-btn');
for (const btn of btns) {
const r = btn.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) {
const data = muteBtnRegistry.get(btn);
if (!data) continue;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
showMutePopup(btn, data.id, data.getUser, data.getFeedUser);
return;
}
}
}, true);
// ① DOM検出: anchor/id特定 + 仮想スクロール差し替え時のリセット + thumbContainer特定。
// 戻り値: null = 処理不要(anchor/id不明、または既に同一IDで処理済み)。
function resolveThumbTarget(root) {
if (!root) return null;
const anchor = (root.matches && root.matches('a[href*="/artworks/"], a[href*="illust_id="], .pickup-carousel a.carousel-item'))
? root
: root.querySelector('a[data-ga4-label="thumbnail_link"], a.thumbnail-link, a[href*="/artworks/"], a[href*="illust_id="], .imgoverlay a[href*="mode=medium"], .pickup-carousel a.carousel-item');
if (!anchor) return null;
const id = getIdFromAnchor(anchor);
if (!id) return null;
// Vue版リスト(new_illust.php等)は仮想スクロールでDOMノードを使い回すため、
// 同じ要素が別の作品IDに差し替わることがある。pxvOverlayDoneフラグだけで
// 判定すると古い作品のオーバーレイ・ミュート対象(id/userId)が残り続けてしまうため、
// 一度処理済みでもIDが変わっていたらオーバーレイを作り直す。
if (anchor.dataset[DS.done]) {
if (anchor.dataset[DS.illustId] === id) return null;
// 古いオーバーレイ・シフトしたブクマボタン位置をリセットして作り直す
root.querySelectorAll('.pxv-wi-overlay').forEach(el => el.remove());
root.querySelectorAll(`[${DS_ATTR.done}]`).forEach(el => {
delete el.dataset[DS.done];
});
root.querySelectorAll(`[${DS_ATTR.shifted}]`).forEach(el => {
delete el.dataset[DS.shifted];
el.style.bottom = '';
el.style.zIndex = '';
});
delete anchor.dataset[DS.done];
}
anchor.dataset[DS.illustId] = id;
// 修正: 画像をクリップしている実際の枠(rounded-8 overflow-hidden)を最優先で使う。
// これが無い場合のみimg/anchorの親へfallbackする。
// キャルーセル構造では外側のrelative divが別セレクタでも拾われてしまい、
// そちらをthumbContainerにすると画像の外(ボタン領域など)に出てしまうため。
const img = anchor.querySelector('img') || root.querySelector('img');
const thumbContainer =
anchor.closest('.rounded-8.overflow-hidden') ||
anchor.querySelector('.illust-thumbnail-container') ||
(img && img.parentElement) ||
anchor.parentElement;
if (!thumbContainer) return null;
// 重複防止はコンテナ自体に張る(root違いで同じ画像に複数回挿入されるのを防ぐ)
if (thumbContainer.dataset[DS.done] && thumbContainer.dataset[DS.illustId] === id) {
anchor.dataset[DS.done] = '1';
return null;
}
anchor.dataset[DS.done] = '1';
thumbContainer.dataset[DS.done] = '1';
thumbContainer.dataset[DS.illustId] = id;
if (getComputedStyle(thumbContainer).position === 'static') {
thumbContainer.style.position = 'relative';
}
thumbContainer.style.isolation = 'isolate';
const isStaccLayout = !!(thumbContainer.closest('.stacc_status, [class*="stacc_"], li.illust, [id^="stacc"]') || thumbContainer.className.includes('_layout-thumbnail'));
if (!isStaccLayout) {
thumbContainer.style.containerType = 'inline-size';
}
processedWorksItems.add(root);
return { anchor, id, thumbContainer, isStaccLayout };
}
// ② オーバーレイDOM生成: 🚫ボタン + ブクマ数/時刻表示 を組み立ててthumbContainerへ挿入。
function buildOverlayUI(thumbContainer, isStaccLayout) {
const overlay = document.createElement('div');
overlay.className = 'pxv-wi-overlay';
// 順番: 🚫 → ブクマ数+時刻 (画像右下に縦整列)
const muteBtn = document.createElement('div');
muteBtn.className = 'pxv-wi-mute-btn';
muteBtn.textContent = '🚫';
muteBtn.title = T.mute;
const countTime = document.createElement('div');
countTime.className = 'pxv-wi-count-time';
const countRow = document.createElement('div');
countRow.className = 'pxv-wi-row pxv-wi-count-row';
applyRatingRowStyle(countRow);
const heartSpan = document.createElement('span');
heartSpan.className = 'pxv-wi-heart';
heartSpan.textContent = '♥';
const countSpan = document.createElement('span');
countSpan.className = 'pxv-wi-count-value';
countSpan.textContent = '…';
countRow.appendChild(heartSpan);
countRow.appendChild(countSpan);
const timeRow = document.createElement('div');
timeRow.className = 'pxv-wi-row pxv-wi-time-row';
applyDateRowStyle(timeRow);
const timeSpan = document.createElement('span');
timeSpan.className = 'pxv-wi-time-value';
timeSpan.textContent = '';
timeRow.appendChild(timeSpan);
countTime.appendChild(countRow);
countTime.appendChild(timeRow);
overlay.appendChild(muteBtn);
overlay.appendChild(countTime);
thumbContainer.appendChild(overlay);
return { overlay, muteBtn, countSpan, timeRow, timeSpan };
}
// ③ ブクマボタン位置調整: 既存の右下固定ブクマボタンとoverlayが重なる場合、ボタン側を上にずらす。
function shiftBookmarkButtonIfOverlapping(thumbContainer, overlay) {
const bookmarkSearchScope =
thumbContainer.closest(`[class*="works-item" i], li, .list-item, [${DS_ATTR.illustId}]`) ||
(thumbContainer.parentElement && thumbContainer.parentElement.parentElement) ||
thumbContainer.parentElement ||
thumbContainer;
const bookmarkBtnWrap =
bookmarkSearchScope.querySelector('.absolute.bottom-0.right-0') ||
bookmarkSearchScope.querySelector('[data-ga4-label="bookmark_button"]')?.parentElement ||
bookmarkSearchScope.querySelector('[data-ga4-label="bookmark_button"]') ||
// モバイルフィード(li.illust)では .bookmark クラスが画像全体を包む
// .imgoverlay.bookmark に付いているため、先に実際のいいねボタン(.like)を
// 優先してマッチさせないと画像全体がシフト対象になってしまう
bookmarkSearchScope.querySelector('.like .btn-like')?.parentElement ||
bookmarkSearchScope.querySelector('.like') ||
bookmarkSearchScope.querySelector('.bookmark') ||
bookmarkSearchScope.querySelector('[class*="bookmark" i]');
if (!bookmarkBtnWrap || bookmarkBtnWrap.dataset[DS.shifted]) return;
const overlayRect = overlay.getBoundingClientRect();
const btnRect = bookmarkBtnWrap.getBoundingClientRect();
const overlaps = !(
btnRect.right < overlayRect.left ||
btnRect.left > overlayRect.right ||
btnRect.bottom < overlayRect.top ||
btnRect.top > overlayRect.bottom
);
if (!overlaps) return;
bookmarkBtnWrap.dataset[DS.shifted] = '1';
if (getComputedStyle(bookmarkBtnWrap).position === 'static') {
bookmarkBtnWrap.style.position = 'relative';
}
bookmarkBtnWrap.style.bottom = overlay.offsetHeight ? `${overlay.offsetHeight + 0}px` : '28px';
bookmarkBtnWrap.style.zIndex = '6';
}
// ④ イベント登録 + データ取得: ユーザー情報先取り、ブクマ数/時刻の非同期反映、ミュートボタンのイベント配線。
function wireOverlayData(root, id, isStaccLayout, overlayUI) {
const { overlay, muteBtn, countSpan, timeRow, timeSpan } = overlayUI;
// 新DOM: ユーザー情報はAPI待たずDOMから先取り(ミュートボタン即押下時の取りこぼし防止)
let userId = null, userName = null;
const userLink = root.querySelector('a[data-ga4-label="user_name_link"], a[data-ga4-label="user_icon_link"]');
if (userLink) {
userId = userLink.id || (userLink.href.match(/\/users\/(\d+)/) || [])[1] || null;
const nameDiv = userLink.querySelector('[title]');
userName = nameDiv ? nameDiv.getAttribute('title') : (userLink.textContent || '').trim();
}
// stacc(通知フィード)のみ対象。作品投稿者とは別の「フィードに流した人」を取得。
const feedUserScope = isStaccLayout
? (root.closest('.stacc_status_summary, .stacc_status') || root)
: null;
const feedUser = feedUserScope ? getFeedUserFromScope(feedUserScope) : null;
getIllustData(id).then(body => {
if (!body) { countSpan.textContent = '-'; return; }
countSpan.textContent = formatNum(body.bookmarkCount);
const t = timeAgo(body.createDate);
if (t) timeSpan.textContent = t; else timeRow.remove();
if (!userId) userId = body.userId;
if (!userName) userName = body.userName;
});
muteBtnRegistry.set(muteBtn, { id, getUser: () => ({ userId, userName }), getFeedUser: () => feedUser });
// 方針: ポップアップ表示は pointerup 単独で行う(click合成の混乱を避ける)。
// ナビゲーション阻止は click の preventDefault/stopPropagation のみ、他ロジックとは分離。
// muteBtn自身はcaptureで登録(targetなので親子の伝播干渉なし)。
muteBtn.addEventListener('pointerdown', e => {
e.stopPropagation();
}, true);
muteBtn.addEventListener('pointerup', e => {
e.stopPropagation();
e.preventDefault();
showMutePopup(muteBtn, id, () => ({ userId, userName }), () => feedUser);
}, true);
muteBtn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
}, true);
// overlay(親要素)側はbubbleで登録すること。captureで登録すると
// capture降下順序(document→overlay→muteBtn)でmuteBtn自身への伝播を止めてしまう。
overlay.addEventListener('pointerdown', e => {
e.stopPropagation();
});
overlay.addEventListener('pointerup', e => {
e.stopPropagation();
});
overlay.addEventListener('click', e => {
e.stopPropagation();
});
}
function processWorksItemThumb(root) {
const target = resolveThumbTarget(root);
if (!target) return;
const { id, thumbContainer, isStaccLayout } = target;
const overlayUI = buildOverlayUI(thumbContainer, isStaccLayout);
shiftBookmarkButtonIfOverlapping(thumbContainer, overlayUI.overlay);
wireOverlayData(root, id, isStaccLayout, overlayUI);
}
const THUMB_SELECTORS = [
'.works-item-illust',
'[class*="works-item"]',
'li[data-ga4-label="thumbnail"]',
'li:has(a[data-ga4-label="thumbnail_link"])',
'div[data-ga4-label="thumbnail_link"]',
'div:has(> div > a[data-ga4-label="thumbnail_link"])',
'div.relative.z-0:has(> a[href*="/artworks/"])',
'li:has([data-ga4-label="bookmark_button"])',
'.stacc_ref_illust_img',
'li.illust',
'article.stream-item',
'li:has(> article.stream-item)',
'li:has(article .imgbox a[href*="/artworks/"])',
'.pickup-carousel a.carousel-item[href*="/artworks/"]',
'.user-latest-works > a[href*="/artworks/"]',
`div[${DS_ATTR.illustId}]`,
'div:has(> div > div > a.gtm-search-box-recommend-artwork)',
];
const THUMB_SEL = THUMB_SELECTORS.join(',');
function scanThumbnails(root = document) {
const items = root.querySelectorAll ? root.querySelectorAll(THUMB_SEL) : [];
items.forEach(item => {
try { processWorksItemThumb(item); }
catch (e) { console.debug('[pxv] scanThumbnails item failed', e); }
});
if (root.matches && root.matches(THUMB_SEL)) {
try { processWorksItemThumb(root); }
catch (e) { console.debug('[pxv] scanThumbnails root failed', e); }
}
}
const thumbObserver = new MutationObserver(muts => {
for (const m of muts) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
scanThumbnails(node);
}
}
});
// BetterPixiv統合でdocument-start実行になったため、body出現前に.observe(document.body,...)
// を呼ぶとTypeErrorで即死する。body出現を待ってから開始する(Filter側のstartWhenReadyと同じ方針)。
function startOverlayWhenBodyReady() {
if (document.body) {
thumbObserver.observe(document.body, { childList: true, subtree: true });
scanThumbnails();
// data-intersection-is-once等の遅延ロードでhref/imgが後から書き換わるケースは
// childList監視だけでは検知できないため、定期的に再スキャンして拾う
setInterval(() => {
try { scanThumbnails(); }
catch (e) { console.debug('[pxv] periodic scanThumbnails failed', e); }
}, 1500);
return;
}
const bodyWatcher = new MutationObserver(() => {
if (document.body) { bodyWatcher.disconnect(); startOverlayWhenBodyReady(); }
});
bodyWatcher.observe(document.documentElement, { childList: true });
}
startOverlayWhenBodyReady();
})();
/* ============================================================
[3/3] Pixiv Preview (元v1.7.4)
============================================================ */
(function () {
'use strict';
/* ============================== shared settings ============================== */
const STORAGE_KEY = 'pxv_unified_settings';
const EVENT_NAME = 'pxv:settings-changed';
const DEFAULTS = { hoverPreview: true, modalPreview: true };
function loadSettings() {
try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') }; }
catch (e) { return { ...DEFAULTS }; }
}
let settings = loadSettings();
window.addEventListener(EVENT_NAME, e => { settings = { ...settings, ...e.detail }; });
const LANG = (navigator.language || 'en').toLowerCase().startsWith('ja') ? 'ja' : 'en';
const isMobile = () => window.matchMedia('(max-width: 768px)').matches || 'ontouchstart' in window;
// CSSテンプレート内・JS内の両方から参照する共有定数。
// ここを変更すればCSS/JS双方に反映される(旧: 57px等が複数箇所にハードコードされ二重管理だった)
const Z_MAX = 2147483647; // 最前面(Pixiv側が同値を使うケースへの対策はDOM順再appendで担保)
const Z_BELOW = Z_MAX - 1; // 最前面のすぐ下(情報オーバーレイ用)
const MOBILE_BOTTOM_GAP_PX = 57; // モバイル画面下の固定余白(約1.5cm @96dpi)
/* ============================== cache ============================== */
// key(id等) -> URL文字列 の対応だけ渡せばキャッシュ付きfetcherが作れる
function createCachedFetcher(urlFn) {
const cache = new Map();
return key => {
if (cache.has(key)) return cache.get(key);
const p = fetch(urlFn(key))
.then(r => r.json()).then(d => d?.body || null).catch(() => null);
cache.set(key, p);
return p;
};
}
// イラストデータ取得はBetterPixiv共通のpxvGetIllustData()に一本化(Overlayと共有)。
// 旧: このスクリプト単独のcreatedCachedFetcher版getIllustData → 1カード最大2回fetchされていた。
const getIllustData = pxvGetIllustData;
// モーダル(フル画像)専用: same-origin版。API制限厳しいためモーダルを開いた時だけ呼ぶ。
const getIllustDataAuth = pxvGetIllustDataAuth;
const getUserData = createCachedFetcher(userId => `https://www.pixiv.net/ajax/user/${userId}?full=1`);
/* ============================== style ============================== */
(document.head || document.documentElement).insertAdjacentHTML('beforeend', `<style id="pxv-preview-style">
a[href*="/artworks/"] :not(img) { pointer-events: none !important; }
a[href*="/artworks/"] img { pointer-events: auto !important; }
.stacc_ref_thumb_left a :not(img) { pointer-events: none !important; }
.stacc_ref_thumb_left a img { pointer-events: auto !important; }
#pxv-hoverbox img, #pxv-modalbox img, #pxv-modalbox canvas { object-fit: contain; }
[data-lowres="true"] { image-rendering: pixelated; image-rendering: crisp-edges; }
body.pxv-modal-open {
position: fixed !important;
width: 100% !important;
overflow: hidden !important;
}
#pxv-modalbox {
position: fixed !important; z-index: ${Z_MAX} !important; top: 0; left: 0;
width: 100vw; height: 100dvh;
background: rgba(0,0,0,.85);
display: none; overflow: hidden;
}
#pxv-modalbox.open { display: flex; align-items: center; justify-content: center; }
#pxv-modalbox.zoomed { overflow: auto; align-items: center; justify-content: center; }
#pxv-modalbox.zoomed { align-items: safe center; justify-content: safe center; }
#pxv-modal-inner {
display: flex; flex-direction: column; align-items: center;
max-width: 100vw; max-height: 100dvh;
}
#pxv-modalbox.zoomed #pxv-modal-inner {
max-width: none; max-height: none;
padding: 20px;
box-sizing: border-box;
}
#pxv-media-wrapper {
position: relative; display: inline-block;
}
#pxv-modalbox:not(.zoomed) img { cursor: zoom-in; }
#pxv-modalbox.zoomed img { cursor: zoom-out; }
/* 情報オーバーレイ: 画面中央下 固定、幅比率 1:3:1 */
#pxv-info-overlay {
position: fixed; bottom: 0; left: 50%; transform: translate(-50%, 6px);
width: 60vw;
max-height: 60%;
display: flex; flex-direction: column;
overflow-y: auto;
background: linear-gradient(0deg, rgba(10,14,30,.88) 0%, rgba(15,18,40,.80) 70%, rgba(20,16,40,.58) 100%);
box-sizing: border-box;
opacity: 0; transition: opacity .22s ease, transform .22s ease;
pointer-events: none;
user-select: text;
z-index: ${Z_BELOW};
}
#pxv-info-overlay::-webkit-scrollbar { width: 3px; }
#pxv-info-overlay::-webkit-scrollbar-thumb { background: rgba(160,130,255,.35); border-radius: 2px; }
#pxv-info-overlay.visible {
opacity: 1; transform: translate(-50%, 0); pointer-events: auto;
}
/* リンク文字: 視認性向上 */
#pxv-info-overlay a {
text-decoration: underline;
text-underline-offset: 2px;
}
#pxv-info-overlay-title {
font-size: 16px; font-weight: 700; color: #c8b8ff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
flex-shrink: 0; padding: 12px 16px 6px;
text-shadow: 0 1px 6px rgba(0,0,0,.8);
text-decoration: underline; text-underline-offset: 3px; display: block;
user-select: text; cursor: pointer;
}
#pxv-info-overlay-title:hover { color: #e0d0ff; }
#pxv-info-overlay-desc {
font-size: 14px; color: rgba(255,255,255,.92); line-height: 1.7;
padding: 4px 32px 14px;
white-space: pre-wrap; word-break: break-all;
text-shadow: 0 1px 4px rgba(0,0,0,.7);
user-select: text; cursor: text; flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,.12);
}
#pxv-info-user-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 16px 4px; flex-shrink: 0;
}
#pxv-info-user-avatar {
width: 28px; height: 28px; border-radius: 50%; object-fit: cover;
flex-shrink: 0; background: rgba(255,255,255,.12); display: none;
}
#pxv-info-user-name {
font-size: 13px; font-weight: 700; color: #a8d8ff;
text-decoration: underline; text-underline-offset: 2px;
text-shadow: 0 1px 4px rgba(0,0,0,.7);
cursor: pointer; user-select: text; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
#pxv-info-user-name:hover { color: #c8e8ff; }
#pxv-info-overlay-meta {
font-size: 13px; color: rgba(255,255,255,.85);
display: flex; flex-wrap: wrap; align-items: center; gap: 14px;
padding: 6px 16px 10px; flex-shrink: 0;
text-shadow: 0 1px 4px rgba(0,0,0,.7);
user-select: text;
border-bottom: 1px solid rgba(255,255,255,.12);
}
#pxv-info-overlay-meta span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
#pxv-info-overlay-date {
font-size: 12px; color: rgba(255,255,255,.6);
margin-left: auto; padding: 0; white-space: nowrap;
user-select: text; cursor: text;
}
#pxv-info-overlay-tags {
font-size: 14px; display: flex; flex-wrap: wrap; gap: 8px;
padding: 8px 16px 10px; flex-shrink: 0;
}
#pxv-info-overlay-tags a {
background: rgba(120,100,255,.22); border: 1px solid rgba(160,130,255,.3);
border-radius: 12px; padding: 4px 12px; color: #c8b8ff;
white-space: nowrap; text-shadow: 0 1px 3px rgba(0,0,0,.6);
text-decoration: underline; text-underline-offset: 2px; cursor: pointer;
transition: background .15s;
user-select: none;
}
#pxv-info-overlay-tags a:hover { background: rgba(120,100,255,.5); color: #e0d0ff; }
/* トップバー */
#pxv-topbar {
position: fixed !important; top: 20px; right: 20px; z-index: ${Z_MAX} !important;
display: flex; align-items: center; gap: 10px;
pointer-events: none;
}
#pxv-info-toggle {
position: fixed; bottom: 16px; right: 16px; z-index: ${Z_MAX};
width: auto; height: auto; padding: 8px 14px; border-radius: 20px;
display: none;
align-items: center; justify-content: center;
background: rgba(20,16,40,.55); border: 1px solid rgba(160,130,255,.4);
color: rgba(255,255,255,.9); font-size: 13px; font-weight: 700; line-height: 1;
cursor: pointer; user-select: none; pointer-events: auto;
box-shadow: 0 4px 12px rgba(0,0,0,.3); transition: background .15s, transform .15s;
transform: translateZ(0); -webkit-transform: translateZ(0);
backface-visibility: hidden; -webkit-backface-visibility: hidden;
will-change: transform;
}
/* wrapperに追加された後は常に表示 */
#pxv-media-wrapper #pxv-info-toggle {
display: flex;
}
#pxv-info-toggle::before {
content: ''; position: absolute; top: -10px; left: -10px; right: -10px; bottom: -10px;
pointer-events: auto;
}
#pxv-info-toggle:hover { background: rgba(120,100,255,.55); transform: scale(1.04); }
#pxv-info-toggle.active { background: rgba(120,100,255,.75); }
#pxv-counter {
background: rgba(0,0,0,.6); color: #fff; padding: 6px 14px;
border-radius: 20px; font-size: 16px; font-weight: bold;
pointer-events: none; user-select: none;
box-shadow: 0 4px 12px rgba(0,0,0,.3); display: none;
}
/* PC幅では常に非表示。表示切替は@media内のbody.pxv-modal-openルールのみ担当 */
#pxv-mobile-footer { display: none; }
@media (max-width: 768px) {
#pxv-topbar { top: 10px; right: 10px; gap: 6px; }
#pxv-modalbox.open #pxv-counter { position: fixed; top: 56px; left: 50%; right: auto; transform: translateX(-50%); }
#pxv-counter { padding: 5px 10px; font-size: 14px; }
#pxv-info-overlay { max-height: 50vh; width: 100vw; padding-bottom: 52px; }
#pxv-info-overlay-title { font-size: 14px; padding: 10px 12px 5px; }
#pxv-info-overlay-meta { font-size: 12px; padding: 4px 12px 3px; }
#pxv-info-overlay-tags { font-size: 13px; padding: 6px 12px 6px; }
#pxv-info-overlay-desc { font-size: 13px; padding: 4px 20px 8px; }
#pxv-info-user-row { padding: 6px 12px 4px; }
#pxv-modal-inner { align-items: center; }
/* 下1.5cm分はmodalBox自体のpaddingで確保し、残り領域内で画像を垂直中央に表示する(下基準の垂直中央) */
#pxv-modalbox { padding-bottom: ${MOBILE_BOTTOM_GAP_PX}px; box-sizing: border-box; }
/* モバイル用黒フッター: タップでモーダル閉、内部に情報ボタン */
#pxv-mobile-footer {
display: none;
position: fixed; left: 0; right: 0; bottom: 0;
height: ${MOBILE_BOTTOM_GAP_PX}px; z-index: ${Z_MAX};
background: linear-gradient(180deg, rgba(14,10,28,.75) 0%, rgba(8,6,18,.94) 45%, rgba(5,4,12,.98) 100%);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-top: 1px solid rgba(160,130,255,.2);
box-shadow: 0 -6px 24px rgba(0,0,0,.35);
grid-template-columns: 1fr auto;
align-items: center;
padding-right: 16px; box-sizing: border-box;
}
body.pxv-modal-open #pxv-mobile-footer {
display: grid;
}
#pxv-mobile-footer-hint {
grid-column: 1;
text-align: center;
color: rgba(255,255,255,.32);
font-size: 14px; letter-spacing: .03em;
pointer-events: none; white-space: nowrap;
}
#pxv-mobile-footer #pxv-info-toggle {
grid-column: 2;
position: static;
display: flex;
background: rgba(120,100,255,.18);
border: 1px solid rgba(160,130,255,.45);
border-radius: 20px;
padding: 9px 22px;
font-size: 13px; font-weight: 700; letter-spacing: .02em;
box-shadow: 0 2px 10px rgba(0,0,0,.3);
}
#pxv-mobile-footer #pxv-info-toggle:active {
background: rgba(120,100,255,.55); transform: scale(.96);
}
#pxv-mobile-footer #pxv-info-toggle.active {
background: rgba(120,100,255,.6); border-color: rgba(200,184,255,.65);
}
}
</style>`);
/* ============================== preview DOM ============================== */
const hoverBox = document.createElement('div');
hoverBox.id = 'pxv-hoverbox';
hoverBox.style.cssText = `position:fixed;z-index:${Z_MAX};top:0;left:-9999px;display:flex;align-items:center;justify-content:center;pointer-events:none;background:transparent;user-select:none;`;
const modalBox = document.createElement('div');
modalBox.id = 'pxv-modalbox';
const modalInner = document.createElement('div');
modalInner.id = 'pxv-modal-inner';
modalBox.appendChild(modalInner);
const counterEl = document.createElement('div');
counterEl.id = 'pxv-counter';
const infoOverlay = document.createElement('div');
infoOverlay.id = 'pxv-info-overlay';
const infoTitle = document.createElement('a');
infoTitle.id = 'pxv-info-overlay-title';
infoTitle.target = '_blank';
infoTitle.rel = 'noopener noreferrer';
const infoDesc = document.createElement('div');
infoDesc.id = 'pxv-info-overlay-desc';
const infoMeta = document.createElement('div');
infoMeta.id = 'pxv-info-overlay-meta';
const infoDate = document.createElement('div');
infoDate.id = 'pxv-info-overlay-date';
const infoTags = document.createElement('div');
infoTags.id = 'pxv-info-overlay-tags';
const infoUserRow = document.createElement('div');
infoUserRow.id = 'pxv-info-user-row';
const userAvatar = document.createElement('img');
userAvatar.id = 'pxv-info-user-avatar';
userAvatar.alt = '';
const userNameLink = document.createElement('a');
userNameLink.id = 'pxv-info-user-name';
userNameLink.target = '_blank';
userNameLink.rel = 'noopener noreferrer';
infoUserRow.appendChild(userAvatar);
infoUserRow.appendChild(userNameLink);
// 順番: タイトル → 本文 → ユーザー → 統計+日時 → タグ
infoOverlay.appendChild(infoTitle);
infoOverlay.appendChild(infoDesc);
infoOverlay.appendChild(infoUserRow);
infoOverlay.appendChild(infoMeta);
infoOverlay.appendChild(infoTags);
const topBar = document.createElement('div');
topBar.id = 'pxv-topbar';
// 情報ボタン(PC: wrapper内埋込み / モバイル: フッター内)
const infoToggleBtn = document.createElement('button');
infoToggleBtn.id = 'pxv-info-toggle';
infoToggleBtn.type = 'button';
infoToggleBtn.textContent = '情報を表示';
topBar.appendChild(infoToggleBtn);
topBar.appendChild(counterEl);
// モバイル用フッター(黒背景、タップでモーダル閉)
const mobileFooter = document.createElement('div');
mobileFooter.id = 'pxv-mobile-footer';
const mobileFooterCloseHint = document.createElement('span');
mobileFooterCloseHint.id = 'pxv-mobile-footer-hint';
mobileFooterCloseHint.textContent = 'プレビューを閉じる';
mobileFooter.appendChild(mobileFooterCloseHint);
mobileFooter.addEventListener('click', e => {
if (e.target.closest('#pxv-info-toggle')) return;
closeModal();
});
// BetterPixiv統合でdocument-start実行になったため、body出現前にappendChild(document.body,...)
// を呼ぶとTypeErrorで即死する。body出現を待ってからまとめて追加する。
function attachPreviewRootsWhenBodyReady() {
if (document.body) {
document.body.appendChild(hoverBox);
document.body.appendChild(modalBox);
document.body.appendChild(topBar);
document.body.appendChild(mobileFooter);
return;
}
const bodyWatcher = new MutationObserver(() => {
if (document.body) { bodyWatcher.disconnect(); attachPreviewRootsWhenBodyReady(); }
});
bodyWatcher.observe(document.documentElement, { childList: true });
}
attachPreviewRootsWhenBodyReady();
const RES_THRESHOLD = 500, SCALE_FACTOR = 1.5, HOVER_OFFSET = 40;
const hover = { activeRef: null, activeAnchor: null, controller: null };
const modal = {
imageUrls: [], currentIndex: 0, ugoiraTimer: null, ugoiraFrames: [],
controller: null, isOpen: false, illustId: null, title: '', description: '',
stats: null, infoPinned: false, savedScrollY: 0
};
let lastMouseX = 0;
function fetchUgoiraMeta(id, signal) {
return fetch(`https://www.pixiv.net/ajax/illust/${id}/ugoira_meta`, { signal })
.then(r => r.json()).then(d => d?.body);
}
function getPageUrls(body, sizeKey = 'regular') {
const base = body?.urls?.[sizeKey];
if (!base || !body?.pageCount) return [];
return Array.from({ length: body.pageCount }, (_, i) => base.replace(/_p\d+/, `_p${i}`));
}
function stripHtml(html) {
if (!html) return '';
return html.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, "'").trim();
}
function formatNum(n) {
if (n === null || n === undefined) return '-';
return Number(n).toLocaleString(LANG === 'ja' ? 'ja-JP' : 'en-US');
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/* ============================== 情報ボタントグル ============================== */
function toggleInfo(e) {
e.preventDefault(); e.stopPropagation();
modal.infoPinned = !modal.infoPinned;
infoToggleBtn.classList.toggle('active', modal.infoPinned);
infoOverlay.classList.toggle('visible', modal.infoPinned);
}
infoToggleBtn.addEventListener('click', toggleInfo);
/* ============================== 情報オーバーレイ ============================== */
function attachInfoOverlay(wrapper) {
const title = modal.title;
const desc = stripHtml(modal.description);
const stats = modal.stats;
const hasMeta = !!stats;
const hasTags = !!(stats?.tags && stats.tags.length);
if (!title && !desc && !hasMeta && !hasTags) {
infoToggleBtn.style.display = 'none';
return;
}
infoTitle.textContent = title || '';
infoTitle.href = modal.illustId ? `https://www.pixiv.net/artworks/${modal.illustId}` : '#';
infoTitle.style.display = title ? '' : 'none';
infoDesc.textContent = desc || '';
infoDesc.style.display = desc ? 'block' : 'none';
infoOverlay.scrollTop = 0;
infoMeta.replaceChildren();
if (hasMeta) {
[['♥', stats.likeCount], ['★', stats.bookmarkCount], ['👁', stats.viewCount]].forEach(([icon, val]) => {
if (val === null || val === undefined) return;
const span = document.createElement('span');
span.textContent = `${icon} ${formatNum(val)}`;
infoMeta.appendChild(span);
});
const dateStr = formatDate(stats?.createDate);
if (dateStr) {
infoDate.textContent = dateStr;
infoMeta.appendChild(infoDate);
}
infoMeta.style.display = infoMeta.children.length ? 'flex' : 'none';
} else {
infoMeta.style.display = 'none';
}
const hasUser = !!(stats?.userId && stats?.userName);
if (hasUser) {
userNameLink.textContent = stats.userName;
userNameLink.href = `https://www.pixiv.net/users/${stats.userId}`;
userAvatar.style.display = 'none';
userAvatar.removeAttribute('src');
infoUserRow.style.display = 'flex';
const targetUserId = stats.userId;
getUserData(targetUserId).then(u => {
if (modal.stats?.userId !== targetUserId) return;
const imgUrl = u?.imageBig || u?.image;
if (imgUrl) { userAvatar.src = imgUrl; userAvatar.style.display = 'block'; }
});
} else {
infoUserRow.style.display = 'none';
}
infoTags.replaceChildren();
if (hasTags) {
stats.tags.forEach(tag => {
const a = document.createElement('a');
a.dataset.tag = tag;
a.textContent = tag;
a.href = `https://www.pixiv.net/tags/${encodeURIComponent(tag)}`;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.addEventListener('click', e => { e.stopPropagation(); });
infoTags.appendChild(a);
});
infoTags.style.display = 'flex';
} else {
infoTags.style.display = 'none';
}
// 情報ボタン: PCはwrapper内(absolute配置)、モバイルはフッター内
if (isMobile()) mobileFooter.appendChild(infoToggleBtn);
else wrapper.appendChild(infoToggleBtn);
wrapper.appendChild(infoOverlay);
// display はCSSに委譲(PC: flex, モバイルも flex で表示)
infoToggleBtn.style.display = '';
infoToggleBtn.classList.toggle('active', modal.infoPinned);
infoOverlay.classList.toggle('visible', modal.infoPinned);
infoOverlay.addEventListener('wheel', e => {
if (!infoOverlay.classList.contains('visible')) return;
e.stopPropagation();
}, { passive: true });
infoOverlay.addEventListener('mousedown', e => e.stopPropagation());
infoOverlay.addEventListener('click', e => e.stopPropagation());
}
function wrapMediaEl(el) {
const wrapper = document.createElement('div');
wrapper.id = 'pxv-media-wrapper';
wrapper.appendChild(el);
return wrapper;
}
// 低解像度画像は SCALE_FACTOR 倍に拡大した上で maxW/maxH に収まる比率を返す。
// (低解像度側にだけ拡大をかけたいので通常画像とは分岐が要る。呼び出し側は isLowRes を見て
// スタイル適用方法を変える — img は auto+max-width/height、canvas は実サイズ指定、という違いがあるため)
function calcScaledSize(origW, origH, maxW, maxH) {
const isLowRes = origW <= RES_THRESHOLD || origH <= RES_THRESHOLD;
const baseW = isLowRes ? origW * SCALE_FACTOR : origW;
const baseH = isLowRes ? origH * SCALE_FACTOR : origH;
const ratio = Math.min(maxW / baseW, maxH / baseH, 1);
return { isLowRes, scaledW: baseW * ratio, scaledH: baseH * ratio };
}
function applySmartSizing(el, origW, origH, isHover) {
const vv = window.visualViewport;
const pinchZoomed = !!(vv && vv.scale !== 1);
// ピンチズーム中はvv.heightがscale倍縮小される値になる→誤サイズ計算防止でwindow基準に切替
const vw = window.innerWidth, vh = (vv && !pinchZoomed) ? vv.height : window.innerHeight;
let maxW = vw;
let maxH = vh;
if (!isHover && isMobile()) {
maxW = vw;
maxH = vh - MOBILE_BOTTOM_GAP_PX; // 画像下 固定余白(約1.5cm)。この高さを超える場合のみ画像側で縮小される
}
const { isLowRes, scaledW, scaledH } = calcScaledSize(origW, origH, maxW, maxH);
if (isLowRes) {
el.style.width = `${scaledW}px`; el.style.height = `${scaledH}px`;
el.setAttribute('data-lowres', 'true');
} else {
el.style.width = 'auto'; el.style.height = 'auto';
el.style.maxWidth = `${maxW}px`; el.style.maxHeight = `${maxH}px`;
el.removeAttribute('data-lowres');
}
}
function updateHoverPosition(mouseX) {
const img = hoverBox.querySelector('img');
if (!img || hoverBox.style.left === '-9999px') return;
const winW = window.innerWidth, isRight = mouseX > winW / 2;
requestAnimationFrame(() => {
const imgW = img.getBoundingClientRect().width;
let left = isRight ? mouseX - imgW - HOVER_OFFSET : mouseX + HOVER_OFFSET;
left = Math.max(0, Math.min(left, winW - imgW));
hoverBox.style.left = `${left}px`; hoverBox.style.top = '0px';
});
}
// サムネURL(c/WxH_.../img-master or custom-thumb/..._square1200|custom1200.jpg)を
// クロップなしのmaster1200版に変換。正方形切り取り前の全体像・より高解像度が手に入る。
// 変換不能な形式(既にmaster1200等)はそのまま返す→フォールバック安全。
function upgradeToMasterUrl(url) {
if (!url) return url;
const m = url.match(/^(https?:\/\/i\.pximg\.net)\/c\/[^/]+\/(?:img-master|custom-thumb)\/(.+?)_(?:square1200|custom1200)\.(jpg|png|jpeg)(\?.*)?$/);
if (m) return `${m[1]}/img-master/${m[2]}_master1200.${m[3]}`;
return url;
}
// ホバープレビュー用サムネイルURL取得。fetch不要→omit欠落問題ともAPI制限とも無縁。
// カード自体が既に読み込み済みのサムネイル(<img>かbackground-image)をそのまま流用する。
function getThumbnailUrl(anchorEl) {
if (!anchorEl) return null;
// 通常: <img>タグ(lazyload中はsrcがplaceholder/dataURIの場合あり→除外)
const img = anchorEl.querySelector('img');
if (img) {
const src = img.currentSrc || img.src || img.dataset.src;
if (src && !src.startsWith('data:')) return src;
}
// ピックアップカルーセル等: background-imageのdiv(自身 or 子孫)
const bgEl = anchorEl.matches('[style*="background-image"]') ? anchorEl : anchorEl.querySelector('[style*="background-image"]');
if (bgEl) {
const m = (bgEl.style.backgroundImage || '').match(/url\(["']?(.*?)["']?\)/);
if (m && m[1]) return m[1];
}
return null;
}
function showHoverPreview(refEl, id) {
if (!settings.hoverPreview) return;
if (isMobile()) return;
// 同z-index時のDOM順対策で毎回body末尾へ再append
document.body.appendChild(hoverBox);
hover.activeRef = refEl;
hoverBox.replaceChildren();
hoverBox.style.left = `${lastMouseX + HOVER_OFFSET}px`;
const origUrl = getThumbnailUrl(refEl);
if (!origUrl) return;
hover.origThumbUrl = origUrl;
hover.imageUrls = [upgradeToMasterUrl(origUrl)];
hover.currentIndex = 0;
renderHoverImage(refEl);
}
function renderHoverImage(refEl) {
const urls = hover.imageUrls;
if (!urls || urls.length === 0) return;
const img = new Image();
const targetUrl = urls[hover.currentIndex];
img.src = targetUrl;
img.style.cssText = 'display:block;box-shadow:0 0 10px rgba(0,0,0,.5);';
img.addEventListener('mousedown', e => e.preventDefault());
img.onload = () => {
if (hover.activeRef !== refEl) return;
applySmartSizing(img, img.naturalWidth, img.naturalHeight, true);
hoverBox.replaceChildren(img);
updateHoverPosition(lastMouseX);
};
// master1200変換URLが403等で失敗した場合、元のクロップ済みサムネにフォールバック
img.onerror = () => {
if (hover.activeRef !== refEl || !hover.origThumbUrl || img.src === hover.origThumbUrl) return;
img.src = hover.origThumbUrl;
};
}
function hideHoverPreview() {
hover.controller?.abort(); hover.controller = null; hover.activeRef = null; hover.activeAnchor = null;
hover.imageUrls = []; hover.currentIndex = 0; hover.origThumbUrl = null;
hoverBox.replaceChildren(); hoverBox.style.left = '-9999px';
}
/* ============================== ズームトグル ============================== */
function toggleZoom() {
const isZoomed = modalBox.classList.toggle('zoomed');
const wrapper = document.getElementById('pxv-media-wrapper');
const img = wrapper?.querySelector('img');
if (!img) return;
if (isZoomed) {
img.style.maxWidth = 'none';
img.style.maxHeight = 'none';
img.style.width = `${img.naturalWidth}px`;
img.style.height = `${img.naturalHeight}px`;
requestAnimationFrame(() => {
modalBox.scrollTop = (modalBox.scrollHeight - modalBox.clientHeight) / 2;
modalBox.scrollLeft = (modalBox.scrollWidth - modalBox.clientWidth) / 2;
});
} else {
applySmartSizing(img, img.naturalWidth, img.naturalHeight, false);
modalBox.scrollTop = 0;
modalBox.scrollLeft = 0;
}
}
/* ============================== スマホ戻るボタンでモーダル閉 ============================== */
function pushModalHistory() {
history.pushState({ pxvModal: true }, '');
}
window.addEventListener('popstate', e => {
if (modal.isOpen) {
closeModal();
}
});
function showModalError() {
modalInner.replaceChildren(Object.assign(document.createElement('div'), {
style: 'color:white;font-size:20px;',
textContent: LANG === 'ja' ? '読み込みに失敗しました' : 'Failed to load',
}));
}
function openModal(id) {
closeModal();
// Pixiv側が後からbody直下に高z-index要素(user-recommender等)を追加してくることがあり、
// z-indexが同値(理論上限2147483647)の場合はDOM順(後の要素が勝つ)で負けてしまう。
// 開く直前にbody末尾へ再度appendして確実に最前面に出す。
document.body.appendChild(modalBox);
if (topBar && topBar.isConnected) document.body.appendChild(topBar);
document.body.appendChild(mobileFooter);
modal.controller = new AbortController();
modal.isOpen = true;
modal.illustId = id;
modal.savedScrollY = window.scrollY || document.documentElement.scrollTop || 0;
document.body.style.top = `-${modal.savedScrollY}px`;
document.body.classList.add('pxv-modal-open');
modalBox.classList.add('open');
modalInner.replaceChildren();
pushModalHistory();
// フル画像取得はsame-origin優先(omitだと一部作品で画像URLが欠落するため)。
// API制限で失敗した場合はomit版へフォールバック(画像は取得できないことがあるが情報表示は維持)。
getIllustDataAuth(id).then(body => body || getIllustData(id)).then(body => {
if (!modal.isOpen) return;
if (!body) { showModalError(); return; }
modal.title = body.title || '';
modal.description = body.description || '';
modal.stats = {
likeCount: body.likeCount,
bookmarkCount: body.bookmarkCount,
viewCount: body.viewCount,
createDate: body.createDate,
tags: (body.tags?.tags || []).map(t => t.tag),
userId: body.userId,
userName: body.userName
};
if (body.illustType === 2) loadAndPlayUgoira(id, modal.controller.signal);
else {
modal.imageUrls = getPageUrls(body, 'regular');
modal.currentIndex = 0;
renderModalImage();
}
});
}
async function loadAndPlayUgoira(id, signal) {
try {
const meta = await fetchUgoiraMeta(id, signal);
if (!meta?.src || !meta?.frames) throw new Error('missing ugoira meta');
const zipRes = await fetch(meta.src, { signal });
if (!zipRes.ok) throw new Error(`zip fetch failed: ${zipRes.status}`);
const zip = await new JSZip().loadAsync(await zipRes.blob());
modal.ugoiraFrames = await Promise.all(meta.frames.map(async frame => {
const file = zip.file(frame.file);
if (!file) throw new Error(`missing frame: ${frame.file}`);
const ext = frame.file.split('.').pop().toLowerCase();
const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
const base64 = await file.async('base64');
const img = new Image();
img.src = `data:${mime};base64,${base64}`;
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
return { img, delay: frame.delay };
}));
const first = modal.ugoiraFrames[0].img;
const canvas = document.createElement('canvas');
canvas.style.boxShadow = '0 0 20px rgba(0,0,0,.5)';
const maxW = window.innerWidth * 0.98, maxH = window.innerHeight * 0.98;
const { isLowRes, scaledW, scaledH } = calcScaledSize(first.width, first.height, maxW, maxH);
const drawW = Math.round(scaledW), drawH = Math.round(scaledH);
if (isLowRes) canvas.setAttribute('data-lowres', 'true');
canvas.width = drawW; canvas.height = drawH;
canvas.style.width = `${drawW}px`; canvas.style.height = `${drawH}px`;
const wrapper = wrapMediaEl(canvas);
modalInner.replaceChildren(wrapper);
attachInfoOverlay(wrapper);
const ctx = canvas.getContext('2d');
let frameIdx = 0;
(function tick() {
if (!modal.isOpen || modal.ugoiraFrames.length === 0) return;
const f = modal.ugoiraFrames[frameIdx];
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(f.img, 0, 0, canvas.width, canvas.height);
frameIdx = (frameIdx + 1) % modal.ugoiraFrames.length;
modal.ugoiraTimer = setTimeout(tick, f.delay);
})();
} catch (err) {
if (err.name === 'AbortError') return;
showModalError();
}
}
function renderModalImage() {
if (modal.imageUrls.length === 0) return;
modalBox.classList.remove('zoomed');
const requestedIndex = modal.currentIndex;
const img = new Image();
img.src = modal.imageUrls[requestedIndex];
img.style.boxShadow = '0 0 20px rgba(0,0,0,.5)';
img.addEventListener('mousedown', e => e.preventDefault());
counterEl.textContent = `${requestedIndex + 1} / ${modal.imageUrls.length}`;
counterEl.style.display = modal.imageUrls.length > 1 ? 'block' : 'none';
const swapIn = () => {
if (modal.currentIndex !== requestedIndex) return;
applySmartSizing(img, img.naturalWidth, img.naturalHeight, false);
counterEl.textContent = `${requestedIndex + 1} / ${modal.imageUrls.length}`;
counterEl.style.display = modal.imageUrls.length > 1 ? 'block' : 'none';
const wrapper = wrapMediaEl(img);
modalInner.replaceChildren(wrapper);
attachInfoOverlay(wrapper);
};
if (img.complete && img.naturalWidth) swapIn(); else img.onload = swapIn;
}
// dir: +1 = 次へ, -1 = 前へ。複数ページ作品の切替をwheel/keydown/click/touchendから共通呼び出し
function navigateModal(dir) {
modal.currentIndex = (modal.currentIndex + dir + modal.imageUrls.length) % modal.imageUrls.length;
renderModalImage();
}
function closeModal() {
const wasOpen = modal.isOpen;
clearTimeout(modal.ugoiraTimer);
modal.controller?.abort();
modal.isOpen = false; modal.controller = null; modal.ugoiraTimer = null;
modal.ugoiraFrames = []; modal.imageUrls = []; modal.currentIndex = 0; modal.illustId = null;
modal.title = ''; modal.description = ''; modal.stats = null;
document.body.classList.remove('pxv-modal-open');
document.body.style.top = '';
// openModal冒頭のクリーンアップ呼び出し(未オープン時)はスクロール復元不要→現在位置を荒らさない
if (wasOpen) window.scrollTo(0, modal.savedScrollY || 0);
modalBox.classList.remove('open', 'zoomed');
modalInner.replaceChildren();
infoToggleBtn.style.display = '';
infoUserRow.style.display = 'none';
userAvatar.style.display = 'none';
userAvatar.removeAttribute('src');
counterEl.style.display = 'none';
}
/* ============================== global handlers ============================== */
function getArtworkAnchor(el) {
// 通常: /artworks/ リンク
const a = el.closest && el.closest('a[href*="/artworks/"]');
if (a) return a;
// stacc: .stacc_ref_thumb_left 内のリンク
const staccThumb = el.closest && el.closest('.stacc_ref_thumb_left');
if (staccThumb) return staccThumb.querySelector('a') || null;
// ピックアップカルーセル: hrefを持たずbackground-imageのみでID判別する
const carouselItem = el.closest && el.closest('.pickup-carousel a.carousel-item');
if (carouselItem) return carouselItem;
// モバイルfeed(li.illust): /artworks/ を持たず illust_id= のみの旧URL形式カードがある
const mobileIllustLi = el.closest && el.closest('li.illust');
if (mobileIllustLi) {
const mobA = mobileIllustLi.querySelector('.imgbox a[href*="illust_id="], a[href*="illust_id="]');
if (mobA) return mobA;
}
return null;
}
function getIdFromAnchor(a) {
if (!a) return null;
// /artworks/123
let m = a.href.match(/artworks\/(\d+)/);
if (m) return m[1];
// /i/123 (stacc形式)
m = a.href.match(/\/i\/(\d+)/);
if (m) return m[1];
// ?illust_id=123
m = a.href.match(/[?&]illust_id=(\d+)/);
if (m) return m[1];
// ピックアップカルーセル: background-imageのファイル名から抽出
// 例: .../img-master/img/2024/06/10/17/02/54/119517412_p0_master1200.jpg
const bgEl = a.matches('[style*="background-image"]') ? a : a.querySelector('[style*="background-image"]');
if (bgEl) {
const bg = bgEl.style.backgroundImage || '';
m = bg.match(/\/(\d+)_p\d+/);
if (m) return m[1];
}
return null;
}
document.addEventListener('mousemove', e => {
lastMouseX = e.clientX;
if (hover.activeRef) updateHoverPosition(e.clientX);
});
function isValidTarget(el) {
if (el.tagName === 'IMG') return true;
// stacc: div[class] 内のimg以外の要素がターゲットになる場合
if (el.closest('.stacc_ref_thumb_left')) return true;
// ピックアップカルーセル: サムネイルがbackground-imageのdivでimgタグを持たない
if (el.closest('.pickup-carousel .illust-thumbnail-container, .pickup-carousel a.carousel-item')) return true;
// 自前注入オーバーレイ(お気に入り数/ミュートボタン等)がimgの上に被さり、
// クリックがIMGまで届かず既定リンク遷移してしまうケースを救済
if (el.closest('.pxv-wi-overlay')) return true;
// モバイルfeed(li.illust)の作品リンク内なら、内部要素の種類(他スクリプトが注入した
// オーバーレイ等)に関わらず有効ターゲットとみなす
if (el.closest('li.illust .imgbox a[href*="illust_id="], li.illust .imgoverlay a[href*="illust_id="]')) return true;
return false;
}
document.addEventListener('mouseover', e => {
if (!isValidTarget(e.target)) return;
const a = getArtworkAnchor(e.target);
if (!a || hover.activeAnchor === a) return;
const id = getIdFromAnchor(a);
if (!id) return;
hover.activeAnchor = a;
showHoverPreview(a, id);
});
document.addEventListener('mouseout', e => {
if (!isValidTarget(e.target)) return;
const a = getArtworkAnchor(e.target);
if (!a) return;
if (e.relatedTarget && a.contains(e.relatedTarget)) return;
hideHoverPreview();
});
function hitsMuteUi(x, y) {
const els = document.querySelectorAll('.pxv-wi-mute-btn, .pxv-mute-popup');
for (const el of els) {
const r = el.getBoundingClientRect();
// アイコン想定(数十px四方)を大きく超える矩形はサムネイル全体を覆う構造ミス/別要素の可能性が高く、
// これを鵜呑みにするとプレビューが一切開けなくなるため無視する
if (r.width > 80 || r.height > 80) continue;
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true;
}
return false;
}
document.addEventListener('click', e => {
if (!settings.modalPreview) return;
// target/elementsFromPoint頼み不可(Android実機でhit-testがズレる場合あり)→矩形直接判定
if (hitsMuteUi(e.clientX, e.clientY)) return;
if (e.target.closest('.pxv-wi-mute-btn, .pxv-mute-popup')) return;
if (!isValidTarget(e.target)) return;
const a = getArtworkAnchor(e.target);
if (!a) return;
const id = getIdFromAnchor(a);
if (!id) return;
e.preventDefault(); e.stopPropagation();
openModal(id);
}, true);
document.addEventListener('keydown', e => {
if (!modal.isOpen) return;
if (e.code === 'Escape') {
if (modalBox.classList.contains('zoomed')) { modalBox.classList.remove('zoomed'); const img = document.querySelector('#pxv-media-wrapper img'); if (img) applySmartSizing(img, img.naturalWidth, img.naturalHeight, false); }
else closeModal();
return;
}
if (modal.ugoiraFrames.length > 0 || modal.imageUrls.length <= 1) return;
if (e.code === 'ArrowRight' || e.code === 'Space') {
e.preventDefault();
navigateModal(1);
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
navigateModal(-1);
}
});
modalBox.addEventListener('click', e => {
if (e.target === modalBox || e.target === modalInner) {
// モバイル: 背景タップは閉じずに次画像へ(閉じるのはフッター担当)
if (isMobile()) {
if (modal.ugoiraFrames.length === 0 && modal.imageUrls.length > 1) navigateModal(1);
return;
}
closeModal();
return;
}
const wrapper = document.getElementById('pxv-media-wrapper');
if (!wrapper) return;
const mediaEl = wrapper.querySelector('img, canvas');
if (mediaEl && e.target === mediaEl) {
if (modal.ugoiraFrames.length > 0) return;
if (isMobile()) {
if (modal.imageUrls.length > 1) navigateModal(1);
} else {
toggleZoom();
}
}
});
modalBox.addEventListener('wheel', e => {
if (isMobile()) return;
if (!modal.isOpen) return;
if (modal.ugoiraFrames.length > 0) return;
if (modal.imageUrls.length <= 1) return;
if (modalBox.classList.contains('zoomed')) return;
e.preventDefault();
navigateModal(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
window.visualViewport?.addEventListener('resize', () => {
if (!modal.isOpen || modalBox.classList.contains('zoomed')) return;
// ネイティブのピンチズーム中(scale!==1)はサイズ再計算をスキップし、画像が動かないようにする
if (window.visualViewport && window.visualViewport.scale !== 1) return;
const img = document.querySelector('#pxv-media-wrapper img');
if (img) applySmartSizing(img, img.naturalWidth, img.naturalHeight, false);
});
let touchStartX = 0;
let touchSwipeBlocked = false;
modalBox.addEventListener('touchstart', e => {
touchStartX = e.touches[0].clientX;
touchSwipeBlocked = !!e.target.closest('#pxv-info-overlay') || e.touches.length > 1;
}, { passive: true });
modalBox.addEventListener('touchend', e => {
if (!modal.isOpen) return;
if (modal.ugoiraFrames.length > 0) return;
if (modal.imageUrls.length <= 1) return;
if (touchSwipeBlocked) return;
if (e.touches.length > 0 || e.changedTouches.length > 1) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) < 40) return;
navigateModal(dx < 0 ? 1 : -1);
}, { passive: true });
})();