// ==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.7 // @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': '', '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': '', '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': '', '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': '', '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. const isCachedSelectorValid = value => typeof value === 'string' && isSelectorValid(value); const isCachedSectionValid = section => section && typeof section === 'object' && !Array.isArray(section) && ['selectorParent', 'selectorHeader', 'selectorImagesContainer'].every(field => isSelectorValid(normalizeSelectorList(section[field]))); 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(isCachedSectionValid) : isCachedSelectorValid(_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(presetSection => { if (!presetSection || typeof presetSection !== 'object' || Array.isArray(presetSection)) return presetSection; const normalized = { ...presetSection }; for (const selectorKey of ['selectorParent', 'selectorHeader', 'selectorImagesContainer']) { if (Object.prototype.hasOwnProperty.call(normalized, selectorKey)) { normalized[selectorKey] = normalizeSelectorList(normalized[selectorKey]); } } return normalized; }); 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_. // Normalize every selector field into a single-line selector string, while // preserving any extra properties (e.g. sanityCheck). const normalizeSection = 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; }; const presetSections = Array.isArray(PRESETS[key]) ? PRESETS[key].map(normalizeSection) : PRESETS[key]; if (Array.isArray(ENV[key])) { const customSections = []; ENV[key].forEach((section, index) => { const normalized = normalizeSection(section); const valid = normalized && typeof normalized === 'object' && !Array.isArray(normalized) && ['selectorParent', 'selectorHeader', 'selectorImagesContainer'].every(field => isSelectorValid(normalizeSelectorList(normalized[field]))); if (valid) { 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_')) { return Array.isArray(previous) ? value.split('\n').map(s => s.trim()).filter(Boolean) : value || null; } 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 formatChildSelector = (parentSelector, childSelector) => splitSelectorList(childSelector).map(selector => { const child = selector.startsWith('&') ? selector.slice(1).trimStart() : selector; return `:is(${normalizeSelectorList(parentSelector)})${child ? ` ${child}` : ''}`; }).join(', '); const _FORMATTED_SELECTORS_MULTI_VIEW_CONTROLS = splitSelectorList(formatChildSelector(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*/`