NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Bobby's Pixiv Utils
// @name:ja Bobby's Pixiv Utils(pixiv便利ツール)
// @name:zh-CN Bobby's Pixiv Utils(pixiv 实用工具)
// @name:zh-HK Bobby's Pixiv Utils(pixiv 實用工具)
// @name:zh-TW Bobby's Pixiv Utils(pixiv 實用工具)
// @namespace https://github.com/BobbyWibowo
// @version 1.8.8
// @description Mobile-friendly. "Edit bookmark" button, bookmark state filter, dates conversion, block AI-generated works, block by Pixiv tags, block by user IDs, UTags integration, and more!
// @description:ja PC・スマホ対応。ブックマーク編集ボタン、ブックマーク表示フィルター、日付表示の変換、AI生成作品・タグ・ユーザーIDによるブロック、UTags連携など。
// @description:zh-CN 兼容电脑和手机。支持编辑收藏、按收藏状态筛选作品、转换日期格式,以及屏蔽 AI 生成作品、指定标签和用户 ID;还支持 UTags 集成等功能。
// @description:zh-HK 支援電腦及手機使用。提供「編輯收藏」按鈕、按收藏狀態過濾作品、轉換日期格式、屏蔽 AI 生成作品及指定標籤/用戶 ID,並支援 UTags 整合等功能。
// @description:zh-TW 支援電腦與手機。提供「編輯收藏」按鈕、依收藏狀態篩選作品、轉換日期格式、封鎖 AI 生成作品及指定標籤/使用者 ID,並支援 UTags 整合等功能。
// @author Bobby Wibowo
// @license MIT
// @match *://www.pixiv.net/*
// @exclude *://www.pixiv.net/setting*
// @exclude *://www.pixiv.net/manage*
// @icon https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @run-at document-start
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant window.onurlchange
// @require https://cdn.jsdelivr.net/npm/sentinel-js@0.0.7/dist/sentinel.min.js#sha256-S8rx06n8uSoJCmTdVlxdMN8x+vDYoC+++ssWvEj4M6s=
// @require https://cdn.jsdelivr.net/npm/@floating-ui/core@1.8.0/dist/floating-ui.core.umd.min.js#sha256-ZZQNhmprbYMTlKS77ZntCjkzC6yYtkM4bZovh6Gh1do=
// @require https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.8.0/dist/floating-ui.dom.umd.min.js#sha256-YaRvlDxOmTeerwc0R4Ea7H6LjxINL2RjFuAbdpS8kKM=
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmconfig@1.0.0/dist/gmConfig.iife.js#sha256-90flNl94y5/N3x9hsSqMvtK2Al5MCONYvD9PGiFDDtg=
// @noframes
// ==/UserScript==
/* global sentinel, FloatingUIDOM, gmConfig */
(function () {
'use strict';
const _LOG_TIME_FORMAT = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
const _log = (prefix, message, ...args) => {
if (typeof message === 'string') return console.log(prefix + message, ...args);
return console.log(prefix, message, ...args);
};
const tlog = (...args) => _log(`[${_LOG_TIME_FORMAT.format(Date.now())}]: `, ...args);
const log = (...args) => _log('[Pixiv Utils]: ', ...args);
/** CONFIG **/
/* Hard-coded default values.
* Please use the script's Settings panel to configure these.
*/
const ENV_DEFAULTS = {
MODE: 'PROD',
SETTINGS_LANGUAGE: 'auto',
ENABLE_EDIT_BOOKMARK: true,
ENABLE_FANCY_EDIT_BOOKMARK: true,
ENABLE_TOGGLE_BOOKMARKED: true,
TEXT_EDIT_BOOKMARK: null,
TEXT_EDIT_BOOKMARK_TOOLTIP: null,
TEXT_TOGGLE_BOOKMARKED: null,
TEXT_TOGGLE_BOOKMARKED_TOOLTIP: null,
TEXT_TOGGLE_BOOKMARKED_SHOW_ALL: null,
TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED: null,
TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED: null,
SELECTORS_HOME: null,
SELECTORS_OWN_PROFILE: null,
SELECTORS_IMAGE: null,
SELECTORS_IMAGE_TITLE: null,
SELECTORS_IMAGE_ARTIST_AVATAR: null,
SELECTORS_IMAGE_AUXILIARY_ELEMENTS: null,
SELECTORS_IMAGE_CONTROLS: null,
SELECTORS_IMAGE_BOOKMARKED: null,
SELECTORS_EXPANDED_VIEW_IMAGE: null,
SELECTORS_EXPANDED_VIEW_CONTROLS: null,
SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: null,
SELECTORS_MULTI_VIEW: null,
SELECTORS_MULTI_VIEW_CONTROLS: null,
SELECTORS_FOLLOW_BUTTON_CONTAINER: null,
SELECTORS_FOLLOW_BUTTON: null,
SELECTORS_RECOMMENDED_USER_CONTAINER: null,
SELECTORS_TAG_BUTTON: null,
DATE_CONVERSION: true,
DATE_CONVERSION_LOCALES: 'en-GB',
DATE_CONVERSION_OPTIONS: {
hour12: true,
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
},
SELECTORS_DATE: null,
REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME: false,
REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME: false,
SECTIONS_TOGGLE_BOOKMARKED: null,
ENABLE_KEYBINDS: true,
PIXIV_HIGHLIGHTED_TAGS: null,
PIXIV_HIGHLIGHTED_TAGS_COMBINE_REGEXES: true,
PIXIV_HIGHLIGHTED_TAGS_FAST: true,
PIXIV_HIGHLIGHTED_BYPASS_BLOCK: false,
PIXIV_HIGHLIGHTED_COLOR: '#32cd32',
PIXIV_BLOCK_AI: false,
PIXIV_BLOCKED_TAGS: null,
PIXIV_BLOCKED_TAGS_COMBINE_REGEXES: true,
PIXIV_BLOCKED_USERS: [],
PIXIV_BLOCKED_TAGS_FAST: true,
PIXIV_REMOVE_BLOCKED: false,
UTAGS_INTEGRATION: true,
UTAGS_BLOCKED_TAGS: [],
ENABLE_FLOATING_TOOLTIPS: true,
FLOATING_TOOLTIPS_STATIONARY_DELAY: 500, // ms
FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE: 3, // px
FLOATING_TOOLTIPS_SHOW_DELAY: 500, // ms (keyboard focus)
FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY: 350, // ms
FLOATING_TOOLTIPS_OPTIONS: {
placement: 'bottom',
strategy: 'fixed',
mainAxisOffset: 25, // px
crossAxisOffset: 0, // px
shiftPadding: 8, // px
maxWidth: 350 // px
},
WAIT_FOR_POLL: 1000 // ms
};
/* Hard-coded preset values.
* Specifying custom values will extend instead of replacing them.
*/
const PRESETS = {
SELECTORS_HOME: /*css*/`
[data-ga4-label="page_root"]:not([data-ga4-entity-id])
`,
SELECTORS_OWN_PROFILE: /*css*/`
/* desktop */
a[href*="settings/profile"],
/* mobile */
.ui-button[href*="setting_profile.php"]
`,
SELECTORS_IMAGE: /*css*/`
/* desktop */
div[data-ga4-label="thumbnail"],
ul > li:has(
a[href*="artworks/"] :is(img[src], figure),
a[href*="novel/"],
a[href*="collections/"] img[src]
),
ul > div:not(.list-none):has(a[href*="artworks/"] img[src]):not(:has(
.gtm-illust-recommend-thumbnail-link,
.gtm-illust-toppage-recommend-user-profile,
.gtm-dashboard-home-latest-works-edit-button
)),
/* recommended works after bookmarking */
figcaption ul > div > div:has(a[href*="artworks/"] img[src]),
/* recommended users on home's illustrations tab */
div:has(> div > div > div > a.gtm-illust-toppage-recommend-user-work[href*="artworks/"] img[src]),
/* expanded view's artist sidebar */
nav:not([style]) > div:has(
a[href*="artworks/"] img[src],
a[href*="novel/"]
),
/* user profile popup */
div[open] a[href*="users/"] ~ div > div:has(a[href*="artworks/"] img[src]),
div[open] div:has(a[href*="users/"]) + div > div:has(a[href*="artworks/"] img[src]),
/* rankings page */
ol > li:has(
a[href*="artworks/"] img[src],
a[href*="novel/"]
),
/* recommended users after following */
body > div > div[style*="translateY"] ul li.inline-block > div > div
> div:has(a[href*="artworks/"] img[src]),
/* mobile */
.works-item-illust:has(.thumb:not([src^="data"])),
/* novel */
.works-item:not(.works-item-illust):has(.thumb:not([src^="data"])),
/* novels page's editor's picks */
.works-item-novel-editor-recommend:has(.cover:not([style^="data"])),
/* feed page */
.stacclist > li.illust,
/* discovery feed */
[class*="works-item-illust__"]:has([class*="style_thumb"]:not([src^="data"]))
`,
SELECTORS_IMAGE_TITLE: /*css*/`
/* desktop */
div > div:not([width]):not([height]) > a[href*="artworks/"],
/* home's recommended works grid */
[data-ga4-label="title_link"],
/* discovery page's grid */
.gtm-illust-recommend-title,
/* following page */
.gtm-followlatestpage-thumbnail-link,
/* rankings page */
a[href*="artworks/"]:has(> div[title]),
/* mobile list view */
.illust-info > a[class*="c-text"]
`,
SELECTORS_IMAGE_ARTIST_AVATAR: /*css*/`
/* desktop */
div > a[href*="users/"]:has(> div > img),
/* home's recommended works grid */
[data-ga4-label="user_icon_link"],
.user .icon
`,
SELECTORS_IMAGE_AUXILIARY_ELEMENTS: /*css*/`
/* illustrations page's popular projects section */
div:has(> a[href*="/bookmark_detail"]),
/* novels page's recommended works section */
.gtm-novel-toppage-recommended-work-series,
/* novel grid's right-side texts */
.pixiv_utils_blocked_image_container
+ div > :is(a, div):not(:has(a[href*="users/"])),
div:has(> div > .pixiv_utils_blocked_image_container)
~ div:not(:has(a[href*="users/"])),
div:has(> div > .pixiv_utils_blocked_image_container)
~ div > div > div:not(:has(a[href*="users/"])),
div:has(> div > div > .pixiv_utils_blocked_image_container)
+ div > div > div:not(:has(a[href*="users/"])),
/* mobile's novel grid's right-side texts */
.detail > :is(.mb-4, .title, .display-tags, .line-clamp, .meta)
`,
SELECTORS_IMAGE_CONTROLS: /*css*/`
div:has(
> [data-ga4-label="bookmark_button"],
> .gtm-illust-recommend-bookmark,
> .gtm-main-bookmark,
> button[type="button"] > svg
),
/* mobile */
.imgoverlay,
.bookmark,
.hSoPoc,
/* mobile's rankings page */
div[class*="style_bookmark__"]
`,
SELECTORS_IMAGE_BOOKMARKED: /*css*/`
/* mobile */
.works-bookmark-button svg path[fill="#FF4060"],
div[class*="style_works-bookmark-button__"] svg path[fill="#FF4060"]
`,
SELECTORS_EXPANDED_VIEW_IMAGE: /*css*/`
/* desktop */
main > section:has(figure) > div:first-of-type,
/* mobile */
.illust-details-view
`,
SELECTORS_EXPANDED_VIEW_CONTROLS: /*css*/`
/* desktop */
main div[style*="translateY"] section,
/* mobile */
.work-interactions
`,
SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: /*css*/`
main > section:has(figure) > :nth-child(2 of div) nav[style] > div > div:has(a[href])
`,
SELECTORS_MULTI_VIEW: /*css*/`
[data-ga4-label="scroll_loader"] [data-ga4-label="work_content"]:has(a[href])
`,
SELECTORS_MULTI_VIEW_CONTROLS: /*css*/`
div:has(> [data-ga4-label="bookmark_button"])
`,
SELECTORS_FOLLOW_BUTTON_CONTAINER: /*css*/`
/* artist page's header */
div:has(> div:first-child > h1):has(> div:nth-child(2) > [data-click-label="follow"]),
/* artist hover popup */
div[open] a[href*="users/"] + div,
/* expanded view's artist bottom bar */
main > section:has(figure) > :nth-child(2 of div),
/* expanded view's artist sidebar */
main:has(section figure) + aside > section:first-of-type,
/* mobile's artist page */
.user-details,
/* mobile's expanded view */
.user-details-card
`,
SELECTORS_FOLLOW_BUTTON: /*css*/`
/* desktop */
[data-click-label="follow"]:not([disabled]),
/* mobile */
.ui-button
`,
SELECTORS_RECOMMENDED_USER_CONTAINER: /*css*/`
/* home's recommended users sidebar */
section > .grid > :nth-child(2) .flex-col.gap-8:first-child .flex-row.items-center:not(.mr-auto),
/* tags page's users tab */
nav:has(a[href*="tags/"]) ~ h2:has(span) + div.grid > li.list-none,
/* recommended users after following */
body > div > div[style*="translateY"] ul li.inline-block,
/* recommended users on home's illustrations tab */
ul > div:has(.gtm-illust-toppage-recommend-user-profile)
`,
SELECTORS_TAG_BUTTON: /*css*/`
:is(ul, div) > div:has(> a[href*="tags/"] div[title]),
/* artist page */
div > div:has(> a[href*="users/"] div[title^="#"]),
/* home page's trending tags */
[data-ga4-label="tags_carousel"] div[style*="gap:"] > div.snap-center
`,
SELECTORS_DATE: /*css*/`
main > section:has(figure) figcaption div > time:first-of-type,
/* "updated on" tooltip */
body > div[id^="react-aria"][role="tooltip"] > div:first-of-type,
/* mobile */
.times,
/* mobile's "updated on" popup */
.reupload-info .tooltip-text
`,
// Keys that starts with "selector", and in array, will automatically be converted to single-line strings.
SECTIONS_TOGGLE_BOOKMARKED: [
// "Newest by followed" page
{
selectorParent: /*css*/`
section:has(> div:nth-child(2) > div:first-child > div:first-child > span)
`,
selectorHeader: /*css*/`
& > div:nth-child(2) > div:first-child:has(> div:first-child > span):has(> div:nth-child(2) span)
`,
selectorImagesContainer: /*css*/`
& > div:nth-child(2) > div:first-child:has(> div:first-child > span):has(> div:nth-child(2) span) + div
`
},
// "Newest by all" page
{
selectorParent: /*css*/`
section:has(> div:nth-child(2) > nav:first-child > a[href*="/new"])
`,
selectorHeader: /*css*/`
& > div:nth-child(2):has(> nav:first-child > a[href*="/new"])
`,
selectorImagesContainer: /*css*/`
& > div:nth-child(2):has(> nav:first-child > a[href*="/new"]) + div
`
},
// Artist page
{
selectorParent: /*css*/`
:is(div, section):has(> div:first-child > div:first-child > div:first-child > h2[font-size][color="text2"])
`,
selectorHeader: /*css*/`
& > div:has(> div:first-child > div:first-child > h2[font-size][color="text2"]):has(> div:first-child > div:first-child span)
`,
selectorImagesContainer: /*css*/`
& > div:has(> div:first-child > div:first-child > h2[font-size][color="text2"]):has(> div:first-child > div:first-child span) ~ div:has(a[href*="artworks/"]:not([href*="users/"]), a[href*="novel/"]:not([href*="users/"]))
`,
sanityCheck: () => {
// Skip if in own profile.
return document.querySelector('a[href*="settings/profile"]');
}
},
// Artist page's requests tab
{
selectorParent: /*css*/`
section:has(> div:first-child > div:first-child > div:first-child > div:first-child > h2:not([font-size], [color]))
`,
selectorHeader: /*css*/`
& > div:has(> div:first-child > div:first-child > div:first-child > h2:not([font-size], [color]))
`,
selectorImagesContainer: /*css*/`
& > div:has(> div:first-child > div:first-child > div:first-child > h2:not([font-size], [color])) + ul
`,
sanityCheck: () => {
// Skip if in own profile.
return document.querySelector('a[href*="settings/profile"]');
}
},
// Tags page
{
selectorParent: /*css*/`
section:has(> div:first-child > div:first-child > div:first-child > h3[font-size][color="text2"])
`,
selectorHeader: /*css*/`
& > div:first-child > div:has(> div:first-child > h3[font-size][color="text2"]):has(> div:first-child span)
`,
selectorImagesContainer: /*css*/`
& > div:nth-child(2):has(a[href*="artworks/"]:not([href*="users/"]), a[href*="novel/"]:not([href*="users/"]))
`
},
// Tags page (new)
{
selectorParent: /*css*/`
div[data-ga4-label="works_content"]
`,
selectorHeader: /*css*/`
& > div:first-child > div:first-child
`,
selectorImagesContainer: /*css*/`
& > div:first-child ~ div
`
},
// Discovery page
{
selectorParent: /*css*/`
div:has(> .gtm-illust-recommend-zone)
`,
selectorHeader: /*css*/`
& > div:first-child > div.grid > div.flex
`,
selectorImagesContainer: /*css*/`
& > .gtm-illust-recommend-zone
`
},
// Rankings page
{
selectorParent: /*css*/`
div[style*="--street-main-ranking-height"] div:has(> nav, > h1, > ol)
`,
selectorHeader: /*css*/`
& > div:has(+ ol)
`,
selectorImagesContainer: /*css*/`
& > ol
`
},
// Mobile artist page's illustrations/bookmarks tab, following page, tags page
{
selectorParent: /*css*/`
.v-nav-tabs + div:not(.header-buttons),
.nav-tab + div,
.search-nav-config + div
`,
selectorHeader: /*css*/`
.pager-view-nav
`,
selectorImagesContainer: /*css*/`
.works-grid-list
`,
sanityCheck: () => {
// Skip if in own profile (intended for bookmarks page).
return document.querySelector('.ui-button[href*="/settings/profile"]');
}
},
// Mobile artist page's home tab
{
selectorParent: /*css*/`
.work-set > div
`,
selectorHeader: /*css*/`
.title-line > div:last-child
`,
selectorImagesContainer: /*css*/`
.works-grid-list
`
},
// Mobile rankings page
{
selectorParent: /*css*/`
div[class*="style_ranking-page__"]
`,
selectorHeader: /*css*/`
& > div[class*="style_header-buttons__"]
`,
selectorImagesContainer: /*css*/`
& > div[class*="style_header-buttons__"] ~ div:not([class]):not([style])
`
}
],
// To ensure any custom values will be inserted into array, or combined together if also an array.
PIXIV_HIGHLIGHTED_TAGS: [],
PIXIV_BLOCKED_TAGS: [],
UTAGS_BLOCKED_TAGS: ['block', 'hide']
};
const ENV_KEYS = Object.keys(ENV_DEFAULTS);
const settingOrDefault = (key, value) => value ?? ENV_DEFAULTS[key];
const getSetting = key => settingOrDefault(key, GM_getValue(key, ENV_DEFAULTS[key]));
const ENV = {};
const existingKeys = new Set(GM_listValues());
for (const key of ENV_KEYS) {
if (!existingKeys.has(key)) GM_setValue(key, ENV_DEFAULTS[key]);
// Fall back to the default for nullish stored values without rewriting storage.
ENV[key] = getSetting(key);
}
// Persisted settings can be corrupted by direct edits in a userscript
// manager. Validate every stored value's shape at startup and replace
// offenders with their defaults in memory (storage is left untouched), so
// one bad setting cannot abort initialization. Every problem is reported
// with the setting's name.
const isPlainStoredObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const isStoredTagRule = entry => typeof entry === 'string' ||
(Array.isArray(entry) && typeof entry[0] === 'string' && (entry.length === 1 || typeof entry[1] === 'string'));
const sanitizeStoredTagRules = value => {
const malformed = [];
const sanitized = [];
value.forEach((entry, index) => {
if (isStoredTagRule(entry)) {
sanitized.push(entry);
} else if (isPlainStoredObject(entry) && Array.isArray(entry.tags) && entry.tags.every(isStoredTagRule)) {
sanitized.push({ tags: entry.tags, ...(typeof entry.color === 'string' ? { color: entry.color } : {}) });
} else {
malformed.push(`item ${index}`);
}
});
return { sanitized, malformed };
};
const isStoredUserId = value => typeof value === 'string' && /^[0-9]+$/.test(value);
for (const key of ENV_KEYS) {
const value = ENV[key];
const fallback = ENV_DEFAULTS[key];
const resetToDefault = reason => {
console.error(`[Pixiv Utils]: Invalid stored value for ${key} (${reason}); using the default instead. Stored value =`, value);
ENV[key] = fallback;
};
if (key.startsWith('SELECTORS_')) {
if (value !== null && typeof value !== 'string' &&
!(Array.isArray(value) && value.every(entry => typeof entry === 'string'))) {
resetToDefault('expected a selector string, an array of strings, or null');
}
} else if (key === 'SECTIONS_TOGGLE_BOOKMARKED') {
if (value !== null && !Array.isArray(value)) resetToDefault('expected an array of sections or null');
} else if (key === 'PIXIV_BLOCKED_TAGS' || key === 'PIXIV_HIGHLIGHTED_TAGS') {
if (typeof value === 'string') {
// Legacy comma-separated literal tags.
ENV[key] = value.split(',').map(entry => entry.trim()).filter(Boolean);
} else if (value !== null) {
if (!Array.isArray(value)) {
resetToDefault('expected an array of rules or null');
} else {
const { sanitized, malformed } = sanitizeStoredTagRules(value);
if (malformed.length) {
console.error(`[Pixiv Utils]: Ignoring malformed rule(s) ${malformed.join(', ')} in ${key}.`);
ENV[key] = sanitized.length ? sanitized : null;
}
}
}
} else if (key === 'PIXIV_BLOCKED_USERS') {
if (value !== null && (!Array.isArray(value) || !value.every(isStoredUserId))) {
resetToDefault('expected an array of user ID strings or null');
}
} else if (key === 'UTAGS_BLOCKED_TAGS') {
if (typeof value === 'string') {
ENV[key] = value.split(',').map(entry => entry.trim()).filter(Boolean);
} else if (value !== null && (!Array.isArray(value) || !value.every(entry => typeof entry === 'string'))) {
resetToDefault('expected an array of tags or null');
}
} else if (key === 'MODE') {
if (value !== 'PROD' && value !== 'DEBUG') resetToDefault('expected "PROD" or "DEBUG"');
} else if (key === 'SETTINGS_LANGUAGE') {
if (!['auto', 'en', 'ja', 'zh', 'zh-CN', 'zh-HK', 'zh-TW', 'zh-Hant'].includes(value)) {
resetToDefault('expected a supported language or "auto"');
}
} else if (key === 'PIXIV_HIGHLIGHTED_COLOR') {
if (typeof value !== 'string') resetToDefault('expected a color string');
} else if (key === 'DATE_CONVERSION_OPTIONS' || key === 'FLOATING_TOOLTIPS_OPTIONS') {
if (value !== null && !isPlainStoredObject(value)) resetToDefault('expected an options object or null');
} else if (key === 'DATE_CONVERSION_LOCALES') {
if (typeof value !== 'string') resetToDefault('expected a locale string');
} else if (key.startsWith('TEXT_')) {
if (value !== null && typeof value !== 'string') resetToDefault('expected a string or null');
} else if (key === 'WAIT_FOR_POLL' || key.startsWith('FLOATING_TOOLTIPS_')) {
if (!Number.isSafeInteger(value) || value < 0) resetToDefault('expected a non-negative integer');
} else if (typeof fallback === 'boolean') {
if (typeof value !== 'boolean') resetToDefault('expected a boolean');
}
}
// UI preferences are read straight from storage at their consumption sites.
const preferenceValidators = {
PREF_TOGGLE_BOOKMARKED_MODE: value => [0, 1, 2].includes(value),
PREF_EDIT_BOOKMARK_SORT_REVERSED: value => typeof value === 'boolean'
};
for (const [key, isValid] of Object.entries(preferenceValidators)) {
if (!existingKeys.has(key)) continue;
const value = GM_getValue(key, null);
if (value === null || isValid(value)) continue;
console.error(`[Pixiv Utils]: Invalid stored value for ${key}; using the default instead. Stored value =`, value);
GM_setValue(key, key === 'PREF_TOGGLE_BOOKMARKED_MODE' ? 0 : false);
}
const _debugMode = ENV.MODE !== 'PROD';
// Settings UI and button text translations.
// zh-CN aliases zh; missing/empty strings fall back to en individually.
const I18N = {
en: {
// App and language.
settingsTitle: 'Pixiv Utils',
'tab.General': 'General',
'tab.Bookmarks': 'Bookmarks',
'tab.Filtering': 'Filtering',
'tab.Users': 'Users',
'tab.Highlights': 'Highlights',
'tab.Tooltips': 'Tooltips',
'tab.UTags': 'UTags',
'tab.Advanced': 'Advanced',
'tab.Backup': 'Backup',
'language.auto': 'Auto (browser language)',
'language.en': 'English',
'language.ja': 'Japanese',
'language.zh-CN': 'Chinese (Simplified)',
'language.zh-HK': 'Chinese (Traditional, Hong Kong)',
'language.zh-TW': 'Chinese (Traditional, Taiwan)',
// General settings.
'label.MODE': 'Debug mode',
'label.WAIT_FOR_POLL': 'Element polling interval (ms)',
'label.SETTINGS_LANGUAGE': 'Settings language',
'label.ENABLE_KEYBINDS': 'Keyboard shortcuts',
'field.debugInfo': 'Show debug messages in the browser console.',
'field.waitForPoll': 'Interval between checks for elements the script is waiting to find. Lower values check more often; higher values reduce polling frequency.',
'field.languageInfo': 'Use your browser language or choose one. Missing translations fall back to English.',
'field.keybindInfo': 'Shift+B: Edit bookmark.\nB: Jump to artwork (Pixiv handles bookmarking).\nLeft/Right Arrow: Navigate supported lists.\nInactive while typing or holding Ctrl/Alt/Meta.',
// Bookmark settings and editor.
'label.ENABLE_EDIT_BOOKMARK': 'Show Edit Bookmark button',
'label.ENABLE_FANCY_EDIT_BOOKMARK': 'Enable fancy Edit Bookmark panel',
'label.ENABLE_TOGGLE_BOOKMARKED': 'Show bookmark filter button',
'label.TEXT_EDIT_BOOKMARK': 'Edit Bookmark button text',
'label.TEXT_EDIT_BOOKMARK_TOOLTIP': 'Edit Bookmark tooltip',
'label.TEXT_TOGGLE_BOOKMARKED': 'Bookmark filter button text (always shown)',
'label.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': 'Bookmark filter tooltip',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': 'When showing all',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': 'When showing bookmarked',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': 'When showing unbookmarked',
'text.TEXT_EDIT_BOOKMARK': '✏️',
'text.TEXT_EDIT_BOOKMARK_TOOLTIP': 'Edit bookmark',
'text.TEXT_TOGGLE_BOOKMARKED': '❤️',
'text.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': 'Cycle bookmarked display (Right-Click to cycle back)',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': 'Show all',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': 'Show bookmarked',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': 'Show not bookmarked',
'label.SECTIONS_TOGGLE_BOOKMARKED': 'Bookmark filter sections',
'bookmark.stateHeading': 'Bookmark filter state labels',
'bookmark.stateInfo': 'One of these appears after the button text, depending on which artworks are shown.',
'field.bookmarkFilterTextInfo': 'This stays visible before the changing state label (for example, “❤️ Show all”).',
'field.fancyPanelInfo': 'Editing bookmarks will not live-update Pixiv\'s native bookmark button.',
'editBookmark.noTags': 'No tags found.',
'editBookmark.loadFailed': 'Failed to load tags.',
'editBookmark.saveFailed': 'Failed to update bookmark.',
'editBookmark.partialSaveFailed': 'The bookmark was only partially updated. Some changes were saved, but others failed: {error}',
'editBookmark.public': 'Public',
'editBookmark.private': 'Private',
'action.refresh': 'Refresh',
'action.createTag': 'Create a tag',
'action.sort': 'Toggle sort order',
'toast.bookmarkUpdated': 'Bookmark updated ✓',
// Rule editor.
'rule.new': 'New rule',
'rule.tagPattern': 'Tag or regex',
'rule.regex': 'Use regex',
'rule.flags': 'Flags',
'rule.patternPlaceholder': 'Enter tag or regex',
'rule.regexInfo': 'Allowed flags: i (ignore case), u (Unicode), v (Unicode sets). Do not combine u and v.',
'rule.add': 'Add tag',
'rule.addGroup': 'Add tag or colored group',
'rule.addInGroup': 'Add tag',
'rule.type': 'Type',
'rule.single': 'Tag or regex',
'rule.colored': 'Colored group',
'rule.groupColor': 'Group color',
'rule.groupTags': 'Group tags',
'rule.collapse': 'Collapse tags',
'rule.expand': 'Expand tags',
'rule.listRequired': 'Expected a list of rules.',
'rule.needsText': '{location}: enter a tag or regex.',
'rule.itemLocation': '{tab} tab, item {item}',
'rule.groupItemLocation': '{tab} tab, group {group}, item {item}',
'rule.groupLocation': 'Group {number}',
'rule.location': 'Rule {number}',
'rule.blockedLocation': 'Blocked rules',
'rule.groupNeedsTags': '{tab} tab, group {number}: add a tag list.',
'rule.invalidRegex': '{location}: {error}',
'rule.combinedRegex': '{location}: combined regex ({flags}): {error}',
'rule.noFlags': 'no flags',
'field.combineRegexInfo': 'Faster matching, but anchors and lookbehinds may behave differently when combined.',
'text.explicitEmpty': 'Use empty text',
'text.fallback': 'Leave blank to use the localized default. Select "Use empty text" to save an empty value.',
// Filtering settings.
'label.PIXIV_BLOCKED_TAGS': 'Blocked tags',
'label.PIXIV_BLOCKED_TAGS_COMBINE_REGEXES': 'Combine regex patterns',
'label.PIXIV_BLOCKED_TAGS_FAST': 'Stop at first block match',
'label.PIXIV_BLOCK_AI': 'Block AI-generated artwork',
'label.PIXIV_REMOVE_BLOCKED': 'Remove blocked artwork (desktop)',
'field.blockAiInfo': 'Only blocks artwork marked as AI-generated by its artist.',
// User blocking.
'label.PIXIV_BLOCKED_USERS': 'Blocked user IDs',
'users.add': 'Add user ID',
'users.id': 'User ID',
'users.invalid': 'Enter digits only for each user ID.',
'users.info': 'Block artwork from these Pixiv users.',
// Highlight settings.
'label.PIXIV_HIGHLIGHTED_TAGS': 'Highlighted tags',
'label.PIXIV_HIGHLIGHTED_COLOR': 'Default highlight color',
'label.PIXIV_HIGHLIGHTED_TAGS_COMBINE_REGEXES': 'Combine regex patterns',
'label.PIXIV_HIGHLIGHTED_TAGS_FAST': 'Stop after first matching colored group',
'label.PIXIV_HIGHLIGHTED_BYPASS_BLOCK': 'Do not block highlighted artwork',
'field.highlightInfo': 'Leave blank for the default (#32cd32). Use "transparent" to hide borders without disabling highlight bypass.',
'field.highlightFastInfo': 'An uncolored match still checks later groups for an explicit color. If none match, the default color is used.',
'field.highlightBypassInfo': 'Does not override UTags blocking.',
// Tooltip settings.
'label.ENABLE_FLOATING_TOOLTIPS': 'Use Floating UI tooltips',
'label.FLOATING_TOOLTIPS_STATIONARY_DELAY': 'Stationary mouse delay',
'label.FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE': 'Mouse movement tolerance',
'label.FLOATING_TOOLTIPS_SHOW_DELAY': 'Keyboard focus delay',
'label.FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY': 'Touch-and-hold delay',
'label.FLOATING_TOOLTIPS_OPTIONS': 'Floating UI options',
'field.delayInfo': 'Delay after the mouse stops (ms).',
'field.toleranceInfo': 'Allowed mouse movement (px).',
'field.floatingShowDelay': 'Delay for keyboard focus (ms).',
'field.floatingTouchHoldDelay': 'Hold duration before showing a tooltip on touchscreens (ms).',
'field.floatingJson': 'Floating UI positioning options (JSON).',
// UTags integration.
'label.UTAGS_INTEGRATION': 'Enable UTags integration',
'label.UTAGS_BLOCKED_TAGS': 'Blocked UTags',
'utags.add': 'Add blocked UTag',
'utags.tag': 'UTag',
'utags.info': "Adds to the built-in 'block' and 'hide' tags.",
// Advanced settings: selectors and date formatting.
'label.SELECTORS_HOME': 'Home page selectors',
'label.SELECTORS_OWN_PROFILE': 'Own profile selectors',
'label.SELECTORS_IMAGE': 'Image card selectors',
'label.SELECTORS_IMAGE_TITLE': 'Image title selectors',
'label.SELECTORS_IMAGE_ARTIST_AVATAR': 'Artist avatar selectors',
'label.SELECTORS_IMAGE_AUXILIARY_ELEMENTS': "Image's auxiliary element selectors",
'label.SELECTORS_IMAGE_CONTROLS': 'Image control selectors',
'label.SELECTORS_IMAGE_BOOKMARKED': 'Bookmark indicator selectors',
'label.SELECTORS_EXPANDED_VIEW_IMAGE': 'Expanded view selectors',
'label.SELECTORS_EXPANDED_VIEW_CONTROLS': 'Expanded view control selectors',
'label.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE': "Expanded view's artist bottom bar selectors",
'label.SELECTORS_MULTI_VIEW': 'Multi-view selectors',
'label.SELECTORS_MULTI_VIEW_CONTROLS': 'Multi-view control selectors',
'label.SELECTORS_FOLLOW_BUTTON_CONTAINER': 'Follow button container selectors',
'label.SELECTORS_FOLLOW_BUTTON': 'Follow button selectors',
'label.SELECTORS_RECOMMENDED_USER_CONTAINER': 'Recommended user container selectors',
'label.SELECTORS_TAG_BUTTON': 'Tag button selectors',
'label.SELECTORS_DATE': 'Date element selectors',
'label.DATE_CONVERSION': 'Convert dates',
'label.DATE_CONVERSION_LOCALES': 'Date locale',
'label.DATE_CONVERSION_OPTIONS': 'Date format',
'label.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME': 'Hide novel recommendations on Home',
'label.REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME': 'Hide collections recommendations on Home',
'selector.heading': 'Custom CSS selectors',
'field.selectorInfo': 'Extra CSS selectors are added to the built-in ones, not used in place of them. Enter one CSS selector per line.',
'field.dateInfo': 'Customize date display with Intl.DateTimeFormat options.',
'sections.add': 'Add section',
'sections.parent': 'Parent selectors',
'sections.header': 'Header selectors',
'sections.images': 'Images container selectors',
'sections.info': 'Adds to the built-in sections. Enter one selector per line; built-in checks stay in place.',
'date.unspecified': 'Default',
'date.systemTimezone': 'System time zone',
'date.example': 'Example: {value}',
'date.invalid': 'Invalid combination: {error}',
'date.expectedObject': 'Expected a date formatting options object.',
'date.weekday': 'Weekday',
'date.era': 'Era',
'date.year': 'Year',
'date.month': 'Month',
'date.day': 'Day',
'date.dayPeriod': 'Day period',
'date.hour': 'Hour',
'date.minute': 'Minute',
'date.second': 'Second',
'date.fractionalSecondDigits': 'Fractional seconds',
'date.hour12': '12-hour clock',
'date.hourCycle': 'Hour cycle',
'date.timeZoneName': 'Time zone name',
'date.dateStyle': 'Date style',
'date.timeStyle': 'Time style',
'date.localeMatcher': 'Locale matcher',
'date.formatMatcher': 'Format matcher',
'date.timeZone': 'Time zone',
'date.calendar': 'Calendar',
'date.numberingSystem': 'Numbering system',
'list.position': 'Move to position (1-{max})',
'list.drag': 'Drag to reorder',
// Backup and import/export.
'label.BACKUP_TOOLS': 'Import / Export settings',
'backup.intro': 'Export all saved settings, including preferences. Import only updates the keys provided.',
'backup.placeholder': 'Paste settings JSON or choose a file...',
'backup.aria': 'Settings JSON',
'backup.export': 'Export',
'backup.copy': 'Copy',
'backup.download': 'Download',
'backup.choose': 'Choose file',
'backup.import': 'Import',
'alert.title': 'Notice',
'alert.ok': 'OK',
'backup.emptyCopy': 'Export settings first.',
'backup.copied': 'Text copied to clipboard.',
'backup.clipboardFailed': "Couldn't copy automatically. The text is selected for manual copying.",
'backup.emptyDownload': 'Export settings first.',
'backup.fileError': 'Could not read file: {error}',
'backup.emptyImport': 'Paste settings JSON or choose a file first.',
'backup.importConfirm': 'Import {count} setting(s)?\n\nProvided keys will overwrite saved values. Other keys will stay unchanged. Unsaved edits in Settings will be lost.',
'backup.imported': 'Settings imported. Reload the page to apply changes.',
'backup.importError': 'Could not import settings: {error}',
'backup.objectError': 'Expected a JSON object.',
'backup.emptyObject': 'The imported object is empty.',
// Shared actions and feedback.
'action.settings': 'Settings',
'action.clearCaches': 'Clear caches',
'action.reload': 'Reload page',
'action.save': 'Save',
'action.saved': 'Saved ✓',
'action.cancel': 'Cancel',
'action.reset': 'Reset',
'action.importExport': 'Import / Export',
'action.close': 'Close',
'message.save': 'Settings saved. Reload the page to apply changes.',
'message.reset': 'Settings reset. Reload the page to apply changes.',
'message.resetConfirm': 'Reset all settings to their defaults?',
'message.openError': 'Failed to open settings: {error}',
'message.validation': '{key}: {error}',
'message.discardChanges': 'Discard unsaved changes?',
'toast.cachesCleared': 'Caches cleared ✓',
'validation.object': 'Expected a JSON object.',
'validation.arrayNull': 'Expected an array or null.',
'validation.arrayObjects': 'Expected an array of objects.',
'validation.string': 'Expected a string.',
'validation.positiveInteger': 'Enter a positive whole number.',
// Runtime tooltip text.
'tooltip.highlightedBy': 'Highlighted by:',
'tooltip.blockedBy': 'Blocked by:',
'tooltip.blockableBy': 'Blockable by:',
'tooltip.skippedDueTo': 'Skipped due to:',
'tooltip.tags': 'Tags: {tags}',
'tooltip.userId': 'User ID: {id}',
'tooltip.aiGenerated': 'AI-generated',
'tooltip.inOwnProfile': 'In own profile',
'tooltip.imageBookmarked': 'Image is bookmarked',
'tooltip.imageHighlighted': 'Image is highlighted',
'tooltip.utag': 'UTag: {tag}',
},
ja: {
// App and language.
settingsTitle: 'Pixiv Utils',
'tab.General': '一般',
'tab.Bookmarks': 'ブックマーク',
'tab.Filtering': 'フィルタリング',
'tab.Users': 'ユーザー',
'tab.Highlights': 'ハイライト',
'tab.Tooltips': 'ツールチップ',
'tab.UTags': 'UTags',
'tab.Advanced': '詳細設定',
'tab.Backup': 'バックアップ',
'language.auto': '自動(ブラウザーの言語)',
'language.en': '英語',
'language.ja': '日本語',
'language.zh-CN': '中国語(簡体字)',
'language.zh-HK': '中国語(繁体字・香港)',
'language.zh-TW': '中国語(繁体字・台湾)',
// General settings.
'label.MODE': 'デバッグモード',
'label.WAIT_FOR_POLL': '要素のポーリング間隔(ミリ秒)',
'label.SETTINGS_LANGUAGE': '設定画面の言語',
'label.ENABLE_KEYBINDS': 'キーボードショートカット',
'field.debugInfo': 'ブラウザーのコンソールにデバッグメッセージを表示します。',
'field.waitForPoll': 'スクリプトが検出を待っている要素を確認する間隔です。値を小さくすると確認頻度が上がり、大きくするとポーリング頻度が下がります。',
'field.languageInfo': 'ブラウザーの言語を使用するか、言語を選択してください。翻訳がない項目は英語で表示されます。',
'field.keybindInfo': 'Shift+B:ブックマークを編集。\nB:作品ページに移動(ブックマーク操作は pixiv 側で処理)。\n左右矢印キー:対応する一覧内を移動。\n文字入力中、または Ctrl/Alt/Meta キーを押している間は無効です。',
// Bookmark settings and editor.
'label.ENABLE_EDIT_BOOKMARK': '「ブックマークを編集」ボタンを表示',
'label.ENABLE_FANCY_EDIT_BOOKMARK': '「ブックマーク編集」パネルを有効にする',
'label.ENABLE_TOGGLE_BOOKMARKED': 'ブックマーク表示フィルターボタンを表示',
'label.TEXT_EDIT_BOOKMARK': '「ブックマークを編集」ボタンのテキスト',
'label.TEXT_EDIT_BOOKMARK_TOOLTIP': '「ブックマークを編集」のツールチップ',
'label.TEXT_TOGGLE_BOOKMARKED': '表示フィルターボタンのテキスト(常に表示)',
'label.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': '表示フィルターボタンのツールチップ',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': 'すべて表示する場合',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': 'ブックマーク済みのみ表示する場合',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '未ブックマークのみ表示する場合',
'text.TEXT_EDIT_BOOKMARK': '✏️',
'text.TEXT_EDIT_BOOKMARK_TOOLTIP': 'ブックマークを編集',
'text.TEXT_TOGGLE_BOOKMARKED': '❤️',
'text.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': 'ブックマークの表示を切り替え(右クリックで逆順に切り替え)',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': 'すべて表示',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': 'ブックマーク済みのみ表示',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '未ブックマークのみ表示',
'label.SECTIONS_TOGGLE_BOOKMARKED': 'ブックマーク表示フィルターの対象セクション',
'bookmark.stateHeading': 'ブックマーク表示フィルターの状態ラベル',
'bookmark.stateInfo': '表示対象の作品に応じて、これらのいずれかがボタンのテキストの後ろに表示されます。',
'field.bookmarkFilterTextInfo': '切り替わる状態ラベルの前に常に表示されます(例:「❤️ すべて表示」)。',
'field.fancyPanelInfo': 'このパネルでブックマークを編集しても、pixiv標準のブックマークボタンにはリアルタイムで反映されません。',
'editBookmark.noTags': 'タグが見つかりません。',
'editBookmark.loadFailed': 'タグの読み込みに失敗しました。',
'editBookmark.saveFailed': 'ブックマークの更新に失敗しました。',
'editBookmark.partialSaveFailed': 'ブックマークは一部のみ更新されました。一部の変更は保存されましたが、残りの変更は失敗しました:{error}',
'editBookmark.public': '公開',
'editBookmark.private': '非公開',
'action.refresh': '再読み込み',
'action.createTag': 'タグを作成',
'action.sort': '並び順を切り替え',
'toast.bookmarkUpdated': 'ブックマークを更新しました ✓',
// Rule editor.
'rule.new': '新しいルール',
'rule.tagPattern': 'タグまたは正規表現',
'rule.regex': '正規表現を使用',
'rule.flags': 'フラグ',
'rule.patternPlaceholder': 'タグまたは正規表現を入力',
'rule.regexInfo': '使用可能なフラグ:i(大文字・小文字を無視)、u(Unicode)、v(Unicode 集合)。u と v は併用不可。',
'rule.add': 'タグを追加',
'rule.addGroup': 'タグまたは色付きグループを追加',
'rule.addInGroup': 'タグを追加',
'rule.type': '種類',
'rule.single': 'タグまたは正規表現',
'rule.colored': '色付きグループ',
'rule.groupColor': 'グループの色',
'rule.groupTags': 'グループ内のタグ',
'rule.collapse': 'タグを折りたたむ',
'rule.expand': 'タグを展開',
'rule.listRequired': 'ルールのリストを指定してください。',
'rule.needsText': '{location}:タグまたは正規表現を入力してください。',
'rule.itemLocation': '{tab}タブの項目 {item}',
'rule.groupItemLocation': '{tab}タブ、グループ {group} の項目 {item}',
'rule.groupLocation': 'グループ {number}',
'rule.location': 'ルール {number}',
'rule.blockedLocation': 'ブロックルール',
'rule.groupNeedsTags': '{tab}タブ、グループ {number}:タグのリストを追加してください。',
'rule.invalidRegex': '{location}:{error}',
'rule.combinedRegex': '{location}:結合した正規表現({flags}):{error}',
'rule.noFlags': 'フラグなし',
'field.combineRegexInfo': 'マッチングが高速になりますが、正規表現を結合するとアンカーや後読みの動作が変わる場合があります。',
'text.explicitEmpty': '空の文字列を使用',
'text.fallback': '空欄の場合は言語に応じた既定のテキストを使用します。空の文字列を保存するには「空の文字列を使用」を選択してください。',
// Filtering settings.
'label.PIXIV_BLOCKED_TAGS': 'ブロックするタグ',
'label.PIXIV_BLOCKED_TAGS_COMBINE_REGEXES': '正規表現のパターンを結合',
'label.PIXIV_BLOCKED_TAGS_FAST': '最初に一致したブロックタグで判定を終了',
'label.PIXIV_BLOCK_AI': 'AI 生成作品をブロック',
'label.PIXIV_REMOVE_BLOCKED': 'ブロックした作品を削除(PC 版)',
'field.blockAiInfo': '作者が AI 生成と設定した作品のみブロックします。',
// User blocking.
'label.PIXIV_BLOCKED_USERS': 'ブロックするユーザー ID',
'users.add': 'ユーザー ID を追加',
'users.id': 'ユーザー ID',
'users.invalid': 'ユーザー ID には数字のみを入力してください。',
'users.info': '指定した pixiv ユーザーの作品をブロックします。',
// Highlight settings.
'label.PIXIV_HIGHLIGHTED_TAGS': 'ハイライトするタグ',
'label.PIXIV_HIGHLIGHTED_COLOR': '既定のハイライト色',
'label.PIXIV_HIGHLIGHTED_TAGS_COMBINE_REGEXES': '正規表現のパターンを結合',
'label.PIXIV_HIGHLIGHTED_TAGS_FAST': '最初に一致した色付きグループで検索を終了',
'label.PIXIV_HIGHLIGHTED_BYPASS_BLOCK': 'ハイライトした作品はブロックしない',
'field.highlightInfo': '空欄の場合は既定色(#32cd32)を使用します。「transparent」を指定すると、ブロック回避機能を維持したまま枠線を非表示にできます。',
'field.highlightFastInfo': '色なしのグループが一致しても、後続の色付きグループを探します。見つからなければ既定色を使用します。',
'field.highlightBypassInfo': 'UTags によるブロックは回避できません。',
// Tooltip settings.
'label.ENABLE_FLOATING_TOOLTIPS': 'Floating UI ツールチップを使用',
'label.FLOATING_TOOLTIPS_STATIONARY_DELAY': 'マウス停止後の待ち時間',
'label.FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE': 'マウス移動の許容範囲',
'label.FLOATING_TOOLTIPS_SHOW_DELAY': 'キーボードフォーカス時の待ち時間',
'label.FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY': 'タッチの長押し時間',
'label.FLOATING_TOOLTIPS_OPTIONS': 'Floating UI のオプション',
'field.delayInfo': 'マウスカーソルが停止してから表示するまでの時間(ミリ秒)。',
'field.toleranceInfo': '許容するマウスカーソルの移動距離(ピクセル)。',
'field.floatingShowDelay': 'キーボードフォーカス時の表示待ち時間(ミリ秒)。',
'field.floatingTouchHoldDelay': 'タッチ操作でツールチップを表示するまでの長押し時間(ミリ秒)。',
'field.floatingJson': 'Floating UI の配置オプション(JSON)。',
// UTags integration.
'label.UTAGS_INTEGRATION': 'UTags 連携を有効にする',
'label.UTAGS_BLOCKED_TAGS': 'ブロック対象の UTag',
'utags.add': 'ブロックする UTag を追加',
'utags.tag': 'UTag',
'utags.info': '組み込みの「block」と「hide」タグに追加されます。',
// Advanced settings: selectors and date formatting.
'label.SELECTORS_HOME': 'ホームページのセレクター',
'label.SELECTORS_OWN_PROFILE': '自分のプロフィールのセレクター',
'label.SELECTORS_IMAGE': '作品カードのセレクター',
'label.SELECTORS_IMAGE_TITLE': '作品タイトルのセレクター',
'label.SELECTORS_IMAGE_ARTIST_AVATAR': '作者アイコンのセレクター',
'label.SELECTORS_IMAGE_AUXILIARY_ELEMENTS': '作品の補助要素のセレクター',
'label.SELECTORS_IMAGE_CONTROLS': '作品操作ボタンのセレクター',
'label.SELECTORS_IMAGE_BOOKMARKED': 'ブックマーク済み表示のセレクター',
'label.SELECTORS_EXPANDED_VIEW_IMAGE': '作品詳細表示のセレクター',
'label.SELECTORS_EXPANDED_VIEW_CONTROLS': '作品詳細表示の操作ボタンのセレクター',
'label.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE': '作品詳細表示の作者情報(下部)のセレクター',
'label.SELECTORS_MULTI_VIEW': '複数作品表示のセレクター',
'label.SELECTORS_MULTI_VIEW_CONTROLS': '複数作品表示の操作ボタンのセレクター',
'label.SELECTORS_FOLLOW_BUTTON_CONTAINER': 'フォローボタンのコンテナーのセレクター',
'label.SELECTORS_FOLLOW_BUTTON': 'フォローボタンのセレクター',
'label.SELECTORS_RECOMMENDED_USER_CONTAINER': 'おすすめユーザーのコンテナーのセレクター',
'label.SELECTORS_TAG_BUTTON': 'タグボタンのセレクター',
'label.SELECTORS_DATE': '日付要素のセレクター',
'label.DATE_CONVERSION': '日付を変換',
'label.DATE_CONVERSION_LOCALES': '日付のロケール',
'label.DATE_CONVERSION_OPTIONS': '日付の表示形式',
'label.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME': 'ホームのおすすめ小説を非表示',
'label.REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME': 'ホームに表示されるおすすめコレクションを非表示にする',
'selector.heading': 'カスタム CSS セレクター',
'field.selectorInfo': '追加した CSS セレクターは組み込みのセレクターに加えられ、置き換えは行われません。1行に1つ入力してください。',
'field.dateInfo': 'Intl.DateTimeFormat のオプションで日付の表示形式をカスタマイズします。',
'sections.add': 'セクションを追加',
'sections.parent': '親要素のセレクター',
'sections.header': 'ヘッダーのセレクター',
'sections.images': '作品一覧コンテナーのセレクター',
'sections.info': '組み込みのセクションに追加されます。CSS セレクターを1行に1つ入力してください。組み込みの判定条件はそのまま適用されます。',
'date.unspecified': '既定値',
'date.systemTimezone': 'システムのタイムゾーン',
'date.example': '表示例:{value}',
'date.invalid': '無効な組み合わせ:{error}',
'date.expectedObject': '日付の書式オプションを含むオブジェクトを指定してください。',
'date.weekday': '曜日',
'date.era': '紀元',
'date.year': '年',
'date.month': '月',
'date.day': '日',
'date.dayPeriod': '午前/午後などの時間帯',
'date.hour': '時',
'date.minute': '分',
'date.second': '秒',
'date.fractionalSecondDigits': '秒の小数部の桁数',
'date.hour12': '12 時間表示',
'date.hourCycle': '時刻の表記方式',
'date.timeZoneName': 'タイムゾーン名',
'date.dateStyle': '日付の形式',
'date.timeStyle': '時刻の形式',
'date.localeMatcher': 'ロケールの照合方式',
'date.formatMatcher': '書式の照合方式',
'date.timeZone': 'タイムゾーン',
'date.calendar': '暦',
'date.numberingSystem': '数字の表記方式',
'list.position': '移動先の位置(1~{max})',
'list.drag': 'ドラッグして並べ替え',
// Backup and import/export.
'label.BACKUP_TOOLS': '設定のインポート/エクスポート',
'backup.intro': '保存済みの設定(表示状態などの環境設定を含む)をすべてエクスポートします。インポート時は指定されたキーのみ更新されます。',
'backup.placeholder': '設定の JSON を貼り付けるか、ファイルを選択してください...',
'backup.aria': '設定の JSON',
'backup.export': 'エクスポート',
'backup.copy': 'コピー',
'backup.download': 'ダウンロード',
'backup.choose': 'ファイルを選択',
'backup.import': 'インポート',
'alert.title': 'お知らせ',
'alert.ok': 'OK',
'backup.emptyCopy': '先に設定をエクスポートしてください。',
'backup.copied': 'テキストをクリップボードにコピーしました。',
'backup.clipboardFailed': '自動的にコピーできませんでした。手動でコピーできるようテキストを選択しました。',
'backup.emptyDownload': '先に設定をエクスポートしてください。',
'backup.fileError': 'ファイルを読み込めませんでした:{error}',
'backup.emptyImport': '設定の JSON を貼り付けるか、ファイルを選択してください。',
'backup.importConfirm': '{count} 件の設定をインポートしますか?\n\n指定されたキーは保存済みの値を上書きします。その他のキーは変更されません。設定画面の未保存の変更は失われます。',
'backup.imported': '設定をインポートしました。反映するにはページを再読み込みしてください。',
'backup.importError': '設定をインポートできませんでした:{error}',
'backup.objectError': 'JSON オブジェクトを指定してください。',
'backup.emptyObject': 'インポートするオブジェクトが空です。',
// Shared actions and feedback.
'action.settings': '設定',
'action.clearCaches': 'キャッシュを消去',
'action.reload': 'ページを再読み込み',
'action.save': '保存',
'action.saved': '保存しました ✓',
'action.cancel': 'キャンセル',
'action.reset': 'リセット',
'action.importExport': 'インポート/エクスポート',
'action.close': '閉じる',
'message.save': '設定を保存しました。反映するにはページを再読み込みしてください。',
'message.reset': '設定をリセットしました。反映するにはページを再読み込みしてください。',
'message.resetConfirm': 'すべての設定を既定値に戻しますか?',
'message.openError': '設定画面を開けませんでした:{error}',
'message.validation': '{key}:{error}',
'message.discardChanges': '未保存の変更を破棄しますか?',
'toast.cachesCleared': 'キャッシュを消去しました ✓',
'validation.object': 'JSON オブジェクトを指定してください。',
'validation.arrayNull': '配列または null を指定してください。',
'validation.arrayObjects': 'オブジェクトの配列を指定してください。',
'validation.string': '文字列を指定してください。',
'validation.positiveInteger': '1以上の整数を入力してください。',
// Runtime tooltip text.
'tooltip.highlightedBy': 'ハイライト条件:',
'tooltip.blockedBy': 'ブロック理由:',
'tooltip.blockableBy': 'ブロック対象の理由:',
'tooltip.skippedDueTo': 'ブロックをスキップした理由:',
'tooltip.tags': 'タグ: {tags}',
'tooltip.userId': 'ユーザー ID: {id}',
'tooltip.aiGenerated': 'AI生成作品',
'tooltip.inOwnProfile': '自分のプロフィール内',
'tooltip.imageBookmarked': '作品はブックマーク済み',
'tooltip.imageHighlighted': '作品はハイライト済み',
'tooltip.utag': 'UTag: {tag}',
},
zh: {
// App and language.
settingsTitle: 'Pixiv Utils',
'tab.General': '常规',
'tab.Bookmarks': '收藏',
'tab.Filtering': '内容过滤',
'tab.Users': '用户',
'tab.Highlights': '高亮',
'tab.Tooltips': '工具提示',
'tab.UTags': 'UTags',
'tab.Advanced': '高级设置',
'tab.Backup': '备份',
'language.auto': '自动(浏览器语言)',
'language.en': '英语',
'language.ja': '日语',
'language.zh-CN': '简体中文(中国大陆)',
'language.zh-HK': '繁体中文(香港)',
'language.zh-TW': '繁体中文(台湾)',
// General settings.
'label.MODE': '调试模式',
'label.WAIT_FOR_POLL': '等待目标元素的轮询间隔(毫秒)',
'label.SETTINGS_LANGUAGE': '设置界面语言',
'label.ENABLE_KEYBINDS': '键盘快捷键',
'field.debugInfo': '在浏览器控制台中显示调试信息。',
'field.waitForPoll': '此设置控制脚本检查待查找元素的间隔。数值越小,检查越频繁;数值越大,轮询频率越低。',
'field.languageInfo': '使用浏览器语言或手动选择语言。缺少翻译的内容将回退为英语。',
'field.keybindInfo': 'Shift+B:编辑收藏。\nB:跳转至作品页面(由 pixiv 处理收藏操作)。\n左右方向键:在支持的列表中导航。\n输入文字或按住 Ctrl/Alt/Meta 时停用。',
// Bookmark settings and editor.
'label.ENABLE_EDIT_BOOKMARK': '显示“编辑收藏”按钮',
'label.ENABLE_FANCY_EDIT_BOOKMARK': '启用增强版“编辑收藏”面板',
'label.ENABLE_TOGGLE_BOOKMARKED': '显示收藏筛选按钮',
'label.TEXT_EDIT_BOOKMARK': '“编辑收藏”按钮文本',
'label.TEXT_EDIT_BOOKMARK_TOOLTIP': '“编辑收藏”按钮提示',
'label.TEXT_TOGGLE_BOOKMARKED': '收藏筛选按钮文本(始终显示)',
'label.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': '收藏筛选按钮提示',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': '显示全部时',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': '仅显示已收藏时',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '仅显示未收藏时',
'text.TEXT_EDIT_BOOKMARK': '✏️',
'text.TEXT_EDIT_BOOKMARK_TOOLTIP': '编辑收藏',
'text.TEXT_TOGGLE_BOOKMARKED': '❤️',
'text.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': '切换收藏筛选模式(右键单击可反向切换)',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': '显示全部',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': '仅显示已收藏',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '仅显示未收藏',
'label.SECTIONS_TOGGLE_BOOKMARKED': '收藏筛选适用的区域',
'bookmark.stateHeading': '收藏筛选状态标签',
'bookmark.stateInfo': '根据当前显示的作品范围,下列其中一个状态标签将显示在按钮文本之后。',
'field.bookmarkFilterTextInfo': '此文本始终显示在状态标签之前(例如“❤️ 显示全部”)。',
'field.fancyPanelInfo': '在此编辑收藏后,Pixiv 自带的收藏按钮不会实时更新。',
'editBookmark.noTags': '未找到标签。',
'editBookmark.loadFailed': '加载标签失败。',
'editBookmark.saveFailed': '更新收藏失败。',
'editBookmark.partialSaveFailed': '收藏仅部分更新。部分更改已保存,但其他更改失败:{error}',
'editBookmark.public': '公开',
'editBookmark.private': '非公开',
'action.refresh': '刷新',
'action.createTag': '创建标签',
'action.sort': '切换排序顺序',
'toast.bookmarkUpdated': '收藏已更新 ✓',
// Rule editor.
'rule.new': '新规则',
'rule.tagPattern': '标签或正则表达式',
'rule.regex': '使用正则表达式',
'rule.flags': '标志',
'rule.patternPlaceholder': '输入标签或正则表达式',
'rule.regexInfo': '可用标志:i(忽略大小写)、u(Unicode)、v(Unicode 集合)。u 和 v 不可同时使用。',
'rule.add': '添加标签',
'rule.addGroup': '添加标签或颜色分组',
'rule.addInGroup': '添加标签',
'rule.type': '类型',
'rule.single': '标签或正则表达式',
'rule.colored': '颜色分组',
'rule.groupColor': '分组颜色',
'rule.groupTags': '分组内的标签',
'rule.collapse': '收起标签',
'rule.expand': '展开标签',
'rule.listRequired': '请提供规则列表。',
'rule.needsText': '{location}:请输入标签或正则表达式。',
'rule.itemLocation': '{tab}选项卡,第 {item} 项',
'rule.groupItemLocation': '{tab}选项卡,分组 {group},第 {item} 项',
'rule.groupLocation': '分组 {number}',
'rule.location': '规则 {number}',
'rule.blockedLocation': '屏蔽规则',
'rule.groupNeedsTags': '{tab}选项卡,分组 {number}:请添加标签列表。',
'rule.invalidRegex': '{location}:{error}',
'rule.combinedRegex': '{location}:合并后的正则表达式({flags}):{error}',
'rule.noFlags': '无标志',
'field.combineRegexInfo': '可提高匹配速度,但合并正则表达式后,锚点和后行断言的行为可能有所不同。',
'text.explicitEmpty': '使用空文本',
'text.fallback': '留空则使用当前语言的默认文本。勾选“使用空文本”可保存为空字符串。',
// Filtering settings.
'label.PIXIV_BLOCKED_TAGS': '屏蔽的标签',
'label.PIXIV_BLOCKED_TAGS_COMBINE_REGEXES': '合并正则表达式模式',
'label.PIXIV_BLOCKED_TAGS_FAST': '匹配到首个屏蔽标签后停止',
'label.PIXIV_BLOCK_AI': '屏蔽 AI 生成作品',
'label.PIXIV_REMOVE_BLOCKED': '直接移除被屏蔽的作品(桌面版)',
'field.blockAiInfo': '仅屏蔽作者标记为 AI 生成的作品。',
// User blocking.
'label.PIXIV_BLOCKED_USERS': '要屏蔽的用户 ID',
'users.add': '添加用户 ID',
'users.id': '用户 ID',
'users.invalid': '每个用户 ID 只能包含数字。',
'users.info': '屏蔽这些 pixiv 用户发布的作品。',
// Highlight settings.
'label.PIXIV_HIGHLIGHTED_TAGS': '高亮的标签',
'label.PIXIV_HIGHLIGHTED_COLOR': '默认高亮颜色',
'label.PIXIV_HIGHLIGHTED_TAGS_COMBINE_REGEXES': '合并正则表达式模式',
'label.PIXIV_HIGHLIGHTED_TAGS_FAST': '匹配到首个有颜色的分组后停止',
'label.PIXIV_HIGHLIGHTED_BYPASS_BLOCK': '不屏蔽已高亮的作品',
'field.highlightInfo': '留空时使用默认颜色(#32cd32)。设为“transparent”可隐藏边框,同时保留高亮作品绕过屏蔽的功能。',
'field.highlightFastInfo': '匹配到未设置颜色的分组后,仍会查找后续有颜色的分组;若无匹配,则使用默认颜色。',
'field.highlightBypassInfo': '无法绕过 UTags 的屏蔽。',
// Tooltip settings.
'label.ENABLE_FLOATING_TOOLTIPS': '使用 Floating UI 工具提示',
'label.FLOATING_TOOLTIPS_STATIONARY_DELAY': '鼠标停止后的延迟',
'label.FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE': '鼠标移动容差',
'label.FLOATING_TOOLTIPS_SHOW_DELAY': '键盘聚焦延迟',
'label.FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY': '触摸长按延迟',
'label.FLOATING_TOOLTIPS_OPTIONS': 'Floating UI 选项',
'field.delayInfo': '鼠标停止移动后等待的时间(毫秒)。',
'field.toleranceInfo': '允许的鼠标移动距离(像素)。',
'field.floatingShowDelay': '键盘聚焦时的显示延迟(毫秒)。',
'field.floatingTouchHoldDelay': '触摸时长按多久才显示工具提示(毫秒)。',
'field.floatingJson': 'Floating UI 定位选项(JSON)。',
// UTags integration.
'label.UTAGS_INTEGRATION': '启用 UTags 集成',
'label.UTAGS_BLOCKED_TAGS': '要屏蔽的 UTag',
'utags.add': '添加要屏蔽的 UTag',
'utags.tag': 'UTag',
'utags.info': '将在内置的“block”和“hide”标签之外额外添加。',
// Advanced settings: selectors and date formatting.
'label.SELECTORS_HOME': '首页选择器',
'label.SELECTORS_OWN_PROFILE': '个人主页选择器',
'label.SELECTORS_IMAGE': '作品卡片选择器',
'label.SELECTORS_IMAGE_TITLE': '作品标题选择器',
'label.SELECTORS_IMAGE_ARTIST_AVATAR': '作者头像选择器',
'label.SELECTORS_IMAGE_AUXILIARY_ELEMENTS': '作品辅助元素选择器',
'label.SELECTORS_IMAGE_CONTROLS': '作品操作按钮选择器',
'label.SELECTORS_IMAGE_BOOKMARKED': '已收藏状态指示器选择器',
'label.SELECTORS_EXPANDED_VIEW_IMAGE': '作品详情视图选择器',
'label.SELECTORS_EXPANDED_VIEW_CONTROLS': '作品详情视图操作按钮选择器',
'label.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE': '作品详情视图底部作者区域选择器',
'label.SELECTORS_MULTI_VIEW': '多作品视图选择器',
'label.SELECTORS_MULTI_VIEW_CONTROLS': '多作品视图操作按钮选择器',
'label.SELECTORS_FOLLOW_BUTTON_CONTAINER': '关注按钮容器选择器',
'label.SELECTORS_FOLLOW_BUTTON': '关注按钮选择器',
'label.SELECTORS_RECOMMENDED_USER_CONTAINER': '推荐用户容器选择器',
'label.SELECTORS_TAG_BUTTON': '标签按钮选择器',
'label.SELECTORS_DATE': '日期元素选择器',
'label.DATE_CONVERSION': '转换日期格式',
'label.DATE_CONVERSION_LOCALES': '日期语言区域',
'label.DATE_CONVERSION_OPTIONS': '日期格式',
'label.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME': '隐藏首页的小说推荐',
'label.REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME': '隐藏首页的珍藏册推荐',
'selector.heading': '自定义 CSS 选择器',
'field.selectorInfo': '额外的 CSS 选择器会附加到内置选择器,不会替换它们。每行输入一个 CSS 选择器。',
'field.dateInfo': '使用 Intl.DateTimeFormat 选项自定义日期显示格式。',
'sections.add': '添加区域',
'sections.parent': '父元素选择器',
'sections.header': '标题区域选择器',
'sections.images': '作品列表容器选择器',
'sections.info': '新增的区域会与内置区域一起使用。每行输入一个 CSS 选择器;内置的检查条件仍会生效。',
'date.unspecified': '默认值',
'date.systemTimezone': '系统时区',
'date.example': '示例:{value}',
'date.invalid': '无效的选项组合:{error}',
'date.expectedObject': '请输入包含日期格式选项的对象。',
'date.weekday': '星期',
'date.era': '纪元',
'date.year': '年',
'date.month': '月',
'date.day': '日',
'date.dayPeriod': '时段(上午/下午等)',
'date.hour': '小时',
'date.minute': '分钟',
'date.second': '秒',
'date.fractionalSecondDigits': '秒的小数位数',
'date.hour12': '12 小时制',
'date.hourCycle': '小时周期',
'date.timeZoneName': '时区名称',
'date.dateStyle': '日期样式',
'date.timeStyle': '时间样式',
'date.localeMatcher': '语言区域匹配方式',
'date.formatMatcher': '格式匹配方式',
'date.timeZone': '时区',
'date.calendar': '历法',
'date.numberingSystem': '数字系统',
'list.position': '移动到指定位置(1–{max})',
'list.drag': '拖动以重新排序',
// Backup and import/export.
'label.BACKUP_TOOLS': '导入/导出设置',
'backup.intro': '导出所有已保存的设置,包括显示状态等偏好设置。导入时只更新提供的键。',
'backup.placeholder': '粘贴设置 JSON,或选择文件...',
'backup.aria': '设置 JSON',
'backup.export': '导出',
'backup.copy': '复制',
'backup.download': '下载',
'backup.choose': '选择文件',
'backup.import': '导入',
'alert.title': '提示',
'alert.ok': '确定',
'backup.emptyCopy': '请先导出设置。',
'backup.copied': '文本已复制到剪贴板。',
'backup.clipboardFailed': '无法自动复制。已选中文本,可手动复制。',
'backup.emptyDownload': '请先导出设置。',
'backup.fileError': '无法读取文件:{error}',
'backup.emptyImport': '请先粘贴设置 JSON 或选择文件。',
'backup.importConfirm': '要导入 {count} 项设置吗?\n\n提供的键将覆盖已保存的值,其他键保持不变。设置界面中尚未保存的更改将丢失。',
'backup.imported': '设置已导入。请重新加载页面以应用更改。',
'backup.importError': '无法导入设置:{error}',
'backup.objectError': '应提供 JSON 对象。',
'backup.emptyObject': '导入的对象为空。',
// Shared actions and feedback.
'action.settings': '设置',
'action.clearCaches': '清除缓存',
'action.reload': '重新加载页面',
'action.save': '保存',
'action.saved': '已保存 ✓',
'action.cancel': '取消',
'action.reset': '重置',
'action.importExport': '导入/导出',
'action.close': '关闭',
'message.save': '设置已保存。请重新加载页面以应用更改。',
'message.reset': '设置已重置。请重新加载页面以应用更改。',
'message.resetConfirm': '确定要将所有设置恢复为默认值吗?',
'message.openError': '无法打开设置界面:{error}',
'message.validation': '{key}:{error}',
'message.discardChanges': '要放弃未保存的更改吗?',
'toast.cachesCleared': '缓存已清除 ✓',
'validation.object': '应提供 JSON 对象。',
'validation.arrayNull': '应提供数组或 null。',
'validation.arrayObjects': '应提供对象数组。',
'validation.string': '应提供字符串。',
'validation.positiveInteger': '请输入正整数。',
// Runtime tooltip text.
'tooltip.highlightedBy': '高亮依据:',
'tooltip.blockedBy': '屏蔽原因:',
'tooltip.blockableBy': '符合屏蔽条件:',
'tooltip.skippedDueTo': '跳过屏蔽的原因:',
'tooltip.tags': '标签:{tags}',
'tooltip.userId': '用户 ID:{id}',
'tooltip.aiGenerated': 'AI 生成作品',
'tooltip.inOwnProfile': '位于自己的个人主页',
'tooltip.imageBookmarked': '作品已收藏',
'tooltip.imageHighlighted': '作品已高亮',
'tooltip.utag': 'UTag:{tag}',
},
'zh-HK': {
// App and language.
settingsTitle: 'Pixiv Utils',
'tab.General': '一般',
'tab.Bookmarks': '收藏',
'tab.Filtering': '內容過濾',
'tab.Users': '用戶',
'tab.Highlights': '高亮顯示',
'tab.Tooltips': '提示框',
'tab.UTags': 'UTags',
'tab.Advanced': '進階設定',
'tab.Backup': '備份',
'language.auto': '自動(跟隨瀏覽器語言)',
'language.en': '英文',
'language.ja': '日文',
'language.zh-CN': '簡體中文(中國內地)',
'language.zh-HK': '繁體中文(香港)',
'language.zh-TW': '繁體中文(台灣)',
// General settings.
'label.MODE': '除錯模式',
'label.WAIT_FOR_POLL': '等待目標元素的輪詢間隔(毫秒)',
'label.SETTINGS_LANGUAGE': '設定介面語言',
'label.ENABLE_KEYBINDS': '鍵盤快捷鍵',
'field.debugInfo': '在瀏覽器控制台顯示除錯訊息。',
'field.waitForPoll': '此設定控制腳本檢查待尋找元素的間隔。數值越小,檢查越頻密;數值越大,輪詢頻率越低。',
'field.languageInfo': '使用瀏覽器語言,或自行選擇語言。未有翻譯的文字會以英文顯示。',
'field.keybindInfo': 'Shift+B:編輯收藏。\nB:前往作品頁面(收藏操作由 pixiv 處理)。\n左右方向鍵:瀏覽支援的列表。\n正在輸入文字或按住 Ctrl/Alt/Meta 時不會觸發。',
// Bookmark settings and editor.
'label.ENABLE_EDIT_BOOKMARK': '顯示「編輯收藏」按鈕',
'label.ENABLE_FANCY_EDIT_BOOKMARK': '啟用進階「編輯收藏」面板',
'label.ENABLE_TOGGLE_BOOKMARKED': '顯示收藏過濾按鈕',
'label.TEXT_EDIT_BOOKMARK': '「編輯收藏」按鈕文字',
'label.TEXT_EDIT_BOOKMARK_TOOLTIP': '「編輯收藏」按鈕提示',
'label.TEXT_TOGGLE_BOOKMARKED': '收藏過濾按鈕文字(一直顯示)',
'label.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': '收藏過濾按鈕提示',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': '顯示全部時',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': '只顯示已收藏時',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '只顯示未收藏時',
'text.TEXT_EDIT_BOOKMARK': '✏️',
'text.TEXT_EDIT_BOOKMARK_TOOLTIP': '編輯收藏',
'text.TEXT_TOGGLE_BOOKMARKED': '❤️',
'text.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': '切換收藏顯示模式(按鼠標右鍵可反方向切換)',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': '顯示全部',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': '只顯示已收藏',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '只顯示未收藏',
'label.SECTIONS_TOGGLE_BOOKMARKED': '收藏過濾適用區域',
'bookmark.stateHeading': '收藏過濾狀態文字',
'bookmark.stateInfo': '按鈕文字後方會按目前的顯示模式出現以下其中一項。',
'field.bookmarkFilterTextInfo': '這段文字會一直顯示於切換中的狀態文字之前(例如「❤️ 顯示全部」)。',
'field.fancyPanelInfo': '在此編輯收藏後,Pixiv 內置的收藏按鈕不會即時更新。',
'editBookmark.noTags': '找不到標籤。',
'editBookmark.loadFailed': '載入標籤失敗。',
'editBookmark.saveFailed': '更新收藏失敗。',
'editBookmark.partialSaveFailed': '收藏只更新了一部分。部分變更已儲存,但其餘變更失敗:{error}',
'editBookmark.public': '公開',
'editBookmark.private': '非公開',
'action.refresh': '重新載入',
'action.createTag': '建立標籤',
'action.sort': '切換排序次序',
'toast.bookmarkUpdated': '收藏已更新 ✓',
// Rule editor.
'rule.new': '添加規則',
'rule.tagPattern': '標籤或正則表達式',
'rule.regex': '使用正則表達式',
'rule.flags': '標誌',
'rule.patternPlaceholder': '輸入標籤或正則表達式',
'rule.regexInfo': '可用標誌:i(不區分大小寫)、u(Unicode)、v(Unicode 集合)。u 與 v 不可同時使用。',
'rule.add': '添加標籤',
'rule.addGroup': '添加標籤或顏色群組',
'rule.addInGroup': '添加標籤',
'rule.type': '類型',
'rule.single': '標籤或正則表達式',
'rule.colored': '顏色群組',
'rule.groupColor': '群組顏色',
'rule.groupTags': '群組標籤',
'rule.collapse': '摺疊標籤',
'rule.expand': '展開標籤',
'rule.listRequired': '請提供規則列表。',
'rule.needsText': '{location}:請輸入標籤或正則表達式。',
'rule.itemLocation': '{tab}分頁,第 {item} 項',
'rule.groupItemLocation': '{tab}分頁,群組 {group},第 {item} 項',
'rule.groupLocation': '群組 {number}',
'rule.location': '規則 {number}',
'rule.blockedLocation': '屏蔽規則',
'rule.groupNeedsTags': '{tab}分頁,群組 {number}:請添加標籤列表。',
'rule.invalidRegex': '{location}:{error}',
'rule.combinedRegex': '{location}:合併正則表達式({flags}):{error}',
'rule.noFlags': '沒有標誌',
'field.combineRegexInfo': '可以加快比對,但合併正則表達式後,錨點和後顧斷言的結果可能有差異。',
'text.explicitEmpty': '使用空字串',
'text.fallback': '留空會使用所選語言的預設文字。勾選「使用空字串」則會儲存空白文字。',
// Filtering settings.
'label.PIXIV_BLOCKED_TAGS': '要屏蔽的標籤',
'label.PIXIV_BLOCKED_TAGS_COMBINE_REGEXES': '合併正則表達式',
'label.PIXIV_BLOCKED_TAGS_FAST': '找到第一個屏蔽標籤後停止比對',
'label.PIXIV_BLOCK_AI': '屏蔽 AI 生成作品',
'label.PIXIV_REMOVE_BLOCKED': '直接移除被屏蔽的作品(電腦版)',
'field.blockAiInfo': '只會屏蔽作者標記為 AI 生成的作品。',
// User blocking.
'label.PIXIV_BLOCKED_USERS': '要屏蔽的用戶 ID',
'users.add': '添加用戶 ID',
'users.id': '用戶 ID',
'users.invalid': '每個用戶 ID 只可包含數字。',
'users.info': '屏蔽這些 pixiv 用戶發佈的作品。',
// Highlight settings.
'label.PIXIV_HIGHLIGHTED_TAGS': '要高亮顯示的標籤',
'label.PIXIV_HIGHLIGHTED_COLOR': '預設高亮顏色',
'label.PIXIV_HIGHLIGHTED_TAGS_COMBINE_REGEXES': '合併正則表達式',
'label.PIXIV_HIGHLIGHTED_TAGS_FAST': '找到首個有設定顏色的群組後停止比對',
'label.PIXIV_HIGHLIGHTED_BYPASS_BLOCK': '不要屏蔽已高亮顯示的作品',
'field.highlightInfo': '留空即使用預設顏色(#32cd32)。填入「transparent」可隱藏邊框,並保留高亮作品略過屏蔽的功能。',
'field.highlightFastInfo': '匹配到未設定顏色的群組後,仍會尋找後續設有顏色的群組;若無匹配,則使用預設顏色。',
'field.highlightBypassInfo': '不能略過 UTags 的屏蔽。',
// Tooltip settings.
'label.ENABLE_FLOATING_TOOLTIPS': '使用 Floating UI 提示框',
'label.FLOATING_TOOLTIPS_STATIONARY_DELAY': '鼠標停下後的延遲時間',
'label.FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE': '鼠標移動容許範圍',
'label.FLOATING_TOOLTIPS_SHOW_DELAY': '鍵盤焦點延遲',
'label.FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY': '觸控長按延遲',
'label.FLOATING_TOOLTIPS_OPTIONS': 'Floating UI 選項',
'field.delayInfo': '鼠標停止移動後的等待時間(毫秒)。',
'field.toleranceInfo': '可容許的鼠標移動距離(像素)。',
'field.floatingShowDelay': '鍵盤焦點的顯示延遲(毫秒)。',
'field.floatingTouchHoldDelay': '觸控時長按多久才顯示提示框(毫秒)。',
'field.floatingJson': 'Floating UI 定位選項(JSON)。',
// UTags integration.
'label.UTAGS_INTEGRATION': '啟用 UTags 整合',
'label.UTAGS_BLOCKED_TAGS': '要屏蔽的 UTag',
'utags.add': '添加要屏蔽的 UTag',
'utags.tag': 'UTag',
'utags.info': '除了內置的「block」和「hide」標籤之外,亦會加入這些標籤。',
// Advanced settings: selectors and date formatting.
'label.SELECTORS_HOME': '首頁選擇器',
'label.SELECTORS_OWN_PROFILE': '自己的個人頁面選擇器',
'label.SELECTORS_IMAGE': '作品卡片選擇器',
'label.SELECTORS_IMAGE_TITLE': '作品標題選擇器',
'label.SELECTORS_IMAGE_ARTIST_AVATAR': '作者頭像選擇器',
'label.SELECTORS_IMAGE_AUXILIARY_ELEMENTS': '作品其他元素選擇器',
'label.SELECTORS_IMAGE_CONTROLS': '作品操作按鈕選擇器',
'label.SELECTORS_IMAGE_BOOKMARKED': '已收藏標記選擇器',
'label.SELECTORS_EXPANDED_VIEW_IMAGE': '作品詳細檢視選擇器',
'label.SELECTORS_EXPANDED_VIEW_CONTROLS': '作品詳細檢視操作按鈕選擇器',
'label.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE': '作品詳細檢視底部作者區域選擇器',
'label.SELECTORS_MULTI_VIEW': '多作品檢視選擇器',
'label.SELECTORS_MULTI_VIEW_CONTROLS': '多作品檢視操作按鈕選擇器',
'label.SELECTORS_FOLLOW_BUTTON_CONTAINER': '關注按鈕容器選擇器',
'label.SELECTORS_FOLLOW_BUTTON': '關注按鈕選擇器',
'label.SELECTORS_RECOMMENDED_USER_CONTAINER': '推薦用戶容器選擇器',
'label.SELECTORS_TAG_BUTTON': '標籤按鈕選擇器',
'label.SELECTORS_DATE': '日期元素選擇器',
'label.DATE_CONVERSION': '轉換日期格式',
'label.DATE_CONVERSION_LOCALES': '日期語言設定',
'label.DATE_CONVERSION_OPTIONS': '日期格式',
'label.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME': '隱藏首頁的小說推薦',
'label.REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME': '隱藏主頁的珍藏冊推薦',
'selector.heading': '自訂 CSS 選擇器',
'field.selectorInfo': '額外的 CSS 選擇器會加入內置選擇器,而不是取代它們。每行輸入一個 CSS 選擇器。',
'field.dateInfo': '透過 Intl.DateTimeFormat 選項自訂日期顯示格式。',
'sections.add': '添加區域',
'sections.parent': '上層元素選擇器',
'sections.header': '標題區域選擇器',
'sections.images': '作品列表容器選擇器',
'sections.info': '添加的區域會連同內置區域一起使用。每行輸入一個 CSS 選擇器;原有的檢查條件仍然適用。',
'date.unspecified': '預設',
'date.systemTimezone': '系統時區',
'date.example': '例子:{value}',
'date.invalid': '選項組合無效:{error}',
'date.expectedObject': '請提供日期格式選項物件。',
'date.weekday': '星期',
'date.era': '紀元',
'date.year': '年',
'date.month': '月',
'date.day': '日',
'date.dayPeriod': '時段(上午/下午等)',
'date.hour': '小時',
'date.minute': '分鐘',
'date.second': '秒',
'date.fractionalSecondDigits': '秒數的小數位',
'date.hour12': '12 小時制',
'date.hourCycle': '小時制類型',
'date.timeZoneName': '時區名稱',
'date.dateStyle': '日期格式',
'date.timeStyle': '時間格式',
'date.localeMatcher': '語言配對方式',
'date.formatMatcher': '格式配對方式',
'date.timeZone': '時區',
'date.calendar': '曆法',
'date.numberingSystem': '數字系統',
'list.position': '移動至指定位置(1–{max})',
'list.drag': '拖曳以重新排序',
// Backup and import/export.
'label.BACKUP_TOOLS': '匯入/匯出設定',
'backup.intro': '匯出所有已儲存的設定,包括個人偏好。匯入時只會更新 JSON 中提供的項目。',
'backup.placeholder': '貼上設定 JSON,或選擇檔案...',
'backup.aria': '設定 JSON',
'backup.export': '匯出',
'backup.copy': '複製',
'backup.download': '下載',
'backup.choose': '選擇檔案',
'backup.import': '匯入',
'alert.title': '提示',
'alert.ok': '確定',
'backup.emptyCopy': '請先匯出設定。',
'backup.copied': '文字已複製到剪貼簿。',
'backup.clipboardFailed': '無法自動複製。已選取文字,可以手動複製。',
'backup.emptyDownload': '請先匯出設定。',
'backup.fileError': '無法讀取檔案:{error}',
'backup.emptyImport': '請先貼上設定 JSON 或選擇檔案。',
'backup.importConfirm': '確定匯入 {count} 項設定?\n\n匯入的項目會覆蓋已儲存的設定,其他項目保持不變。設定視窗內尚未儲存的更改會遺失。',
'backup.imported': '設定已匯入。請重新載入頁面以套用更改。',
'backup.importError': '無法匯入設定:{error}',
'backup.objectError': '必須提供 JSON 物件。',
'backup.emptyObject': '匯入的物件沒有內容。',
// Shared actions and feedback.
'action.settings': '設定',
'action.clearCaches': '清除快取',
'action.reload': '重新載入頁面',
'action.save': '儲存',
'action.saved': '已儲存 ✓',
'action.cancel': '取消',
'action.reset': '重設',
'action.importExport': '匯入/匯出',
'action.close': '關閉',
'message.save': '設定已儲存。請重新載入頁面以套用更改。',
'message.reset': '設定已重設。請重新載入頁面以套用更改。',
'message.resetConfirm': '確定將所有設定恢復至預設值?',
'message.openError': '無法開啟設定:{error}',
'message.validation': '{key}:{error}',
'message.discardChanges': '捨棄未儲存的更改?',
'toast.cachesCleared': '已清除快取 ✓',
'validation.object': '必須提供 JSON 物件。',
'validation.arrayNull': '必須提供數組或 null。',
'validation.arrayObjects': '必須提供物件數組。',
'validation.string': '必須提供字串。',
'validation.positiveInteger': '請輸入正整數。',
// Runtime tooltip text.
'tooltip.highlightedBy': '高亮原因:',
'tooltip.blockedBy': '屏蔽原因:',
'tooltip.blockableBy': '符合屏蔽條件:',
'tooltip.skippedDueTo': '略過屏蔽的原因:',
'tooltip.tags': '標籤:{tags}',
'tooltip.userId': '用戶 ID:{id}',
'tooltip.aiGenerated': 'AI 生成作品',
'tooltip.inOwnProfile': '位於自己的個人頁面',
'tooltip.imageBookmarked': '作品已收藏',
'tooltip.imageHighlighted': '作品已高亮顯示',
'tooltip.utag': 'UTag:{tag}',
},
'zh-TW': {
// App and language.
settingsTitle: 'Pixiv Utils',
'tab.General': '一般',
'tab.Bookmarks': '收藏',
'tab.Filtering': '內容篩選',
'tab.Users': '使用者',
'tab.Highlights': '醒目標示',
'tab.Tooltips': '工具提示',
'tab.UTags': 'UTags',
'tab.Advanced': '進階設定',
'tab.Backup': '備份',
'language.auto': '自動(瀏覽器語言)',
'language.en': '英文',
'language.ja': '日文',
'language.zh-CN': '簡體中文(中國大陸)',
'language.zh-HK': '繁體中文(香港)',
'language.zh-TW': '繁體中文(台灣)',
// General settings.
'label.MODE': '偵錯模式',
'label.WAIT_FOR_POLL': '等待目標元素的輪詢間隔(毫秒)',
'label.SETTINGS_LANGUAGE': '設定介面語言',
'label.ENABLE_KEYBINDS': '鍵盤快捷鍵',
'field.debugInfo': '在瀏覽器主控台顯示偵錯訊息。',
'field.waitForPoll': '此設定控制指令碼檢查待尋找元素的間隔。數值越小,檢查越頻繁;數值越大,輪詢頻率越低。',
'field.languageInfo': '使用瀏覽器語言或手動選擇語言。缺少翻譯的項目將顯示英文。',
'field.keybindInfo': 'Shift+B:編輯收藏。\nB:前往作品頁面(由 pixiv 處理收藏操作)。\n左右方向鍵:瀏覽支援的清單。\n輸入文字或按住 Ctrl/Alt/Meta 時不會觸發。',
// Bookmark settings and editor.
'label.ENABLE_EDIT_BOOKMARK': '顯示「編輯收藏」按鈕',
'label.ENABLE_FANCY_EDIT_BOOKMARK': '啟用進階「編輯收藏」面板',
'label.ENABLE_TOGGLE_BOOKMARKED': '顯示收藏篩選按鈕',
'label.TEXT_EDIT_BOOKMARK': '「編輯收藏」按鈕文字',
'label.TEXT_EDIT_BOOKMARK_TOOLTIP': '「編輯收藏」按鈕的工具提示',
'label.TEXT_TOGGLE_BOOKMARKED': '收藏篩選按鈕文字(持續顯示)',
'label.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': '收藏篩選按鈕的工具提示',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': '顯示全部時',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': '僅顯示已收藏時',
'label.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '僅顯示未收藏時',
'text.TEXT_EDIT_BOOKMARK': '✏️',
'text.TEXT_EDIT_BOOKMARK_TOOLTIP': '編輯收藏',
'text.TEXT_TOGGLE_BOOKMARKED': '❤️',
'text.TEXT_TOGGLE_BOOKMARKED_TOOLTIP': '切換收藏篩選模式(按滑鼠右鍵可反向切換)',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL': '顯示全部',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED': '僅顯示已收藏',
'text.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED': '僅顯示未收藏',
'label.SECTIONS_TOGGLE_BOOKMARKED': '收藏篩選適用區塊',
'bookmark.stateHeading': '收藏篩選狀態標籤',
'bookmark.stateInfo': '按鈕文字後方會依目前的篩選狀態顯示下列其中一項。',
'field.bookmarkFilterTextInfo': '這段文字會固定顯示在變動的狀態文字前方(例如「❤️ 顯示全部」)。',
'field.fancyPanelInfo': '在此編輯收藏後,Pixiv 內建的收藏按鈕不會即時更新。',
'editBookmark.noTags': '找不到標籤。',
'editBookmark.loadFailed': '載入標籤失敗。',
'editBookmark.saveFailed': '更新收藏失敗。',
'editBookmark.partialSaveFailed': '收藏僅部分更新。部分變更已儲存,但其他變更失敗:{error}',
'editBookmark.public': '公開',
'editBookmark.private': '非公開',
'action.refresh': '重新整理',
'action.createTag': '新增標籤',
'action.sort': '切換排序順序',
'toast.bookmarkUpdated': '收藏已更新 ✓',
// Rule editor.
'rule.new': '新增規則',
'rule.tagPattern': '標籤或正規表示式',
'rule.regex': '使用正規表示式',
'rule.flags': '旗標',
'rule.patternPlaceholder': '輸入標籤或正規表示式',
'rule.regexInfo': '允許的旗標:i(不區分大小寫)、u(Unicode)、v(Unicode 集合)。u 與 v 不可同時使用。',
'rule.add': '新增標籤',
'rule.addGroup': '新增標籤或色彩群組',
'rule.addInGroup': '新增標籤',
'rule.type': '類型',
'rule.single': '標籤或正規表示式',
'rule.colored': '色彩群組',
'rule.groupColor': '群組顏色',
'rule.groupTags': '群組內標籤',
'rule.collapse': '收合標籤',
'rule.expand': '展開標籤',
'rule.listRequired': '請提供規則清單。',
'rule.needsText': '{location}:請輸入標籤或正規表示式。',
'rule.itemLocation': '{tab}索引標籤,第 {item} 項',
'rule.groupItemLocation': '{tab}索引標籤,群組 {group},第 {item} 項',
'rule.groupLocation': '群組 {number}',
'rule.location': '規則 {number}',
'rule.blockedLocation': '屏蔽規則',
'rule.groupNeedsTags': '{tab}索引標籤,群組 {number}:請新增標籤清單。',
'rule.invalidRegex': '{location}:{error}',
'rule.combinedRegex': '{location}:合併後的正規表示式({flags}):{error}',
'rule.noFlags': '無旗標',
'field.combineRegexInfo': '可加快比對速度,但合併正規表示式後,錨點與後顧斷言的行為可能有所不同。',
'text.explicitEmpty': '使用空字串',
'text.fallback': '留空將使用目前語言的預設文字。勾選「使用空字串」可儲存空白文字。',
// Filtering settings.
'label.PIXIV_BLOCKED_TAGS': '要屏蔽的標籤',
'label.PIXIV_BLOCKED_TAGS_COMBINE_REGEXES': '合併正規表示式模式',
'label.PIXIV_BLOCKED_TAGS_FAST': '符合第一個屏蔽標籤後停止比對',
'label.PIXIV_BLOCK_AI': '屏蔽 AI 生成作品',
'label.PIXIV_REMOVE_BLOCKED': '直接移除被屏蔽的作品(電腦版)',
'field.blockAiInfo': '只屏蔽作者自行標記為 AI 生成的作品。',
// User blocking.
'label.PIXIV_BLOCKED_USERS': '要屏蔽的使用者 ID',
'users.add': '新增使用者 ID',
'users.id': '使用者 ID',
'users.invalid': '每個使用者 ID 只能包含數字。',
'users.info': '屏蔽這些 pixiv 使用者發布的作品。',
// Highlight settings.
'label.PIXIV_HIGHLIGHTED_TAGS': '要醒目標示的標籤',
'label.PIXIV_HIGHLIGHTED_COLOR': '預設醒目標示顏色',
'label.PIXIV_HIGHLIGHTED_TAGS_COMBINE_REGEXES': '合併正規表示式模式',
'label.PIXIV_HIGHLIGHTED_TAGS_FAST': '符合第一個設有顏色的群組後停止比對',
'label.PIXIV_HIGHLIGHTED_BYPASS_BLOCK': '不要屏蔽已醒目標示的作品',
'field.highlightInfo': '留空將使用預設顏色(#32cd32)。設為「transparent」可隱藏邊框,同時保留醒目標示作品的屏蔽略過功能。',
'field.highlightFastInfo': '符合未設定顏色的群組後,仍會尋找後續設有顏色的群組;若無符合者,則使用預設顏色。',
'field.highlightBypassInfo': '無法略過 UTags 的屏蔽。',
// Tooltip settings.
'label.ENABLE_FLOATING_TOOLTIPS': '使用 Floating UI 工具提示',
'label.FLOATING_TOOLTIPS_STATIONARY_DELAY': '滑鼠停止移動後的延遲',
'label.FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE': '允許的滑鼠移動幅度',
'label.FLOATING_TOOLTIPS_SHOW_DELAY': '鍵盤焦點延遲',
'label.FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY': '觸控長按延遲',
'label.FLOATING_TOOLTIPS_OPTIONS': 'Floating UI 選項',
'field.delayInfo': '滑鼠停止移動後的等待時間(毫秒)。',
'field.toleranceInfo': '允許的滑鼠移動距離(像素)。',
'field.floatingShowDelay': '鍵盤焦點的顯示延遲(毫秒)。',
'field.floatingTouchHoldDelay': '觸控時長按多久才顯示工具提示(毫秒)。',
'field.floatingJson': 'Floating UI 定位選項(JSON)。',
// UTags integration.
'label.UTAGS_INTEGRATION': '啟用 UTags 整合',
'label.UTAGS_BLOCKED_TAGS': '要屏蔽的 UTag',
'utags.add': '新增要屏蔽的 UTag',
'utags.tag': 'UTag',
'utags.info': '將在內建的「block」與「hide」標籤之外額外新增。',
// Advanced settings: selectors and date formatting.
'label.SELECTORS_HOME': '首頁選擇器',
'label.SELECTORS_OWN_PROFILE': '自己的個人頁面選擇器',
'label.SELECTORS_IMAGE': '作品卡片選擇器',
'label.SELECTORS_IMAGE_TITLE': '作品標題選擇器',
'label.SELECTORS_IMAGE_ARTIST_AVATAR': '作者頭像選擇器',
'label.SELECTORS_IMAGE_AUXILIARY_ELEMENTS': '作品輔助元素選擇器',
'label.SELECTORS_IMAGE_CONTROLS': '作品操作按鈕選擇器',
'label.SELECTORS_IMAGE_BOOKMARKED': '已收藏標示選擇器',
'label.SELECTORS_EXPANDED_VIEW_IMAGE': '作品詳細檢視選擇器',
'label.SELECTORS_EXPANDED_VIEW_CONTROLS': '作品詳細檢視操作按鈕選擇器',
'label.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE': '作品詳細檢視底部作者區塊選擇器',
'label.SELECTORS_MULTI_VIEW': '多作品檢視選擇器',
'label.SELECTORS_MULTI_VIEW_CONTROLS': '多作品檢視操作按鈕選擇器',
'label.SELECTORS_FOLLOW_BUTTON_CONTAINER': '關注按鈕容器選擇器',
'label.SELECTORS_FOLLOW_BUTTON': '關注按鈕選擇器',
'label.SELECTORS_RECOMMENDED_USER_CONTAINER': '推薦使用者容器選擇器',
'label.SELECTORS_TAG_BUTTON': '標籤按鈕選擇器',
'label.SELECTORS_DATE': '日期元素選擇器',
'label.DATE_CONVERSION': '轉換日期格式',
'label.DATE_CONVERSION_LOCALES': '日期語系',
'label.DATE_CONVERSION_OPTIONS': '日期格式',
'label.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME': '隱藏首頁的小說推薦',
'label.REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME': '隱藏首頁的珍藏冊推薦',
'selector.heading': '自訂 CSS 選擇器',
'field.selectorInfo': '額外的 CSS 選擇器會附加到內建選擇器,不會取代它們。每行輸入一個 CSS 選擇器。',
'field.dateInfo': '使用 Intl.DateTimeFormat 選項自訂日期顯示格式。',
'sections.add': '新增區塊',
'sections.parent': '父元素選擇器',
'sections.header': '標題區域選擇器',
'sections.images': '作品清單容器選擇器',
'sections.info': '新增區塊會與內建區塊一併套用。每行輸入一個 CSS 選擇器;內建的檢查條件仍然有效。',
'date.unspecified': '預設值',
'date.systemTimezone': '系統時區',
'date.example': '範例:{value}',
'date.invalid': '無效的選項組合:{error}',
'date.expectedObject': '請提供包含日期格式選項的物件。',
'date.weekday': '星期',
'date.era': '紀元',
'date.year': '年',
'date.month': '月',
'date.day': '日',
'date.dayPeriod': '時段(上午/下午等)',
'date.hour': '小時',
'date.minute': '分鐘',
'date.second': '秒',
'date.fractionalSecondDigits': '秒的小數位數',
'date.hour12': '12 小時制',
'date.hourCycle': '小時制類型',
'date.timeZoneName': '時區名稱',
'date.dateStyle': '日期樣式',
'date.timeStyle': '時間樣式',
'date.localeMatcher': '語系比對方式',
'date.formatMatcher': '格式比對方式',
'date.timeZone': '時區',
'date.calendar': '曆法',
'date.numberingSystem': '數字系統',
'list.position': '移動至指定位置(1–{max})',
'list.drag': '拖曳以重新排序',
// Backup and import/export.
'label.BACKUP_TOOLS': '匯入/匯出設定',
'backup.intro': '匯出所有已儲存的設定(包含使用偏好)。匯入時只更新 JSON 中提供的項目。',
'backup.placeholder': '貼上設定 JSON,或選擇檔案...',
'backup.aria': '設定 JSON',
'backup.export': '匯出',
'backup.copy': '複製',
'backup.download': '下載',
'backup.choose': '選擇檔案',
'backup.import': '匯入',
'alert.title': '提示',
'alert.ok': '確定',
'backup.emptyCopy': '請先匯出設定。',
'backup.copied': '文字已複製到剪貼簿。',
'backup.clipboardFailed': '無法自動複製。已選取文字,可手動複製。',
'backup.emptyDownload': '請先匯出設定。',
'backup.fileError': '無法讀取檔案:{error}',
'backup.emptyImport': '請先貼上設定 JSON 或選擇檔案。',
'backup.importConfirm': '確定要匯入 {count} 項設定嗎?\n\n匯入的項目將覆寫已儲存的設定,其他設定維持不變。設定視窗中尚未儲存的變更將會遺失。',
'backup.imported': '設定已匯入。請重新載入頁面以套用變更。',
'backup.importError': '無法匯入設定:{error}',
'backup.objectError': '應提供 JSON 物件。',
'backup.emptyObject': '匯入的物件是空的。',
// Shared actions and feedback.
'action.settings': '設定',
'action.clearCaches': '清除快取',
'action.reload': '重新載入頁面',
'action.save': '儲存',
'action.saved': '已儲存 ✓',
'action.cancel': '取消',
'action.reset': '重設',
'action.importExport': '匯入/匯出',
'action.close': '關閉',
'message.save': '設定已儲存。請重新載入頁面以套用變更。',
'message.reset': '設定已重設。請重新載入頁面以套用變更。',
'message.resetConfirm': '確定要將所有設定還原為預設值嗎?',
'message.openError': '無法開啟設定介面:{error}',
'message.validation': '{key}:{error}',
'message.discardChanges': '要捨棄尚未儲存的變更嗎?',
'toast.cachesCleared': '快取已清除 ✓',
'validation.object': '應提供 JSON 物件。',
'validation.arrayNull': '應提供陣列或 null。',
'validation.arrayObjects': '應提供物件陣列。',
'validation.string': '應提供字串。',
'validation.positiveInteger': '請輸入正整數。',
// Runtime tooltip text.
'tooltip.highlightedBy': '醒目標示依據:',
'tooltip.blockedBy': '屏蔽原因:',
'tooltip.blockableBy': '符合屏蔽條件:',
'tooltip.skippedDueTo': '略過屏蔽的原因:',
'tooltip.tags': '標籤:{tags}',
'tooltip.userId': '使用者 ID:{id}',
'tooltip.aiGenerated': 'AI 生成作品',
'tooltip.inOwnProfile': '位於自己的個人頁面',
'tooltip.imageBookmarked': '作品已收藏',
'tooltip.imageHighlighted': '作品已醒目標示',
'tooltip.utag': 'UTag:{tag}',
},
};
// Region-specific Traditional Chinese. zh-CN is an alias for the `zh`
// dictionary; older zh-Hant preferences continue to use Taiwan wording.
const browserSettingsLocale = () => {
for (const code of navigator.languages?.length ? navigator.languages : [navigator.language || 'en']) {
const language = String(code).toLowerCase();
const primary = language.split('-')[0];
if (primary === 'ja' || primary === 'en') return primary;
if (primary === 'zh') {
const subtags = language.split('-');
if (subtags.includes('hk') || subtags.includes('mo')) return 'zh-HK';
if (subtags.includes('tw') || subtags.includes('hant')) return 'zh-TW';
return 'zh';
}
}
return 'en';
};
const resolveSettingsLocale = choice => {
if (choice === 'zh-CN') return 'zh';
if (choice === 'zh-Hant') return 'zh-TW'; // backwards compatibility
return ['en', 'ja', 'zh', 'zh-HK', 'zh-TW'].includes(choice)
? choice
: browserSettingsLocale();
};
let settingsUiLocale = resolveSettingsLocale(ENV.SETTINGS_LANGUAGE);
const tr = (key, replacements = {}, locale = settingsUiLocale) => {
const template = I18N[locale]?.[key] || I18N.en[key] || key;
if (_debugMode) {
if (!I18N.en[key]) console.warn('[Pixiv Utils]: Missing i18n key:', key);
else if (locale !== 'en' && !I18N[locale]?.[key]) console.warn('[Pixiv Utils]: Missing locale translation:', locale, key);
}
return template.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(replacements, name) ? String(replacements[name]) : match);
};
// Read the installed userscript version directly from its @version metadata.
const settingsPanelTitle = () => `${tr('settingsTitle')} v${GM_info.script.version}`;
let customPromptDialog = null;
let customPromptMessage = null;
let customPromptCancel = null;
let customPromptOk = null;
let activePrompt = null;
const customPromptQueue = [];
const presentNextPrompt = () => {
if (!customPromptDialog || customPromptDialog.open || !customPromptQueue.length) return;
activePrompt = customPromptQueue.shift();
customPromptMessage.textContent = activePrompt.message;
customPromptDialog.setAttribute('role', activePrompt.confirm ? 'dialog' : 'alertdialog');
customPromptCancel.hidden = !activePrompt.confirm;
customPromptDialog.showModal();
(activePrompt.confirm ? customPromptCancel : customPromptOk).focus();
};
const closePrompt = accepted => {
if (!customPromptDialog?.open) return;
if (activePrompt) activePrompt.result = Boolean(accepted);
customPromptDialog.close();
};
const enqueuePrompt = (message, confirm) => {
initializePromptDialog();
const request = { message: String(message), confirm, result: false, resolve: null };
if (confirm) {
return new Promise(resolve => {
request.resolve = resolve;
customPromptQueue.push(request);
presentNextPrompt();
});
}
customPromptQueue.push(request);
presentNextPrompt();
};
const showAlert = message => enqueuePrompt(message, false);
const showConfirm = message => enqueuePrompt(message, true);
const initializePromptDialog = () => {
if (!customPromptDialog) {
const dialog = document.createElement('dialog');
dialog.className = 'pixiv-utils-alert';
dialog.setAttribute('role', 'alertdialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'pixiv-utils-alert-title');
dialog.setAttribute('aria-describedby', 'pixiv-utils-alert-message');
const content = document.createElement('div');
content.className = 'pixiv-utils-alert-content';
const header = document.createElement('div');
header.className = 'pixiv-utils-alert-header';
const title = document.createElement('h2');
title.id = 'pixiv-utils-alert-title';
title.textContent = tr('alert.title');
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'pixiv-utils-alert-close';
closeButton.textContent = '✕';
closeButton.title = tr('action.close');
closeButton.setAttribute('aria-label', tr('action.close'));
closeButton.addEventListener('click', () => closePrompt(false));
header.append(title, closeButton);
const messageNode = document.createElement('p');
messageNode.id = 'pixiv-utils-alert-message';
messageNode.className = 'pixiv-utils-alert-message';
const actions = document.createElement('div');
actions.className = 'pixiv-utils-alert-actions';
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.className = 'pixiv-utils-alert-cancel';
cancelButton.textContent = tr('action.cancel');
cancelButton.hidden = true;
cancelButton.addEventListener('click', () => closePrompt(false));
const okButton = document.createElement('button');
okButton.type = 'button';
okButton.className = 'pixiv-utils-alert-ok';
okButton.textContent = tr('alert.ok');
okButton.addEventListener('click', () => closePrompt(true));
actions.append(cancelButton, okButton);
content.append(header, messageNode, actions);
dialog.append(content);
dialog.addEventListener('click', event => {
if (event.target === dialog) closePrompt(false);
});
dialog.addEventListener('close', () => {
const request = activePrompt;
activePrompt = null;
request?.resolve?.(request.result);
presentNextPrompt();
});
(document.body || document.documentElement).append(dialog);
customPromptDialog = dialog;
customPromptMessage = messageNode;
customPromptCancel = cancelButton;
customPromptOk = okButton;
}
presentNextPrompt();
};
const _DOCUMENT_FRAGMENT = document.createDocumentFragment();
const isSelectorValid = selector => {
try {
_DOCUMENT_FRAGMENT.querySelector(selector);
return true;
} catch {
return false;
}
};
// Split top-level commas and discard CSS comments without touching quoted values.
// Collapse runs of whitespace (including newlines) into single spaces outside of
// quoted strings, so multi-line template literals normalize to single-line selectors.
const splitSelectorList = selector => {
const parts = [];
let part = '';
let depth = 0;
let quote = null;
let escaped = false;
const appendSpace = () => {
if (part.length === 0 || part.endsWith(' ')) return;
part += ' ';
};
for (let i = 0; i < selector.length; i++) {
const char = selector[i];
if (escaped) {
part += char;
escaped = false;
continue;
}
if (char === '\\') {
part += char;
escaped = true;
continue;
}
if (quote) {
part += char;
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
part += char;
quote = char;
continue;
}
if (char === '/' && selector[i + 1] === '*') {
const commentEnd = selector.indexOf('*/', i + 2);
if (commentEnd === -1) {
appendSpace();
part += selector.slice(i);
break;
}
appendSpace();
i = commentEnd + 1;
continue;
}
if (/\s/.test(char)) {
appendSpace();
continue;
}
if (char === '(' || char === '[') {
depth++;
} else if (char === ')' || char === ']') {
depth--;
} else if (char === ',' && depth === 0) {
const trimmed = part.trim();
if (trimmed) parts.push(trimmed);
part = '';
continue;
}
part += char;
}
const trimmed = part.trim();
if (trimmed) parts.push(trimmed);
return parts.filter(Boolean);
};
const normalizeSelectorList = value => {
const selector = Array.isArray(value) ? value.join(', ') : value || '';
return splitSelectorList(selector).join(', ');
};
// Valid CSS that matches nothing. Used to disable a single feature whose
// selectors are unusable without affecting any other feature.
const NEVER_MATCH_SELECTOR = '[data-pixiv_utils_disabled]';
const CONFIG = {};
// Reuse pre-built selectors from GM storage when they satisfy the integrity check.
const _PREBUILT_KEY = '_PREBUILT';
const _selectorKeys = Object.keys(ENV).filter(key =>
key.startsWith('SELECTORS_') || key === 'SECTIONS_TOGGLE_BOOKMARKED');
// Debug mode always bypasses and invalidates the pre-built selector cache.
if (_debugMode) GM_setValue(_PREBUILT_KEY, null);
const _prebuilt = GM_getValue(_PREBUILT_KEY, null);
// A hand-edited cache can hold syntactically invalid selectors; reject it
// so the build path below re-validates and isolates them. On a cache hit
// this is the only validation pass selector settings get on a page load.
// Normalize a section's selector fields without validating them. Shared by
// the pre-built cache read path (which only needs comparable strings) and
// normalizeSection().
const normalizeSectionFields = section => {
if (section === null || typeof section !== 'object' || Array.isArray(section)) return section;
const result = { ...section };
for (const selectorKey of ['selectorParent', 'selectorHeader', 'selectorImagesContainer']) {
if (Object.prototype.hasOwnProperty.call(result, selectorKey)) {
result[selectorKey] = normalizeSelectorList(result[selectorKey]);
}
}
return result;
};
// Normalize and validate a section in a single pass; null when the section
// or any of its selector fields is missing, empty, or invalid.
const normalizeSection = section => {
const result = normalizeSectionFields(section);
if (!result || typeof result !== 'object' || Array.isArray(result)) return null;
const valid = ['selectorParent', 'selectorHeader', 'selectorImagesContainer'].every(field =>
typeof result[field] === 'string' && result[field] !== '' && isSelectorValid(result[field]));
return valid ? result : null;
};
// Cache probe: normalize first so hand-edited multi-line or commented
// selectors are judged on their parsed form.
const isValidSection = section => normalizeSection(section) !== null;
const _prebuiltValid = !_debugMode && Boolean(_prebuilt) && typeof _prebuilt === 'object' && !Array.isArray(_prebuilt) &&
_prebuilt.version === GM_info.script.version &&
_prebuilt.selectors && typeof _prebuilt.selectors === 'object' && !Array.isArray(_prebuilt.selectors) &&
_selectorKeys.every(key => Object.prototype.hasOwnProperty.call(_prebuilt.selectors, key)) &&
_selectorKeys.every(key => key === 'SECTIONS_TOGGLE_BOOKMARKED'
? Array.isArray(_prebuilt.selectors[key]) && _prebuilt.selectors[key].every(isValidSection)
: typeof _prebuilt.selectors[key] === 'string' && isSelectorValid(_prebuilt.selectors[key]));
const _prebuiltSelectors = _prebuiltValid ? _prebuilt.selectors : null;
for (const key of Object.keys(ENV)) {
const isSelectorKey = key.startsWith('SELECTORS_') || key === 'SECTIONS_TOGGLE_BOOKMARKED';
if (isSelectorKey && _prebuiltSelectors) {
if (key === 'SECTIONS_TOGGLE_BOOKMARKED') {
const storedSections = _prebuiltSelectors[key];
CONFIG[key] = Array.isArray(storedSections)
? storedSections.map(storedSection => {
const result = { ...storedSection };
if (Array.isArray(PRESETS[key])) {
const normalizedPresets = PRESETS[key].map(normalizeSectionFields);
const match = normalizedPresets.find(presetSection =>
presetSection && typeof presetSection === 'object' && !Array.isArray(presetSection) &&
presetSection.selectorParent === result.selectorParent &&
presetSection.selectorHeader === result.selectorHeader &&
presetSection.selectorImagesContainer === result.selectorImagesContainer);
if (match && typeof match.sanityCheck === 'function') {
result.sanityCheck = match.sanityCheck;
}
}
return result;
})
: storedSections;
} else {
CONFIG[key] = _prebuiltSelectors[key];
}
continue;
}
// Extend hard-coded preset values with user-defined custom values, if applicable.
if (key.startsWith('SELECTORS_')) {
const presetSelector = normalizeSelectorList(PRESETS[key]);
const customSelector = normalizeSelectorList(ENV[key]);
const combinedSelector = [presetSelector, customSelector].filter(Boolean).join(', ');
if (isSelectorValid(combinedSelector)) {
CONFIG[key] = combinedSelector;
} else {
// Isolate the failure to this one setting: keep the built-in selectors
// when they are valid on their own, otherwise disable only this
// feature via a selector that never matches.
const usePresets = Boolean(customSelector) && isSelectorValid(presetSelector);
const reason = usePresets
? 'the custom selector was ignored and the built-in selectors remain active'
: 'the affected feature is disabled';
console.error(`[Pixiv Utils]: ${key} contains an invalid selector; ${reason}. Stored value =`, ENV[key]);
CONFIG[key] = usePresets ? presetSelector : NEVER_MATCH_SELECTOR;
}
} else if (key === 'SECTIONS_TOGGLE_BOOKMARKED') {
// Selectors may be written as multi-line template literals, just like SELECTORS_.
// normalizeSection() normalizes and validates every field in a single pass
// while preserving extra properties (e.g. sanityCheck); invalid sections
// are dropped here, and runtime registration trusts CONFIG.
const presetSections = Array.isArray(PRESETS[key])
? PRESETS[key].map(normalizeSection).filter((section, index) => {
if (section) return true;
console.error(`[Pixiv Utils]: ${key} preset section ${index} contains an invalid selector and was ignored. Stored value =`, PRESETS[key][index]);
return false;
})
: PRESETS[key];
if (Array.isArray(ENV[key])) {
const customSections = [];
ENV[key].forEach((section, index) => {
const normalized = normalizeSection(section);
if (normalized) {
customSections.push(normalized);
} else {
console.error(`[Pixiv Utils]: ${key} section ${index} contains an invalid selector and was ignored. Stored value =`, section);
}
});
CONFIG[key] = [...(Array.isArray(presetSections) ? presetSections : []), ...customSections];
} else if (ENV[key] !== null && typeof ENV[key] === 'object' && !Array.isArray(ENV[key])) {
CONFIG[key] = normalizeSection(ENV[key]);
} else {
CONFIG[key] = presetSections;
if (ENV[key] !== null && ENV[key] !== undefined) {
CONFIG[key] = ENV[key];
}
}
if (!Array.isArray(CONFIG[key])) {
console.error(`[Pixiv Utils]: ${key} must be an array of sections; using built-in sections. Stored value =`, ENV[key]);
CONFIG[key] = Array.isArray(presetSections) ? presetSections : [];
}
} else if (Array.isArray(PRESETS[key])) {
CONFIG[key] = PRESETS[key];
if (ENV[key]) {
const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim());
CONFIG[key].push(...customValues);
}
} else {
CONFIG[key] = PRESETS[key] || null;
if (ENV[key] !== null) {
CONFIG[key] = ENV[key];
}
}
}
// Store freshly built selectors for future runs when no valid pre-built cache was found.
if (_prebuiltSelectors) {
log('Reusing cached pre-built CSS selectors.');
} else {
const _builtSelectors = {};
for (const key of _selectorKeys) {
if (key === 'SECTIONS_TOGGLE_BOOKMARKED') {
const sections = CONFIG[key];
_builtSelectors[key] = Array.isArray(sections)
? sections.map(section => {
const result = {};
if (section && typeof section === 'object' && !Array.isArray(section)) {
for (const selectorKey of ['selectorParent', 'selectorHeader', 'selectorImagesContainer']) {
if (Object.prototype.hasOwnProperty.call(section, selectorKey)) {
result[selectorKey] = section[selectorKey];
}
}
}
return Object.keys(result).length ? result : section;
})
: sections;
} else {
_builtSelectors[key] = CONFIG[key];
}
}
if (!_debugMode) {
GM_setValue(_PREBUILT_KEY, { version: GM_info.script.version, selectors: _builtSelectors });
log('CSS selectors have been pre-built and cached for future runs.');
}
}
// Empty highlight-color overrides use the built-in default at runtime.
// Keep storage unchanged (including explicit empty strings).
if (CONFIG.PIXIV_HIGHLIGHTED_COLOR === '') {
CONFIG.PIXIV_HIGHLIGHTED_COLOR = ENV_DEFAULTS.PIXIV_HIGHLIGHTED_COLOR;
}
// Runtime UI text comes from i18n only when a TEXT_* override is nullish.
// An explicitly stored empty string is a deliberate override, not a fallback.
for (const key of ENV_KEYS) {
if (key.startsWith('TEXT_') && CONFIG[key] == null) CONFIG[key] = tr(`text.${key}`);
}
let logDebug = () => {};
if (_debugMode) {
logDebug = tlog;
logDebug('CONFIG:', CONFIG);
};
/** GLOBAL UTILS **/
const addPageDateStyle = /*css*/`
.bookmark-detail-unit .meta:not([data-pixiv_utils_duplicate_date]) {
display: none !important;
}
.bookmark-detail-unit .meta[data-pixiv_utils_duplicate_date] {
display: block;
font-size: 16px;
font-weight: bold;
color: inherit;
margin-left: 0;
margin-top: 10px;
}
`;
// Initialize formatters only when dates are actually needed (page conversion
// or artwork metadata). A malformed stored format must not abort the script.
let dateFormatters = null;
const getDateFormatter = (includeTime = true) => {
if (!dateFormatters) {
const create = (locale, options) => {
const dateOnly = { ...options };
for (const key of ['hour12', 'hourCycle', 'hour', 'minute', 'second']) delete dateOnly[key];
return {
full: new Intl.DateTimeFormat(locale, options),
dateOnly: new Intl.DateTimeFormat(locale, dateOnly)
};
};
try {
dateFormatters = create(CONFIG.DATE_CONVERSION_LOCALES, CONFIG.DATE_CONVERSION_OPTIONS);
} catch (error) {
console.warn('[Pixiv Utils]: Invalid date formatting configuration; using defaults:', error);
dateFormatters = create(ENV_DEFAULTS.DATE_CONVERSION_LOCALES, ENV_DEFAULTS.DATE_CONVERSION_OPTIONS);
}
}
return includeTime ? dateFormatters.full : dateFormatters.dateOnly;
};
const formatArtworkDate = date => {
try { return getDateFormatter().format(new Date(date)); } catch { return null; }
};
const convertDate = (element, fixJapanTime = false) => {
// Support "updated on" popups
const updatedOnRegexes = [
/(^Image updated on )(.*)$/i // EN
];
let prefix = '';
let date;
let dateHasHMS = true;
let duplicateDate = element.nextElementSibling;
if (!duplicateDate || !duplicateDate.dataset.pixiv_utils_duplicate_date) {
duplicateDate = element.cloneNode(true);
duplicateDate.dataset.pixiv_utils_duplicate_date = true;
element.after(duplicateDate);
}
const attr = element.getAttribute('datetime');
if (attr) {
date = new Date(attr);
} else {
let dateText = element.textContent.trim();
if (!dateText.includes(':')) {
dateHasHMS = false;
}
for (const regex of updatedOnRegexes) {
const _match = dateText.match(regex);
if (_match) {
dateText = _match[2];
prefix = _match[1];
break;
}
}
// For dates hard-coded to Japan locale.
const match = dateText.match(/^(\d{4})年(\d{2})月(\d{2})日 (\d{2}:\d{2})$/);
if (match) {
dateText = `${match[2]}-${match[3]}-${match[1]} ${match[4]}`;
}
// For pages which have the date display hardcoded to Japan time.
if (fixJapanTime) {
dateText += ' UTC+9';
}
date = new Date(dateText);
}
if (!date) {
return false;
}
const timestamp = date.getTime();
if (Number.isNaN(timestamp) || duplicateDate.dataset.pixiv_utils_date_timestamp === String(timestamp)) {
return false;
}
if (prefix) {
duplicateDate.dataset.pixiv_utils_date_prefix = prefix;
}
const dateString = getDateFormatter(dateHasHMS).format(date);
duplicateDate.dataset.pixiv_utils_date_timestamp = timestamp;
duplicateDate.textContent = prefix + dateString;
return true;
};
/** INTERCEPT EARLY FOR CERTAIN ROUTES **/
const waitPageLoaded = () => {
return new Promise(resolve => {
if (document.readyState === 'complete' ||
document.readyState === 'loaded' ||
document.readyState === 'interactive') {
resolve();
} else {
document.addEventListener('DOMContentLoaded', resolve);
}
});
};
const path = location.pathname;
// Codes beyond this block will not execute for these routes (mainly for efficiency).
if (path.startsWith('/bookmark_add.php') || path.startsWith('/novel/bookmark_add.php')) {
if (CONFIG.DATE_CONVERSION) {
waitPageLoaded().then(() => {
GM_addStyle(addPageDateStyle);
const date = document.querySelector('.bookmark-detail-unit .meta');
if (date) {
// This page has the date display hardcoded to Japan time without an accompanying timestamp.
convertDate(date, true);
}
});
}
log('bookmark_add.php detected. Excluding date conversion, script has terminated early.');
return;
}
/** SETTINGS PANEL (gmConfig 1.0.0, existing per-key GM storage) **/
// The library has no public storage adapter. Its TypeScript-private _load method and
// public save method can be overridden without changing the distributed library.
// Keep @kobzi/gmconfig pinned to 1.0.0: these method names are not public API.
const settingsJson = value => JSON.stringify(value, null, 2);
const regexRuleTitle = rule => rule.regex
? `/${String(rule.value || '').replace(/(^|[^\\])\//g, '$1\\/')}/${rule.flags || ''}`
: rule.value || tr('rule.new');
const isPlainObject = value => value !== null && typeof value === 'object' &&
!Array.isArray(value);
const editorTag = value => Array.isArray(value)
? { value: value[0], regex: true, flags: value[1] || '' }
: { value, regex: false, flags: '' };
const storedTag = value => value.regex
? [value.value, value.flags || '']
: value.value;
const tagsToEditor = (value, highlighted = false) => {
if (typeof value === 'string') value = value.split(',').map(s => s.trim()).filter(Boolean);
if (!Array.isArray(value)) return [];
if (!highlighted) return value.map(editorTag);
return value.map(entry => isPlainObject(entry)
? {
type: 'group',
value: '',
regex: false,
flags: '',
color: entry.color || '',
tags: Array.isArray(entry.tags) ? entry.tags.map(editorTag) : []
}
: { type: 'tag', color: '', tags: [], ...editorTag(entry) });
};
const tagsFromEditor = (value, highlighted = false) => {
if (!Array.isArray(value)) return [];
if (!highlighted) return value.map(storedTag);
return value.map(entry => entry.type === 'group'
? {
tags: (entry.tags || []).map(storedTag),
...(entry.color ? { color: entry.color } : {})
}
: storedTag(entry));
};
const sectionSelectorKeys = ['selectorParent', 'selectorHeader', 'selectorImagesContainer'];
const sectionsToEditor = value => Array.isArray(value)
? value.map(entry =>
Object.fromEntries(sectionSelectorKeys.map(key => [key,
Array.isArray(entry[key]) ? entry[key].join('\n') : entry[key] || ''])))
: [];
const sectionsFromEditor = (value, previous) => {
if (!value.length && previous == null) return null;
return value.map(entry => Object.fromEntries(sectionSelectorKeys.map(key => {
const lines = entry[key].split('\n').map(v => v.trim()).filter(Boolean);
// Preserve selector arrays across edits and reordering; never reformat CSS commas.
const matching = Array.isArray(previous) && previous.find(item =>
Array.isArray(item[key]) && item[key].join('\n') === entry[key]);
return [key, (matching || lines.length > 1) ? lines : lines.join(', ')];
})));
};
const utagsToEditor = value => (Array.isArray(value)
? value
: typeof value === 'string' ? value.split(',').map(v => v.trim()).filter(Boolean) : [])
.map(value => ({ value }));
// Keep Pixiv user IDs as strings, including any leading zeroes. Never use
// a numeric input, since converting large IDs to Number can lose precision.
const isPixivUserId = value => typeof value === 'string' && /^[0-9]+$/.test(value);
const validateBlockedUsersEditor = value => Array.isArray(value) &&
value.every(entry => isPlainObject(entry) && isPixivUserId(entry.userId));
const localizedTextField = key => ({
render: () => {
const root = document.createElement('div');
root.className = 'pixiv-utils-text-editor';
const input = document.createElement('input');
input.id = key;
input.type = 'text';
input.className = 'gc-c';
input.placeholder = tr(`text.${key}`);
const label = document.createElement('label');
label.className = 'pixiv-utils-empty-override';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const span = document.createElement('span');
span.textContent = tr('text.explicitEmpty');
label.append(checkbox, span);
checkbox.addEventListener('change', () => {
if (checkbox.checked) input.value = '';
input.disabled = checkbox.checked;
// A change on the checkbox still needs to update gmConfig's dirty state.
input.dispatchEvent(new Event('input', { bubbles: true }));
});
root.append(input, label);
root._textInput = input;
root._emptyOverride = checkbox;
return root;
},
get: element => element._emptyOverride.checked ? '' : element._textInput.value || null,
set: (element, value) => {
element._emptyOverride.checked = value === '';
element._textInput.value = value || '';
element._textInput.disabled = value === '';
}
});
// Tag strings are single-line. Only case and Unicode flags affect matching.
const validTagRegexFlags = flags => typeof flags === 'string' && /^[iuv]*$/.test(flags);
const validateTagRegex = (pattern, flags) => {
if (!validTagRegexFlags(flags)) return tr('rule.regexInfo');
try { RegExp(pattern, flags); } catch (error) { return error.message; }
return '';
};
const validateTagRules = (value, highlighted = false, combine = true) => {
if (!Array.isArray(value)) return tr('rule.listRequired');
const compiledGroups = new Map();
const ruleLocation = (item, group = null) => tr(
group === null ? 'rule.itemLocation' : 'rule.groupItemLocation',
{
tab: tr(highlighted ? 'tab.Highlights' : 'tab.Filtering'),
item: item + 1,
group: group === null ? '' : group + 1
}
);
const check = (rules, getLocation) => {
for (const [index, rule] of rules.entries()) {
const location = getLocation(index);
if (!rule || typeof rule.value !== 'string') return tr('rule.needsText', { location });
if (!rule.regex) continue;
const flags = rule.flags || '';
const regexError = validateTagRegex(rule.value, flags);
if (regexError) return tr('rule.invalidRegex', { location, error: regexError });
// Mirrors the runtime's grouping by the exact flags string.
if (combine) {
if (!compiledGroups.has(flags)) compiledGroups.set(flags, { sources: [], locations: [] });
const compiledGroup = compiledGroups.get(flags);
compiledGroup.sources.push(rule.value);
compiledGroup.locations.push(location);
}
}
return '';
};
let error = '';
if (highlighted) {
for (const [index, entry] of value.entries()) {
if (entry.type === 'group') {
if (!Array.isArray(entry.tags)) {
return tr('rule.groupNeedsTags', {
tab: tr('tab.Highlights'),
number: index + 1
});
}
error = check(entry.tags, item => ruleLocation(item, index));
} else {
error = check([entry], () => ruleLocation(index));
}
if (error) return error;
}
} else if ((error = check(value, item => ruleLocation(item)))) return error;
if (combine) {
for (const [flags, group] of compiledGroups) {
try { RegExp(group.sources.join('|'), flags); } catch (err) {
return tr('rule.combinedRegex', {
location: group.locations.join(', '),
flags: flags || tr('rule.noFlags'),
error: err.message
});
}
}
}
return '';
};
const tagRuleSchema = () => ({
value: {
type: 'text',
label: tr('rule.tagPattern'),
default: '',
row: 'rule',
placeholder: tr('rule.patternPlaceholder')
},
regex: { type: 'checkbox', label: tr('rule.regex'), default: false, row: 'rule' },
flags: {
type: 'text',
label: tr('rule.flags'),
default: '',
row: 'rule',
placeholder: 'iuv',
depends: v => Boolean(v.regex)
}
});
const dateOptionChoices = {
weekday: ['', 'long', 'short', 'narrow'],
era: ['', 'long', 'short', 'narrow'],
year: ['', 'numeric', '2-digit'],
month: ['', 'numeric', '2-digit', 'long', 'short', 'narrow'],
day: ['', 'numeric', '2-digit'],
dayPeriod: ['', 'narrow', 'short', 'long'],
hour: ['', 'numeric', '2-digit'],
minute: ['', 'numeric', '2-digit'],
second: ['', 'numeric', '2-digit'],
fractionalSecondDigits: ['', '1', '2', '3'],
hour12: ['', 'true', 'false'],
hourCycle: ['', 'h11', 'h12', 'h23', 'h24'],
timeZoneName: ['', 'short', 'long', 'shortOffset', 'longOffset', 'shortGeneric', 'longGeneric'],
dateStyle: ['', 'full', 'long', 'medium', 'short'],
timeStyle: ['', 'full', 'long', 'medium', 'short'],
localeMatcher: ['', 'lookup', 'best fit'],
formatMatcher: ['', 'basic', 'best fit']
};
const dateTextOptions = ['timeZone', 'calendar', 'numberingSystem'];
const dateKnownOptions = [...Object.keys(dateOptionChoices), ...dateTextOptions];
const dateOptionsField = {
render: field => {
const root = document.createElement('div');
root.id = 'DATE_CONVERSION_OPTIONS';
root.className = 'pixiv-utils-date-editor';
root._extraDateOptions = {};
for (const key of dateKnownOptions) {
const label = document.createElement('label');
label.textContent = tr(`date.${key}`);
let input;
if (key in dateOptionChoices) {
input = document.createElement('select');
for (const option of dateOptionChoices[key]) {
const el = document.createElement('option');
el.value = option;
el.textContent = option === '' ? tr('date.unspecified') : option;
input.append(el);
}
} else {
input = document.createElement('input');
input.type = 'text';
input.placeholder = key === 'timeZone' ? tr('date.systemTimezone') : tr('date.unspecified');
}
input.dataset.dateOption = key;
label.append(input);
root.append(label);
}
const preview = document.createElement('output');
preview.className = 'pixiv-utils-date-preview';
root.append(preview);
const read = () => {
const result = { ...root._extraDateOptions };
for (const input of root.querySelectorAll('[data-date-option]')) {
const key = input.dataset.dateOption;
if (input.value !== '') {
result[key] = key === 'hour12'
? input.value === 'true'
: key === 'fractionalSecondDigits' ? Number(input.value) : input.value;
}
}
return result;
};
const updatePreview = () => {
try {
const options = read();
preview.textContent = tr('date.example', {
value: new Intl.DateTimeFormat(
CONFIG.DATE_CONVERSION_LOCALES, options).format(new Date('2026-10-22T12:34:56Z'))
});
preview.title = '';
} catch (error) {
preview.textContent = tr('date.invalid', { error: error.message });
}
};
root._readDate = read;
root._updatePreview = updatePreview;
root.addEventListener('input', updatePreview);
root.addEventListener('change', updatePreview);
return root;
},
get: element => element._readDate(),
set: (element, value) => {
const options = isPlainObject(value) ? value : {};
element._extraDateOptions = Object.fromEntries(
Object.entries(options).filter(([key]) => !dateKnownOptions.includes(key)));
for (const input of element.querySelectorAll('[data-date-option]')) {
const v = options[input.dataset.dateOption];
input.value = v == null ? '' : String(v);
}
element._updatePreview();
},
validate: value => {
if (!isPlainObject(value)) return tr('date.expectedObject');
try { Intl.DateTimeFormat(CONFIG.DATE_CONVERSION_LOCALES, value); } catch (error) { return error.message; }
return '';
}
};
const jsonObjectField = (nullable = false) => ({
type: 'textarea',
rows: 9,
attrs: { spellcheck: 'false' },
validate: value => {
try {
const parsed = JSON.parse(value);
if (nullable ? (parsed !== null && !Array.isArray(parsed)) : !isPlainObject(parsed)) {
return tr(nullable ? 'validation.arrayNull' : 'validation.object');
}
if (nullable && Array.isArray(parsed) &&
!parsed.every(entry => isPlainObject(entry))) return tr('validation.arrayObjects');
} catch (error) { return error.message; }
return '';
}
});
// Only expose supported positioning options.
const validateFloatingOptions = value => {
if (!isPlainObject(value)) throw new TypeError('Expected a Floating UI options object.');
const allowed = Object.keys(ENV_DEFAULTS.FLOATING_TOOLTIPS_OPTIONS);
for (const [key, option] of Object.entries(value)) {
if (!allowed.includes(key)) throw new TypeError(`Unknown Floating UI option: ${key}`);
if (key === 'placement') {
if (!/^(top|right|bottom|left)(-(start|end))?$/.test(option)) {
throw new TypeError('placement must be top, right, bottom, left, or a -start/-end variant.');
}
} else if (key === 'strategy') {
if (!['fixed', 'absolute'].includes(option)) throw new TypeError('strategy must be fixed or absolute.');
} else if (!Number.isFinite(option) ||
(key !== 'crossAxisOffset' && option < 0) ||
(key === 'maxWidth' && option <= 0)) {
throw new TypeError(`${key} must be a finite ${key === 'maxWidth' ? 'positive' : key === 'crossAxisOffset' ? '' : 'non-negative '}number.`);
}
}
};
// UI serialization only. No storage changes occur until Save, Reset or Import.
const settingToEditor = (key, value) => {
if (key === 'SETTINGS_LANGUAGE') return value === 'zh' ? 'zh-CN' : value === 'zh-Hant' ? 'zh-TW' : value;
if (key === 'MODE') return value === 'DEBUG';
if (key === 'SECTIONS_TOGGLE_BOOKMARKED') return sectionsToEditor(value);
if (key === 'UTAGS_BLOCKED_TAGS') return utagsToEditor(value);
if (key === 'PIXIV_BLOCKED_USERS') {
return Array.isArray(value) ? value.map(userId => ({ userId })) : [];
}
if (key === 'PIXIV_BLOCKED_TAGS' || key === 'PIXIV_HIGHLIGHTED_TAGS') {
return tagsToEditor(value, key === 'PIXIV_HIGHLIGHTED_TAGS');
}
if (key === 'FLOATING_TOOLTIPS_OPTIONS') return settingsJson(value);
if (key.startsWith('SELECTORS_')) return Array.isArray(value) ? value.join('\n') : value || '';
return value;
};
const editorToSetting = (key, value, previous) => {
if (key === 'SETTINGS_LANGUAGE') return value === 'zh-CN' ? 'zh' : value;
if (key === 'MODE') return value ? 'DEBUG' : 'PROD';
if (key === 'SECTIONS_TOGGLE_BOOKMARKED') return sectionsFromEditor(value, previous);
if (key === 'UTAGS_BLOCKED_TAGS') return value.map(rule => rule.value);
if (key === 'PIXIV_BLOCKED_USERS') {
if (!validateBlockedUsersEditor(value)) throw new TypeError(tr('users.invalid'));
return value.map(entry => entry.userId);
}
if (key === 'PIXIV_BLOCKED_TAGS' || key === 'PIXIV_HIGHLIGHTED_TAGS') {
return tagsFromEditor(value, key === 'PIXIV_HIGHLIGHTED_TAGS');
}
if (key === 'FLOATING_TOOLTIPS_OPTIONS') return JSON.parse(value);
if (key.startsWith('SELECTORS_')) {
// Always persist the line-separated textarea as an array; boot joins the
// entries with commas, and plain string values stay supported for hand
// edits made outside the settings panel.
return value.split('\n').map(s => s.trim()).filter(Boolean);
}
return value;
};
const settingsTab = key => {
if (key === 'SETTINGS_LANGUAGE' || key === 'DATE_CONVERSION' || key.startsWith('DATE_CONVERSION_') ||
key === 'ENABLE_KEYBINDS' || key === 'REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME' ||
key === 'REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME') return 'General';
if (key.includes('BOOKMARK') && !key.startsWith('SELECTORS_') &&
key !== 'SECTIONS_TOGGLE_BOOKMARKED') return 'Bookmarks';
if (key === 'PIXIV_BLOCKED_USERS') return 'Users';
if (key.startsWith('PIXIV_HIGHLIGHTED')) return 'Highlights';
if (key.startsWith('PIXIV_')) return 'Filtering';
if (key.startsWith('FLOATING_TOOLTIPS_') || key === 'ENABLE_FLOATING_TOOLTIPS') return 'Tooltips';
if (key.startsWith('UTAGS_')) return 'UTags';
return 'Advanced';
};
const makeSettingsDefinitions = () => {
const definitions = {};
const tabOrder = ['General', 'Bookmarks', 'Filtering', 'Users', 'Highlights', 'Tooltips', 'UTags', 'Advanced'];
const priority = {
MODE: -20,
WAIT_FOR_POLL: -10,
// Group the behavior switches above the tag/color editors.
PIXIV_BLOCKED_TAGS_FAST: -20,
PIXIV_REMOVE_BLOCKED: -10,
PIXIV_HIGHLIGHTED_TAGS_FAST: -20,
PIXIV_HIGHLIGHTED_BYPASS_BLOCK: -10,
PIXIV_HIGHLIGHTED_COLOR: -5
};
const options = Object.entries(ENV_DEFAULTS).sort(([a], [b]) =>
Number(b === 'SETTINGS_LANGUAGE') - Number(a === 'SETTINGS_LANGUAGE') ||
tabOrder.indexOf(settingsTab(a)) - tabOrder.indexOf(settingsTab(b)) ||
(priority[a] ?? 0) - (priority[b] ?? 0));
for (const [key, fallback] of options) {
const field = { label: tr(`label.${key}`), tab: tr(`tab.${settingsTab(key)}`), default: settingToEditor(key, fallback) };
if (key === 'MODE') {
field.type = 'checkbox';
field.label = tr('label.MODE');
field.description = tr('field.debugInfo');
} else if (key === 'WAIT_FOR_POLL') {
field.type = 'number';
field.min = 1;
field.step = 1;
field.validate = value => Number.isSafeInteger(value) && value > 0
? ''
: tr('validation.positiveInteger');
} else if (key === 'SETTINGS_LANGUAGE') {
field.type = 'select';
field.options = {
auto: tr('language.auto'),
en: tr('language.en'),
ja: tr('language.ja'),
'zh-CN': tr('language.zh-CN'),
'zh-HK': tr('language.zh-HK'),
'zh-TW': tr('language.zh-TW')
};
field.description = tr('field.languageInfo');
} else if (key.startsWith('TEXT_')) {
Object.assign(field, localizedTextField(key));
} else if (typeof fallback === 'boolean') {
field.type = 'checkbox';
} else if (typeof fallback === 'number') {
field.type = 'number';
field.min = 0;
} else if (key === 'PIXIV_BLOCKED_TAGS' || key === 'PIXIV_HIGHLIGHTED_TAGS') {
const highlighted = key === 'PIXIV_HIGHLIGHTED_TAGS';
field.type = 'list';
field.addLabel = tr(highlighted ? 'rule.addGroup' : 'rule.add');
field.reorder = true;
field.itemTitle = r => r.type === 'group' ? tr('rule.colored') : regexRuleTitle(r);
field.item = highlighted
? {
type: {
type: 'select',
label: tr('rule.type'),
default: 'tag',
options: { tag: tr('rule.single'), group: tr('rule.colored') }
},
value: { ...tagRuleSchema().value, depends: r => r.type !== 'group' },
regex: { ...tagRuleSchema().regex, depends: r => r.type !== 'group' },
flags: { ...tagRuleSchema().flags, depends: r => r.type !== 'group' && r.regex },
color: {
type: 'text',
label: tr('rule.groupColor'),
default: '',
placeholder: ENV_DEFAULTS.PIXIV_HIGHLIGHTED_COLOR,
depends: r => r.type === 'group'
},
tags: {
type: 'list',
label: tr('rule.groupTags'),
default: [],
item: tagRuleSchema(),
itemTitle: r => regexRuleTitle(r),
addLabel: tr('rule.addInGroup'),
reorder: true,
depends: r => r.type === 'group'
}
}
: tagRuleSchema();
field.description = tr('rule.regexInfo');
} else if (key === 'DATE_CONVERSION_OPTIONS') {
Object.assign(field, dateOptionsField);
field.description = tr('field.dateInfo');
} else if (key === 'FLOATING_TOOLTIPS_OPTIONS') {
Object.assign(field, jsonObjectField());
field.validate = value => {
try { validateFloatingOptions(JSON.parse(value)); } catch (error) { return error.message; }
return '';
};
field.description = tr('field.floatingJson');
} else if (key === 'SECTIONS_TOGGLE_BOOKMARKED') {
field.type = 'list';
field.addLabel = tr('sections.add');
field.reorder = true;
field.itemTitle = entry => entry.selectorParent || tr('sections.parent');
field.item = {
selectorParent: {
type: 'textarea',
rows: 2,
required: true,
default: '',
label: tr('sections.parent')
},
selectorHeader: {
type: 'textarea',
rows: 2,
required: true,
default: '',
label: tr('sections.header')
},
selectorImagesContainer: {
type: 'textarea',
rows: 2,
required: true,
default: '',
label: tr('sections.images')
}
};
field.description = tr('sections.info');
field.validate = value => {
for (const entry of value) {
const values = [entry.selectorParent, entry.selectorHeader, entry.selectorImagesContainer];
if (values.some(v => !normalizeSelectorList(v))) return tr('sections.info');
}
return '';
};
} else if (key === 'PIXIV_BLOCKED_USERS') {
field.type = 'list';
field.addLabel = tr('users.add');
field.reorder = true;
field.itemTitle = entry => entry.userId || tr('users.id');
field.item = {
userId: {
type: 'text',
label: tr('users.id'),
default: '',
required: true,
attrs: { inputmode: 'numeric', pattern: '[0-9]+' }
}
};
field.description = tr('users.info');
field.validate = value => validateBlockedUsersEditor(value) ? '' : tr('users.invalid');
} else if (key === 'UTAGS_BLOCKED_TAGS') {
field.type = 'list';
field.addLabel = tr('utags.add');
field.reorder = true;
field.itemTitle = entry => entry.value || tr('utags.tag');
field.item = {
value: { type: 'text', label: tr('utags.tag'), default: '', required: true }
};
field.description = tr('utags.info');
field.validate = value => value.some(entry => !entry.value?.trim()) ? tr('validation.string') : '';
} else if (key.startsWith('SELECTORS_')) {
field.type = 'textarea';
field.rows = 3;
} else if (typeof fallback === 'string') {
field.type = 'text';
if (key === 'PIXIV_HIGHLIGHTED_COLOR') {
field.description = tr('field.highlightInfo');
field.placeholder = ENV_DEFAULTS.PIXIV_HIGHLIGHTED_COLOR;
}
} else {
field.type = 'textarea';
field.rows = 5;
field.validate = value => {
try {
JSON.parse(value);
return '';
} catch (error) {
return error.message;
}
};
}
if (key === 'TEXT_TOGGLE_BOOKMARKED') field.description = tr('field.bookmarkFilterTextInfo');
if (key === 'ENABLE_KEYBINDS') field.description = tr('field.keybindInfo');
if (key === 'ENABLE_FANCY_EDIT_BOOKMARK') field.description = tr('field.fancyPanelInfo');
if (key === 'PIXIV_BLOCK_AI') field.description = tr('field.blockAiInfo');
if (key.endsWith('_COMBINE_REGEXES')) field.description = tr('field.combineRegexInfo');
if (key === 'PIXIV_HIGHLIGHTED_BYPASS_BLOCK') field.description = tr('field.highlightBypassInfo');
if (key === 'PIXIV_HIGHLIGHTED_TAGS_FAST') field.description = tr('field.highlightFastInfo');
if (key === 'FLOATING_TOOLTIPS_STATIONARY_DELAY') field.description = tr('field.delayInfo');
if (key === 'FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE') field.description = tr('field.toleranceInfo');
if (key === 'FLOATING_TOOLTIPS_SHOW_DELAY') field.description = tr('field.floatingShowDelay');
if (key === 'FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY') field.description = tr('field.floatingTouchHoldDelay');
if (key === 'WAIT_FOR_POLL') field.description = tr('field.waitForPoll');
definitions[key] = field;
}
// Backup controls are managed locally and never persisted as configuration values.
definitions.BACKUP_TOOLS = {
tab: tr('tab.Backup'),
label: tr('label.BACKUP_TOOLS'),
save: false,
default: '',
...backupField
};
return definitions;
};
// Validate a backup as one unit before writing any GM storage keys. Existing
// per-key storage and partial-import semantics are preserved.
const validateBackupSettings = imported => {
if (!isPlainObject(imported)) throw new TypeError(tr('backup.objectError'));
const get = key => Object.hasOwn(imported, key)
? imported[key]
: getSetting(key);
const textRuleValid = value => {
if (typeof value === 'string') return true;
if (!Array.isArray(value) || value.length !== 2 ||
typeof value[0] !== 'string' || typeof value[1] !== 'string') return false;
return validTagRegexFlags(value[1]);
};
const validateTagArray = (key, value, highlighted) => {
if (value === null) return;
// Keep compatibility with legacy comma-separated custom-tag settings.
if (typeof value === 'string') value = value.split(',').map(tag => tag.trim()).filter(Boolean);
if (!Array.isArray(value)) throw new TypeError(`${key}: expected a list, string, or null.`);
for (const entry of value) {
if (highlighted && isPlainObject(entry)) {
if (!Array.isArray(entry.tags) || !entry.tags.every(textRuleValid) ||
(entry.color !== undefined && typeof entry.color !== 'string')) {
throw new TypeError(`${key}: invalid colored group.`);
}
} else if (!textRuleValid(entry)) {
throw new TypeError(`${key}: invalid literal tag or regex [pattern, flags].`);
}
}
const error = validateTagRules(tagsToEditor(value, highlighted), highlighted,
Boolean(get(`${key}_COMBINE_REGEXES`)));
if (error) throw new TypeError(`${key}: ${error}`);
};
for (const [key, value] of Object.entries(imported)) {
if (['__proto__', 'constructor', 'prototype'].includes(key)) {
throw new TypeError(`Unsafe setting name: ${key}`);
}
if (!Object.hasOwn(ENV_DEFAULTS, key)) {
// Preserve old/extra preference keys in backups; only known config keys
// have schemas. This retains the original partial-import semantics.
if (key === 'PREF_TOGGLE_BOOKMARKED_MODE' &&
(!Number.isInteger(value) || value < 0 || value > 2)) {
throw new TypeError(`${key}: expected an integer between 0 and 2.`);
}
continue;
}
const def = ENV_DEFAULTS[key];
if (key === 'MODE') {
if (!['DEBUG', 'PROD'].includes(value)) throw new TypeError('MODE: expected DEBUG or PROD.');
} else if (key === 'SETTINGS_LANGUAGE') {
if (!['auto', 'en', 'ja', 'zh', 'zh-CN', 'zh-HK', 'zh-TW', 'zh-Hant'].includes(value)) {
throw new TypeError(`${key}: unsupported language.`);
}
} else if (key === 'PIXIV_BLOCKED_TAGS' || key === 'PIXIV_HIGHLIGHTED_TAGS') {
validateTagArray(key, value, key === 'PIXIV_HIGHLIGHTED_TAGS');
} else if (key === 'PIXIV_BLOCKED_USERS') {
if (!Array.isArray(value) || !value.every(isPixivUserId)) {
throw new TypeError(`${key}: ${tr('users.invalid')}`);
}
} else if (key === 'UTAGS_BLOCKED_TAGS') {
if (typeof value !== 'string' &&
!(Array.isArray(value) && value.every(item => typeof item === 'string'))) {
throw new TypeError(`${key}: expected a string or array of strings.`);
}
} else if (key === 'SECTIONS_TOGGLE_BOOKMARKED') {
if (value !== null && (!Array.isArray(value) || !value.every(section =>
isPlainObject(section) && sectionSelectorKeys.every(name => {
const selector = section[name];
if (typeof selector === 'string') return Boolean(selector.trim());
return Array.isArray(selector) && selector.length > 0 &&
selector.every(item => typeof item === 'string' && Boolean(item.trim()));
})))) {
throw new TypeError(`${key}: expected sections containing all three selectors.`);
}
} else if (key.startsWith('SELECTORS_')) {
if (value !== null && typeof value !== 'string' &&
!(Array.isArray(value) && value.every(item => typeof item === 'string'))) {
throw new TypeError(`${key}: expected a selector string, array, or null.`);
}
} else if (key === 'DATE_CONVERSION_OPTIONS') {
if (!isPlainObject(value)) throw new TypeError(`${key}: expected an object.`);
try {
Intl.DateTimeFormat(get('DATE_CONVERSION_LOCALES'), value);
} catch (error) { throw new TypeError(`${key}: ${error.message}`); }
} else if (key === 'DATE_CONVERSION_LOCALES') {
if (typeof value !== 'string') throw new TypeError(`${key}: expected a locale string.`);
try { Intl.DateTimeFormat(value, get('DATE_CONVERSION_OPTIONS')); } catch (error) { throw new TypeError(`${key}: ${error.message}`); }
} else if (key === 'FLOATING_TOOLTIPS_OPTIONS') {
validateFloatingOptions(value);
} else if (key === 'WAIT_FOR_POLL') {
if (!Number.isSafeInteger(value) || value < 1) {
throw new TypeError(`${key}: expected a positive integer.`);
}
} else if (key.startsWith('TEXT_') || def === null) {
if (value !== null && typeof value !== 'string') {
throw new TypeError(`${key}: expected a string or null.`);
}
} else if (typeof def === 'boolean') {
if (typeof value !== 'boolean') throw new TypeError(`${key}: expected a boolean.`);
} else if (typeof def === 'number') {
if (!Number.isFinite(value) || value < 0) {
throw new TypeError(`${key}: expected a non-negative number.`);
}
} else if (typeof def === 'string' && typeof value !== 'string') {
throw new TypeError(`${key}: expected a string.`);
}
}
};
const importExistingSettings = async (json, panel = settingsPanel) => {
try {
const imported = JSON.parse(json);
if (!isPlainObject(imported)) throw new TypeError(tr('backup.objectError'));
if (!Object.keys(imported).length) throw new Error(tr('backup.emptyObject'));
validateBackupSettings(imported);
if (!await showConfirm(tr('backup.importConfirm', { count: Object.keys(imported).length }))) return false;
for (const [key, value] of Object.entries(imported)) {
if (key === _PREBUILT_KEY || key.startsWith('PREF_')) continue;
GM_setValue(key, value);
if (panel?.isOpen && Object.hasOwn(ENV_DEFAULTS, key)) {
panel.set(key, settingToEditor(key, settingOrDefault(key, value)));
}
}
GM_setValue(_PREBUILT_KEY, null);
if (panel?.isOpen) {
// Update the stored-value baseline, so Save doesn't undo imported values.
await panel._load();
panel._mark();
refreshSettingsEnhancements(panel, false);
}
showAlert(tr('backup.imported'));
return true;
} catch (error) {
showAlert(tr('backup.importError', { error: error.message }));
return false;
}
};
const backupField = {
render: () => {
const root = document.createElement('div');
root.className = 'pixiv-utils-backup';
const instructions = document.createElement('p');
instructions.textContent = tr('backup.intro');
const textarea = document.createElement('textarea');
textarea.rows = 16;
textarea.placeholder = tr('backup.placeholder');
textarea.spellcheck = false;
textarea.setAttribute('aria-label', tr('backup.aria'));
const buttons = document.createElement('div');
buttons.className = 'pixiv-utils-backup-buttons';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,application/json';
fileInput.hidden = true;
const addButton = (label, onClick) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', onClick);
buttons.append(button);
return button;
};
addButton(tr('backup.export'), () => {
textarea.value = settingsJson(Object.fromEntries(
GM_listValues().filter(key => key !== _PREBUILT_KEY && !key.startsWith('PREF_')).map(key => [key, GM_getValue(key)])));
});
addButton(tr('backup.copy'), async () => {
if (!textarea.value.trim()) return showAlert(tr('backup.emptyCopy'));
try {
await navigator.clipboard.writeText(textarea.value);
showAlert(tr('backup.copied'));
} catch (error) {
textarea.focus();
textarea.select();
showAlert(tr('backup.clipboardFailed'));
}
});
addButton(tr('backup.download'), () => {
if (!textarea.value.trim()) return showAlert(tr('backup.emptyDownload'));
const blob = new Blob([textarea.value], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'Bobbys_Pixiv_Utils_Settings.json';
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
addButton(tr('backup.choose'), () => fileInput.click());
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
try { textarea.value = await file.text(); } catch (error) { showAlert(tr('backup.fileError', { error: error.message })); }
fileInput.value = '';
});
addButton(tr('backup.import'), () => {
if (!textarea.value.trim()) return showAlert(tr('backup.emptyImport'));
importExistingSettings(textarea.value);
});
root.append(instructions, textarea, buttons, fileInput);
return root;
},
get: () => '',
set: () => {}
};
// Keep all visual-only list helpers independent of the underlying stored rule values.
const getSettingsHighlightColor = () =>
document.querySelector('.gc-p input[id="PIXIV_HIGHLIGHTED_COLOR"]')?.value.trim() ||
ENV_DEFAULTS.PIXIV_HIGHLIGHTED_COLOR;
const updateColorInputBorder = (input, fallback = '') => {
const color = input.value.trim() || fallback;
// Native color parsing accepts named colors, rgb(), transparent, etc.
if (color && CSS.supports('color', color)) {
input.style.borderColor = color;
// An outer ring thickens the visual border without changing element dimensions.
input.style.boxShadow = `0 0 0 1px ${color}`;
} else {
input.style.removeProperty('border-color');
input.style.removeProperty('box-shadow');
}
};
const syncColoredGroupToggles = (scope = document) => {
const groups = scope.matches?.('.gc-g:has(> .gc-list[id$="_tags"])')
? [scope]
: scope.querySelectorAll('.gc-p .gc-g:has(> .gc-list[id$="_tags"]), .gc-g:has(> .gc-list[id$="_tags"])');
let highlightFallback;
for (const container of groups) {
const list = container.querySelector(':scope > .gc-list');
const hasTags = Boolean(list?.querySelector(':scope > .gc-list-items > .gc-li'));
let button = container.querySelector(':scope > .pixiv-utils-collapse-group');
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = 'pixiv-utils-collapse-group';
// Existing groups default to collapsed; new empty groups stay expanded.
container.classList.toggle('pixiv-utils-nested-collapsed', hasTags);
button.addEventListener('click', () => {
container.classList.toggle('pixiv-utils-nested-collapsed');
syncColoredGroupToggles();
});
container.querySelector(':scope > label')?.after(button);
}
if (!hasTags) container.classList.remove('pixiv-utils-nested-collapsed');
const collapsed = container.classList.contains('pixiv-utils-nested-collapsed');
button.textContent = tr(collapsed ? 'rule.expand' : 'rule.collapse');
button.setAttribute('aria-expanded', String(!collapsed));
button.hidden = !hasTags;
const input = container.closest('.gc-li-b')?.querySelector('input[id$="_color"]');
if (input) {
if (highlightFallback === undefined) highlightFallback = getSettingsHighlightColor();
input.placeholder = highlightFallback;
if (!input.dataset.pixivUtilsColorListener) {
input.addEventListener('input', () => updateColorInputBorder(input, getSettingsHighlightColor()));
input.dataset.pixivUtilsColorListener = 'true';
}
updateColorInputBorder(input, highlightFallback);
}
}
};
// Shared notices and section dividers are rendered once per panel/reinit. The
// underlying setting fields retain their original IDs and value adapters.
const syncSettingsDecorations = () => {
const insertBefore = (key, className, content = '') => {
const group = document.getElementById(key)?.closest('.gc-g');
if (!group || group.parentElement.querySelector(`:scope > .${className}[data-before="${key}"]`)) return;
const node = document.createElement('div');
node.className = className;
node.dataset.before = key;
if (content) node.textContent = content;
group.before(node);
};
for (const key of ['DATE_CONVERSION', 'REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME',
'TEXT_EDIT_BOOKMARK', 'TEXT_TOGGLE_BOOKMARKED_SHOW_ALL', 'PIXIV_BLOCK_AI',
'PIXIV_BLOCKED_TAGS', 'PIXIV_HIGHLIGHTED_COLOR', 'PIXIV_HIGHLIGHTED_TAGS',
'FLOATING_TOOLTIPS_STATIONARY_DELAY', 'UTAGS_BLOCKED_TAGS',
'SELECTORS_HOME', 'SECTIONS_TOGGLE_BOOKMARKED', 'FLOATING_TOOLTIPS_OPTIONS']) {
insertBefore(key, 'pixiv-utils-settings-divider');
}
insertBefore('TEXT_EDIT_BOOKMARK', 'pixiv-utils-global-note', tr('text.fallback'));
insertBefore('SELECTORS_HOME', 'pixiv-utils-section-heading', tr('selector.heading'));
insertBefore('SELECTORS_HOME', 'pixiv-utils-global-note', tr('field.selectorInfo'));
insertBefore('TEXT_TOGGLE_BOOKMARKED_SHOW_ALL', 'pixiv-utils-section-heading', tr('bookmark.stateHeading'));
insertBefore('TEXT_TOGGLE_BOOKMARKED_SHOW_ALL', 'pixiv-utils-global-note', tr('bookmark.stateInfo'));
const mainColor = document.querySelector('.gc-p input[id="PIXIV_HIGHLIGHTED_COLOR"]');
if (mainColor) {
if (!mainColor.dataset.pixivUtilsColorListener) {
mainColor.addEventListener('input', () => {
updateColorInputBorder(mainColor, ENV_DEFAULTS.PIXIV_HIGHLIGHTED_COLOR);
// Empty group colors inherit the actively edited main highlight color.
for (const groupColor of document.querySelectorAll('.gc-p input[id$="_color"]')) {
groupColor.placeholder = getSettingsHighlightColor();
if (!groupColor.value.trim()) updateColorInputBorder(groupColor, getSettingsHighlightColor());
}
});
mainColor.dataset.pixivUtilsColorListener = 'true';
}
updateColorInputBorder(mainColor, ENV_DEFAULTS.PIXIV_HIGHLIGHTED_COLOR);
}
};
// List ordering is a UI-only enhancement. gmConfig reads records from the
// actual .gc-list-items DOM order; moving existing nodes preserves all live
// input values, nested editors and their original storage serialization.
const initListOrderControls = panel => {
const dialog = panel._dlg;
if (!dialog) return;
let state = panel._pixivListOrderState;
if (!state) {
state = { observers: new Map(), dragging: null, touchDrag: null, dropTarget: null };
panel._pixivListOrderState = state;
const clearDropTarget = () => {
state.dropTarget?.classList.remove('pixiv-utils-drop-before', 'pixiv-utils-drop-after');
state.dropTarget = null;
};
const draggedListItem = event => event.target.closest?.('.gc-li');
const moveItem = (item, index) => {
const items = item.parentElement;
if (!items?.classList.contains('gc-list-items')) return;
const siblings = [...items.children].filter(node => node !== item);
const destination = Math.min(Math.max(index, 0), siblings.length);
if ([...items.children].indexOf(item) === destination) return;
items.insertBefore(item, siblings[destination] ?? null);
refreshList(items);
// gmConfig's own list-item input listener calls _onListChange() for
// the correct field, including parent records for nested lists.
item.dispatchEvent(new Event('input', { bubbles: true }));
};
const onDragStart = event => {
if (!event.target.matches?.('.pixiv-utils-drag-handle')) return;
const item = draggedListItem(event);
if (!item?.parentElement?.classList.contains('gc-list-items')) return;
state.dragging = item;
if (event.dataTransfer) {
// Firefox requires setData for native HTML5 drag-and-drop to start.
event.dataTransfer.setData('text/plain', 'pixiv-utils-list-item');
event.dataTransfer.effectAllowed = 'move';
}
item.classList.add('pixiv-utils-dragging');
};
const onDragOver = event => {
const item = draggedListItem(event);
if (!state.dragging || !item || item === state.dragging ||
item.parentElement !== state.dragging.parentElement) {
clearDropTarget();
return;
}
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const after = event.clientY >= item.getBoundingClientRect().top + item.getBoundingClientRect().height / 2;
clearDropTarget();
state.dropTarget = item;
item.classList.add(after ? 'pixiv-utils-drop-after' : 'pixiv-utils-drop-before');
};
const dropItem = (dragged, item) => {
if (!dragged || !item || item === dragged || item.parentElement !== dragged.parentElement) return false;
const siblings = [...dragged.parentElement.children].filter(node => node !== dragged);
const after = item.classList.contains('pixiv-utils-drop-after');
moveItem(dragged, siblings.indexOf(item) + (after ? 1 : 0));
clearDropTarget();
return true;
};
const onDrop = event => {
if (dropItem(state.dragging, draggedListItem(event))) event.preventDefault();
};
// Native HTML drag-and-drop has limited touch support. Reuse the same
// destination logic for touch-pointer dragging, keeping index input as a
// keyboard-accessible fallback on all devices.
const onPointerDown = event => {
if (event.pointerType !== 'touch' || !event.target.matches?.('.pixiv-utils-drag-handle')) return;
const item = draggedListItem(event);
if (!item?.parentElement?.classList.contains('gc-list-items')) return;
event.preventDefault();
state.touchDrag = { item, pointerId: event.pointerId };
item.classList.add('pixiv-utils-dragging');
try { event.target.setPointerCapture?.(event.pointerId); } catch {}
};
const onPointerMove = event => {
const drag = state.touchDrag;
if (!drag || event.pointerId !== drag.pointerId) return;
event.preventDefault();
const item = document.elementFromPoint(event.clientX, event.clientY)?.closest('.gc-li');
clearDropTarget();
if (!item || item === drag.item || item.parentElement !== drag.item.parentElement) return;
const box = item.getBoundingClientRect();
const after = event.clientY >= box.top + box.height / 2;
state.dropTarget = item;
item.classList.add(after ? 'pixiv-utils-drop-after' : 'pixiv-utils-drop-before');
};
const onPointerUp = event => {
const drag = state.touchDrag;
if (!drag || event.pointerId !== drag.pointerId) return;
if (event.type === 'pointerup') dropItem(drag.item, state.dropTarget);
drag.item.classList.remove('pixiv-utils-dragging');
state.touchDrag = null;
clearDropTarget();
};
const onDragEnd = () => {
state.dragging?.classList.remove('pixiv-utils-dragging');
state.dragging = null;
clearDropTarget();
};
dialog.addEventListener('dragstart', onDragStart);
dialog.addEventListener('dragover', onDragOver);
dialog.addEventListener('drop', onDrop);
dialog.addEventListener('dragend', onDragEnd);
dialog.addEventListener('pointerdown', onPointerDown);
dialog.addEventListener('pointermove', onPointerMove);
dialog.addEventListener('pointerup', onPointerUp);
dialog.addEventListener('pointercancel', onPointerUp);
state.cleanup = () => {
onDragEnd();
state.touchDrag?.item.classList.remove('pixiv-utils-dragging');
state.touchDrag = null;
for (const observer of state.observers.values()) observer.disconnect();
state.observers.clear();
dialog.removeEventListener('dragstart', onDragStart);
dialog.removeEventListener('dragover', onDragOver);
dialog.removeEventListener('drop', onDrop);
dialog.removeEventListener('dragend', onDragEnd);
dialog.removeEventListener('pointerdown', onPointerDown);
dialog.removeEventListener('pointermove', onPointerMove);
dialog.removeEventListener('pointerup', onPointerUp);
dialog.removeEventListener('pointercancel', onPointerUp);
};
state.moveItem = moveItem;
}
const refreshList = items => {
const entries = [...items.children].filter(item => item.classList.contains('gc-li'));
for (const [index, item] of entries.entries()) {
item._refresh?.();
const control = item.querySelector(':scope > .gc-li-h > .pixiv-utils-list-position');
if (control) {
control.value = String(index + 1);
control.max = String(entries.length);
control.title = tr('list.position', { max: entries.length });
control.setAttribute('aria-label', control.title);
}
}
};
const setupList = list => {
const items = list.querySelector(':scope > .gc-list-items');
if (!items) return;
for (const item of items.children) {
if (!item.classList.contains('gc-li')) continue;
const head = item.querySelector(':scope > .gc-li-h');
if (!head || head.querySelector('.pixiv-utils-list-position')) continue;
const position = document.createElement('input');
position.type = 'number';
position.min = '1';
position.step = '1';
position.inputMode = 'numeric';
position.className = 'pixiv-utils-list-position';
// Index typing is purely navigation; don't send intermediate digits
// through gmConfig's own form/list input listeners.
position.addEventListener('input', event => event.stopPropagation());
position.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); position.blur(); }
});
position.addEventListener('change', () => {
const count = items.children.length;
const raw = position.value.trim();
if (!/^\d+$/.test(raw)) { refreshList(items); return; }
const index = Number(raw);
if (!Number.isSafeInteger(index) || index < 1 || index > count) {
refreshList(items);
return;
}
state.moveItem(item, index - 1);
refreshList(items);
});
const handle = document.createElement('button');
handle.type = 'button';
handle.className = 'pixiv-utils-drag-handle';
handle.textContent = '≡';
handle.title = tr('list.drag');
handle.setAttribute('aria-label', tr('list.drag'));
handle.draggable = true;
const title = head.querySelector('.gc-li-t');
head.insertBefore(handle, title ?? head.firstChild);
handle.after(position);
}
refreshList(items);
if (!state.observers.has(items)) {
// Observe only direct item add/remove/reorder mutations, not title
// changes within items; avoids repeat work on every keystroke.
const observer = new MutationObserver(() => {
initListOrderControls(panel); // install for newly added nested lists
refreshList(items);
});
observer.observe(items, { childList: true });
state.observers.set(items, observer);
}
};
for (const [items, observer] of state.observers) {
if (!items.isConnected) { observer.disconnect(); state.observers.delete(items); }
}
for (const list of dialog.querySelectorAll('.gc-list')) setupList(list);
};
const disposeListOrderControls = panel => {
panel?._pixivListOrderState?.cleanup?.();
if (panel) panel._pixivListOrderState = null;
};
const createPixivUtilsConfig = BaseConfig => class PixivUtilsConfig extends BaseConfig {
async _load () {
// gmConfig 1.0.0 originally loads a single JSON string. Directly read the
// exact same per-key values as the rest of this userscript instead.
this._storedOriginals = {};
this._initialEditorValues = {};
for (const [key, field] of Object.entries(this._fields)) {
if (!(key in ENV_DEFAULTS)) continue; // backup buttons have no storage key
const stored = GM_getValue(key, ENV_DEFAULTS[key]);
this._storedOriginals[key] = stored;
const uiValue = settingToEditor(key, settingOrDefault(key, stored));
field.value = uiValue;
this._initialEditorValues[key] = JSON.stringify(uiValue);
}
this._clean = this._json();
}
async save () {
const current = this._vals();
for (const [key, fallback] of Object.entries(ENV_DEFAULTS)) {
if (!(key in current)) continue;
const currentJson = JSON.stringify(current[key]);
if (!this._resetting && currentJson === this._initialEditorValues[key]) continue;
const stored = this._resetting
? fallback
: editorToSetting(key, current[key], this._storedOriginals[key]);
GM_setValue(key, stored);
this._storedOriginals[key] = stored;
this._initialEditorValues[key] = currentJson;
}
// Always invalidate pre-built selectors on save, so they are rebuilt on
// the next page load even when no SELECTORS_* setting was changed.
GM_setValue(_PREBUILT_KEY, null);
log('Invalidated cached pre-built CSS selectors.');
this._mark();
this._clean = this._json();
}
async reset () {
if (!await showConfirm(tr('message.resetConfirm'))) return;
this._resetting = true;
try {
await super.reset();
} finally {
this._resetting = false;
GM_setValue(_PREBUILT_KEY, null);
}
}
// The library's footer import/export icon opens our Backup tab instead.
async importExport () {
this._activate?.(tr('tab.Backup'));
return false;
}
};
const settingsStyle = /*css*/`
.gc-p, .pixiv-utils-alert {
color-scheme: light dark;
--pixiv-utils-ui-surface: Canvas;
--pixiv-utils-ui-raised: color-mix(in srgb, CanvasText 6%, Canvas);
--pixiv-utils-ui-text: CanvasText;
--pixiv-utils-ui-muted: color-mix(in srgb, CanvasText 68%, Canvas);
--pixiv-utils-ui-border: color-mix(in srgb, CanvasText 20%, transparent);
--pixiv-utils-ui-subtle: color-mix(in srgb, CanvasText 7%, transparent);
--pixiv-utils-ui-accent: #0096fa;
--pixiv-utils-ui-radius: 8px;
--pixiv-utils-ui-shadow: 0 12px 36px rgba(0, 0, 0, 0.24);
}
.pixiv-utils-alert {
box-sizing: border-box;
width: min(440px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
max-height: calc(100dvh - 32px);
padding: 0;
overflow: hidden;
border: 1px solid var(--pixiv-utils-ui-border);
border-radius: var(--pixiv-utils-ui-radius);
background: var(--pixiv-utils-ui-surface);
color: var(--pixiv-utils-ui-text);
box-shadow: var(--pixiv-utils-ui-shadow);
font-family: inherit;
font-size: 14px;
line-height: 1.45;
}
.pixiv-utils-toast {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
padding: 10px 14px;
border-radius: var(--pixiv-utils-ui-radius, 8px);
background: var(--pixiv-utils-ui-surface, Canvas);
color: var(--pixiv-utils-ui-text, CanvasText);
border: 1px solid var(--pixiv-utils-ui-border, rgba(0,0,0,0.2));
box-shadow: var(--pixiv-utils-ui-shadow, 0 12px 36px rgba(0,0,0,0.24));
font: inherit;
font-size: 14px;
}
.pixiv-utils-toast-primary {
background: var(--pixiv-utils-ui-accent, #0096fa);
color: #fff;
border: none;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-tags {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
max-height: 50vh;
overflow-y: auto;
margin: 0;
padding: 0;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-alert-message {
padding: 0 0 16px;
}
.pixiv-utils-edit-bookmark-status {
padding: 0 16px;
}
.pixiv-utils-edit-bookmark-tag {
min-height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 0 16px;
border-radius: 6px;
cursor: pointer;
color: var(--pixiv-utils-ui-text, CanvasText);
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
.pixiv-utils-edit-bookmark-tag:hover {
background: var(--pixiv-utils-ui-subtle, rgba(0, 0, 0, 0.06));
border-radius: 0;
}
.pixiv-utils-edit-bookmark-privacy label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.pixiv-utils-edit-bookmark-tag-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
margin-left: 0;
padding: 1px 6px;
border-radius: 999px;
background: var(--pixiv-utils-ui-raised, rgba(0, 0, 0, 0.08));
color: var(--pixiv-utils-ui-muted, #666);
font-size: 11px;
line-height: 1.2;
}
.pixiv-utils-edit-bookmark-tag input[type='checkbox'] {
margin-left: auto;
}
.pixiv-utils-edit-bookmark-header-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px 8px;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-create-link {
font-size: 12px;
color: var(--pixiv-utils-ui-accent, #0096fa);
text-decoration: none;
cursor: pointer;
transition: none;
}
.pixiv-utils-edit-bookmark-create-link:disabled,
.pixiv-utils-edit-bookmark-create-link[disabled] {
color: var(--pixiv-utils-ui-muted, #666);
pointer-events: none;
opacity: 0.7;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-alert-actions button:first-child {
margin-right: auto;
}
.pixiv-utils-edit-bookmark-panel button,
.pixiv-utils-edit-bookmark-panel a {
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
.pixiv-utils-edit-bookmark-create textarea {
flex: 1;
min-height: 24px;
resize: none;
border: 1px solid var(--pixiv-utils-ui-border, rgba(0,0,0,0.2));
border-radius: 6px;
background-color: var(--pixiv-utils-ui-surface, Canvas);
color: var(--pixiv-utils-ui-text, CanvasText);
padding: 4px 8px;
font: inherit;
}
.pixiv-utils-edit-bookmark-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.pixiv-utils-edit-bookmark-counter {
padding: 0;
font-size: 12px;
color: var(--pixiv-utils-ui-muted, #666);
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-sort-toggle {
border: 0;
background: transparent;
color: var(--pixiv-utils-ui-muted, #666);
cursor: pointer;
font: inherit;
font-size: 12px;
line-height: 1;
min-height: 0;
min-width: 24px;
padding: 0 4px;
margin: 0;
transform: translateY(-1px);
}
.pixiv-utils-edit-bookmark-privacy {
display: flex;
gap: 16px;
padding: 8px 16px 0;
}
.pixiv-utils-edit-bookmark-privacy label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-reload-button {
position: relative;
min-height: 36px;
padding: 6px 13px;
border: 1px solid var(--pixiv-utils-ui-border);
border-radius: 6px;
background: var(--pixiv-utils-ui-surface);
color: var(--pixiv-utils-ui-muted);
font: inherit;
font-size: 11px;
font-weight: 500;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-reload-button:hover:not(:disabled) {
border-color: var(--pixiv-utils-ui-accent);
background: var(--pixiv-utils-ui-raised);
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-reload-button:disabled,
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-apply:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.pixiv-utils-edit-bookmark-create {
display: none;
flex-direction: row;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
}
.pixiv-utils-edit-bookmark-status {
padding: 8px 16px 0;
color: var(--pixiv-utils-ui-muted, #666);
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-apply,
.pixiv-utils-edit-bookmark-panel .pixiv-utils-reload-button {
position: relative;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-apply.is-loading .pixiv-utils-edit-bookmark-apply-label,
.pixiv-utils-edit-bookmark-panel .pixiv-utils-reload-button.is-loading .pixiv-utils-reload-button-label {
/* Keep the label's box so the button never resizes while loading. */
visibility: hidden;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-spinner {
display: none;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
pointer-events: none;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-apply.is-loading .pixiv-utils-edit-bookmark-spinner,
.pixiv-utils-edit-bookmark-panel .pixiv-utils-reload-button.is-loading .pixiv-utils-edit-bookmark-spinner {
display: flex;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-spinner::before {
content: '';
width: 1em;
height: 1em;
border: 0.15em solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: pixiv-utils-edit-bookmark-spin 0.75s linear infinite;
}
@keyframes pixiv-utils-edit-bookmark-spin {
to { transform: rotate(360deg); }
}
/* Status area: the spinner sits in flow (not overlaid) while the initial
tag list is fetched. */
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-status .pixiv-utils-edit-bookmark-spinner {
position: static;
padding: 4px 0;
}
.pixiv-utils-edit-bookmark-panel .pixiv-utils-edit-bookmark-status.is-loading .pixiv-utils-edit-bookmark-spinner {
display: flex;
}
.pixiv-utils-alert::backdrop { background: rgba(0, 0, 0, 0.48); }
.pixiv-utils-alert-content { display: flex; flex-direction: column; max-height: inherit; }
.pixiv-utils-alert-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px 8px;
}
.pixiv-utils-alert-header h2 {
min-width: 0;
margin: 0;
color: var(--pixiv-utils-ui-text);
font: inherit;
font-size: 16px;
font-weight: 650;
overflow-wrap: anywhere;
}
.pixiv-utils-alert-message {
margin: 0;
padding: 8px 16px 16px;
overflow: auto;
overscroll-behavior: contain;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.5;
}
.pixiv-utils-alert-actions { display: flex; justify-content: flex-end; padding: 0 16px 14px; }
.pixiv-utils-alert button {
box-sizing: border-box;
min-height: 40px;
border: 1px solid var(--pixiv-utils-ui-border);
border-radius: 6px;
background: var(--pixiv-utils-ui-raised);
color: var(--pixiv-utils-ui-text);
font: inherit;
cursor: pointer;
}
.pixiv-utils-alert button:hover:not(:disabled) { border-color: var(--pixiv-utils-ui-accent); }
.pixiv-utils-alert button:focus-visible { outline: 2px solid var(--pixiv-utils-ui-accent); outline-offset: 2px; }
.pixiv-utils-alert button.pixiv-utils-alert-close {
display: grid;
flex: 0 0 36px;
place-items: center;
min-width: 36px;
width: 36px;
height: 36px;
max-height: 36px;
min-height: 36px;
padding: 0;
border-color: transparent !important;
border-radius: 6px !important;
background: transparent !important;
color: var(--pixiv-utils-ui-muted) !important;
font-size: 20px !important;
line-height: 1;
opacity: 0.6;
transition: opacity 0.12s, background 0.12s, border-color 0.12s, color 0.12s;
}
.pixiv-utils-alert button.pixiv-utils-alert-close:hover {
border-color: var(--pixiv-utils-ui-border) !important;
background: var(--pixiv-utils-ui-subtle) !important;
color: var(--pixiv-utils-ui-text) !important;
opacity: 1;
}
.pixiv-utils-alert-ok {
min-width: 76px;
padding: 7px 16px;
border-color: transparent !important;
background: var(--pixiv-utils-ui-accent) !important;
color: HighlightText !important;
}
.pixiv-utils-alert-ok:hover:not(:disabled) { filter: brightness(1.08); }
.pixiv-utils-alert-cancel { min-width: 76px; margin-right: 8px; padding: 7px 16px; }
@media (max-width: 480px) {
.pixiv-utils-alert { width: calc(100vw - 24px); max-width: calc(100vw - 24px); max-height: calc(100dvh - 32px); }
.pixiv-utils-alert-header { padding: 12px 12px 6px; }
.pixiv-utils-alert-message { max-height: calc(100dvh - 180px); padding: 8px 12px 14px; }
.pixiv-utils-alert-actions { padding: 0 12px 12px; }
.pixiv-utils-alert button { min-height: 44px; }
.pixiv-utils-alert button.pixiv-utils-alert-close { min-height: 36px; }
}
/* gmConfig's .gc-c uses width:100%; account for padding and borders. */
.gc-p, .gc-p * {
box-sizing: border-box;
}
.gc-p .gc-c, .gc-p input[type="text"], .gc-p textarea,
.gc-p select, .gc-p .pixiv-utils-backup textarea {
max-width: 100%;
}
.gc-p {
--gc-a: var(--pixiv-utils-ui-accent);
accent-color: var(--pixiv-utils-ui-accent);
color: var(--pixiv-utils-ui-text);
background: var(--pixiv-utils-ui-surface);
border: 1px solid var(--pixiv-utils-ui-border);
border-radius: var(--pixiv-utils-ui-radius);
box-shadow: var(--pixiv-utils-ui-shadow);
font-family: inherit;
font-size: 14px;
line-height: 1.45;
}
.gc-p > form,
.gc-p .gc-hd, .gc-p .gc-b, .gc-p .gc-f {
color: var(--pixiv-utils-ui-text);
background: var(--pixiv-utils-ui-surface);
}
.gc-p .gc-hd, .gc-p .gc-f {
border-color: var(--pixiv-utils-ui-border);
}
.gc-p .gc-hd {
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--pixiv-utils-ui-border);
}
.gc-p .gc-hd > span {
font-size: 16px;
font-weight: 650;
line-height: 1.35;
}
.gc-p .gc-hd > button.gc-ib {
flex: 0 0 36px;
width: 36px;
height: 36px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--pixiv-utils-ui-muted);
font-size: 20px;
}
.gc-p .gc-hd > button.gc-ib:hover {
border-color: var(--pixiv-utils-ui-border);
background: var(--pixiv-utils-ui-subtle);
color: var(--pixiv-utils-ui-text);
}
.gc-p .gc-f {
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--pixiv-utils-ui-border);
background: var(--pixiv-utils-ui-raised);
}
.gc-p .gc-f button {
min-height: 36px;
padding: 6px 13px;
border: 1px solid var(--pixiv-utils-ui-border);
border-radius: 6px;
background: var(--pixiv-utils-ui-surface);
background-image: none;
color: var(--pixiv-utils-ui-text);
font: inherit;
font-weight: 500;
box-shadow: none;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.gc-p .gc-f button:hover {
border-color: color-mix(in srgb, var(--pixiv-utils-ui-accent) 48%, var(--pixiv-utils-ui-border));
background: var(--pixiv-utils-ui-raised);
}
.gc-p .gc-f button.gc-bp {
border-color: var(--pixiv-utils-ui-accent);
background: var(--pixiv-utils-ui-accent);
color: #fff;
font-weight: 650;
}
.gc-p .gc-f button.gc-bp:hover {
border-color: color-mix(in srgb, var(--pixiv-utils-ui-accent) 84%, black);
background: color-mix(in srgb, var(--pixiv-utils-ui-accent) 84%, black);
}
.gc-p .gc-f > .gc-h {
color: var(--pixiv-utils-ui-muted);
}
.gc-p .gc-f > .gc-h button.pixiv-utils-reload-button {
min-height: 36px;
padding: 6px 13px;
border: 1px solid var(--pixiv-utils-ui-border);
border-radius: 6px;
background: var(--pixiv-utils-ui-surface);
color: var(--pixiv-utils-ui-text);
font: inherit;
font-weight: 500;
}
.gc-p .gc-f > .gc-h button.pixiv-utils-reload-button:hover {
border-color: var(--pixiv-utils-ui-accent);
background: var(--pixiv-utils-ui-raised);
}
.gc-p .gc-f > button.gc-ib {
flex: 0 0 36px;
width: 36px;
padding: 0;
border-color: transparent;
background: transparent;
color: var(--pixiv-utils-ui-muted);
font-size: 18px;
}
.gc-p .gc-f > button.gc-ib:hover {
border-color: var(--pixiv-utils-ui-border);
color: var(--pixiv-utils-ui-text);
}
.gc-p :is(input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]), select, textarea) {
border-color: var(--pixiv-utils-ui-border);
border-radius: 6px;
background-color: var(--pixiv-utils-ui-surface);
color: var(--pixiv-utils-ui-text);
}
.gc-p :is(input, select, textarea, button):focus-visible {
outline: 2px solid var(--pixiv-utils-ui-accent);
outline-offset: 2px;
}
/* Keep the outer dialog height constant across tabs; scroll only its content. */
.gc-p {
height: min(740px, calc(100dvh - 40px));
max-height: calc(100dvh - 40px);
}
.gc-p.gc-p > form {
height: 100%;
min-height: 0;
max-height: none;
}
.gc-p .gc-hd, .gc-p .gc-f {
flex: 0 0 auto;
}
.gc-p .gc-b {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
/* The tablist is placed above the scrolling settings body, visually
separated from the header and fields like a floating toolbar. */
.gc-p .gc-tb {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
flex-wrap: nowrap;
gap: 4px;
min-width: 0;
max-width: calc(100% - 24px);
margin: 8px 12px 0;
padding: 5px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: thin;
border: 1px solid var(--pixiv-utils-ui-border) !important;
border-radius: var(--pixiv-utils-ui-radius);
background: var(--pixiv-utils-ui-subtle);
background-image: none !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12) !important;
}
.gc-p .gc-tb::before, .gc-p .gc-tb::after { content: none !important; }
/* Rounded list cards without affecting input dimensions. */
.gc-p :is(.gc-li, .gc-list, .gc-li-b) {
border-radius: 6px;
}
.gc-p .gc-li {
border: 1px solid var(--pixiv-utils-ui-border);
background: var(--pixiv-utils-ui-raised);
}
.gc-p .gc-li-h {
gap: 6px;
padding: 2px 3px 5px;
border-bottom: 1px solid var(--pixiv-utils-ui-border);
background: transparent;
}
.gc-p .gc-li-b {
padding: 5px 3px 2px;
background: transparent;
}
.gc-p .gc-li-t {
color: var(--pixiv-utils-ui-text);
}
/* Keep tab geometry stable; use a filled selected state instead of the
active bottom underline, which clashed with gmConfig's tab strip. */
.gc-p .gc-tb > button {
flex: 0 0 auto;
min-height: 34px;
padding: 7px 12px;
border: 0 !important;
border-radius: 6px;
font-weight: 400 !important;
white-space: nowrap;
box-shadow: none !important;
background-image: none !important;
}
@media (hover: hover) {
.gc-p .gc-tb > button:hover {
background-color: var(--pixiv-utils-ui-subtle);
}
}
.gc-p .gc-tb > button:is([aria-selected="true"], [data-active="true"], .active, .gc-a, .gc-ta) {
background-color: var(--pixiv-utils-ui-accent) !important;
color: #fff !important;
box-shadow: inset 0 0 0 1px var(--pixiv-utils-ui-accent) !important;
}
.gc-p .gc-tb + .gc-b {
border-top: 0 !important;
}
.gc-p .pixiv-utils-section-heading {
margin: 10px 0 3px;
font-size: 14px;
font-weight: 600;
line-height: 1.35;
}
.gc-p .gc-g:has(#ENABLE_KEYBINDS) .gc-h {
white-space: pre-line;
}
.gc-p .pixiv-utils-settings-divider {
border-top: 1px solid currentColor;
opacity: 0.2;
margin: 7px 0 4px;
}
.gc-p .pixiv-utils-global-note {
font-size: 12px;
line-height: 1.5;
opacity: 0.8;
margin: 0 0 5px;
}
.gc-p .gc-g, .gc-p .gc-li-b, .gc-p .gc-list,
.gc-p .gc-list-items {
min-width: 0;
max-width: 100%;
}
.gc-p .gc-g {
margin-bottom: 4px;
}
.gc-p .gc-li-b > .gc-r .gc-g:has(input[id$="_regex"]) .gc-ck {
/* Match the label height of neighboring textboxes before the checkbox. */
padding-top: 23px;
align-items: center;
}
.gc-p .pixiv-utils-text-editor {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.gc-p .pixiv-utils-empty-override {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
}
.gc-p .pixiv-utils-empty-override input {
width: auto;
}
.gc-p .pixiv-utils-date-editor {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
}
.gc-p .pixiv-utils-date-editor label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.gc-p .pixiv-utils-date-editor select,
.gc-p .pixiv-utils-date-editor input {
padding: 6px;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.gc-p .pixiv-utils-date-preview {
grid-column: 1 / -1;
font-size: 12px;
opacity: 0.85;
padding: 8px;
}
/* Keep rule editors proportional when the flags field is hidden. */
.gc-p .gc-li-b > .gc-r:has(> .gc-g input[id$="_value"]) {
display: grid;
grid-template-columns: minmax(0, 1fr) 78px 74px;
align-items: start;
gap: 8px;
}
.gc-p .gc-li-b > .gc-r:has(> .gc-g input[id$="_value"]) > .gc-g {
min-width: 0;
}
.gc-p .gc-li-b > .gc-r > .gc-g:has(input[id$="_value"]) {
grid-column: 1;
}
.gc-p .gc-li-b > .gc-r > .gc-g:has(input[id$="_regex"]) {
grid-column: 2;
}
.gc-p .gc-li-b > .gc-r > .gc-g:has(input[id$="_flags"]) {
grid-column: 3;
}
.gc-p .gc-li-b > .gc-r:has(> .gc-g input[id$="_value"]) .gc-c {
min-width: 0;
box-sizing: border-box;
}
.gc-p .pixiv-utils-collapse-group {
align-self: flex-start;
font: inherit;
font-size: 11px;
min-height: 30px;
padding: 4px 9px;
cursor: pointer;
border: 1px solid var(--pixiv-utils-ui-border);
border-radius: 6px;
background: var(--pixiv-utils-ui-surface);
color: var(--pixiv-utils-ui-text);
}
.gc-p .pixiv-utils-collapse-group:hover {
border-color: var(--pixiv-utils-ui-accent);
color: var(--pixiv-utils-ui-accent);
}
.gc-p .gc-list-add {
min-height: 34px;
padding: 5px 11px;
border: 1px solid var(--pixiv-utils-ui-accent);
border-radius: 6px;
background: transparent;
color: var(--pixiv-utils-ui-accent);
font: inherit;
font-size: 13px;
font-weight: 550;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.gc-p .gc-list-add:hover:not(:disabled) {
background: var(--pixiv-utils-ui-accent);
color: #fff;
}
.gc-p .gc-list-add:disabled {
border-color: var(--pixiv-utils-ui-border);
color: var(--pixiv-utils-ui-muted);
cursor: not-allowed;
opacity: 0.65;
}
/* Native modal backdrop dims Pixiv without covering the settings UI. */
dialog.gc-p::backdrop {
background: rgba(0, 0, 0, 0.56);
}
.gc-p .gc-li-h > .gc-li-t {
min-width: 0;
}
.gc-p .pixiv-utils-list-position {
width: 60px; min-width: 0; padding: 3px 2px;
margin-right: 6px;
font: inherit; font-size: 12px; text-align: center;
}
.gc-p .pixiv-utils-drag-handle {
flex: 0 0 auto; font: inherit; font-size: 20px; line-height: 1;
border: 0; padding: 2px 5px; background: transparent;
cursor: grab; touch-action: none; user-select: none;
}
.gc-p .pixiv-utils-drag-handle:active {
cursor: grabbing;
}
.gc-p .pixiv-utils-dragging {
opacity: 0.55;
}
.gc-p .pixiv-utils-drop-before {
box-shadow: inset 0 3px 0 var(--gc-a, #888);
}
.gc-p .pixiv-utils-drop-after {
box-shadow: inset 0 -3px 0 var(--gc-a, #888);
}
.gc-p .pixiv-utils-nested-collapsed > .gc-list {
display: none;
}
.gc-p .pixiv-utils-backup {
display: flex;
flex-direction: column;
gap: 10px;
}
.gc-p .pixiv-utils-backup p {
margin: 0;
}
.gc-p .pixiv-utils-backup textarea {
width: 100%; min-height: 260px; box-sizing: border-box;
font: 12px/1.5 monospace; resize: vertical;
}
.gc-p textarea[id="FLOATING_TOOLTIPS_OPTIONS"] {
font: 12px/1.5 monospace; resize: vertical;
}
.gc-p .pixiv-utils-backup-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.gc-p .pixiv-utils-backup-buttons button {
padding: 5px 9px;
cursor: pointer;
}
.gc-p .gc-f > .gc-h button {
cursor: pointer;
}
@media (max-width: 480px) {
.gc-p {
position: fixed; inset: 0; margin: 0;
width: 100vw !important; max-width: none !important;
height: 100dvh; max-height: 100dvh;
border-radius: 0;
}
.gc-p > form {
width: 100%;
}
.gc-p .gc-hd {
padding: 10px 12px;
padding-top: max(10px, env(safe-area-inset-top));
}
.gc-p .gc-b {
padding: 10px 12px;
}
.gc-p .gc-tb {
max-width: calc(100% - 20px);
margin: 6px 10px 8px;
padding: 4px;
}
.gc-p .gc-tb > button {
min-height: 38px;
padding: 7px 10px;
}
.gc-p .gc-f {
flex-wrap: wrap; gap: 6px; padding: 8px 12px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
.gc-p .gc-f button {
min-height: 44px;
}
.gc-p .gc-f > .gc-h button.pixiv-utils-reload-button {
min-height: 44px;
}
.gc-p .gc-f > .gc-h {
margin-right: auto;
}
.gc-p .pixiv-utils-date-editor {
grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr));
}
.gc-p .gc-li {
padding: 6px;
}
.gc-p .gc-li-b > .gc-r:has(> .gc-g input[id$="_value"]) {
grid-template-columns: minmax(0, 1fr) 65px 51px;
gap: 5px;
}
.gc-p .gc-r > .gc-g {
min-width: 0;
}
.gc-p .gc-c, .gc-p .pixiv-utils-date-editor :is(input, select),
.gc-p .pixiv-utils-backup textarea {
font-size: 16px;
}
.gc-p .gc-li-h {
flex-wrap: wrap;
}
.gc-p .pixiv-utils-backup-buttons {
align-items: stretch;
}
.gc-p .pixiv-utils-backup-buttons button {
min-height: 36px;
}
/* Stack regex fields on narrow screens to preserve usable input widths. */
.gc-p .gc-li-b > .gc-r:has(> .gc-g input[id$="_value"]) {
grid-template-columns: minmax(0, 1fr) 62px;
}
.gc-p .gc-li-b > .gc-r > .gc-g:has(input[id$="_value"]) {
grid-column: 1 / -1;
}
.gc-p .gc-li-b > .gc-r > .gc-g:has(input[id$="_regex"]) {
grid-column: 1;
}
.gc-p .gc-li-b > .gc-r > .gc-g:has(input[id$="_flags"]) {
grid-column: 2;
}
.gc-p .gc-li-b > .gc-r .gc-g:has(input[id$="_regex"]) .gc-ck {
padding-top: 0;
}
}
`;
// Settings-only CSS is unnecessary until the user opens the panel.
// Keep it for the rest of this page session so reopening cannot add duplicates.
let settingsStyleAdded = false;
const addSettingsStyleOnce = () => {
if (settingsStyleAdded) return;
GM_addStyle(settingsStyle);
settingsStyleAdded = true;
};
const localizePanelChrome = panel => {
const dialog = panel._dlg;
if (!dialog) return;
const footer = dialog.querySelector('.gc-f');
const buttons = footer?.querySelectorAll(':scope > button');
if (buttons?.length >= 4) {
const actions = ['action.importExport', 'action.reset', 'action.cancel', 'action.save'];
for (let i = 0; i < 4; i++) {
if (i === 0) buttons[i].title = tr(actions[i]);
else if (i < 3) buttons[i].textContent = tr(actions[i]);
else {
const saved = buttons[i].textContent.startsWith('Saved') ||
buttons[i].textContent === tr('action.saved');
const translated = tr(saved ? 'action.saved' : actions[i]);
if (buttons[i].textContent !== translated) buttons[i].textContent = translated;
}
}
}
dialog.querySelector('.gc-hd > button')?.setAttribute('title', tr('action.close'));
const reload = footer?.querySelector('.gc-h button');
if (reload && reload.textContent !== tr('action.reload')) reload.textContent = tr('action.reload');
};
// Compatibility fallback: only the UI panel is initialized on demand;
// the pinned unminified IIFE is eagerly loaded via @require.
const loadSettingsConstructor = async () => {
if (typeof gmConfig !== 'function') throw new Error('gmConfig IIFE not loaded.');
return createPixivUtilsConfig(gmConfig);
};
let settingsPanel = null;
let settingsOpening = false;
// Session-only memory of the settings panel view: the tab that was active
// when it was last closed and each tab's scroll offset. Module-level so a
// fresh panel instance restores the previous view instead of resetting it.
const settingsTabScrollPositions = new Map();
let settingsActiveTabIndex = null;
const floatSettingsTabs = panel => {
const form = panel._dlg?.querySelector(':scope > form');
const body = form?.querySelector(':scope > .gc-b');
const tabs = body?.querySelector(':scope > .gc-tb');
if (!tabs) return;
// gmConfig renders tabs inside its scrollable body. Move the same tablist
// above the body, retaining its event listeners and active-tab state.
// reinit() replaces only the body, so discard its previous floating tablist.
form.querySelector(':scope > .gc-tb')?.remove();
body.before(tabs);
// All tabs share one scroll container. Remember its scrollTop by stable tab
// index so switching to a shorter tab cannot lose the previous tab's offset.
if (tabs._pixivScrollRestoreBound) return;
tabs._pixivScrollRestoreBound = true;
const positions = settingsTabScrollPositions;
const buttons = () => [...tabs.querySelectorAll(':scope > button')];
const activeIndex = list => {
const index = list.findIndex(button =>
button.matches('[aria-selected="true"], [data-active="true"], .active, .gc-a, .gc-ta'));
return index >= 0 ? index : -1;
};
let currentIndex = activeIndex(buttons());
if (currentIndex < 0) currentIndex = 0;
if (settingsActiveTabIndex === null) settingsActiveTabIndex = currentIndex;
// Capture continuously so closing the panel mid-scroll keeps the offset.
body.addEventListener('scroll', () => {
positions.set(currentIndex, body.scrollTop);
});
// Capture before gmConfig's own click handler changes the active tab.
// Restore on the next frame, after the destination tab's layout is applied.
let restoring = false;
tabs.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target.closest('button') : null;
if (!target || target.parentElement !== tabs) return;
const list = buttons();
const targetIndex = list.indexOf(target);
if (targetIndex < 0) return;
const outgoingIndex = activeIndex(list);
// A synthetic restore click lands on a freshly rendered panel whose
// outgoing tab is still at offset 0; keep the remembered offset intact.
if (!restoring) positions.set(outgoingIndex >= 0 ? outgoingIndex : currentIndex, body.scrollTop);
currentIndex = targetIndex;
settingsActiveTabIndex = targetIndex;
requestAnimationFrame(() => {
if (!panel.isOpen || !body.isConnected || !tabs.isConnected) return;
body.scrollTop = positions.get(targetIndex) ?? 0;
});
}, true);
// Restore the view from the last time the panel was open: activate the
// remembered tab (gmConfig re-renders on click), then put the scroll
// container back at its offset on the next frame.
const remembered = settingsActiveTabIndex;
const tabList = buttons();
if (remembered !== null && remembered < tabList.length) {
if (remembered !== currentIndex) {
restoring = true;
try { tabList[remembered].click(); } finally { restoring = false; }
}
requestAnimationFrame(() => {
if (!panel.isOpen || !body.isConnected || !tabs.isConnected) return;
body.scrollTop = positions.get(remembered) ?? 0;
});
}
};
const refreshSettingsEnhancements = (panel, localize = true) => {
floatSettingsTabs(panel);
syncColoredGroupToggles();
syncSettingsDecorations();
initListOrderControls(panel);
if (localize) localizePanelChrome(panel);
};
const openSettingsPanel = async () => {
if (settingsOpening || settingsPanel?.isOpen) return;
settingsOpening = true;
try {
settingsUiLocale = resolveSettingsLocale(getSetting('SETTINGS_LANGUAGE'));
await waitPageLoaded();
addSettingsStyleOnce();
const SettingsConstructor = await loadSettingsConstructor();
const panel = new SettingsConstructor();
await panel.init(makeSettingsDefinitions(), {
title: settingsPanelTitle(),
width: 'min(900px, 96vw)',
closeOnSave: false, // Keep the Reload page button available after Save.
onBeforeClose: () => showConfirm(tr('message.discardChanges')),
onBeforeSave: values => {
for (const [key, highlighted] of [
['PIXIV_BLOCKED_TAGS', false],
['PIXIV_HIGHLIGHTED_TAGS', true]
]) {
const error = validateTagRules(
values[key],
highlighted,
Boolean(values[`${key}_COMBINE_REGEXES`])
);
if (error) {
showAlert(error);
return false;
}
}
// Defensive validation for additional conversions done during persistence.
for (const key of ['FLOATING_TOOLTIPS_OPTIONS', 'UTAGS_BLOCKED_TAGS', 'SECTIONS_TOGGLE_BOOKMARKED', 'PIXIV_BLOCKED_USERS']) {
try { editorToSetting(key, values[key], ENV[key]); } catch (error) { showAlert(tr('message.validation', { key, error: error.message })); return false; }
}
},
onOpen: () => {
refreshSettingsEnhancements(panel, false);
// Nested list changes do not fire top-level onChange. Refresh only the
// affected highlighted group (or the highlighted list for top-level changes).
panel._dlg?.addEventListener('click', event => {
if (!event.target.closest('.gc-list-add, .gc-bd')) return;
const group = event.target.closest('.gc-g:has(> .gc-list[id$="_tags"])');
const highlighted = group || document.querySelector('.gc-p .gc-list[id="PIXIV_HIGHLIGHTED_TAGS"]');
if (highlighted) queueMicrotask(() => syncColoredGroupToggles(highlighted));
});
panel._dlg?.addEventListener('change', event => {
if (!event.target.matches('select[id$="_type"]')) return;
const item = event.target.closest('.gc-li');
if (item) queueMicrotask(() => syncColoredGroupToggles(item));
});
localizePanelChrome(panel);
// The library temporarily displays "Saved ✓" after Save.
// Keep that internal label translated without changing save behavior.
const saveButton = panel._dlg?.querySelector('.gc-f > button.gc-bp');
if (saveButton) {
const observer = new MutationObserver(() => localizePanelChrome(panel));
observer.observe(saveButton, { childList: true, characterData: true, subtree: true });
panel._chromeObserver = observer;
}
},
onClose: () => {
panel._chromeObserver?.disconnect();
panel._chromeObserver = null;
disposeListOrderControls(panel);
},
onChange: (key, value) => {
if (key === 'SETTINGS_LANGUAGE') {
const locale = resolveSettingsLocale(value);
if (locale !== settingsUiLocale) {
settingsUiLocale = locale;
queueMicrotask(() => {
if (!panel.isOpen) return;
panel.reinit(makeSettingsDefinitions(), settingsPanelTitle());
refreshSettingsEnhancements(panel);
});
}
}
},
footerContent: () => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'pixiv-utils-reload-button';
button.textContent = tr('action.reload');
// gmConfig already supplies a beforeunload warning for unsaved edits.
button.addEventListener('click', () => location.reload());
return button;
},
onSave: () => { showAlert(tr('message.save')); },
onReset: () => {
const locale = resolveSettingsLocale(ENV_DEFAULTS.SETTINGS_LANGUAGE);
if (locale !== settingsUiLocale) {
settingsUiLocale = locale;
panel.reinit(makeSettingsDefinitions(), settingsPanelTitle());
refreshSettingsEnhancements(panel);
}
showAlert(tr('message.reset'));
}
});
settingsPanel = panel;
await panel.open();
} catch (error) {
console.error('[Pixiv Utils]: Failed to open settings:', error);
showAlert(tr('message.openError', { error: error.message }));
} finally { settingsOpening = false; }
};
GM_registerMenuCommand(`⚙️ ${tr('action.settings')}`, openSettingsPanel);
/** MAIN UTILS */
const SELECTORS_IMAGE_CONTAINER_SIMPLIFIED = [
'div[open]:has(a[href*="users/"])', // user profile popup
'aside > section > nav', // expanded view's other works sidebar
'body > div > div[style*="translateY"] ul li.inline-block > div > div', // recommended users after following
'div:has(> div > div > div > div > a.gtm-illust-toppage-recommend-user-work)' // recommended users on home's illustrations tab
].join(', ');
const SELECTORS_IMAGE_SMALL = [
'li:has(a[href*="novel/"])', // novel
'aside:has(iframe[src*="premium_lp"]) > ul > li' // tags page's popular works
].join(', ');
const SELECTORS_IMAGE_NO_CONTROLS = [
'aside:has(iframe[src*="premium_lp"]) > ul > li' // tags page's popular works
].join(', ');
const SELECTORS_IMAGE_MOBILE = '.works-item';
// Both rule types compile literal tags and regexes identically. Keep one
// compiler while retaining highlighted group order and explicit color priority.
// Malformed stored rules are reported by setting name and skipped, so one
// corrupt rule cannot abort initialization or disable its sibling rules.
const compileTagRules = (tags, combineRegexes, settingName) => {
const result = { string: new Set(), regexp: [] };
const byFlags = new Map();
const invalid = [];
const compile = (pattern, flags) => {
try {
return new RegExp(pattern, flags);
} catch (error) {
invalid.push(`/${pattern}/${flags || ''} (${error.message})`);
return null;
}
};
for (const tag of tags || []) {
if (typeof tag === 'string') {
result.string.add(tag);
} else if (Array.isArray(tag)) {
const flags = tag[1] || '';
if (combineRegexes) {
if (!byFlags.has(flags)) byFlags.set(flags, []);
byFlags.get(flags).push(tag[0]);
} else {
const regex = compile(tag[0], flags);
if (regex) result.regexp.push(regex);
}
}
}
if (combineRegexes) {
for (const [flags, patterns] of byFlags) {
try {
result.regexp.push(new RegExp(patterns.join('|'), flags));
} catch {
// One bad pattern must not disable its whole flags group: fall back
// to compiling the remaining patterns individually.
for (const pattern of patterns) {
const regex = compile(pattern, flags);
if (regex) result.regexp.push(regex);
}
}
}
}
if (invalid.length) {
console.error(`[Pixiv Utils]: Skipping invalid regex rule(s) in ${settingName}:`, invalid);
}
return result;
};
const PIXIV_HIGHLIGHTED_TAGS_FORMATTED = [];
for (const [index, config] of CONFIG.PIXIV_HIGHLIGHTED_TAGS.entries()) {
const grouped = config && typeof config === 'object' && !Array.isArray(config);
const compiled = compileTagRules(grouped ? config.tags : [config],
CONFIG.PIXIV_HIGHLIGHTED_TAGS_COMBINE_REGEXES,
grouped ? `PIXIV_HIGHLIGHTED_TAGS (group ${index + 1})` : 'PIXIV_HIGHLIGHTED_TAGS');
if (!compiled.string.size && !compiled.regexp.length) continue;
if (grouped && typeof config.color === 'string') compiled.color = config.color;
PIXIV_HIGHLIGHTED_TAGS_FORMATTED.push(compiled);
}
const PIXIV_HIGHLIGHTED_TAGS_VALIDATED = PIXIV_HIGHLIGHTED_TAGS_FORMATTED.length;
const blockedTagRules = compileTagRules(CONFIG.PIXIV_BLOCKED_TAGS,
CONFIG.PIXIV_BLOCKED_TAGS_COMBINE_REGEXES, 'PIXIV_BLOCKED_TAGS');
const PIXIV_BLOCKED_TAGS_STRING = blockedTagRules.string;
const PIXIV_BLOCKED_TAGS_REGEXP = blockedTagRules.regexp;
// Keep artist IDs as strings for exact matching, with no Number conversion.
const PIXIV_BLOCKED_USERS_SET = new Set(CONFIG.PIXIV_BLOCKED_USERS);
const PIXIV_BLOCKED_TAGS_VALIDATED = PIXIV_BLOCKED_TAGS_STRING.size ||
PIXIV_BLOCKED_TAGS_REGEXP.length ||
PIXIV_BLOCKED_USERS_SET.size ||
CONFIG.PIXIV_BLOCK_AI;
const SELECTORS_UTAGS = CONFIG.UTAGS_BLOCKED_TAGS.map(s =>
`[data-utags_tag="${CSS.escape(s)}"]`).join(', ');
if (_debugMode) {
// Wrap in an object so that browser console collapses them by default.
logDebug('RULES:', {
PIXIV_HIGHLIGHTED_TAGS_FORMATTED,
PIXIV_BLOCKED_TAGS_STRING,
PIXIV_BLOCKED_TAGS_REGEXP,
PIXIV_BLOCKED_USERS_SET,
SELECTORS_UTAGS
});
}
let currentUrl = new URL(window.location.href);
const notify = url => {
const newUrl = new URL(url || window.location.href, window.location.origin);
if (currentUrl.href !== newUrl.href) {
const event = new CustomEvent('detectnavigate', {
detail: { currentUrl, newUrl }
});
window.dispatchEvent(event);
currentUrl = newUrl;
}
};
if (window.onurlchange === null) {
window.addEventListener('urlchange', event => {
notify(event.url);
});
logDebug('Using window.onurlchange.');
} else {
const oldMethods = {};
['pushState', 'replaceState'].forEach(method => {
oldMethods[method] = history[method];
history[method] = function (...args) {
oldMethods[method].apply(this, args);
notify(args[2]);
};
});
window.addEventListener('popstate', event => {
notify();
});
logDebug('Using window.onurlchange polyfill.');
}
class SimpleCache {
constructor (maxSize = 100) {
const size = Number(maxSize);
this.maxSize = Number.isFinite(size) && size > 0 ? Math.floor(size) : 1;
this.map = new Map();
}
get (key) {
if (!this.map.has(key)) {
return undefined;
}
const value = this.map.get(key);
this.map.delete(key);
this.map.set(key, value); // refresh LRU
return value;
}
has (key) {
return this.map.has(key);
}
set (key, value) {
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.maxSize && this.map.size > 0) {
const firstKey = this.map.keys().next().value;
this.map.delete(firstKey); // evict LRU
}
this.map.set(key, value);
}
delete (key) {
return this.map.delete(key);
}
clear () {
this.map.clear();
}
get size () {
return this.map.size;
}
}
/** MAIN STYLES **/
const formatChildSelectorList = (parentSelector, childSelector) => {
const parent = normalizeSelectorList(parentSelector);
return splitSelectorList(childSelector).map(selector => {
const child = selector.startsWith('&') ? selector.slice(1).trimStart() : selector;
return `:is(${parent})${child ? ` ${child}` : ''}`;
});
};
const formatChildSelector = (parentSelector, childSelector) =>
formatChildSelectorList(parentSelector, childSelector).join(', ');
const _FORMATTED_SELECTORS_MULTI_VIEW_CONTROLS =
formatChildSelectorList(CONFIG.SELECTORS_MULTI_VIEW, CONFIG.SELECTORS_MULTI_VIEW_CONTROLS);
const SELECTORS_IMAGE_HIGHLIGHTED =
':not(.page-count, [size="16"], [size="24"]):has(> img:not([src^="data"]))::after';
const SELECTORS_DATE_ORIGINAL = `:is(${CONFIG.SELECTORS_DATE}):not([data-pixiv_utils_duplicate_date])`;
const mainStyle = /*css*/`
.flex:has(+ .pixiv_utils_edit_bookmark_container) {
flex-grow: 1;
}
${CONFIG.SELECTORS_MULTI_VIEW_CONTROLS} > .flex:has(+ .pixiv_utils_edit_bookmark_container) {
flex-grow: 0;
padding-right: 4px;
}
.ranking-item.muted .pixiv_utils_edit_bookmark_container {
display: none;
}
.pixiv_utils_edit_bookmark_container + div:has([data-ga4-label="dot_menu"]) {
margin-left: 8px;
}
.pixiv_utils_edit_bookmark {
color: rgb(245, 245, 245);
background: rgba(0, 0, 0, 0.5);
display: block;
box-sizing: border-box;
padding: 0px 8px;
margin-top: 7px;
margin-right: 2px;
border-radius: 10px;
font-weight: bold;
font-size: 10px;
line-height: 20px;
height: 20px;
cursor: pointer;
user-select: none;
position: relative;
z-index: 1;
}
:is(${CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS}) .pixiv_utils_edit_bookmark,
:is(${_FORMATTED_SELECTORS_MULTI_VIEW_CONTROLS.join(', ')}) .pixiv_utils_edit_bookmark {
font-size: 12px;
height: 24px;
line-height: 24px;
margin-top: 5px;
margin-right: 7px;
}
${CONFIG.SELECTORS_MULTI_VIEW_CONTROLS} .pixiv_utils_edit_bookmark {
margin-right: 0 !important;
}
[data-pixiv_utils_edit_bookmark_novel] .pixiv_utils_edit_bookmark {
margin-top: -25px;
}
/* some novel items */
:is(
.absolute.right-0,
.novel-series-thumbnail,
.works-item-novel,
.works-item-novel-editor-recommend
) {
.pixiv_utils_edit_bookmark {
margin-top: 7px !important;
}
}
:not(#higher_specificity) :is(li, div):has(> .pixiv_utils_image_artist_container) {
position: relative !important;
}
.pixiv_utils_image_artist_container {
position: absolute;
padding: 5px;
bottom: 0;
left: 0;
max-width: calc(100% - 76px);
}
.pixiv_utils_image_artist {
color: rgb(245, 245, 245);
background: rgba(0, 0, 0, 0.5);
box-sizing: border-box;
padding: 0px 8px;
border-radius: 10px;
font-size: 14px;
line-height: 20px;
height: 20px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
float: left;
width: 100%;
}
div:not(
[class*="style_tagFilter"],
.charcoal-dropdown-selector-root
):has(+ .pixiv_utils_toggle_bookmarked_container) {
flex-grow: 1;
}
div:not([class*="style_tagFilter"]):has(+ .pixiv_utils_toggle_bookmarked_container):not(:first-child) {
justify-content: flex-end;
}
div:has(> .pixiv_utils_toggle_bookmarked_container) {
align-items: center;
}
.pixiv_utils_toggle_bookmarked_container {
text-align: center;
}
.pixiv_utils_toggle_bookmarked {
color: rgb(245, 245, 245);
background: rgb(58, 58, 58);
display: inline-block;
box-sizing: border-box;
padding: 6px;
border-radius: 10px;
font-weight: bold;
margin-left: 12px;
cursor: pointer;
user-select: none;
}
.pixiv_utils_toggle_bookmarked:hover {
text-decoration: none;
}
.pixiv_utils_toggle_bookmarked span {
padding: 0 4px;
}
:not(#higher_specificity) :not(:is(${SELECTORS_IMAGE_MOBILE}))[data-pixiv_utils_toggle_bookmarked_hide] {
display: none !important;
}
:not(#higher_specificity) :is(${SELECTORS_IMAGE_MOBILE})[data-pixiv_utils_toggle_bookmarked_hide] > * {
visibility: hidden !important;
}
${CONFIG.SELECTORS_IMAGE_CONTROLS} {
display: flex;
justify-content: flex-end;
&.place-items-center {
display: grid;
}
&.items-center .pixiv_utils_edit_bookmark {
margin-top: 0;
}
}
[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
box-shadow: inset 0 0 0 3px var(--pixiv_utils_highlight_color, ${CONFIG.PIXIV_HIGHLIGHTED_COLOR});
border-radius: 8px;
content: '';
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
}
:is(${SELECTORS_IMAGE_CONTAINER_SIMPLIFIED}) [data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED},
:is(${SELECTORS_IMAGE_SMALL})[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED},
:is(${SELECTORS_IMAGE_MOBILE})[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
box-shadow: inset 0 0 0 2px var(--pixiv_utils_highlight_color, ${CONFIG.PIXIV_HIGHLIGHTED_COLOR});
}
/* expanded view's artist bottom bar */
${CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE}[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-radius: 4px;
}
/* mobile images */
:is(${SELECTORS_IMAGE_MOBILE})[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-radius: 0;
}
/* user profile popup */
div[open]:has(a[href*="users/"]) div > div:has(a[href*="artworks/"] img[src]) {
&[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-radius: 0;
}
&[data-pixiv_utils_highlight]:nth-child(1) ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-bottom-left-radius: 8px;
}
&[data-pixiv_utils_highlight]:nth-child(3) ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-bottom-right-radius: 8px;
}
}
/* recommended users after following */
body > div > div[style*="translateY"] ul li.inline-block > div > div > div:has(a[href*="artworks/"] img[src]),
/* recommended users on home's illustrations tab */
div:has(> div > div > div > a.gtm-illust-toppage-recommend-user-work[href*="artworks/"] img[src]) {
&[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-radius: 0;
}
&[data-pixiv_utils_highlight]:nth-child(1) ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-top-left-radius: 8px;
}
&[data-pixiv_utils_highlight]:nth-child(3) ${SELECTORS_IMAGE_HIGHLIGHTED} {
border-top-right-radius: 8px;
}
}
:not(#higher_specificity) :is(div, a):has(+ .pixiv_utils_blocked_image_container) {
display: none !important;
}
.pixiv_utils_blocked_image {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
color: rgb(92, 92, 92);
min-width: 90px;
aspect-ratio: 1 / 1;
}
:is(${SELECTORS_IMAGE_SMALL}) .pixiv_utils_blocked_image {
min-width: 80px;
}
.pixiv_utils_blocked_image svg {
fill: currentcolor;
}
.ranking-item .pixiv_utils_blocked_image {
max-width: 150px;
margin: 0 auto;
border: 1px solid rgb(242, 242, 242);
}
.works-item:not(.works-item-illust) .pixiv_utils_blocked_image {
min-width: 64px;
max-width: 64px;
}
/* Pixiv's built-in tags mute. */
.ranking-item.muted .work img {
filter: brightness(50%);
}
.ranking-item.muted .muted-thumbnail .negative {
position: relative;
z-index: 1;
color: rgb(92, 92, 92);
}
/* Only use black background on desktop layout. */
body > div:not(#wrapper) .pixiv_utils_blocked_image,
body > div:not(#wrapper) .ranking-item.muted .work img {
background: rgb(0, 0, 0);
}
[data-pixiv_utils_blocked] {
.series-title, .tag-container, .show-more-creator-works-button,
.pqkmS, /* desktop: show more creator works button */
._illust-series-title-text,
:is(${CONFIG.SELECTORS_IMAGE_TITLE}):not(.pixiv_utils_blocked_image_container),
:is(${CONFIG.SELECTORS_IMAGE_ARTIST_AVATAR}),
:is(${CONFIG.SELECTORS_IMAGE_AUXILIARY_ELEMENTS}),
:is(${CONFIG.SELECTORS_IMAGE_CONTROLS}) {
display: none !important;
}
}
[data-pixiv_utils_blocked] .pixiv_utils_image_artist_container {
max-width: calc(100% - 10px);
}
[data-pixiv_utils_blocked] .pixiv_utils_image_artist {
background: none;
padding: 0;
width: 0;
}
[data-pixiv_utils_expanded_view_blocked] :is([role="presentation"], .swipe-detector) :is(img, canvas) {
filter: blur(32px);
}
[data-pixiv_utils_expanded_view_blocked] :is([role="presentation"], .swipe-detector):hover :is(img, canvas) {
filter: unset;
}
${CONFIG.DATE_CONVERSION
? /*css*/`
${SELECTORS_DATE_ORIGINAL} {
display: none !important;
}
[data-pixiv_utils_duplicate_date]:not([data-pixiv_utils_date_prefix]) {
font-size: 14px !important;
font-weight: bold !important;
color: rgb(214, 214, 214) !important;
}`
: ''}
${CONFIG.ENABLE_FLOATING_TOOLTIPS
? /*css*/`
.pixiv-utils-floating-tooltip {
position: fixed;
z-index: 9999;
width: max-content;
max-width: min(var(--pixiv-utils-floating-max-width, 350px), calc(100vw - 16px));
padding: 5px 9px;
border-radius: 4px;
--pixiv-utils-tooltip-bg: rgba(58, 58, 58, 0.94);
background: var(--pixiv-utils-tooltip-bg);
color: #d6d6d6;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
white-space: pre-line;
overflow-wrap: anywhere;
pointer-events: none;
box-sizing: border-box;
}
.pixiv-utils-tooltip-title {
display: block;
font-weight: 700;
color: #f4f4f4;
}
.pixiv-utils-tooltip-ai-row { margin-top: 3px; }
.pixiv-utils-tooltip-ai {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-size: 0.86em;
background: rgba(255,255,255,0.08);
color: #bcbcbc;
}
.pixiv-utils-tooltip-ai[data-state="blockable"] {
background: rgba(255, 132, 82, 0.15);
color: #f2b8a1;
}
.pixiv-utils-tooltip-ai[data-state="blocked"] {
background: rgba(255, 97, 97, 0.27);
color: #ffcece;
font-weight: 600;
}
.pixiv-utils-tooltip-tags { margin-top: 3px; }
.pixiv-utils-tooltip-tag-separator { color: #9ea1a7; }
.pixiv-utils-tooltip-tag {
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.pixiv-utils-tooltip-tag[data-match="highlighted"],
.pixiv-utils-tooltip-tag[data-match="both"] {
background: rgba(63, 190, 98, 0.18);
border-bottom: 2px solid var(--pixiv-utils-tooltip-tag-color, #32cd32);
}
.pixiv-utils-tooltip-tag[data-match="blocked"],
.pixiv-utils-tooltip-tag[data-match="both"] {
color: #ffd5d5;
background: rgba(234, 84, 84, 0.24);
}
.pixiv-utils-tooltip-tag[data-match="blocked"] {
border-bottom: 2px solid #fa8080;
}
.pixiv-utils-tooltip-tag[data-match="both"] {
background: linear-gradient(90deg, rgba(63,190,98,0.2), rgba(234,84,84,0.25));
}
.pixiv-utils-tooltip-date {
margin-top: 3px;
color: #aeb1b7;
font-size: 0.91em;
}
.pixiv-utils-tooltip-footer {
margin-top: 7px;
padding-top: 6px;
border-top: 1px solid rgba(255,255,255,0.22);
font-size: 0.92em;
}
.pixiv-utils-tooltip-footer-section + .pixiv-utils-tooltip-footer-section {
margin-top: 5px;
}
.pixiv-utils-tooltip-footer-heading {
display: block;
font-weight: 600;
color: #e2e2e2;
}
.pixiv-utils-tooltip-footer-body { display: block; }
.pixiv-utils-floating-tooltip[data-mode="touch"] {
pointer-events: auto;
cursor: pointer;
touch-action: manipulation;
}
.pixiv-utils-floating-tooltip[data-open] {
animation: pixiv-utils-floating-enter 120ms ease-out both;
}
.pixiv-utils-floating-arrow {
position: absolute;
width: 12px;
height: 12px;
background: var(--pixiv-utils-tooltip-bg);
pointer-events: none;
}
/* Render only the half facing outside the tooltip. Unlike a rotated
square, these triangles never paint over the translucent body. */
.pixiv-utils-floating-arrow[data-side="bottom"] {
clip-path: polygon(50% 0, 0 50%, 100% 50%);
}
.pixiv-utils-floating-arrow[data-side="top"] {
clip-path: polygon(0 50%, 100% 50%, 50% 100%);
}
.pixiv-utils-floating-arrow[data-side="right"] {
clip-path: polygon(0 50%, 50% 0, 50% 100%);
}
.pixiv-utils-floating-arrow[data-side="left"] {
clip-path: polygon(50% 0, 100% 50%, 50% 100%);
}
@keyframes pixiv-utils-floating-enter {
from { opacity: 0; }
to { opacity: 1; }
}`
: ''}
`;
const BLOCKED_IMAGE_HTML = /*html*/`
<div radius="4" class="pixiv_utils_blocked_image">
<svg viewBox="0 0 24 24" style="width: 48px; height: 48px;">
<path d="M5.26763775,4 L9.38623853,11.4134814 L5,14.3684211 L5,18 L13.0454155,18 L14.1565266,20 L5,20
C3.8954305,20 3,19.1045695 3,18 L3,6 C3,4.8954305 3.8954305,4 5,4 L5.26763775,4 Z M9.84347336,4 L19,4
C20.1045695,4 21,4.8954305 21,6 L21,18 C21,19.1045695 20.1045695,20 19,20 L18.7323623,20 L17.6212511,18
L19,18 L19,13 L16,15 L15.9278695,14.951913 L9.84347336,4 Z M16,7 C14.8954305,7 14,7.8954305 14,9
C14,10.1045695 14.8954305,11 16,11 C17.1045695,11 18,10.1045695 18,9 C18,7.8954305 17.1045695,7 16,7 Z
M7.38851434,1.64019979 L18.3598002,21.3885143 L16.6114857,22.3598002 L5.64019979,2.61148566
L7.38851434,1.64019979 Z"></path>
</svg>
</div>
`;
/** MAIN **/
GM_addStyle(mainStyle);
const uuidv4 = () => {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
);
};
const WAIT_FOR_POLL = Number.isSafeInteger(CONFIG.WAIT_FOR_POLL) && CONFIG.WAIT_FOR_POLL > 0
? CONFIG.WAIT_FOR_POLL
: ENV_DEFAULTS.WAIT_FOR_POLL;
const WAIT_FOR_TIMEOUT = 30000; // ms; callers can safely retry on later observations.
// Share one polling timer across all pending element lookups. Each request
// retains its own deadline, detached-element check and navigation cancellation.
const pendingWaitFors = new Set();
let waitForTimer = null;
const finishWaitFor = (pending, result) => {
if (pending.done) return;
pending.done = true;
pendingWaitFors.delete(pending);
if (!pendingWaitFors.size && waitForTimer !== null) {
clearInterval(waitForTimer);
waitForTimer = null;
}
pending.resolve(result);
};
const waitFor = (func, element = document, timeout = WAIT_FOR_TIMEOUT) => {
if (typeof func !== 'function') return Promise.resolve(null);
return new Promise(resolve => {
const deadline = performance.now() + timeout;
const pending = { element, resolve, done: false, check: null };
pending.check = () => {
// Pixiv can unmount an artwork without changing the URL.
if ((element !== document && !element.isConnected) || performance.now() >= deadline) {
finishWaitFor(pending, null);
return;
}
let result;
try { result = func(element); } catch (error) {
console.warn('[Pixiv Utils]: waitFor check failed:', error);
finishWaitFor(pending, null);
return;
}
if (result) finishWaitFor(pending, result);
};
// Check immediately; enqueue only unresolved requests.
pending.check();
if (pending.done) return;
pendingWaitFors.add(pending);
if (waitForTimer === null) {
waitForTimer = setInterval(() => {
for (const request of [...pendingWaitFors]) {
if (pendingWaitFors.has(request)) request.check();
}
}, WAIT_FOR_POLL);
}
});
};
// Keep one low-frequency sweep for all long-lived element observers rather
// than one timer per artwork. It stops whenever there are no tracked observers.
const activeElementObservers = new Map();
let observerCleanupTimer = null;
const cleanupDetachedElementObservers = () => {
for (const [element, observer] of activeElementObservers) {
if (!element.isConnected) {
observer.disconnect();
activeElementObservers.delete(element);
delete element.dataset.pixiv_utils_observing;
}
}
if (!activeElementObservers.size && observerCleanupTimer !== null) {
clearInterval(observerCleanupTimer);
observerCleanupTimer = null;
}
};
const initElementObserver = (element, callback, options = {}) => {
if (!element || typeof callback !== 'function' || typeof options !== 'object' || !Object.keys(options).length) {
return false;
}
// Skip if already observing.
if (element.dataset.pixiv_utils_observing) {
return false;
}
if (options.attributes &&
(!options.attributeFilter || options.attributeFilter.includes('pixiv_utils_observing'))) {
console.error('initElementObserver cannot be initiated without proper attributes filtering', element);
return false;
}
// Mark as observing.
element.dataset.pixiv_utils_observing = true;
const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
const observer = new MutationObserver((mutations, observer) => {
callback.call(this, mutations, observer);
});
observer.observe(element, options);
activeElementObservers.set(element, observer);
if (observerCleanupTimer === null) {
observerCleanupTimer = setInterval(cleanupDetachedElementObservers, 5000);
}
return observer;
};
let cachedOwnUserId = null;
let cachedCsrfToken = null;
const ensurePixivCredentials = () => {
if (cachedOwnUserId && cachedCsrfToken) return;
const sources = [];
for (const meta of [
document.querySelector('#init-config, meta[name="init-config"]'), // Vue layout (mobile)
document.querySelector('#meta-global-data, meta[name="global-data"]'), // legacy layout
]) {
if (meta?.content) {
try { sources.push(JSON.parse(meta.content)); } catch (_) {}
}
}
const next = document.querySelector('#__NEXT_DATA__'); // Next.js layout (desktop)
if (next?.textContent) {
try {
const data = JSON.parse(next.textContent);
const serialized = data?.props?.pageProps?.serverSerializedPreloadedState;
if (serialized) {
sources.push(typeof serialized === 'string' ? JSON.parse(serialized) : serialized);
}
} catch (_) {}
}
for (const data of sources) {
if (!cachedOwnUserId) {
// Vue -> Next.js -> legacy
const id = [
data?.['pixiv.user.id'],
data?.userData?.self?.id, data?.userData?.self?.userId,
data?.api?.userData?.self?.id, data?.api?.userData?.self?.userId,
data?.userData?.id, data?.userData?.userId
].find(v => /^\d+$/.test(String(v ?? '')));
if (id) cachedOwnUserId = String(id);
}
if (!cachedCsrfToken) {
// Vue -> Next.js -> legacy
const token = [
data?.['pixiv.context.postKey'],
data?.api?.token,
data?.token
].find(v => v);
if (token) cachedCsrfToken = String(token);
}
if (cachedOwnUserId && cachedCsrfToken) return;
}
if (!cachedOwnUserId) throw new Error('Could not determine your Pixiv user ID.');
if (!cachedCsrfToken) throw new Error('Could not obtain Pixiv CSRF token.');
};
// Toast queue (alert-like, no overlapping/lost calls).
const toastQueue = [];
let activeToast = null;
const presentNextToast = () => {
if (activeToast || !toastQueue.length) return;
const request = toastQueue.shift();
const toast = document.createElement('div');
toast.className = `pixiv-utils-toast${request.primary ? ' pixiv-utils-toast-primary' : ''}`;
toast.textContent = request.message;
toast.setAttribute('role', 'status');
(document.querySelector('.pixiv-utils-alert') || document.body || document.documentElement).append(toast);
activeToast = toast;
let timer = null;
const dismiss = () => {
if (timer) clearTimeout(timer);
toast.remove();
if (activeToast === toast) {
activeToast = null;
presentNextToast();
}
};
if (request.duration > 0) timer = setTimeout(dismiss, request.duration);
request.dismiss = dismiss;
};
const enqueueToast = (message, options = {}) => {
const request = {
message: String(message),
primary: Boolean(options.primary),
duration: options.duration === undefined ? 2500 : Number(options.duration)
};
toastQueue.push(request);
presentNextToast();
return () => {
const index = toastQueue.indexOf(request);
if (index >= 0) {
toastQueue.splice(index, 1);
return;
}
if (request.dismiss) request.dismiss();
};
};
const showToast = (message, options) => enqueueToast(message, options);
const BOOKMARK_API = {
illust: {
cacheKey: 'illust',
tagSegment: 'illusts',
kind: 'illust',
bookmarkTagsUrl: (userId) => `https://www.pixiv.net/ajax/user/${userId}/illusts/bookmark/tags`,
bookmarkDataUrl: (id) => `https://www.pixiv.net/ajax/illust/${id}/bookmarkData`,
bookmarkAddPageUrl: (id) => `https://www.pixiv.net/bookmark_add.php?type=illust&illust_id=${id}`
},
novel: {
cacheKey: 'novel',
tagSegment: 'novels',
kind: 'novel',
bookmarkTagsUrl: (userId) => `https://www.pixiv.net/ajax/user/${userId}/novels/bookmark/tags`,
bookmarkDataUrl: (id) => `https://www.pixiv.net/ajax/novel/${id}/bookmarkData`,
bookmarkAddPageUrl: (id) => `https://www.pixiv.net/novel/bookmark_add.php?id=${id}`
}
};
const getBookmarkApi = (isNovel) => (isNovel ? BOOKMARK_API.novel : BOOKMARK_API.illust);
// Session-cached bookmark tags, keyed by work type so novel and illust
// tags never overwrite each other.
const bookmarkTagCaches = { illust: null, novel: null };
const bookmarkTagPromises = { illust: null, novel: null };
const bookmarkStateCache = new SimpleCache(10);
GM_registerMenuCommand(`🧹 ${tr('action.clearCaches')}`, () => {
GM_setValue(_PREBUILT_KEY, null);
for (const key of Object.keys(bookmarkTagCaches)) {
bookmarkTagCaches[key] = null;
bookmarkTagPromises[key] = null;
}
bookmarkStateCache.clear();
try { if (typeof PIXIV_DATA_CACHE !== 'undefined') PIXIV_DATA_CACHE.clear(); } catch (_) {}
showToast(tr('toast.cachesCleared') || 'Caches cleared ✓', { primary: true });
});
// Each bookmark API request is retried this many times before failing.
const BOOKMARK_API_RETRIES = 3;
// Drop every cache derived from the server's bookmark state so the next read
// refetches it. Called after each committed mutation, including when a later
// operation in the same save fails.
const invalidateBookmarkCaches = (isNovel, id) => {
const api = getBookmarkApi(isNovel);
bookmarkTagCaches[api.cacheKey] = null;
bookmarkTagPromises[api.cacheKey] = null;
bookmarkStateCache.delete(`${api.cacheKey}:${id}`);
};
// Fetches a bookmark API endpoint, retrying transient failures. JSON is the
// default response shape; pass parseResponse for others (bookmark_add.php
// returns HTML). Every request bypasses the browser cache: Pixiv's responses
// for these routes can be cached, and stale bookmark state breaks editing.
const fetchBookmarkApi = async (url, options, errorMessage, parseResponse = async res => {
const json = await res.json();
if (json.error) throw new Error(json.message || errorMessage);
return json;
}) => {
let lastError;
for (let attempt = 1; attempt <= BOOKMARK_API_RETRIES + 1; attempt += 1) {
try {
const res = await fetch(url, { ...options, cache: 'no-store' });
if (!res.ok) throw new Error(errorMessage);
return await parseResponse(res);
} catch (error) {
lastError = error;
if (attempt <= BOOKMARK_API_RETRIES) {
logDebug(`Bookmark API request failed (attempt ${attempt}/${BOOKMARK_API_RETRIES + 1}), retrying:`, error);
}
}
}
throw lastError;
};
// Thrown when a bookmark save committed some operations but failed before
// completing all of them. completedOperations lists what was saved.
class PartialBookmarkSaveError extends Error {
constructor (message, completedOperations) {
super(message);
this.name = 'PartialBookmarkSaveError';
this.completedOperations = completedOperations;
}
}
const fetchBookmarkTagsFor = async (isNovel, force = false) => {
const api = getBookmarkApi(isNovel);
const key = api.cacheKey;
if (force) {
bookmarkTagCaches[key] = null;
bookmarkTagPromises[key] = null;
}
if (bookmarkTagCaches[key]) return bookmarkTagCaches[key];
if (bookmarkTagPromises[key]) return bookmarkTagPromises[key];
const promise = (async () => {
const userId = cachedOwnUserId;
const url = api.bookmarkTagsUrl(userId);
const json = await fetchBookmarkApi(url, {
credentials: 'same-origin',
headers: { 'x-csrf-token': cachedCsrfToken, 'x-user-id': userId }
}, tr('editBookmark.loadFailed'));
const body = json.body || json;
const collected = [];
const collect = (arr) => {
for (const item of (arr || [])) {
const tag = item.tag ?? item.name ?? item.value;
const count = item.cnt ?? item.count ?? item.total ?? 0;
if (tag && String(tag) !== '未分類') collected.push({ tag: String(tag), count: Number(count) || 0 });
}
};
collect(body?.tags?.public);
collect(body?.tags?.private);
collect(body?.public);
collect(body?.private);
const map = new Map();
for (const entry of collected) {
const existing = map.get(entry.tag);
if (!existing || existing.count < entry.count) map.set(entry.tag, entry);
}
const result = [...map.values()].filter(entry => entry.count > 0).sort((a, b) => b.count - a.count || String(b.tag).localeCompare(String(a.tag)));
bookmarkTagCaches[key] = result;
return result;
})();
bookmarkTagPromises[key] = promise;
try {
return await promise;
} catch (error) {
bookmarkTagPromises[key] = null;
throw error;
}
};
const getBookmarkState = async (id, isNovel) => {
const api = getBookmarkApi(isNovel);
const cacheKey = `${api.cacheKey}:${id}`;
const cached = bookmarkStateCache.get(cacheKey);
if (cached) return cached;
const userId = cachedOwnUserId;
const dataUrl = api.bookmarkDataUrl(id);
const dataJson = await fetchBookmarkApi(dataUrl, {
credentials: 'same-origin',
headers: { 'x-csrf-token': cachedCsrfToken, 'x-user-id': userId }
}, tr('editBookmark.loadFailed'));
const body = dataJson.body;
const isBookmarkable = body?.isBookmarkable ?? true;
const bookmarkId = body?.bookmarkData?.id || null;
const isBookmarked = Boolean(bookmarkId);
let originalTags = [];
let isPrivate = false;
if (isBookmarkable !== false && isBookmarked) {
const pageUrl = api.bookmarkAddPageUrl(id);
const html = await fetchBookmarkApi(pageUrl, {
credentials: 'same-origin',
headers: { 'x-csrf-token': cachedCsrfToken, 'x-user-id': userId }
}, tr('editBookmark.loadFailed'), async res => res.text());
const tagPatterns = [
/name\s*=\s*["']?tag["']?[^>]*value\s*=\s*["']([^"']*)/i,
/value\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']?tag["']?/i
];
let tagValue = null;
for (const pattern of tagPatterns) {
const match = html.match(pattern);
if (match && match[1]) { tagValue = match[1]; break; }
}
if (tagValue !== null) originalTags = tagValue.trim().split(/\s+/).filter(Boolean);
const restrictPatterns = [
/name\s*=\s*["']?restrict["']?[^>]*value\s*=\s*["']?1["']?[^>]*checked/i,
/value\s*=\s*["']?1["']?[^>]*name\s*=\s*["']?restrict["']?[^>]*checked/i,
/name\s*=\s*["']?restrict["']?[^>]*checked[^>]*value\s*=\s*["']?1["']?/i,
/checked[^>]*name\s*=\s*["']?restrict["']?[^>]*value\s*=\s*["']?1["']?/i
];
isPrivate = restrictPatterns.some(pattern => pattern.test(html));
}
const state = { isBookmarkable, bookmarkId, isBookmarked, originalTags, isPrivate };
bookmarkStateCache.set(cacheKey, state);
return state;
};
const applyBookmarkTags = async (id, isNovel, addedTags, removedTags, isPrivate) => {
const state = await getBookmarkState(id, isNovel);
if (state.isBookmarkable === false) throw new Error(tr('editBookmark.saveFailed'));
const bookmarkId = state.bookmarkId;
const userId = cachedOwnUserId;
const headers = { 'x-csrf-token': cachedCsrfToken, 'x-user-id': userId, 'Content-Type': 'application/json' };
const typeSegment = getBookmarkApi(isNovel).tagSegment;
const toRemove = removedTags || [];
const toAdd = addedTags || [];
// Operations are sequential, not transactional: an earlier mutation stays
// committed when a later one fails. Track what was saved so partial
// failures can be reported and stale caches are never reused.
const completedOperations = [];
const runOperation = async (operation, url, body) => {
await fetchBookmarkApi(url, {
method: 'POST',
credentials: 'same-origin',
headers,
body: JSON.stringify(body)
}, tr('editBookmark.saveFailed'));
completedOperations.push(operation);
// The server already committed this change, so cached reads must not
// serve stale state even if a later operation fails.
invalidateBookmarkCaches(isNovel, id);
};
try {
if (!bookmarkId) {
// Not bookmarked yet: create a bookmark (tags included).
await runOperation('add', `https://www.pixiv.net/ajax/${typeSegment}/bookmarks/add`, {
[isNovel ? 'novel_id' : 'illust_id']: String(id),
restrict: isPrivate ? 1 : 0,
comment: '',
tags: toAdd
});
} else {
if (toRemove.length) {
await runOperation('removeTags', `https://www.pixiv.net/ajax/${typeSegment}/bookmarks/remove_tags`, { removeTags: toRemove, bookmarkIds: [bookmarkId] });
}
if (toAdd.length) {
await runOperation('addTags', `https://www.pixiv.net/ajax/${typeSegment}/bookmarks/add_tags`, { tags: toAdd, bookmarkIds: [bookmarkId] });
}
const currentPrivate = state.isPrivate === true;
if (typeof isPrivate === 'boolean' && currentPrivate !== isPrivate) {
const restrictValue = isPrivate ? 'private' : 'public';
await runOperation('editRestrict', `https://www.pixiv.net/ajax/${typeSegment}/bookmarks/edit_restrict`, { bookmarkIds: [bookmarkId], bookmarkRestrict: restrictValue });
}
}
} catch (error) {
if (completedOperations.length > 0) {
throw new PartialBookmarkSaveError(tr('editBookmark.partialSaveFailed', { error: error.message }), completedOperations);
}
throw error;
}
return { changed: completedOperations.length > 0, completedOperations };
};
const openEditBookmarkPanel = async (id, isNovel) => {
if (document.querySelector('.pixiv-utils-edit-bookmark-panel')) return;
addSettingsStyleOnce();
const dialog = document.createElement('dialog');
dialog.className = 'pixiv-utils-alert pixiv-utils-edit-bookmark-panel';
dialog.setAttribute('aria-modal', 'true');
const content = document.createElement('div');
content.className = 'pixiv-utils-alert-content';
const header = document.createElement('div');
header.className = 'pixiv-utils-alert-header';
const title = document.createElement('h2');
title.textContent = tr('text.TEXT_EDIT_BOOKMARK_TOOLTIP');
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'pixiv-utils-alert-close';
closeButton.textContent = '✕';
closeButton.title = tr('action.close');
closeButton.addEventListener('click', () => dialog.close());
header.append(title, closeButton);
const body = document.createElement('div');
body.className = 'pixiv-utils-alert-message';
const status = document.createElement('div');
status.className = 'pixiv-utils-edit-bookmark-status';
// Message and spinner are siblings so updating the message text never
// removes the spinner element.
const statusMessage = document.createElement('span');
statusMessage.className = 'pixiv-utils-edit-bookmark-status-message';
const statusSpinner = document.createElement('span');
statusSpinner.className = 'pixiv-utils-edit-bookmark-spinner';
statusSpinner.setAttribute('aria-hidden', 'true');
status.append(statusMessage, statusSpinner);
const counter = document.createElement('div');
counter.className = 'pixiv-utils-edit-bookmark-counter';
const tagsContainer = document.createElement('div');
tagsContainer.className = 'pixiv-utils-edit-bookmark-tags';
const createTagButton = document.createElement('a');
createTagButton.className = 'pixiv-utils-edit-bookmark-create-link';
createTagButton.role = 'button';
createTagButton.tabIndex = 0;
createTagButton.textContent = tr('action.createTag');
const createTagBox = document.createElement('div');
createTagBox.className = 'pixiv-utils-edit-bookmark-create';
createTagBox.style.display = 'none';
const createTagInput = document.createElement('textarea');
createTagInput.rows = 1;
const createTagSave = document.createElement('button');
createTagSave.type = 'button';
createTagSave.textContent = tr('action.save');
const createTagCancel = document.createElement('button');
createTagCancel.type = 'button';
createTagCancel.textContent = tr('action.cancel');
createTagBox.append(createTagInput, createTagSave, createTagCancel);
const headerRow = document.createElement('div');
headerRow.className = 'pixiv-utils-edit-bookmark-header-row';
headerRow.style.display = 'none';
const headerLeft = document.createElement('div');
headerLeft.className = 'pixiv-utils-edit-bookmark-header-left';
const sortToggleButton = document.createElement('button');
sortToggleButton.type = 'button';
let sortReversed = GM_getValue('PREF_EDIT_BOOKMARK_SORT_REVERSED', false);
sortToggleButton.className = 'pixiv-utils-edit-bookmark-sort-toggle';
sortToggleButton.textContent = sortReversed ? '↑' : '↓';
sortToggleButton.title = tr('action.sort');
sortToggleButton.disabled = true;
sortToggleButton.setAttribute('disabled', '');
sortToggleButton.addEventListener('click', () => {
sortReversed = !sortReversed;
GM_setValue('PREF_EDIT_BOOKMARK_SORT_REVERSED', sortReversed);
sortToggleButton.textContent = sortReversed ? '↑' : '↓';
renderTags(tags, tagsEnabled);
});
headerLeft.append(counter, sortToggleButton);
headerRow.append(headerLeft, createTagButton);
tagsContainer.append(createTagBox);
body.append(headerRow, status, tagsContainer);
const actions = document.createElement('div');
actions.className = 'pixiv-utils-alert-actions';
const refreshButton = document.createElement('button');
refreshButton.type = 'button';
refreshButton.className = 'pixiv-utils-reload-button';
// Same overlay-spinner pattern as the apply button: the label keeps its
// space while loading so the button never resizes.
const refreshButtonLabel = document.createElement('span');
refreshButtonLabel.className = 'pixiv-utils-reload-button-label';
refreshButtonLabel.textContent = tr('action.refresh');
const refreshButtonSpinner = document.createElement('span');
refreshButtonSpinner.className = 'pixiv-utils-edit-bookmark-spinner';
refreshButtonSpinner.setAttribute('aria-hidden', 'true');
refreshButton.append(refreshButtonLabel, refreshButtonSpinner);
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.className = 'pixiv-utils-alert-cancel';
cancelButton.textContent = tr('action.cancel');
const privacyContainer = document.createElement('div');
privacyContainer.className = 'pixiv-utils-edit-bookmark-privacy';
privacyContainer.style.display = 'none';
const publicRadio = document.createElement('input');
publicRadio.type = 'radio';
publicRadio.name = 'pixiv_utils_bookmark_privacy';
publicRadio.value = 'public';
publicRadio.checked = true;
const publicLabel = document.createElement('label');
publicLabel.append(publicRadio, document.createTextNode(tr('editBookmark.public')));
const privateRadio = document.createElement('input');
privateRadio.type = 'radio';
privateRadio.name = 'pixiv_utils_bookmark_privacy';
privateRadio.value = 'private';
publicRadio.disabled = true;
privateRadio.disabled = true;
const privateLabel = document.createElement('label');
privateLabel.append(privateRadio, document.createTextNode(tr('editBookmark.private')));
privacyContainer.append(publicLabel, privateLabel);
body.append(privacyContainer);
const applyButton = document.createElement('button');
applyButton.type = 'button';
applyButton.className = 'pixiv-utils-alert-ok pixiv-utils-edit-bookmark-apply';
// The label keeps occupying its space while saving (visibility instead of
// removal) and the spinner overlays the button, so the button never
// resizes even though its initial size depends on locale and font.
const applyButtonLabel = document.createElement('span');
applyButtonLabel.className = 'pixiv-utils-edit-bookmark-apply-label';
applyButtonLabel.textContent = tr('action.save');
const applyButtonSpinner = document.createElement('span');
applyButtonSpinner.className = 'pixiv-utils-edit-bookmark-spinner';
applyButtonSpinner.setAttribute('aria-hidden', 'true');
applyButton.append(applyButtonLabel, applyButtonSpinner);
applyButton.disabled = true;
actions.append(refreshButton, cancelButton, applyButton);
const setButtonLoading = (button, loading) => {
button.classList.toggle('is-loading', loading);
};
const setStatusLoading = loading => {
status.classList.toggle('is-loading', loading);
};
content.append(header, body, actions);
dialog.append(content);
(document.body || document.documentElement).append(dialog);
const selected = new Set();
const newlyCreated = new Set();
let tagsEnabled = false;
let bookmarkDataReady = false;
const updateCounter = () => {
counter.textContent = `${selected.size}/10`;
createTagButton.disabled = !bookmarkDataReady || selected.size >= 10;
if (createTagButton.disabled) createTagButton.setAttribute('disabled', '');
else createTagButton.removeAttribute('disabled');
const checkboxes = tagsContainer.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => {
if (!tagsEnabled) {
cb.disabled = true;
return;
}
if (selected.has(cb.value)) {
cb.disabled = false;
} else {
cb.disabled = selected.size >= 10;
}
});
};
const createTagLabel = (entry, enabled) => {
const label = document.createElement('label');
label.className = 'pixiv-utils-edit-bookmark-tag';
const text = document.createElement('span');
text.textContent = `#${entry.tag}`;
const count = document.createElement('span');
count.className = 'pixiv-utils-edit-bookmark-tag-count';
count.textContent = String(entry.count ?? 0);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = entry.tag;
checkbox.checked = selected.has(entry.tag);
checkbox.disabled = !enabled;
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
if (selected.size >= 10) {
checkbox.checked = false;
return;
}
selected.add(entry.tag);
} else {
selected.delete(entry.tag);
}
updateCounter();
});
label.append(text, count, checkbox);
return label;
};
const renderTags = (tags, enabled = true) => {
setStatusLoading(false);
tagsEnabled = enabled;
const ordered = [...tags].sort((a, b) => {
const aAssigned = originalBookmarkTags.includes(a.tag);
const bAssigned = originalBookmarkTags.includes(b.tag);
const aNew = newlyCreated.has(a.tag);
const bNew = newlyCreated.has(b.tag);
const aGroup = aNew ? 2 : (aAssigned ? 0 : 1);
const bGroup = bNew ? 2 : (bAssigned ? 0 : 1);
if (aGroup !== bGroup) return aGroup - bGroup;
if (aNew && bNew) return 0;
const base = b.count - a.count || String(b.tag).localeCompare(String(a.tag));
return sortReversed ? -base : base;
});
tagsContainer.replaceChildren();
statusMessage.textContent = tags.length ? '' : tr('editBookmark.noTags');
for (const entry of ordered) {
tagsContainer.append(createTagLabel(entry, enabled));
}
tagsContainer.append(createTagBox);
updateCounter();
};
let tags = [];
let originalBookmarkTags = [];
const load = async (force = false) => {
applyButton.disabled = true;
refreshButton.disabled = true;
if (!force) {
setStatusLoading(true);
headerRow.style.display = 'none';
privacyContainer.style.display = 'none';
}
publicRadio.disabled = true;
privateRadio.disabled = true;
bookmarkDataReady = false;
tagsEnabled = false;
updateCounter();
sortToggleButton.disabled = true;
sortToggleButton.setAttribute('disabled', '');
try {
tags = await fetchBookmarkTagsFor(isNovel, force);
renderTags(tags, false);
headerRow.style.display = '';
privacyContainer.style.display = '';
// Fetch current assigned tags from bookmark_add.php (cached per session).
// The status spinner cleared once the tags rendered, so the reload
// button spinner marks this second fetch phase.
setButtonLoading(refreshButton, true);
const state = await getBookmarkState(id, isNovel).finally(() => {
setButtonLoading(refreshButton, false);
});
if (state.isBookmarkable === false) {
statusMessage.textContent = tr('editBookmark.saveFailed');
applyButton.disabled = true;
refreshButton.disabled = false;
return false;
}
let originalTags = Array.isArray(state.originalTags) ? state.originalTags : [];
if (state.isBookmarked) {
privateRadio.checked = state.isPrivate;
publicRadio.checked = !state.isPrivate;
} else {
privateRadio.checked = false;
publicRadio.checked = true;
}
originalTags = [...new Set(originalTags)];
selected.clear();
for (const tag of originalTags) {
if (tag && tag !== '未分類') selected.add(tag);
if (!tags.some(e => e.tag === tag) && tag && tag !== '未分類') {
tags.push({ tag, count: 1 });
}
}
// store original tags for diff
originalBookmarkTags = [...new Set(originalTags.filter(tag => tag && tag !== '未分類'))];
bookmarkDataReady = true;
renderTags(tags, true);
headerRow.style.display = '';
privacyContainer.style.display = '';
publicRadio.disabled = false;
privateRadio.disabled = false;
sortToggleButton.disabled = false;
sortToggleButton.removeAttribute('disabled');
applyButton.disabled = false;
return true;
} catch (error) {
setStatusLoading(false);
statusMessage.textContent = error?.message || tr('editBookmark.loadFailed');
applyButton.disabled = true;
return false;
} finally {
refreshButton.disabled = false;
}
};
refreshButton.addEventListener('click', () => {
invalidateBookmarkCaches(isNovel, id);
setButtonLoading(refreshButton, true);
load(true).finally(() => {
setButtonLoading(refreshButton, false);
});
});
cancelButton.addEventListener('click', () => dialog.close());
applyButton.addEventListener('click', async () => {
applyButton.disabled = true;
setButtonLoading(applyButton, true);
try {
const selectedTags = [...selected];
const selectedPrivate = privateRadio.checked;
const added = selectedTags.filter(tag => !originalBookmarkTags.includes(tag));
const removed = originalBookmarkTags.filter(tag => !selectedTags.includes(tag));
const result = await applyBookmarkTags(id, isNovel, added, removed, selectedPrivate);
if (result.changed) {
showToast(tr('toast.bookmarkUpdated'), { primary: true });
}
dialog.close();
} catch (error) {
if (error instanceof PartialBookmarkSaveError) {
// Earlier operations were already committed, so refresh the panel
// from the server instead of showing stale selections, and keep it
// open. Only a fully successful save closes the dialog.
logDebug('Bookmark save partially failed. Completed operations:', error.completedOperations);
await load(true);
showAlert(error.message);
} else {
showAlert(error?.message || tr('editBookmark.saveFailed'));
}
} finally {
applyButton.disabled = false;
setButtonLoading(applyButton, false);
}
});
dialog.addEventListener('click', (event) => {
if (event.target !== dialog) return;
dialog.close();
});
const closeOnNavigate = () => dialog.close();
window.addEventListener('detectnavigate', closeOnNavigate);
dialog.addEventListener('close', () => {
window.removeEventListener('detectnavigate', closeOnNavigate);
if (activeToast && dialog.contains(activeToast)) {
(document.body || document.documentElement).append(activeToast);
}
dialog.remove();
});
createTagButton.addEventListener('click', (event) => {
event.preventDefault();
if (createTagButton.disabled) return;
createTagButton.style.display = 'none';
createTagBox.style.display = 'flex';
createTagInput.focus();
});
createTagCancel.addEventListener('click', () => {
createTagBox.style.display = 'none';
createTagButton.style.display = '';
createTagInput.value = '';
});
createTagSave.addEventListener('click', () => {
const newTag = createTagInput.value.trim();
if (!newTag) return;
const existing = tags.find(e => e.tag === newTag);
if (existing) {
// Reuse the existing entry instead of creating a duplicate checkbox.
if (selected.size < 10) selected.add(newTag);
const existingCheckbox = [...tagsContainer.querySelectorAll('.pixiv-utils-edit-bookmark-tag input[type="checkbox"]')]
.find(cb => cb.value === newTag);
if (existingCheckbox) existingCheckbox.checked = selected.has(newTag);
createTagBox.style.display = 'none';
createTagButton.style.display = '';
createTagInput.value = '';
updateCounter();
return;
}
const tagData = { tag: newTag, count: 0 };
tags.push(tagData);
newlyCreated.add(newTag);
if (selected.size < 10) selected.add(newTag);
const label = createTagLabel(tagData, true);
tagsContainer.insertBefore(label, createTagBox);
if (tags.length) statusMessage.textContent = '';
createTagBox.style.display = 'none';
createTagButton.style.display = '';
createTagInput.value = '';
updateCounter();
});
dialog.showModal();
await load(false);
};
const editBookmarkHref = (id, isNovel) => isNovel
? `https://www.pixiv.net/novel/bookmark_add.php?id=${id}`
: `https://www.pixiv.net/bookmark_add.php?type=illust&illust_id=${id}`;
const editBookmarkButton = (id, isNovel = false) => {
const buttonContainer = document.createElement('div');
buttonContainer.className = 'pixiv_utils_edit_bookmark_container';
if (isNovel) buttonContainer.dataset.pixiv_utils_edit_bookmark_novel = 'true';
const button = document.createElement('a');
button.className = 'pixiv_utils_edit_bookmark';
button.textContent = CONFIG.TEXT_EDIT_BOOKMARK;
if (CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP) {
button.title = CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP;
}
button.href = editBookmarkHref(id, isNovel);
if (CONFIG.ENABLE_FANCY_EDIT_BOOKMARK) {
button.addEventListener('click', event => {
event.preventDefault();
openEditBookmarkPanel(id, isNovel);
});
}
buttonContainer.append(button);
return buttonContainer;
};
const hasEditBookmarkButton = (controls, id, isNovel) => {
const button = controls?.querySelector('.pixiv_utils_edit_bookmark_container .pixiv_utils_edit_bookmark');
return Boolean(button?.isConnected && controls.contains(button) &&
button.href === editBookmarkHref(id, isNovel));
};
const ensureEditBookmarkButton = (controls, id, isNovel, placement = 'prepend') => {
const existing = controls.querySelector('.pixiv_utils_edit_bookmark_container');
if (!CONFIG.ENABLE_EDIT_BOOKMARK) {
existing?.remove();
return false;
}
if (hasEditBookmarkButton(controls, id, isNovel)) return false;
existing?.remove();
const created = editBookmarkButton(id, isNovel);
if (placement === 'beforeLast' && controls.lastChild) controls.lastChild.before(created);
else if (placement === 'append') controls.append(created);
else controls.prepend(created);
return true;
};
// Ordered by priority; IMG SRCs are only considered where explicitly requested.
const ITEM_SOURCES = [
{ selector: 'a[href*="artworks/"]', regex: /artworks\/(\d+)/, type: 'illust' },
{ selector: 'a[href*="illust_id="]', regex: /illust_id=(\d+)/, type: 'illust' },
{ selector: 'a[href*="novel/show.php?id="]', regex: /novel\/show\.php\?id=(\d+)/, type: 'novel' },
{ selector: 'a[href*="novel/series/"]', regex: /novel\/series\/(\d+)/, type: 'novel' },
{ selector: 'a[href*="collections/"]', regex: /collections\/(\d+)/, type: 'collections' }
];
const findItemSource = (element, type) => {
for (const source of ITEM_SOURCES) {
if (type && source.type !== type) continue;
const node = element.querySelector(source.selector);
if (!node) continue;
const match = node.href.match(source.regex);
return {
id: match?.[1] ?? null,
type: source.type,
link: node
};
}
return null;
};
// Toggle Bookmarked Modes.
// 0 = Show all
// 1 = Show not bookmarked
// 2 = Show bookmarked
const _TB_MIN = 0;
const _TB_MAX = 2;
const getToggleBookmarkedMode = element => {
const section = element.closest('[data-pixiv_utils_toggle_bookmarked_section]');
return section ? Number(section.dataset.pixiv_utils_toggle_bookmarked_mode) : null;
};
const setToggleBookmarkedMode = (section, imagesContainer, mode) => {
section.dataset.pixiv_utils_toggle_bookmarked_mode = mode;
imagesContainer.dataset.pixiv_utils_toggle_bookmarked_mode = mode;
};
const MUST_COMPUTE_BOOKMARK_BUTTONS = normalizeSelectorList(/*css*/`
button:not([aria-haspopup="true"]) > svg:has(path),
a[href*="bookmark_add.php"] > svg:has(path)
`);
const isImageBookmarked = element => {
const hasBookmarkedClass = Boolean(element.querySelector(CONFIG.SELECTORS_IMAGE_BOOKMARKED));
if (!hasBookmarkedClass) {
const bookmarkButton = element.querySelector(MUST_COMPUTE_BOOKMARK_BUTTONS);
if (bookmarkButton) {
return window.getComputedStyle(bookmarkButton).getPropertyValue('color') === 'rgb(255, 64, 96)';
}
}
return hasBookmarkedClass;
};
// Pixiv exposes the artist on different props in Vue and React views. Reuse
// these fields for the artist label and for user-ID blocking. Never convert
// string IDs to Number (large IDs and leading zeroes must be preserved).
const pixivUserIdString = value => {
if (typeof value === 'string') return /^[0-9]+$/.test(value) ? value : null;
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
? String(value)
: null;
};
const readArtistMetadata = (source, target) => {
if (!source || typeof source !== 'object') return;
const user = source.author_details || source.user || source.author;
const id = pixivUserIdString(source.userId ?? source.user_id ??
user?.userId ?? user?.user_id ?? user?.id);
const name = source.userName ?? source.user_name ??
user?.userName ?? user?.user_name ?? user?.name;
if (target.userId === null && id !== null) target.userId = id;
if (target.userName === null && typeof name === 'string' && name) target.userName = name;
};
const extractArtistMetadata = element => {
const artist = { userId: null, userName: null };
if (element.__vue__) {
readArtistMetadata(element.__vue__._props?.item, artist);
readArtistMetadata(element.__vue__._props?.illustDetails, artist);
} else {
// React view: the card's own React props carry artist fields under rawThumbnail/thumbnail/work.
const propsKey = Object.keys(element).find(k => k.startsWith('__reactProps'));
const props = propsKey && element[propsKey]?.children?.props;
if (props) {
for (const key of ['rawThumbnail', 'thumbnail', 'work']) {
readArtistMetadata(props[key], artist);
if (artist.userId !== null && artist.userName !== null) break;
}
}
}
return artist;
};
const reactPixivDataSource = props => {
const children = props.children;
const sources = [];
const nestedChildren = children?.props?.children;
if (props.tags) sources.push(props);
if (props.content?.tags) sources.push(props.content);
if (props.content?.thumbnails?.length) sources.push(props.content.thumbnails[0]);
for (const key of ['rawThumbnail', 'thumbnail', 'work']) {
if (props[key]) sources.push(props[key]);
}
if (Array.isArray(nestedChildren)) {
for (const child of nestedChildren) {
if (child?.props?.thumbnail) sources.push(child.props.thumbnail);
}
}
if (Array.isArray(children)) {
for (const child of children) {
const childProps = child?.props;
const nestedProps = childProps?.children?.props;
if (childProps?.collection) sources.push(childProps.collection);
else if (nestedProps?.workId && nestedProps?.workType) sources.push(nestedProps);
}
} else if (children) {
if (children.props?.thumbnail) sources.push(children.props.thumbnail);
const content = children.props?.content;
if (content?.thumbnails?.length) sources.push(content.thumbnails[0]);
else if (content) sources.push(content);
}
return sources.find(source => source && typeof source === 'object') || null;
};
const readVuePixivData = (source, data, detailsLoaded) => {
readArtistMetadata(source, data);
const isNovel = source.novelId || source.work_type === 'novel' ||
source.readingTime || source.wordCount;
const id = isNovel ? (source.novelId ?? source.id) : source.id;
data.id = id == null ? null : String(id);
if (isNovel) data.type = 'novel';
if (source.title != null) data.title = source.title;
if (!detailsLoaded) return;
const ai = source.ai_type ?? source.aiType;
data.ai = ai == null ? null : ai === 2;
data.tags = source.tags ?? null;
if (Number.isFinite(source.upload_timestamp)) {
data.createDate = formatArtworkDate(source.upload_timestamp * 1000);
}
};
const normalizePixivWorkType = type => {
if (type === 'novel') return 'novel';
if (type === 'collection' || type === 'collections') return 'collections';
return 'illust';
};
const extractImagePixivData = async element => {
const data = {
id: null,
title: null,
ai: null,
type: 'illust',
tags: null,
createDate: null,
userId: null,
userName: null
};
if (element.__vue__) {
for (const key of ['item', 'illustDetails']) {
const source = element.__vue__._props?.[key];
if (!source) continue;
const itemNotLoaded = key === 'item' && source.notLoaded;
readVuePixivData(source, data, !itemNotLoaded);
const cacheKey = data.id == null ? null : `${data.type}:${data.id}`;
const cached = cacheKey ? PIXIV_DATA_CACHE.get(cacheKey) : undefined;
if (cached) return cached;
if (itemNotLoaded) {
const awaited = await waitFor(() => !source.notLoaded, element);
if (!awaited) break;
readVuePixivData(source, data, true);
}
}
} else {
const reactFiberKey = Object.keys(element).find(k => k.startsWith('__reactFiber'));
if (reactFiberKey) {
const MAX_STEPS = 5;
let step = 0;
const traverseChild = obj => {
if (!obj || !obj.memoizedProps) return;
step++;
const props = obj.memoizedProps;
const source = reactPixivDataSource(props);
if (source) {
const id = source.id ?? source.illust_id ?? source.workId;
const type = normalizePixivWorkType(source.type ?? source.workType ?? source.kind);
const cached = id == null ? null : PIXIV_DATA_CACHE.get(`${type}:${id}`);
if (cached) {
Object.assign(data, cached);
return true;
}
readArtistMetadata(source, data);
if (id != null) data.id = String(id);
if (source.title != null) data.title = source.title;
data.ai = source.aiType == null ? null : source.aiType === 2;
data.type = type;
data.tags = source.tags ?? null;
const date = Number.isFinite(source.illust_upload_timestamp)
? (source.illust_upload_timestamp * 1000)
: source.createDate;
if (date !== undefined) data.createDate = formatArtworkDate(date);
}
// Source metadata identifies the artwork's artist more reliably than
// ancestor props, which can refer to a different user.
if (data.userId === null || data.userName === null) readArtistMetadata(props, data);
return data.tags === null && step < MAX_STEPS && traverseChild(obj.child);
};
if (traverseChild(element[reactFiberKey])) return data;
}
}
if (!Array.isArray(data.tags)) data.tags = [];
data.tags = data.tags
.map(tag => typeof tag === 'string' ? tag : tag?.name)
.filter(tag => typeof tag === 'string');
return data;
};
// Coalesce simultaneous reads from the same source element. Pixiv can recycle
// cards, so only complete results are retained in the bounded ID cache.
// Extraction checks this cache as soon as it identifies an artwork ID/type.
const PIXIV_DATA_CACHE = new SimpleCache(300);
const pendingPixivData = new WeakMap();
const getImagePixivData = async element => {
let task = pendingPixivData.get(element);
if (task) return task;
task = (async () => {
const data = await extractImagePixivData(element);
data.complete = Array.isArray(data.tags) && data.ai !== null &&
(!PIXIV_BLOCKED_USERS_SET.size || data.userId !== null);
const cacheKey = data.id == null ? null : `${data.type}:${data.id}`;
if (cacheKey && data.complete) PIXIV_DATA_CACHE.set(cacheKey, data);
return data;
})();
pendingPixivData.set(element, task);
try {
return await task;
} finally {
if (pendingPixivData.get(element) === task) pendingPixivData.delete(element);
}
};
const getImageData = async (element, fallbackElement = element) => {
const pixivData = await getImagePixivData(element);
const needsFallback = pixivData.id == null || pixivData.id === '';
const fallback = needsFallback ? findItemSource(fallbackElement) : null;
const id = needsFallback ? fallback?.id ?? null : pixivData.id;
const type = normalizePixivWorkType(needsFallback ? fallback?.type : pixivData.type);
pixivData.id = id;
pixivData.type = type;
let link = fallback?.link ?? null;
if (!link) link = findItemSource(fallbackElement, type)?.link ?? null;
return {
pixivData,
data: {
id,
type,
novel: type === 'novel',
collections: type === 'collections',
link
}
};
};
// Structured tooltip details belong to an artwork element, not a global string.
// WeakMap entries naturally expire when Pixiv drops a recycled grid element.
const floatingTooltipDetails = new WeakMap();
// Bound when the shared Floating UI tooltip is initialized.
let syncActiveFloatingTooltip = () => {};
const createTooltipFooter = (heading, body) => ({ heading, body });
// Cache moderation results on the pixiv data object itself, so they are retained
// alongside the cached pixiv data (and reused across elements for the same artwork).
// Per-element bypass conditions (own profile, bookmarked, highlighted) are NOT part
// of this cached result; they are evaluated separately in evaluateArtworkModeration.
const getImageModeration = pixivData => {
if (pixivData && pixivData.moderation) {
return pixivData.moderation;
}
const moderation = {
highlightOpts: PIXIV_HIGHLIGHTED_TAGS_VALIDATED ? isImageHighlightedByData(pixivData) : false,
blockOpts: PIXIV_BLOCKED_TAGS_VALIDATED ? isImageBlockedByData(pixivData) : false
};
if (pixivData) pixivData.moderation = moderation;
return moderation;
};
// Share filtering decisions between standard cards, multi-view and expanded
// view; presentation and actual blocking remain view-specific.
const evaluateArtworkModeration = (pixivData, { isOwnProfile = false, bookmarked = false } = {}) => {
const moderation = getImageModeration(pixivData);
const skipReason = moderation.blockOpts
? getImageBlockSkipReason({
isOwnProfile,
bookmarked,
highlighted: CONFIG.PIXIV_HIGHLIGHTED_BYPASS_BLOCK && moderation.highlightOpts
})
: [];
return { pixivData, moderation, skipReason };
};
const setModerationTooltip = (target, evaluation, blocked = false) => {
const { highlightOpts, blockOpts } = evaluation.moderation;
const footer = [];
if (highlightOpts) footer.push({ heading: tr('tooltip.highlightedBy'), body: highlightOpts.hint });
if (blockOpts) {
footer.push({
heading: tr(blocked ? 'tooltip.blockedBy' : 'tooltip.blockableBy'),
body: blockOpts.hint
});
}
const skipReason = blocked ? [] : evaluation.skipReason || [];
if (skipReason.length) {
footer.push({
heading: tr('tooltip.skippedDueTo'),
body: skipReason.join('\n')
});
}
setImageTitle(target, {
pixivData: evaluation.pixivData,
moderation: blocked ? { ...evaluation.moderation, actuallyBlocked: true } : evaluation.moderation,
footer
});
};
const createImageTooltipDetails = ({ pixivData = {}, footer, moderation = {} }) => {
const sections = !footer
? []
: Array.isArray(footer)
? footer.filter(section => section?.body)
: [{ heading: '', body: String(footer).trim() }];
const tags = Array.isArray(pixivData.tags) ? pixivData.tags : [];
const identity = [pixivData.id, pixivData.type].filter(Boolean).join(' · ');
const details = {
title: pixivData.title || identity,
ai: Boolean(pixivData.ai),
tags,
date: pixivData.createDate || '',
footer: sections,
highlightedTagColors: moderation.highlightOpts?.tagColors || new Map(),
blockedTags: new Set(moderation.blockOpts?.blockedTags || []),
aiBlockable: Boolean(moderation.blockOpts?.blockedAI),
actuallyBlocked: Boolean(moderation.actuallyBlocked)
};
const lines = [];
if (details.title) lines.push(details.title);
if (details.ai) lines.push(tr('tooltip.aiGenerated'));
if (tags.length) lines.push(tags.join(', '));
if (details.date) lines.push(details.date);
for (const section of sections) {
if (section.heading) lines.push(section.heading);
if (section.body) lines.push(section.body);
}
return { ...details, plainText: lines.join('\n').trim() };
};
// Use textContent and DOM nodes instead of innerHTML: titles and tags come
// from untrusted Pixiv artwork metadata and must never be interpreted as HTML.
const renderImageTooltip = (container, target) => {
const details = floatingTooltipDetails.get(target);
if (!details) {
container.textContent = target.dataset.pixiv_utils_tooltip || '';
return;
}
const nodes = [];
const textRow = (tag, className, text) => {
const node = document.createElement(tag);
node.className = className;
node.textContent = text;
return node;
};
if (details.title) {
nodes.push(textRow('strong', 'pixiv-utils-tooltip-title', details.title));
}
if (details.ai) {
const state = details.actuallyBlocked && details.aiBlockable
? 'blocked'
: details.aiBlockable ? 'blockable' : 'neutral';
const ai = textRow('span', 'pixiv-utils-tooltip-ai', tr('tooltip.aiGenerated'));
ai.dataset.state = state;
const row = document.createElement('div');
row.className = 'pixiv-utils-tooltip-ai-row';
row.append(ai);
nodes.push(row);
}
if (details.tags.length) {
const list = document.createElement('div');
list.className = 'pixiv-utils-tooltip-tags';
for (const [index, tag] of details.tags.entries()) {
if (index) list.append(textRow('span', 'pixiv-utils-tooltip-tag-separator', ', '));
const highlighted = details.highlightedTagColors.has(tag);
const blocked = details.blockedTags.has(tag);
if (!highlighted && !blocked) {
list.append(document.createTextNode(tag));
continue;
}
const node = textRow('span', 'pixiv-utils-tooltip-tag', tag);
node.dataset.match = highlighted && blocked ? 'both' : blocked ? 'blocked' : 'highlighted';
if (highlighted) {
const color = details.highlightedTagColors.get(tag);
if (typeof color === 'string' && CSS.supports('color', color)) {
node.style.setProperty('--pixiv-utils-tooltip-tag-color', color);
}
}
list.append(node);
}
nodes.push(list);
}
if (details.date) nodes.push(textRow('div', 'pixiv-utils-tooltip-date', details.date));
if (details.footer.length) {
const footer = document.createElement('div');
footer.className = 'pixiv-utils-tooltip-footer';
for (const section of details.footer) {
const row = document.createElement('div');
row.className = 'pixiv-utils-tooltip-footer-section';
if (section.heading) row.append(textRow('span', 'pixiv-utils-tooltip-footer-heading', section.heading));
if (section.body) row.append(textRow('span', 'pixiv-utils-tooltip-footer-body', section.body));
footer.append(row);
}
nodes.push(footer);
}
container.replaceChildren(...nodes);
};
const setImageTitle = (element, options = {}) => {
if (!element) return;
const details = createImageTooltipDetails(options);
if (!details.plainText) {
floatingTooltipDetails.delete(element);
delete element.dataset.pixiv_utils_tooltip;
element.removeAttribute('title');
syncActiveFloatingTooltip(element);
return;
}
if (CONFIG.ENABLE_FLOATING_TOOLTIPS) {
floatingTooltipDetails.set(element, details);
element.removeAttribute('title');
// Retain plain text for selector, accessibility and native fallback paths.
element.dataset.pixiv_utils_tooltip = details.plainText;
syncActiveFloatingTooltip(element);
} else {
floatingTooltipDetails.delete(element);
delete element.dataset.pixiv_utils_tooltip;
element.title = details.plainText;
}
};
// Central matching predicate shared by blocking and highlighting.
const matchesConfiguredTag = (tag, strings, regexps) =>
strings.has(tag) || regexps.some(pattern => pattern.test(tag));
const isImageHighlightedByData = data => {
const result = {
highlightedTags: [],
tagColors: new Map(),
hint: '',
color: null
};
for (const config of PIXIV_HIGHLIGHTED_TAGS_FORMATTED) {
for (const tag of data.tags) {
if (matchesConfiguredTag(tag, config.string, config.regexp)) {
result.highlightedTags.push(tag);
// Retain each matched tag's group color for tooltip presentation.
// First match wins, consistent with the first-match color behavior.
if (!result.tagColors.has(tag)) {
result.tagColors.set(tag, config.color || CONFIG.PIXIV_HIGHLIGHTED_COLOR);
}
if (!result.color && config.color) {
result.color = config.color;
}
// If fast, immediately break away from this set of tags.
if (CONFIG.PIXIV_HIGHLIGHTED_TAGS_FAST) {
break;
}
}
}
// If fast, and color is already set due to a match from the current config,
// do not bother checking the other sets of tags.
if (CONFIG.PIXIV_HIGHLIGHTED_TAGS_FAST && result.color) {
break;
}
}
if (!result.highlightedTags.length) {
return false;
}
if (!result.color) {
result.color = CONFIG.PIXIV_HIGHLIGHTED_COLOR;
}
result.hint = tr('tooltip.tags', { tags: result.highlightedTags.join(', ') });
return result;
};
const doHighlightImage = (element, obj = {}) => {
// Skip if already highlighted.
if (element.dataset.pixiv_utils_highlight) {
return false;
}
element.dataset.pixiv_utils_highlight = true;
if (obj.options.color) {
element.style.setProperty('--pixiv_utils_highlight_color', obj.options.color);
}
return true;
};
const isImageBlockedByData = data => {
const blockedUserId = data.userId !== null && PIXIV_BLOCKED_USERS_SET.has(data.userId)
? data.userId
: null;
const result = {
blockedUserId,
blockedAI: false,
blockedTags: [],
hint: ''
};
// The artist match takes precedence in fast mode. No AI or tag checks are
// necessary; incomplete metadata can still be blocked by a known user ID.
if (CONFIG.PIXIV_BLOCKED_TAGS_FAST && blockedUserId !== null) {
result.hint = tr('tooltip.userId', { id: blockedUserId });
return result;
}
result.blockedAI = CONFIG.PIXIV_BLOCK_AI && data.ai;
if (CONFIG.PIXIV_BLOCKED_TAGS_FAST && result.blockedAI) {
result.hint = tr('tooltip.aiGenerated');
return result;
}
for (const tag of data.tags || []) {
if (matchesConfiguredTag(tag, PIXIV_BLOCKED_TAGS_STRING, PIXIV_BLOCKED_TAGS_REGEXP)) {
result.blockedTags.push(tag);
if (CONFIG.PIXIV_BLOCKED_TAGS_FAST) break;
}
}
if (blockedUserId === null && !result.blockedAI && !result.blockedTags.length) return false;
if (blockedUserId !== null) result.hint += tr('tooltip.userId', { id: blockedUserId });
if (result.blockedAI) result.hint += `${result.hint ? '\n' : ''}${tr('tooltip.aiGenerated')}`;
if (result.blockedTags.length) result.hint += `${result.hint ? '\n' : ''}${tr('tooltip.tags', { tags: result.blockedTags.join(', ') })}`;
return result;
};
const getImageBlockSkipReason = (options = {}) => {
const skipReason = [];
if (options.isOwnProfile) {
skipReason.push(tr('tooltip.inOwnProfile'));
}
if (options.bookmarked) {
skipReason.push(tr('tooltip.imageBookmarked'));
}
if (options.highlighted) {
skipReason.push(tr('tooltip.imageHighlighted'));
}
return skipReason;
};
const setImageBlocked = (element, options = {}) => {
// Skip if already blocked (check for element due to possibility of dynamic update).
const alreadyBlocked = element.querySelector('.pixiv_utils_blocked_image_container');
if (!options.priority && alreadyBlocked) {
return false;
}
element.dataset.pixiv_utils_blocked = true;
// Process using this section's current filter, not another section's mode.
const sectionMode = getToggleBookmarkedMode(element);
if (sectionMode !== null && sectionMode !== 0) {
element.dataset.pixiv_utils_toggle_bookmarked_hide = true;
}
// For mobile, never remove blocked, as it does not behave well with Pixiv's in-place navigation.
if (options.remove && !options.mobile) {
element.style.setProperty('display', 'none', 'important');
return true;
}
if (!alreadyBlocked) {
const blockedThumb = document.createElement('a');
blockedThumb.className = 'pixiv_utils_blocked_image_container';
blockedThumb.href = options.link.href;
blockedThumb.innerHTML = BLOCKED_IMAGE_HTML;
options.link.after(blockedThumb);
}
return true;
};
const doBlockImage = (element, obj = {}) => {
// Do not ever remove in sections known to have display issues.
let remove = CONFIG.PIXIV_REMOVE_BLOCKED;
if (element.closest(SELECTORS_IMAGE_CONTAINER_SIMPLIFIED) ||
element.matches(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE)) {
remove = false;
}
if (obj.skipReason) {
logDebug(`Image is blockable, but skipped (reason: ${obj.skipReason})`, element);
return false;
}
const status = setImageBlocked(element, {
mobile: element.matches(SELECTORS_IMAGE_MOBILE),
remove,
link: obj.data.link
});
if (status) {
setModerationTooltip(element, obj.evaluation, true);
logDebug(`Image blocked (${obj.options.hint.replace('\n', ', ')})`, element);
}
return true;
};
const addImageArtist = (element, pixivData) => {
// Keep an attached matching label, but rebuild one removed by Pixiv.
const artist = {
userId: pixivData.userId ?? null,
userName: pixivData.userName ?? null
};
if (artist.userId === null || artist.userName === null) {
const fallback = extractArtistMetadata(element);
if (artist.userId === null) artist.userId = fallback.userId;
if (artist.userName === null) artist.userName = fallback.userName;
}
if (artist.userId === null || !artist.userName) return false;
const href = `https://www.pixiv.net/users/${encodeURIComponent(artist.userId)}`;
const old = element.querySelector('.pixiv_utils_image_artist_container');
const link = old?.querySelector('.pixiv_utils_image_artist');
if (link?.isConnected && element.contains(link) &&
link.href === href && link.textContent === artist.userName) return false;
old?.remove();
const div = document.createElement('div');
div.className = 'pixiv_utils_image_artist_container';
const replacement = document.createElement('a');
replacement.className = 'pixiv_utils_image_artist';
replacement.href = href;
replacement.textContent = artist.userName;
div.append(replacement);
element.append(div);
element.dataset.pixiv_utils_artist_added = true;
return true;
};
const doImage = async (element, options = {}) => {
const { pixivData, data } = await getImageData(element);
if (!element.isConnected || data.id === null || !data.link) return false;
if (options.isHome) {
if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && data.novel) {
element.style.setProperty('display', 'none', 'important');
logDebug('Novel recommendation removed from home', element);
return true;
}
if (CONFIG.REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME && data.collections) {
element.style.setProperty('display', 'none', 'important');
logDebug('Collections recommendation removed from home', element);
return true;
}
}
// Process new entries using their own section's current filter mode.
const bookmarked = isImageBookmarked(element);
const sectionMode = getToggleBookmarkedMode(element);
if (sectionMode !== null) {
if (sectionMode === 1 && bookmarked) {
element.dataset.pixiv_utils_toggle_bookmarked_hide = true;
} else if (sectionMode === 2 && !bookmarked) {
element.dataset.pixiv_utils_toggle_bookmarked_hide = true;
} else {
delete element.dataset.pixiv_utils_toggle_bookmarked_hide;
}
}
// Skip if edit bookmark button already inserted, unless forced.
if (!options.forced &&
hasEditBookmarkButton(element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS), data.id, data.novel) &&
(!element.dataset.pixiv_utils_artist_added ||
element.querySelector('.pixiv_utils_image_artist_container'))) {
return false;
}
// Init MutationObserver for dynamic images.
if (element.__vue__) {
if (!element.dataset.pixiv_utils_last_tx) {
initElementObserver(element, () => {
const lastGrid = element.dataset.pixiv_utils_last_grid === 'true';
if (element.dataset.tx !== element.dataset.pixiv_utils_last_tx ||
element.classList.contains('grid') !== lastGrid) {
options.forced = true;
// Do not defer with processNewElement() in case it is currently hidden.
doImage(element, options);
}
}, {
attributes: true,
// Monitor class tag to also detect list/grid view change.
attributeFilter: ['class', 'data-tx']
});
}
element.dataset.pixiv_utils_last_tx = element.dataset.tx;
element.dataset.pixiv_utils_last_grid = element.classList.contains('grid');
}
// Skip if already blocked, unless forced.
if (element.dataset.pixiv_utils_blocked) {
if (options.forced) {
delete element.dataset.pixiv_utils_blocked;
const blockedThumb = element.querySelector('.pixiv_utils_blocked_image_container');
if (blockedThumb) {
blockedThumb.remove();
}
} else {
return false;
}
}
// Reset other statuses if forced.
if (options.forced) {
element.removeAttribute('title');
delete element.dataset.pixiv_utils_highlight;
element.style.removeProperty('--pixiv_utils_highlight_color');
}
const evaluation = evaluateArtworkModeration(pixivData, { isOwnProfile, bookmarked });
const { moderation, skipReason } = evaluation;
const { highlightOpts, blockOpts } = moderation;
if (highlightOpts) doHighlightImage(element, { options: highlightOpts });
if (blockOpts) {
const blocked = doBlockImage(element, {
options: blockOpts,
data,
evaluation,
skipReason: skipReason.join(', ')
});
if (blocked) return true;
}
setModerationTooltip(element, evaluation);
// Exit early if collections, because they do not have independent bookmark edit page.
if (data.collections) {
return false;
}
// Exit early if in own profile, and not in bookmarks tab.
if (options.isOwnProfile && currentUrl.pathname.includes('/bookmarks/') === false) {
return false;
}
// Exit early if in sections where images won't have control buttons.
if (element.closest(SELECTORS_IMAGE_CONTAINER_SIMPLIFIED)) {
return false;
}
const doNotWaitControls = element.matches(SELECTORS_IMAGE_NO_CONTROLS);
let imageControls = element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS);
if (!imageControls && !data.novel && !doNotWaitControls) {
imageControls = await waitFor(() =>
element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS), element);
if (!element.isConnected) return false;
}
if (!imageControls && !doNotWaitControls) {
return false;
}
if (!options.skipArtistTag) {
const artistTag = element.querySelector('a[href*="users/"]:not(.pixiv_utils_image_artist)');
let hasVisibleArtistTag = Boolean(artistTag);
// If the image itself is visible, but its built-in artist tag is not.
if (hasVisibleArtistTag && element.offsetParent !== null) {
hasVisibleArtistTag = artistTag.offsetParent !== null;
}
// Add artist tag if necessary.
const needsArtist = !hasVisibleArtistTag &&
!element.closest('.user-details-card ~ div .works-horizontal-list') &&
(currentUrl.pathname.includes('/users/') === false || currentUrl.pathname.includes('/bookmarks/'));
if (needsArtist) addImageArtist(element, pixivData);
else {
element.querySelector('.pixiv_utils_image_artist_container')?.remove();
delete element.dataset.pixiv_utils_artist_added;
}
}
if (imageControls) ensureEditBookmarkButton(imageControls, data.id, data.novel);
return true;
};
const doBlockMultiView = (element, obj = {}) => {
if (obj.skipReason) {
logDebug(`Multi view is blockable, but skipped (reason: ${obj.skipReason})`, element);
return false;
}
// For multi view artwork, always hide the whole entry instead.
element.parentNode.style.setProperty('display', 'none', 'important');
logDebug(`Multi view entry removed (${obj.options.hint})`, element);
return true;
};
const doMultiView = async (element, options = {}) => {
const pixivDataSource = element.querySelector('div[data-ga4-label="thumbnail_link"]') || element;
const { pixivData, data } = await getImageData(pixivDataSource, element);
if (!element.isConnected || data.id === null) return false;
if (options.isHome) {
if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && data.novel) {
element.parentNode.style.setProperty('display', 'none', 'important');
logDebug('Novel recommendation removed from home', element);
return true;
}
if (CONFIG.REMOVE_COLLECTIONS_RECOMMENDATIONS_FROM_HOME && data.collections) {
element.parentNode.style.setProperty('display', 'none', 'important');
logDebug('Collections recommendation removed from home', element);
return true;
}
}
const evaluation = evaluateArtworkModeration(pixivData, {
bookmarked: isImageBookmarked(element)
});
if (evaluation.moderation.blockOpts) {
const blocked = doBlockMultiView(element, {
options: evaluation.moderation.blockOpts,
skipReason: evaluation.skipReason.join(', ')
});
if (blocked) return true;
}
setModerationTooltip(element, evaluation);
// Exit early if collections, because they do not have independent bookmark edit page.
if (data.collections) {
return false;
}
const multiViewControls = element.querySelector(CONFIG.SELECTORS_MULTI_VIEW_CONTROLS);
if (!multiViewControls) return false;
if (hasEditBookmarkButton(multiViewControls, data.id, data.novel)) return false;
ensureEditBookmarkButton(multiViewControls, data.id, data.novel, 'beforeLast');
return true;
};
const doBlockExpandedView = (element, obj = {}) => {
if (obj.skipReason) {
logDebug(`Expanded view is blockable, but skipped (reason: ${obj.skipReason})`, element);
return false;
}
element.dataset.pixiv_utils_expanded_view_blocked = true;
setModerationTooltip(obj.titleTarget, obj.evaluation, true);
logDebug(`Expanded view blocked (${obj.options.hint})`, element);
return true;
};
const EXPANDED_VIEW_TITLE_TARGET = normalizeSelectorList(/*css*/`
figure:not([style*="width"], [style*="width"]),
.swipe-detector
`);
const doExpandedViewControls = async (element, options = {}) => {
const image = element.closest(CONFIG.SELECTORS_EXPANDED_VIEW_IMAGE);
if (!image) {
return false;
}
const { pixivData, data } = await getImageData(image);
if (!element.isConnected || data.id === null) return false;
if (PIXIV_BLOCKED_TAGS_VALIDATED) {
// Always reset expanded view's blocked status first.
delete image.dataset.pixiv_utils_expanded_view_blocked;
}
const titleTarget = image.querySelector(EXPANDED_VIEW_TITLE_TARGET) || image;
const evaluation = evaluateArtworkModeration(pixivData, {
bookmarked: isImageBookmarked(element)
});
let blocked = false;
if (evaluation.moderation.blockOpts) {
blocked = doBlockExpandedView(image, {
titleTarget,
options: evaluation.moderation.blockOpts,
evaluation,
skipReason: evaluation.skipReason.join(', ')
});
}
if (!blocked) setModerationTooltip(titleTarget, evaluation);
// Init MutationObserver for dynamic expanded view.
if (image.__vue__) {
const target = image.querySelector('.swipe-detector');
if (!image.dataset.pixiv_utils_last_id) {
initElementObserver(target, mutations => {
getImageData(image).then(({ data }) => {
if (data.id !== image.dataset.pixiv_utils_last_id) {
options.forced = true;
doExpandedViewControls(element, options);
}
});
}, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['href', 'src']
});
}
image.dataset.pixiv_utils_last_id = data.id;
}
// A replaced or detached button is not proof this control was processed.
if (!options.forced && hasEditBookmarkButton(element, data.id, data.novel)) return false;
// Re-attempt to convert date.
if (CONFIG.DATE_CONVERSION) {
const dates = image.querySelectorAll(SELECTORS_DATE_ORIGINAL);
for (const date of dates) {
convertDate(date);
}
}
let id = null;
let isNovel = false;
const match = currentUrl.pathname.match(/artworks\/(\d+)/);
if (match && match[1]) {
id = match[1];
} else if (currentUrl.pathname.startsWith('/novel/show.php')) {
id = currentUrl.searchParams.get('id');
if (!/^\d+$/.test(id)) id = null;
isNovel = true;
}
if (id !== null) {
ensureEditBookmarkButton(element, id, isNovel, 'append');
// Re-process expanded view's artist bottom bar.
const images = document.querySelectorAll(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE);
for (const image of images) {
// These can be deferred with processNewElement() because they are not hidden even when blocked.
processNewElement(doImage, image, { forced: true, skipArtistTag: true });
}
return true;
}
return false;
};
const updateToggleBookmarkedButton = (button, mode) => {
const label = mode === 0
? CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL
: mode === 1
? CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED
: CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED;
const span = document.createElement('span');
span.textContent = label;
button.replaceChildren(document.createTextNode(CONFIG.TEXT_TOGGLE_BOOKMARKED), span);
};
let toggling = false;
const toggleBookmarked = (section, button, imagesContainer, options = {}) => {
if (toggling) return false;
toggling = true;
let mode = options.sync
? GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN)
: Number(section.dataset.pixiv_utils_toggle_bookmarked_mode) + (options.rightClick ? -1 : 1);
if (mode > _TB_MAX) mode = _TB_MIN;
else if (mode < _TB_MIN) mode = _TB_MAX;
setToggleBookmarkedMode(section, imagesContainer, mode);
updateToggleBookmarkedButton(button, mode);
// Read all bookmark states before writing visibility attributes, avoiding
// alternating computed-style reads and DOM changes across artwork cards.
if (Number.isInteger(mode)) {
const visibility = [];
for (const image of imagesContainer.querySelectorAll(CONFIG.SELECTORS_IMAGE)) {
const hide = mode !== 0 &&
(Boolean(image.dataset.pixiv_utils_blocked) ||
(mode === 1 ? isImageBookmarked(image) : !isImageBookmarked(image)));
visibility.push([image, hide]);
}
for (const [image, hide] of visibility) {
const hidden = image.hasAttribute('data-pixiv_utils_toggle_bookmarked_hide');
if (hide && !hidden) image.dataset.pixiv_utils_toggle_bookmarked_hide = true;
else if (!hide && hidden) delete image.dataset.pixiv_utils_toggle_bookmarked_hide;
}
}
// The most recently selected mode is still the default for new sections.
GM_setValue('PREF_TOGGLE_BOOKMARKED_MODE', mode);
toggling = false;
return true;
};
const doToggleBookmarkedSection = (element, sectionConfig) => {
// Skip if this config has a sanity check function, and it passes.
if (typeof sectionConfig.sanityCheck === 'function' && sectionConfig.sanityCheck()) {
return false;
}
const header = element.querySelector(sectionConfig.selectorHeader);
if (!header) {
return false;
}
const imagesContainer = element.querySelector(sectionConfig.selectorImagesContainer);
if (!imagesContainer) {
return false;
}
// Skip if already processed.
if (element.dataset.pixiv_utils_toggle_bookmarked_section) {
if (element.dataset.pixiv_utils_toggle_bookmarked_section ===
imagesContainer.dataset.pixiv_utils_toggle_bookmarked_section) {
return false;
}
logDebug('Refreshing toggle bookmarked section due to images container update', element);
}
// Keep the section's temporary mode when its image container is replaced.
// New sections initialize from the last saved preference.
const mode = element.dataset.pixiv_utils_toggle_bookmarked_mode === undefined
? GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN)
: Number(element.dataset.pixiv_utils_toggle_bookmarked_mode);
// Mark as processed.
const uuid = element.dataset.pixiv_utils_toggle_bookmarked_section || uuidv4();
element.dataset.pixiv_utils_toggle_bookmarked_section =
imagesContainer.dataset.pixiv_utils_toggle_bookmarked_section = uuid;
setToggleBookmarkedMode(element, imagesContainer, mode);
// Clear old button if it's being refreshed.
const oldButtonContainer = element.querySelector('.pixiv_utils_toggle_bookmarked_container');
if (oldButtonContainer) {
oldButtonContainer.remove();
}
const buttonContainer = document.createElement('div');
buttonContainer.className = 'pixiv_utils_toggle_bookmarked_container';
const button = document.createElement('a');
button.className = 'pixiv_utils_toggle_bookmarked';
updateToggleBookmarkedButton(button, mode);
if (CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP) {
button.title = CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP;
}
// Left click.
button.addEventListener('click', event => {
event.preventDefault();
toggleBookmarked(element, button, imagesContainer, {
sync: event.shiftKey
});
});
// Right click.
button.addEventListener('contextmenu', event => {
event.preventDefault();
toggleBookmarked(element, button, imagesContainer, {
rightClick: true
});
});
buttonContainer.append(button);
header.append(buttonContainer);
return true;
};
const doTagButton = element => {
let tag = null;
const tags = element.querySelectorAll('div[title]');
for (const tagElement of tags) {
const raw = tagElement.textContent;
// Prefer the first hashtag. Otherwise keep the last candidate.
if (raw.startsWith('#')) {
tag = raw.slice(1);
break;
}
tag = raw;
}
if (!tag) {
return false;
}
const blocked = matchesConfiguredTag(
tag,
PIXIV_BLOCKED_TAGS_STRING,
PIXIV_BLOCKED_TAGS_REGEXP
);
if (blocked) {
element.style.setProperty('display', 'none', 'important');
logDebug(`Tag button blocked (${tag})`);
}
return blocked;
};
const doRecommendedUser = element => {
const artistLink = element.querySelector('a[href*="users/"]');
const match = artistLink?.href.match(/users\/(\d+)/);
if (!match) return false;
const blocked = PIXIV_BLOCKED_USERS_SET.has(match[1]);
if (blocked) {
element.style.setProperty('display', 'none', 'important');
logDebug(`Recommended user removed (${match[1]})`);
}
return blocked;
};
const doUtags = async (element, options) => {
let image = element.closest(CONFIG.SELECTORS_IMAGE);
let mobile = false;
if (image) {
mobile = image.matches(SELECTORS_IMAGE_MOBILE);
} else {
// For mobile images, re-attempt query with some patience.
image = element.closest(SELECTORS_IMAGE_MOBILE);
if (image) {
mobile = true;
const awaited = await waitFor(() => image.querySelector('.thumb:not([src^="data"])'), image);
if (!image.isConnected || !awaited) return false;
}
}
const utag = element.dataset.utags_tag;
if (image) {
const { pixivData, data } = await getImageData(image);
if (!image.isConnected || data.id === null || !data.link) return false;
// Only block images if not in own profile, and not bookmarked.
const skipReason = getImageBlockSkipReason({
isOwnProfile: options.isOwnProfile,
bookmarked: isImageBookmarked(image)
});
const moderation = getImageModeration(pixivData);
if (skipReason.length) {
setImageTitle(image, {
pixivData,
moderation,
footer: [
createTooltipFooter(tr('tooltip.blockableBy'), tr('tooltip.utag', { tag: utag })),
createTooltipFooter(tr('tooltip.skippedDueTo'), skipReason.join('\n'))
]
});
logDebug(`Image is blockable, but skipped (reason: ${skipReason.join(', ')})`, image);
return false;
} else {
const status = setImageBlocked(image, {
mobile,
remove: false,
link: data.link,
priority: true
});
if (status) {
setImageTitle(image, {
pixivData,
moderation: { ...moderation, actuallyBlocked: true },
footer: [createTooltipFooter(tr('tooltip.blockedBy'), tr('tooltip.utag', { tag: utag }))]
});
logDebug(`Image blocked (UTag: ${utag})`, image);
}
return status;
}
}
const multiView = element.closest(CONFIG.SELECTORS_MULTI_VIEW);
if (multiView) {
// For multi view artwork, always hide the whole entry instead.
multiView.parentNode.style.setProperty('display', 'none', 'important');
logDebug(`Multi view entry removed (UTag: ${utag})`, multiView);
return true;
}
const recommendedUserContainer = element.closest(CONFIG.SELECTORS_RECOMMENDED_USER_CONTAINER);
if (recommendedUserContainer) {
recommendedUserContainer.style.setProperty('display', 'none', 'important');
logDebug(`Recommended user removed (UTag: ${utag})`, recommendedUserContainer);
return true;
}
const followButtonContainer = element.closest(CONFIG.SELECTORS_FOLLOW_BUTTON_CONTAINER);
if (followButtonContainer) {
const followButton = followButtonContainer.querySelector(CONFIG.SELECTORS_FOLLOW_BUTTON);
if (followButton) {
// Cosmetic only. This will not disable Pixiv's built-in "F" keybind.
followButton.classList.add('disabled');
followButton.disabled = true;
logDebug(`Follow button disabled (UTag: ${utag})`, followButtonContainer);
// Return early since there will only be one follow button per container.
return true;
}
}
return false;
};
const isElementPassedOrVisible = element => {
if (element.style.display === 'none') {
return false;
}
// If element is already above the viewport, treat as visible.
// We do not want the UI to jump around if user scrolls up after navigating back/forth between pages.
const rect = element.getBoundingClientRect();
if (rect.bottom < 0) {
return true;
}
// Standard partial-visibility check.
return (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0
);
};
let imagesIntersectionObserver = null;
// Track deferred work independently of the observer instance so SPA navigation
// can transfer connected targets to the replacement instead of dropping them.
const pendingImageIntersections = new Map();
let pendingImageCleanupTimer = null;
const cleanupPendingImageIntersections = () => {
for (const [element] of pendingImageIntersections) {
if (element.isConnected) continue;
pendingImageIntersections.delete(element);
imagesIntersectionObserver?.unobserve(element);
delete element.pixiv_utils_intersection_observer;
}
if (!pendingImageIntersections.size && pendingImageCleanupTimer !== null) {
clearInterval(pendingImageCleanupTimer);
pendingImageCleanupTimer = null;
}
};
const initImagesIntersectionObserver = () => {
imagesIntersectionObserver?.disconnect();
const observer = new IntersectionObserver(entries => {
// A disconnected observer can still deliver already-queued notifications.
if (observer !== imagesIntersectionObserver) return;
for (const entry of entries) {
const opts = pendingImageIntersections.get(entry.target);
if (!opts) { observer.unobserve(entry.target); continue; }
if (!entry.target.isConnected) {
pendingImageIntersections.delete(entry.target);
observer.unobserve(entry.target);
delete entry.target.pixiv_utils_intersection_observer;
continue;
}
if (!entry.isIntersecting) continue;
pendingImageIntersections.delete(entry.target);
observer.unobserve(entry.target);
delete entry.target.pixiv_utils_intersection_observer;
if (typeof opts.func === 'function') opts.func(entry.target, opts.options);
}
cleanupPendingImageIntersections();
}, { delay: 0, threshold: 0 });
imagesIntersectionObserver = observer;
cleanupPendingImageIntersections();
for (const element of pendingImageIntersections.keys()) observer.observe(element);
};
let isHome = false;
let isOwnProfile = false;
const isOwnProfileByUrl = (url = currentUrl) => {
if (!cachedOwnUserId) return false;
const segments = url.pathname.split('/');
const userIndex = segments.indexOf('users');
return userIndex !== -1 && segments[userIndex + 1] === String(cachedOwnUserId);
};
window.addEventListener('detectnavigate', event => {
const pendingCount = pendingWaitFors.size;
for (const pending of [...pendingWaitFors]) finishWaitFor(pending, null);
if (pendingCount) logDebug(`Cleared ${pendingCount} pending waitFor request(s).`);
cleanupDetachedElementObservers();
// Quickly determine page type using current URL.
isHome = event.detail.newUrl.pathname === '/' || event.detail.newUrl.pathname === '/en/';
isOwnProfile = !isHome && isOwnProfileByUrl(event.detail.newUrl);
// Re-init IntersectionObserver for images.
initImagesIntersectionObserver();
});
const processNewElement = (func, element, options = {}) => {
if (isElementPassedOrVisible(element)) {
if (pendingImageIntersections.delete(element)) {
imagesIntersectionObserver?.unobserve(element);
delete element.pixiv_utils_intersection_observer;
cleanupPendingImageIntersections();
}
return func(element, options);
}
// The latest requested work supersedes older deferred options for this card.
const pending = { func, options };
pendingImageIntersections.set(element, pending);
element.pixiv_utils_intersection_observer = pending;
imagesIntersectionObserver?.observe(element);
if (pendingImageCleanupTimer === null) {
pendingImageCleanupTimer = setInterval(cleanupPendingImageIntersections, 10000);
}
};
/** FLOATING UI TOOLTIPS (one delegated, reusable instance) **/
const initFloatingTooltips = () => {
const tooltipSelector = '[data-pixiv_utils_tooltip]';
if (typeof FloatingUIDOM === 'undefined' || typeof FloatingUIDOM.computePosition !== 'function') {
console.error('[Pixiv Utils]: Floating UI unavailable; falling back to native titles.');
// No further tooltip events are registered if the @require dependency failed.
for (const element of document.querySelectorAll(tooltipSelector)) {
element.title = element.dataset.pixiv_utils_tooltip;
}
syncActiveFloatingTooltip = element => {
if (element.dataset.pixiv_utils_tooltip) element.title = element.dataset.pixiv_utils_tooltip;
else element.removeAttribute('title');
};
return;
}
const { computePosition, autoUpdate, offset, flip, shift, arrow } = FloatingUIDOM;
const defaults = ENV_DEFAULTS.FLOATING_TOOLTIPS_OPTIONS;
let options = defaults;
try {
validateFloatingOptions(CONFIG.FLOATING_TOOLTIPS_OPTIONS);
options = { ...defaults, ...CONFIG.FLOATING_TOOLTIPS_OPTIONS };
} catch (error) {
console.warn('[Pixiv Utils]: Invalid Floating UI options; using defaults.', error);
}
const mouseDelay = Math.max(0, Number(CONFIG.FLOATING_TOOLTIPS_STATIONARY_DELAY) || 0);
const showDelay = Math.max(0, Number(CONFIG.FLOATING_TOOLTIPS_SHOW_DELAY) || 0);
const touchHoldDelay = Math.max(0, Number(CONFIG.FLOATING_TOOLTIPS_TOUCH_HOLD_DELAY) || 0);
const mouseTolerance = Math.max(0, Number(CONFIG.FLOATING_TOOLTIPS_MOVEMENT_TOLERANCE) || 0);
const mouseToleranceSq = mouseTolerance * mouseTolerance;
const touchToleranceSq = Math.max(12, mouseTolerance) ** 2;
const tooltip = document.createElement('div');
tooltip.id = 'pixiv-utils-floating-tooltip';
tooltip.className = 'pixiv-utils-floating-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.style.display = 'none';
tooltip.style.position = options.strategy;
tooltip.style.setProperty('--pixiv-utils-floating-max-width', `${options.maxWidth}px`);
const content = document.createElement('div');
const arrowElement = document.createElement('div');
arrowElement.className = 'pixiv-utils-floating-arrow';
arrowElement.dataset.side = options.placement.split('-')[0];
arrowElement.setAttribute('aria-hidden', 'true');
tooltip.append(content, arrowElement);
document.body.append(tooltip);
let activeTarget = null;
let activeMode = null;
let pendingTarget = null;
let pendingTimer = null;
let hoveredMouseTarget = null;
let mouseX = 0;
let mouseY = 0;
let mouseTracking = false;
let touchGesture = null;
let suppressedTouchClick = null;
let lastTouchAt = -Infinity;
let stopPositioning = null;
let activeObserver = null;
let previousAriaDescription = null;
let positionSerial = 0;
const stopMouseTracking = () => {
if (!mouseTracking) return;
document.body.removeEventListener('pointermove', trackMouseMovement);
mouseTracking = false;
};
const clearPending = () => {
clearTimeout(pendingTimer);
pendingTimer = null;
pendingTarget = null;
};
const hideTooltip = () => {
++positionSerial; // Discard already-running computePosition promises.
stopPositioning?.();
stopPositioning = null;
activeObserver?.disconnect();
activeObserver = null;
if (activeTarget) {
if (previousAriaDescription === null) activeTarget.removeAttribute('aria-describedby');
else activeTarget.setAttribute('aria-describedby', previousAriaDescription);
}
previousAriaDescription = null;
activeTarget = null;
activeMode = null;
tooltip.style.display = 'none';
tooltip.removeAttribute('data-open');
tooltip.removeAttribute('data-mode');
};
const cancelTooltip = () => {
clearPending();
stopMouseTracking();
hoveredMouseTarget = null;
touchGesture = null;
suppressedTouchClick = null;
hideTooltip();
};
const isEligible = target => target?.isConnected &&
Boolean(target.dataset.pixiv_utils_tooltip) &&
!target.hasAttribute('data-pixiv_utils_toggle_bookmarked_hide');
// Initial touch coordinates are sometimes unavailable or reported as (0, 0).
// In that case use the artwork element with 25px less than the user's offset.
const validTouchPoint = (x, y) => Number.isFinite(x) && Number.isFinite(y) &&
x >= 0 && y >= 0 && (x !== 0 || y !== 0) &&
(window.innerWidth <= 0 || x <= window.innerWidth) &&
(window.innerHeight <= 0 || y <= window.innerHeight);
const pointReference = (target, x, y) => ({
contextElement: target,
getBoundingClientRect: () => ({
x, y, top: y, bottom: y, left: x, right: x, width: 0, height: 0
})
});
const updatePosition = (reference, target, mainAxisOffset = options.mainAxisOffset) => {
const request = ++positionSerial;
computePosition(reference, tooltip, {
placement: options.placement,
strategy: options.strategy,
middleware: [
offset({ mainAxis: mainAxisOffset, crossAxis: options.crossAxisOffset }),
flip(),
shift({ padding: options.shiftPadding }),
arrow({ element: arrowElement })
]
}).then(({ x, y, placement, middlewareData }) => {
if (request !== positionSerial || target !== activeTarget || !isEligible(target)) return;
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
// Reset previous placement's properties when Floating UI flips sides.
arrowElement.style.top = arrowElement.style.right = '';
arrowElement.style.bottom = arrowElement.style.left = '';
const { x: ax, y: ay } = middlewareData.arrow || {};
if (ax != null) arrowElement.style.left = `${ax}px`;
if (ay != null) arrowElement.style.top = `${ay}px`;
const side = placement.split('-')[0];
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side];
// Half of the 12px arrow box sits beyond the edge, with only its
// exterior triangle painted. Keep the box square for flip() geometry.
arrowElement.dataset.side = side;
arrowElement.style[opposite] = '-6px';
}).catch(error => logDebug('Floating UI positioning error:', error));
};
const showTooltip = (target, mode) => {
if (!isEligible(target)) return;
if (activeTarget === target && activeMode === mode) return;
hideTooltip();
activeTarget = target;
activeMode = mode;
previousAriaDescription = target.getAttribute('aria-describedby');
const describedBy = previousAriaDescription?.trim();
if (!describedBy?.split(/\s+/).includes(tooltip.id)) {
target.setAttribute('aria-describedby', [describedBy, tooltip.id].filter(Boolean).join(' '));
}
renderImageTooltip(content, target);
tooltip.style.display = 'block';
tooltip.setAttribute('data-open', '');
tooltip.dataset.mode = mode;
// Keep the mouse's initial cursor anchor and keyboard's element anchor.
// Touch uses its original touchstart position, not the artwork bounds.
const touch = mode === 'touch' && touchGesture?.target === target ? touchGesture : null;
const touchPointValid = touch && validTouchPoint(touch.x, touch.y);
const reference = mode === 'mouse'
? pointReference(target, mouseX, mouseY)
: touchPointValid ? pointReference(target, touch.x, touch.y) : target;
const mainAxisOffset = mode === 'touch' && !touchPointValid
? options.mainAxisOffset - 25
: options.mainAxisOffset;
const update = () => {
if (!isEligible(target)) { hideTooltip(); return; }
updatePosition(reference, target, mainAxisOffset);
};
stopPositioning = autoUpdate(reference, tooltip, update);
activeObserver = new MutationObserver(() => {
if (!isEligible(target)) { hideTooltip(); return; }
renderImageTooltip(content, target);
update();
});
activeObserver.observe(target, {
attributes: true,
attributeFilter: ['data-pixiv_utils_tooltip', 'data-pixiv_utils_toggle_bookmarked_hide']
});
};
syncActiveFloatingTooltip = element => {
if (element !== activeTarget) return;
if (!isEligible(element)) { hideTooltip(); return; }
renderImageTooltip(content, element);
// A content change can affect size before ResizeObserver runs.
// autoUpdate's ResizeObserver will correct positioning after layout.
};
const queueShow = (target, mode, delay) => {
clearPending();
if (!isEligible(target)) return;
pendingTarget = target;
pendingTimer = setTimeout(() => {
pendingTimer = null;
if (pendingTarget !== target) return;
pendingTarget = null;
if (mode === 'mouse') {
stopMouseTracking();
if (hoveredMouseTarget !== target ||
!target.contains(document.elementFromPoint(mouseX, mouseY))) return;
} else if (mode === 'focus' && !target.contains(document.activeElement)) return;
else if (mode === 'touch' && touchGesture?.target !== target) return;
showTooltip(target, mode);
if (mode === 'touch' && touchGesture?.target === target) touchGesture.opened = true;
}, delay);
};
const startMouseTracking = () => {
if (mouseTracking) return;
document.body.addEventListener('pointermove', trackMouseMovement, { passive: true });
mouseTracking = true;
};
const trackMouseMovement = event => {
if (event.pointerType !== 'mouse' || !hoveredMouseTarget) return;
const dx = event.clientX - mouseX;
const dy = event.clientY - mouseY;
if (dx * dx + dy * dy < mouseToleranceSq) return;
mouseX = event.clientX;
mouseY = event.clientY;
if (activeMode === 'mouse' && activeTarget === hoveredMouseTarget) return;
queueShow(hoveredMouseTarget, 'mouse', mouseDelay);
};
document.body.addEventListener('pointerover', event => {
if (event.pointerType !== 'mouse') return;
const target = event.target.closest?.(tooltipSelector);
if (!target || target === hoveredMouseTarget) return;
// Mouse entry should not interrupt an existing keyboard-focus tooltip.
if (target === activeTarget && activeMode === 'focus') {
hoveredMouseTarget = target;
return;
}
cancelTooltip();
hoveredMouseTarget = target;
mouseX = event.clientX;
mouseY = event.clientY;
startMouseTracking();
queueShow(target, 'mouse', mouseDelay);
});
document.body.addEventListener('pointerout', event => {
if (event.pointerType !== 'mouse' || !hoveredMouseTarget ||
!hoveredMouseTarget.contains(event.target) ||
hoveredMouseTarget.contains(event.relatedTarget)) return;
clearPending();
stopMouseTracking();
if (activeMode === 'mouse') hideTooltip();
hoveredMouseTarget = null;
});
// Touch is a deliberate long-press gesture, not a delayed tap. Keep its
// state separate from pointer hover/focus so normal taps still navigate.
const rememberTouchClick = (target, x, y) => {
suppressedTouchClick = {
target, x, y, hasPosition: validTouchPoint(x, y), until: Date.now() + 1000
};
};
// Capture before Pixiv's delegated link handlers; suppress only the click
// produced by a consumed touch gesture, never arbitrary later clicks.
window.addEventListener('click', event => {
const suppressed = suppressedTouchClick;
if (!suppressed) return;
if (Date.now() > suppressed.until) { suppressedTouchClick = null; return; }
if (event.pointerType && event.pointerType !== 'touch') return;
const dx = event.clientX - suppressed.x;
const dy = event.clientY - suppressed.y;
if ((suppressed.hasPosition && validTouchPoint(event.clientX, event.clientY) &&
dx * dx + dy * dy > 25 ** 2) ||
!(suppressed.target === event.target || suppressed.target.contains?.(event.target) ||
event.target.contains?.(suppressed.target))) return;
suppressedTouchClick = null;
event.preventDefault();
event.stopImmediatePropagation();
}, true);
// The tooltip itself consumes its dismissal tap; the underlying artwork
// never receives the event, even if the tooltip overlaps its link.
tooltip.addEventListener('click', event => {
if (activeMode !== 'touch') return;
event.preventDefault();
event.stopImmediatePropagation();
hideTooltip();
});
tooltip.addEventListener('pointerdown', event => {
if (activeMode === 'touch') event.stopPropagation();
});
tooltip.addEventListener('touchstart', event => {
if (activeMode === 'touch') event.stopPropagation();
}, { passive: true });
document.body.addEventListener('pointerdown', event => {
if (event.pointerType === 'touch') return;
const target = event.target.closest?.(tooltipSelector);
if (!target || (activeTarget && activeTarget !== target)) cancelTooltip();
});
document.body.addEventListener('touchstart', event => {
lastTouchAt = Date.now();
// A new physical gesture must not inherit a previous hold's click guard.
suppressedTouchClick = null;
clearPending();
touchGesture = null;
if (event.touches.length !== 1 || event.changedTouches.length !== 1) return;
const touch = event.changedTouches[0];
const target = event.target.closest?.(tooltipSelector);
// A touch outside an already-open popover closes it and consumes the
// tap. A long hold can still open a different artwork immediately.
if (activeMode === 'touch') {
rememberTouchClick(event.target, touch.clientX, touch.clientY);
hideTooltip();
} else if (activeMode) hideTooltip();
stopMouseTracking();
hoveredMouseTarget = null;
if (!isEligible(target)) return;
const hasPosition = validTouchPoint(touch.clientX, touch.clientY);
touchGesture = {
id: touch.identifier,
target,
x: touch.clientX,
y: touch.clientY,
moveX: hasPosition ? touch.clientX : null,
moveY: hasPosition ? touch.clientY : null,
opened: false
};
queueShow(target, 'touch', touchHoldDelay);
}, { passive: true });
document.body.addEventListener('touchmove', event => {
const gesture = touchGesture;
if (!gesture) return;
if (event.touches.length !== 1) {
clearPending();
touchGesture = null;
return;
}
const touch = [...event.changedTouches].find(item => item.identifier === gesture.id);
if (!touch) return;
// If touchstart reported invalid coordinates, establish a movement
// baseline from the first valid touchmove without changing the anchor.
if (!validTouchPoint(touch.clientX, touch.clientY)) return;
if (gesture.moveX === null) {
gesture.moveX = touch.clientX;
gesture.moveY = touch.clientY;
return;
}
const dx = touch.clientX - gesture.moveX;
const dy = touch.clientY - gesture.moveY;
if (dx * dx + dy * dy >= touchToleranceSq) {
clearPending();
touchGesture = null;
if (gesture.opened && activeMode === 'touch') hideTooltip();
}
}, { passive: true });
const finishTouch = event => {
const gesture = touchGesture;
if (!gesture || ![...event.changedTouches].some(item => item.identifier === gesture.id)) return;
if (gesture.opened || (activeTarget === gesture.target && activeMode === 'touch')) {
rememberTouchClick(gesture.target, gesture.x, gesture.y);
}
clearPending();
touchGesture = null;
// A completed long press stays open until deliberately dismissed.
};
document.body.addEventListener('touchend', finishTouch);
document.body.addEventListener('touchcancel', finishTouch);
// Native long-press context menus do not dismiss touch tooltips.
// touchend/touchcancel will finish the gesture but leave an opened tooltip
// visible until the user dismisses it normally.
// Never leave a touch popover floating over content after scrolling.
document.addEventListener('scroll', () => {
if (touchGesture) { clearPending(); touchGesture = null; }
if (activeMode === 'touch') hideTooltip();
}, true);
document.body.addEventListener('focusin', event => {
// Mobile browsers may focus a link after pointerdown; don't double-schedule.
if (Date.now() - lastTouchAt < 700) return;
const target = event.target.closest?.(tooltipSelector);
if (!target) return;
if (activeTarget === target) return;
if (activeTarget) hideTooltip();
queueShow(target, 'focus', showDelay);
});
document.body.addEventListener('focusout', event => {
const target = event.target.closest?.(tooltipSelector);
if (!target || target.contains(event.relatedTarget)) return;
if (pendingTarget === target) clearPending();
if (activeTarget === target && activeMode === 'focus') hideTooltip();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') cancelTooltip();
});
window.addEventListener('detectnavigate', cancelTooltip);
window.addEventListener('pagehide', cancelTooltip);
document.addEventListener('visibilitychange', () => {
if (document.hidden) cancelTooltip();
});
};
/** SENTINEL */
waitPageLoaded().then(() => {
// A single reusable tooltip serves all elements through event delegation.
if (CONFIG.ENABLE_FLOATING_TOOLTIPS) initFloatingTooltips();
// Immediately attempt to cache user ID and/or CSRF token.
try {
ensurePixivCredentials();
} catch (error) {
console.error(error);
}
// Immediately attempt to determine page type.
isHome = Boolean(document.querySelector(CONFIG.SELECTORS_HOME));
isOwnProfile = isOwnProfileByUrl() || Boolean(document.querySelector(CONFIG.SELECTORS_OWN_PROFILE));
sentinel.on(CONFIG.SELECTORS_HOME, () => { isHome = true; });
sentinel.on(CONFIG.SELECTORS_OWN_PROFILE, () => { isOwnProfile = true; });
// Init IntersectionObserver for images.
initImagesIntersectionObserver();
// Wrap sentinel callbacks to avoid unhandled async rejections.
const originalSentinelOn = sentinel.on.bind(sentinel);
sentinel.on = (selector, callback) => originalSentinelOn(selector, element => {
try {
const result = callback(element);
if (result && typeof result.then === 'function') {
result.catch(error => console.warn('[Pixiv Utils]: sentinel callback failed:', error));
}
} catch (error) {
console.warn('[Pixiv Utils]: sentinel callback failed:', error);
}
});
// Expanded View Controls
sentinel.on(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS, element => {
doExpandedViewControls(element);
});
// Images
sentinel.on(CONFIG.SELECTORS_IMAGE, element => {
processNewElement(doImage, element, { isHome, isOwnProfile });
});
// Expanded view's artist bottom images
sentinel.on(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE, element => {
processNewElement(doImage, element, { isHome, isOwnProfile, skipArtistTag: true });
});
// Multi View Entries
sentinel.on(CONFIG.SELECTORS_MULTI_VIEW, element => {
processNewElement(doMultiView, element, { isHome });
});
// Toggle Bookmarked Sections. Their selectors were normalized and validated
// once at boot (cache check or build path), so registration trusts CONFIG.
if (CONFIG.ENABLE_TOGGLE_BOOKMARKED) {
for (const sectionConfig of CONFIG.SECTIONS_TOGGLE_BOOKMARKED) {
sentinel.on(sectionConfig.selectorParent, element => {
doToggleBookmarkedSection(element, sectionConfig);
});
const formattedSelector = formatChildSelector(
sectionConfig.selectorParent,
sectionConfig.selectorImagesContainer
);
sentinel.on(formattedSelector, element => {
const parent = element.closest(sectionConfig.selectorParent);
if (parent && !element.dataset.pixiv_utils_toggle_bookmarked_section) {
doToggleBookmarkedSection(parent, sectionConfig);
}
});
}
}
// Tag Buttons
if (PIXIV_BLOCKED_TAGS_STRING.size || PIXIV_BLOCKED_TAGS_REGEXP.length) {
sentinel.on(CONFIG.SELECTORS_TAG_BUTTON, element => {
processNewElement(doTagButton, element);
});
}
// Users
if (PIXIV_BLOCKED_USERS_SET.size) {
sentinel.on(CONFIG.SELECTORS_RECOMMENDED_USER_CONTAINER, element => {
processNewElement(doRecommendedUser, element);
});
}
// Dates
if (CONFIG.DATE_CONVERSION) {
sentinel.on([
`:has(> :is(${CONFIG.SELECTORS_DATE})):not(:has(> [data-pixiv_utils_duplicate_date]))`,
`.reupload-info:has(${SELECTORS_DATE_ORIGINAL})`
], element => {
const date = element.querySelector(SELECTORS_DATE_ORIGINAL);
if (date) {
convertDate(date);
}
});
}
// UTags Integration
if (CONFIG.UTAGS_INTEGRATION) {
sentinel.on(SELECTORS_UTAGS, element => {
doUtags(element, { isOwnProfile });
});
}
log('Started.');
if (_debugMode) {
setInterval(() => {
if (pendingWaitFors.size) {
logDebug('waitFor', pendingWaitFors.values().next().value.element);
}
}, WAIT_FOR_POLL);
}
});
/** KEYBINDS **/
if (CONFIG.ENABLE_KEYBINDS) {
const selectors = {
editBookmark: CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS
.split(', ').map(s => `${s} .pixiv_utils_edit_bookmark`).join(', ')
};
const onCooldown = {};
// Default to 1 second cooldown in between keybind event.
const processKeyEvent = (id, element, cooldown = 1000) => {
if (!element) {
return false;
}
if (onCooldown[id]) {
tlog(`"${id}" keybind still on cooldown.`);
return false;
}
if (cooldown > 0) {
onCooldown[id] = true;
setTimeout(() => { onCooldown[id] = false; }, cooldown);
}
element.click();
logDebug(`${id} keybind processed`, element);
};
document.addEventListener('keydown', event => {
if (event.defaultPrevented || event.repeat || event.metaKey) {
return;
}
// Ignore keybinds during IME composition and when focused on an interactive
// control (input, textarea, select, or editable element).
if (event.isComposing || event.keyCode === 229) {
return;
}
if (document.activeElement &&
(['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) ||
document.activeElement.isContentEditable)) {
return;
}
if (event.key === 'b' || event.key === 'B') {
// "Shift+B" for Edit Bookmark.
// Pixiv has built-in keybind "B" for just bookmarking.
// Ignore "Ctrl" and "Alt".
if (event.ctrlKey || event.altKey) {
return;
}
// "Shift+B": Edit bookmark.
if (event.shiftKey) {
event.stopPropagation();
const element = document.querySelector(selectors.editBookmark);
return processKeyEvent('bookmarkEdit', element);
}
// "B": Scroll to image, only if it has not been scrolled away.
// In conjunction with Pixiv's built-in keybind.
const largeImage = document.querySelector('img[src*="p0_master"], img[src*="p0_original"]');
if (largeImage) {
const rect = largeImage.getBoundingClientRect();
if (rect?.top > 0) {
largeImage.scrollIntoView({ behavior: 'smooth' });
}
}
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
// "Arrow Left" and "Arrow Right" for navigating pages.
// Ignore "Ctrl", "Alt", and "Shift".
if (event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
let element;
if (currentUrl.pathname.includes('/users/')) {
// On artist profile pages.
if (event.key === 'ArrowLeft') {
element = document.querySelector('a[aria-label="Previous"][href]:not([hidden]), a[href]:not([hidden]):has(> pixiv-icon[name$="Prev"]');
} else if (event.key === 'ArrowRight') {
element = document.querySelector('a[aria-label="Next"][href]:not([hidden]), a[href]:not([hidden]):has(> pixiv-icon[name$="Next"]');
}
} else if (currentUrl.pathname.includes('/tags/') ||
currentUrl.pathname.startsWith('/bookmark_new_illust.php')) {
// On tags and "Newest by followed" pages.
if (event.key === 'ArrowLeft') {
element = document.querySelector('nav > a[aria-disabled="false"][href*="?p="]:first-child');
} else if (event.key === 'ArrowRight') {
element = document.querySelector('nav > a[aria-disabled="false"][href*="?p="]:last-child');
}
} else if (currentUrl.pathname.startsWith('/ranking.php')) {
// On rankings pages.
if (event.key === 'ArrowLeft') {
element = document.querySelector('div > a:has(> div.icon-v1-prev)');
} else if (event.key === 'ArrowRight') {
element = document.querySelector('div > a:has(> div.icon-v1-next)');
}
}
if (element) {
return processKeyEvent('pageNavigate', element, 100);
}
}
});
logDebug('Listening for keybinds.');
} else {
logDebug('Keybinds disabled.');
}
})();