Customize the sorting and collapsing of staff roles in all types of subjects
- // ==UserScript==
- // @name Bangumi-Custom Staff Sorting and Collapsing
- // @name:zh-CN 班固米-条目职位自定义排序与折叠
- // @version 1.4.4-2.0
- // @description Customize the sorting and collapsing of staff roles in all types of subjects
- // @author weiduhuo
- // @namespace https://github.com/weiduhuo/scripts
- // @match *://bgm.tv/subject/*
- // @match *://bgm.tv/character/*
- // @match *://bgm.tv/person/*
- // @match *://bgm.tv/settings/privacy*
- // @match *://bangumi.tv/subject/*
- // @match *://bangumi.tv/character/*
- // @match *://bangumi.tv/person/*
- // @match *://bangumi.tv/settings/privacy*
- // @match *://chii.in/subject/*
- // @match *://chii.in/character/*
- // @match *://chii.in/person/*
- // @match *://chii.in/settings/privacy*
- // @grant none
- // @license MIT
- // @description:zh-CN 对所有类型条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置
- // ==/UserScript==
- (function () {
- 'use strict';
- const SCRIPT_NAME = '班固米-职位排序组件';
- const CURRENT_DATA_VERSION = '2.0';
- const DEBUG = false;
- /** 自定义`console` (避免重写妨碍其他脚本正常使用`console`) */
- const C = {
- log: console.log,
- error: console.error,
- // DEBUG模式下,阻断console的相关操作
- debug: DEBUG ? console.debug : function () {},
- time: DEBUG ? console.time : function () {},
- timeEnd: DEBUG ? console.timeEnd : function () {},
- }
- /** 排序延迟时间 */
- const SORTING_DELAY = 0; // 数值0仍然有意义,将延迟到所有同步脚本之后执行
- /** 防抖延迟时间 */
- const DEBOUNCE_DELAY = 500;
- /** 是否对职位信息进行了折叠,忽略网页自身`sub_group`的折叠 (依此判断 `更多制作人员` 开关的必要性) */
- let hasFolded = false;
- /** 尾部折叠图标的激活阈值相对于视口高度的系数 */
- const sideTipRate = 0.25;
- /** @type {number} 尾部折叠图标的激活行数阈值 */
- let sideTipLineThr = null;
- /** @type {Array<HTMLElement> | null} 最后一组`sub_group`的数据包 */
- let lastGroup = null;
- /** `infobox`以被折叠元素结尾 */
- let endWithFold = false;
- /**
- * 图标,已在`loadStaffStyle`中通过父元素类名`staff_sorting_icon`约束所显示的范围
- */
- const ICON = {
- /** 三角形顶点向右,可表展开按键 */
- TRIANGLE_RIGHT: `
- <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height='0.7em'>
- <polygon points='0.5,0 12.5,6.5 0.5,13' fill='currentColor' />
- </svg>
- `,
- /** 三角形顶点向下,可表折叠按键 */
- TRIANGLE_DOWN: `
- <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height='0.7em'>
- <polygon points='0,0.5 13,0.5 6.5,12.5' fill='currentColor' />
- </svg>
- `,
- /** 三角形顶点向上,可表折叠按键 */
- TRIANGLE_UP: `
- <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height='0.7em'>
- <polygon points='0,12.5 13,12.5 6.5,0.5' fill='currentColor' />
- </svg>
- `,
- /** 加号,可表展开按键 */
- PLUS: `
- <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 10 10' width='100%' height='100%'>
- <polygon points='1,4 9,4 9,6 1,6' fill='currentColor' />
- <polygon points='4,1 6,1 6,9 4,9' fill='currentColor' />
- </svg>
- `,
- };
- /**
- * 枚举所支持的条目类型
- */
- const SubjectType = {
- // 所支持的类型
- ANIME: {en: 'anime', zh: '动画'},
- BOOK: {en: 'book', zh: '书籍'},
- MUSIC: {en: 'music', zh: '音乐'},
- GAME: {en: 'game', zh: '游戏'},
- REAL: {en: 'real', zh: '三次元'},
- CHARACTER: {en: 'character', zh: '角色'},
- PERSON: {en: 'person', zh: '人物'},
- /**
- * @param {boolean} [isObj=false] - `true`时返回对象序列,`false`时返回英文序列
- * @returns {{ en: string, zh: string }[] | string[]}
- */
- getAll(isObj = false) {
- if (isObj) return utils.filterEnumValues(this);
- else return utils.filterEnumValues(this).map(item => item.en);
- },
- /** @returns {string | null} 有效则返回原数值,无效则返回空 */
- parse(value) {
- if (this.getAll().includes(value)) return value;
- return null;
- },
- needPrase(value) {
- return value !== this.CHARACTER.en && value !== this.PERSON.en;
- },
- };
- /**
- * 枚举各类型条目的排序折叠功能启用状态
- */
- const SortEnableState = {
- /** 全部功能禁用 */
- ALL_DISABLED: 'allDisable',
- /** 启用部分功能,仅排序不折叠 */
- PARTIAL_ENABLED: 'partialEnable',
- /** 启用全部功能 */
- ALL_ENABLED: 'allEnable',
- /** @returns {Array<string>} */
- getAll() {
- return utils.filterEnumValues(this);
- },
- };
- /**
- * 枚举二次折叠的开关的可选位置 (相对于职位名称)
- */
- const RefoldSwitchPosition = {
- NONE: 'none',
- LEFT: 'left',
- RIGHT: 'right',
- /** @returns {Array<string>} */
- getAll() {
- return utils.filterEnumValues(this);
- },
- }
- /**
- * 管理`localStorage`的键名与初值。
- * 键值分为全局配置与各类型条目配置、简单类型与复杂类型
- */
- const Key = {
- /** 键名前缀 */
- KEY_PREF: 'BangumiStaffSorting',
- /** 数据版本 */
- DATA_VERSION: '_dataVersion__',
- /** 超过此行数的职位信息将被二次折叠*/
- REFOLD_THRESHOLD_KEY: '_refoldThreshold__',
- REFOLD_THRESHOLD_OLD_KEY: 'refoldThreshold',
- REFOLD_THRESHOLD_DEFAULT: 4,
- REFOLD_THRESHOLD_DISABLED: 0,
- /** 是否继承历史的匹配记录 */
- INHERIT_PREVIOUS_MATCHES_KEY: '_inheritPreMatches__',
- INHERIT_PREVIOUS_MATCHES_DEFAULT: true,
- /** 二次折叠的开关位置 */
- REFOLD_SWITCH_POSITION_KEY: '_refoldSwitchPosition__',
- REFOLD_SWITCH_POSITION_DEFAULT: RefoldSwitchPosition.RIGHT,
- /** 各类型条目模块的展开状态 */
- BLOCK_OPEN_KEY: 'BlockOpen',
- BLOCK_OPEN_OLD_KEY: 'blockOpen', // dataVerion < 1.4
- BLOCK_OPEN_DEFAULT: false,
- /** 各类型条目的功能启用状态 */
- ENABLE_STATE_KEY: 'EnableState',
- ENABLE_STATE_DEFAULT: SortEnableState.ALL_ENABLED,
- /** 各类型条目的自定义排序与折叠 (复杂类型,参见 {@link StaffMapListType}) */
- STAFF_MAP_LIST_KEY: 'StaffMapList',
- /** 职位排序索引的分组映射 (复杂类型,参见 {@link jobOrderMapType}) */
- JOB_ORDER_MAP_KEY: 'JobOrderMap',
- /** 当前使用的键值的所属条目类型 (可即时切换) */
- _subType: null,
- makeKey(key, type = null) {
- this.setSubType(type);
- if (this.isGlobalData(key)) return `${this.KEY_PREF}_${key}`;
- else return `${this.KEY_PREF}_${this._subType}${key}`;
- },
- setSubType(type) {
- if (type) this._subType = type;
- },
- isComplexData(key) {
- return [
- this.STAFF_MAP_LIST_KEY, this.JOB_ORDER_MAP_KEY,
- ].includes(key);
- },
- isGlobalData(key) {
- return [
- this.DATA_VERSION, this.INHERIT_PREVIOUS_MATCHES_KEY,
- this.REFOLD_THRESHOLD_KEY, this.REFOLD_THRESHOLD_OLD_KEY,
- this.REFOLD_SWITCH_POSITION_KEY,
- ].includes(key);
- }
- }
- /**
- * 配置存储,提供`localStorage`的接口。
- * 仅对简单数据类型进行解析、编码、缓存,复杂数据类型放权给外部
- * (为便于进行防抖动绑定,由对象类型改为静态类实现)
- */
- class Store {
- /** 数据缓存,仅对简单类型的键值 */
- static _cache = {};
- /** 需要对数据进行更新 */
- static updateRequired = false;
- /** 定义防抖逻辑的占位 (忽略短时间内改变多对键值的极端情况) */
- static debouncedSet;
- /** 为缺损的配置进行初始化 */
- static initialize() {
- // 缓存初始化
- Store._cache = {};
- // 绑定防抖逻辑,确保 this 指向 Store
- Store.debouncedSet = debounce(Store._set.bind(this));
- // 全局配置初始化
- ['REFOLD_THRESHOLD', 'INHERIT_PREVIOUS_MATCHES', 'REFOLD_SWITCH_POSITION'].forEach(
- (key) => Store._setDefault(key)
- );
- // 局部配置初始化
- SubjectType.getAll().forEach((type) => {
- ['BLOCK_OPEN', 'ENABLE_STATE'].forEach(
- (key) => Store._setDefault(key, type)
- );
- });
- // 检查数据版本
- if (Store.get(Key.DATA_VERSION) !== CURRENT_DATA_VERSION) {
- Store.updateRequired = true;
- Store.preProcessConfigUpdate();
- }
- }
- /** 版本变更,重要配置更新,主任务前执行 */
- static preProcessConfigUpdate() {
- // 键名变更,重要数据进行继承
- if (Store.get(Key.DATA_VERSION) < '1.4') { // 已涵盖 null
- // 变更了全局配置的键名格式
- const preRefoldThreshold = Store.get(Key.REFOLD_THRESHOLD_OLD_KEY);
- if (preRefoldThreshold !== null) {
- Store.set(Key.REFOLD_THRESHOLD_KEY, preRefoldThreshold);
- }
- }
- }
- /** 版本变更,次要配置更新,主任务后执行 */
- static postProcessConfigUpdate() {
- if (!Store.updateRequired) return;
- const preDataVer = Store.get(Key.DATA_VERSION);
- C.log(`${SCRIPT_NAME}:数据版本 ${preDataVer} > ${CURRENT_DATA_VERSION} 更新中...`);
- // [固定]对默认设置的解析缓存进行更新
- SubjectType.getAll().forEach((type) => {
- const jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, type);
- if (jsonStr) return; // 跳过自定义设置
- const staffMapList = new StaffMapList(type);
- staffMapList.initialize(null, true);
- if (Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY)) staffMapList.inheritPreMatches();
- staffMapList.saveResolvedData();
- });
- // [暂时]删除旧有的键值对,低价值数据直接丢弃
- if (preDataVer < '1.4') {
- // 已完成继承
- Store.remove(Key.REFOLD_THRESHOLD_OLD_KEY);
- // 模块的展开状态键名变更
- SubjectType.getAll().forEach((type) => {
- Store.remove(Key.BLOCK_OPEN_OLD_KEY, type);
- });
- // 旧有的异步通信接口
- Store.removeByPrefix(`${Key.KEY_PREF}Interface`);
- }
- // 更新全部完毕后再修改版本号,防止被提前终止
- Store.set(Key.DATA_VERSION, CURRENT_DATA_VERSION);
- C.log(`${SCRIPT_NAME}:数据更新成功`);
- }
- static removeByPrefix(pref) {
- // 将键名缓存到数组中,避免遍历过程中修改 localStorage 导致索引混乱
- const keysToRemove = [];
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- if (key && key.startsWith(pref)) keysToRemove.push(key);
- }
- // 遍历并删除匹配的键
- keysToRemove.forEach((key) => localStorage.removeItem(key));
- }
- static _setDefault(_key, type = null) {
- if (this.get(Key[`${_key}_KEY`], type) === null)
- this.set(Key[`${_key}_KEY`], Key[`${_key}_DEFAULT`]);
- }
- static set(key, value, type = null, isHighFreq = false) {
- if (isHighFreq) this.debouncedSet(key, value, type);
- else this._set(key, value, type);
- }
- static _set(key, value, type = null) {
- Key.setSubType(type);
- const fullKey = Key.makeKey(key);
- if (!Key.isComplexData(key)) {
- this._cache[fullKey] = value; // 同步到缓存 (注意缓存的需是原始数据)
- value = JSON.stringify(value);
- }
- localStorage.setItem(fullKey, value);
- }
- static get(key, type = null) {
- Key.setSubType(type);
- const fullKey = Key.makeKey(key);
- // 简单数据类型,命中缓存
- if (!Key.isComplexData() && Store._isCacheHit(fullKey)) {
- // C.debug(`HIT CHACHE - ${fullKey}: ${this._cache[fullKey]}`);
- return this._cache[fullKey];
- }
- // 无缓存,读取并缓存
- const value = localStorage.getItem(fullKey);
- if (Key.isComplexData(key)) return value;
- const parsedValue = JSON.parse(value);
- this._cache[fullKey] = parsedValue;
- return parsedValue;
- }
- static remove(key, type = null) {
- Key.setSubType(type);
- const fullKey = Key.makeKey(key);
- // 同时删除缓存与数据
- delete this._cache[fullKey];
- localStorage.removeItem(fullKey);
- }
- static _isCacheHit(fullKey) {
- return Object.prototype.hasOwnProperty.call(this._cache, fullKey);
- }
- }
- /**
- * 包含`RegExp`元素的`JSON`格式化字符串。
- * 1. 用于`StaffMapList`解析、编码,其最短的有效字符串为`"[]"`,示设置空缺。
- * 2. 用于`JobOrderMap`编码,由于其只在规定区域内还有`RegExp`元素,
- * 全局使用`reviver`解析性能较低,故单独处理
- */
- const RegexInJSON = {
- regexPattern: /^\/(.+)\/([gimsuy]*)$/,
- /**
- * 解析`StaffMapList`字符串。
- * 用于初步解析与有效性检测,
- * 更进一步的解析,将在`StaffMapList`中进行。
- * 仅检查:
- * 1. 是否满足`JSON`格式
- * 2. 是否为数组类型
- * 3. 字符串样式的正则表达式,是否满足规定格式
- * @returns {Array | null} 空值表失败
- */
- parse(text) {
- let parsedData;
- try {
- parsedData = JSON.parse(text, this._reviver);
- } catch (e) {
- C.error(`${SCRIPT_NAME}:StaffMapList 解析失败 - ${e}`);
- return null;
- }
- if (!utils.isArray(parsedData)) {
- C.error(`${SCRIPT_NAME}:StaffMapList 类型错误 - 非数组类型`);
- return null;
- }
- return parsedData;
- },
- /**
- * 将`RegexInList`转为`JSON`格式化字符串
- * @param {string | number | undefined} space
- */
- stringify(data, space) {
- return JSON.stringify(data, this._replacer, space);
- },
- /** 解析`JSON`字符串中的正则表达式 */
- _reviver(key, value) {
- if (utils.isString(value) && value.startsWith('/')) {
- const match = value.match(RegexInJSON.regexPattern);
- if (!match) {
- throw new Error(`正则表达式 "${value}" 不符合 ${RegexInJSON.regexPattern} 格式`);
- } try {
- return new RegExp(match[1], match[2]);
- } catch (e) {
- throw new Error(`正则表达式 "${value}" 非法 - ${e}`);
- }
- }
- return value;
- },
- /** 将正则表达式转化为字符串,以满足`JSON`格式 */
- _replacer(key, value) {
- if (utils.isRegExp(value)) return value.toString();
- return value;
- },
- }
- /**
- * 职位排序与折叠设置,是职位排序与默认折叠的职位二者信息的复合 (此为原罪)。
- * 其有两种表现形式:
- * 1. `StaffMapList`,即`this.data`,面向用户,结构简明直接,供用户自定义编辑。
- * 2. `JobOrderMap`,面向程序,来自对`StaffMapList`的解析与历史匹配记录。
- * 由`this.{exactOrderMap, regexOrderList, insertUnmatched, insertSubGroup}`组成
- */
- class StaffMapList {
- /**
- * @typedef {string | RegExp | number} MatchJob
- * - 匹配职位名称,其中`number`表示紧邻其后`RegExp`的匹配优先级 (解析时实则为弱关联,不需严格相邻)
- * @typedef {Array<MatchJob | [boolean | MatchJob, ...MatchJob[]]>} StaffMapListType
- * - 其中`boolean`表示子序列内的职位是否默认折叠,缺损值为`false`,需位于子序列的首位才有意义
- * (默认配置中`,,`表示在`JSON`数组中插入`null`元素,用于输出格式化文本时标记换行)
- *
- * @typedef {Object} jobOrderMapType
- * - 是一张分组映射表,用于映射匹配职位的序列号,初值为`StaffMapList`的解析数据。
- * 其中序列号`number`的大小表示该匹配职位的次序,奇偶表示该匹配职位是否默认折叠。
- * (由原先的`Map<MatchJob, number>`整表改进而为分组表,极大减少了解析与维护开销)
- * @property {Object<string, number>} exact - 精确匹配,
- * 以及用于缓存正则匹配、插入未被匹配的历史记录,并于任务结束后写入`Store`
- * @property {Array<[RegExp, number]>} regex - 正则匹配,
- * 基于需遍历使用、对象类型的键不得为字符串以外的类型,采用键值对数组
- * @property {Object<string, number>} insert - 插入匹配,
- * 目前有插入未被匹配的职位信息、插入二级职位引导信息 (后者的优先级更高,且高于其他匹配)
- */
- /** 未被匹配职位信息的插入前缀`'=='` */
- static _insertUnmatchedPref = '==';
- /** 二级职位引导信息的插入前缀`'>>'` */
- static _insertSubGroupPref = '>>';
- /** 正则匹配优先级缺损值 */
- static _defaultRegexPriority = 100;
- /** 懒加载的默认配置 */
- static _defaultLazyData = {
- [SubjectType.ANIME.en]: () => [,
- "===【注释】正则匹配优先级:缺损-100, dd-优先于缺损, 20d-`宣`相关, 30d-`3D`相关, 40d-`设`相关, 50d-`画`相关, 60d-`音`相关, 70d-`色`相关, 80d-`制`相关, 9dd-兜底 ===",,
- ,
- "中文名", /^(罗马|拼音|索引)名$/, [true, "英文名"],,
- "类型", "适合年龄", /地区/, "语言", "对白", /话数/, [true, "季数"], /(片|时)长/,,
- "放送开始", /开始|放送|播出/, 90, /放送星期/, "放送时间", /(上|公)映/, /发售/,,
- ,
- "团长", "超监督", "总监督", "总导演",,
- "导演", "联合导演", "副导演", [true, /^((OP|ED).*)?(执行|主任).*导演/, /导演助/],,
- "系列监督", "原作", "原著", [true, /连载|連載/], "原作插图", [true, "原作协力", /原作/],,
- "原案", /(人物|角色).*原案/, 400, /(人物|角色).*(设|設)/, [true, 983, /人物|角色/],,
- "系列构成", "剧本统筹", /系列|构成|大纲/,,
- 201, /(脚|剧)本|编剧|故事|主笔|文(艺|芸|案)|脚色/, 990, /内容/, [true, 601, /对白|台词/],,
- 20, /分镜|台本/, "OP・ED 分镜", "氛围稿", /氛围|Image ?Board|イメージボード/i,,
- /^(主|总)演出$/, "演出", "演出制作", /(片(头|尾)|OP|ED)演出/, [true, "演出助理", /^演出助/],,
- ,
- /^(主要?动画师|总作画)$/, "总作画监督", /(作|原)画总监/, [true, "总作画监督助理"], /机械导演/,,
- [false, "作画监督"], 501, /(机械|动作|特效)?(作|原|操)画.*(导演|监)|作监$/,,
- [true, "作画监督助理", 500, /作画监督(助|.+佐|协力)/],,
- 989, /原案/, 405, /(设|設)定/, , [true, 989, /机械/],,
- "道具设计", 507, /(设|設)计|Design|デザイン/i, /字(符|体)|icon|アイコン|logo|ロゴ/i,,
- 507, /构图|Layout/i, [true, 502, /操画/], [false, "原画", "原画师"],,
- [true, "第二原画", /(作|原)画监修/, 508, /原画|修型/],,
- /作画特殊?效果?/, 987, /作画|绘制/, /数码绘图/, [true, "扫描", "描线"],,
- [false, 500, /动画?.*检查?/], [true, "补间动画"], 508, /(动|動)画/,,
- ,
- /色彩(导演|监督|总监)/, /色彩.*设(计|定)/, [true, 90, /色彩设计.+佐/],,
- "色彩演出", /色彩((脚|剧)本|故事版)|Color ?Script|カラースクリプト/i,,
- [false, /色彩?指定/, 700, /(色|仕).?(检|検|校)/], [true, "上色", 701, /色彩|仕上/, 984, /色/],,
- ,
- /(美|艺)(术|術).*(导演|(監|监)督|总监|主管|括)/, [true, 90, /美(术|術)(監|监)督(助|.+佐)/],,
- "主美", 406, /概念(美|艺)术|视觉概念/, /美术设(计|定)/, /美(术|術)(board|板|ボード)/,,
- /(场|布)景设计/, [false, "背景美术", /^美(术|術)$/, 506, /(场|布|绘|制)景/, 983, /景/],,
- [false, 507, /(美|艺)(术|術)|アート|ART WORK/i],,
- [true, /美(术|術)(辅|补)佐/], [true, /原图整理/], [/工艺|创意/],,
- ,
- 301, /(CGI?|3D|三维|立体|电脑).*(导演|(監|监)督|总监|主管|统|ディレクター)/, /^(3D ?CGI?|三维)$/,,
- /(CGI?|3D).*演出/, /(3D|三维) ?(layout|LO|构图)/i, /(CGI?|3D).*(设计|(美|艺)术|アート)/,,
- /(建模|(模|造)(型|形)|モデリング)(导演|监督|总监|主管|ディレクター)/, [true, /(建模|(模|造)(型|形))/],,
- /绑(骨|定)(监督|总监)/, [true, /绑|骨/, 303, /材质|贴图|テクスチャー/],,
- [true, /数字背景/, 988, /地形|建筑|资(源|产)/, /管线|Pipeline/i, 10, /LookDev|^UE/, "工程师"],,
- /(动作|#戏|战斗.*)(导演|监督|主管)/, /动作设计/, [true, 504, /动作|表情/], /帧|动作?捕捉?/,,
- /(CGI?|3D).*原画/, /(CGI?|3D|三维).*(动画|アニメーション)/,,
- /(CGI?|3D|三维)特效/, 303, /(CGI?|3D|三维|电脑图形).*制作/,,
- [true, 302, /(CGI?|3D|三维|电脑图形).*(制作人|プロデュース)/, /(CGI?|3D|电脑图形).*制片人/],,
- [true, 980, /CGI?|3D|三维|立体|电脑|コンピュータ/],,
- /Motion Graphic|モーショングラフィック/i,,
- ,
- 300, /2DCG.*导演/, /2D(w| ?works|ワークス)/i, /UI|图形界面/, 980, /2D|二维/,,
- ,
- "摄影监督", "副摄影监督", 607, /(中|后)(期|制).*(导演|监督|总监|管|统)/,,
- [false, /張り込み|ハリコミ/, /摄(影|制)/], [true, /线拍/],,
- [true, 608, /(中|后)期/, /(摄|撮)|合成|照明|光(源|照)|灯光|映像/, 302, /渲染|解算/],,
- /((效|効)|特技)(导演|(監|监)督|总监|主管)/, [true, 609, /(效|効)|特技|Effect|监视器|モニター/i],,
- /(V|C)?FX/, [true, 980, /视觉/], [true, 987, /技(术|術)/],,
- ,
- [false, "剪辑", "编集"], [true, 609, /剪辑|(编|編)集/, /联机/], [true, "场记"],,
- /标题|字幕|タイトル/, /(冲|洗)印|現像|デジタルラボ|介质/, "转录",,
- ,
- /(音(响|频)|声音).*(导演|监督|总监|主管)/, "音响", [true, /音响/], "音效", [true, 600, /音效|拟音/],,
- [false, "录音"], [true, "录音助理", 600, /录|録/], [true, /声音/, 604, /音频|(整|调)音|母带|混|声|調整/],,
- /(配音|演员).*(导演|监)/, "主演", "配音", /^(cast(ing)?|キャスティング)$/i,,
- [true, 603, /配音|出演|演(员|出)|キャスティング/], [true, /(配音|演员|角色).*(管|统)|选角/],,
- ,
- "音乐", /音乐(导演|监督|总监)/, [true, "音乐制作人", "音乐制作", "音乐助理"],,
- [true, /音乐/, 606, /音|乐/, 605, /奏/],,
- "主题歌演出", [true, "主题歌作词", "主题歌作曲", "主题歌编曲"],,
- "插入歌演出", [true, "插入歌作词", "插入歌作曲", "插入歌编曲"],,
- /片头曲(.*演(出|唱))?$/, [true, /片头曲/], /片尾曲(.*演(出|唱))?$/, [true, /片尾曲/],,
- [true, 603, /作(词|詞)/, 603, /歌|曲/, /song/i], [true, /(选|選)曲/],,
- ,
- "企画", [true, "企画协力"], "企划制作人", 202, /企(画|划)|策划|战略/,,
- 980, /出品/, [true, "总监制", 989, /监制/],,
- 800, /总制片|(统|統|概)括/, "制片人", "制片", "执行制片人", [true, 808, /制片|プロデューサー/],,
- ,
- [true, 980, /主编/, 989, /编辑|ライター/, /排版|数据|翻译|清书/, 980, /审|检验/],,
- [true, 981, /发行/, 200, /宣(传|伝|发)?|推广|广告|広報|パンフレット/],,
- [true, 402, /市场|运营|营销|销售|セールス|商(务|业)|(商|产)品|パッケージ|衍生|周边|授权|ライセンス|品牌|IP/],,
- [true, 987, /人事|法务|维权|(财|税)务|后(勤|盾)|支援|^助理/, /(水滴|深空)攻坚/, 989, /支持/],,
- 509, /取材|考(据|证|証)/, [true, 987, /校/],,
- ,
- "动画制片人",,
- [true, 809, /(制|製)作(管理|主任|担当|デスク|总|総|.?监|人)/, "担当制作"],,
- [true, 989, /统|統|管理/, "计划管理", 801, /制作助./, 980, /プロダクション|マネージャー|经理|PM/],,
- [true, 403, /(设|設)定(制作|管理)/], [true, "制作进行", "制作进行协力", "制作协调"],,
- "动画制作", 989, /制作/, /定格动画|(ねんど|パペット)アニメ/,,
- 980, /制作(协|協)力/, 990, /(协|協)力?/, 30, /手语|发音/, 987, /(监|監)修|顾问|指导/,,
- [false, /同人|题字/, /谢|Thanks/i],,
- ,
- "别名", /.+名$/,,
- /官方网站|官网|公式|HP/, [true, 90, /(官方网站|官网|公式|HP).*(备份|制作)/], /推特|Twitter|^X$/i, "微博",,
- "发行", "开发", "播放电视台", "其他电视台", /bilibili/i, 609, /网络|播放|配(给|給|送|信)?|番|版/,,
- "播放结束", /结束/,,
- /JAN(码|番号)|imdb/i, "链接", "价格",,
- ,
- "其他", /其他/, [true, "备注"],,
- [false, "===此处插入未被匹配的职位==="],,
- 10, /许可证|备案号/, 980, /官方(支持|伙伴)?/,,
- "制作", "製作", "製作协力", /製作委員会|(制作)?著作|出品方?$|版权/, "Copyright",
- ],
- [SubjectType.BOOK.en]: () => [,
- "中文名", /名/,,
- [false, "===此处插入未被匹配的职位==="],,
- ">>>此处插入二级职位引导>>>",,
- "ISBN",
- ],
- [SubjectType.MUSIC.en]: () => [,
- "制作人",,
- "艺术家", "作词", "作曲", "编曲",,
- "脚本", "声乐", "乐器", "混音", "母带制作",,
- "插图", "原作", "出版方", "厂牌",
- ],
- };
- /** @type {StaffMapListType} 主数据 */
- data = [];
- /** @type {Object<string, number>} 精确匹配的映射表,同时用于缓存匹配记录 */
- exactOrderMap = {};
- /**
- * @type {Array<[string | RegExp, number, number]>}
- * 正则匹配的映射序列,子序列为正则表达式、序列号、匹配优先级
- * (允许正则表达式适时活化)
- */
- regexOrderList = [];
- /** 是否为默认数据 */
- isDefault = null;
- /** 重新解析数据 */
- newResolvedData = false;
- /** 新记录的匹配计数器 */
- newlyMatchedCount = 0;
- /** 是否开启折叠功能 */
- foldable = false;
- /** 是否执行了折叠功能 */
- hasFolded = false;
- /** 未被匹配职位信息的插入位置 */
- insertUnmatched = Infinity;
- /** 二级职位引导信息的插入位置 (一旦启用,即为最高优先级) */
- insertSubGroup = null;
- /** 默认配置格式化文本的缓存 */
- _defaultTextBuffer = null;
- constructor(subType) {
- /** 所属条目类型(不可变更)*/
- this.subType = subType; // 小心 Key._subType 被其他模块切换
- /** 序列号计数器 */
- this.index = 0;
- /** 排序功能启用状态 */
- this.sortEnableState = Store.get(Key.ENABLE_STATE_KEY, this.subType);
- /** `_resolveData`中当前正则匹配的优先级 */
- this._priority = StaffMapList._defaultRegexPriority;
- }
- /**
- * 依据`EnableState`进行初始化,使其具备职位匹配的能力。
- * 若仅为获取`StaffMapList`格式化字符串,则不需要执行本初始化。
- * @param {any[] | null | undefined} [parsedData=null]
- * - 由设置界面传入的被初步解析的数据
- * @param {boolean} [forcedEnable=false]
- * - 是否开启强制模式 (默认关闭),强制模式下关闭`EnableState`检查并禁用折叠
- */
- initialize(parsedData = undefined, forcedEnable = false) {
- // 非强制模式下,禁止了排序功能
- if (!forcedEnable && this.sortEnableState === SortEnableState.ALL_DISABLED) return;
- // 是否开启折叠功能
- this.foldable = !forcedEnable && this.sortEnableState === SortEnableState.ALL_ENABLED;
- this._init();
- // 尝试优先获取存储的解析数据
- if (parsedData === undefined) {
- if (this._loadResolvedData()) return;
- else this._init(); // 需再次初始化
- }
- // 在设置界面保存设置时进行强制重新解析,或者在条目界面进行初始化解析
- if (!this._loadData(parsedData)) {
- this._setDefault();
- this.isDefault = true;
- }
- // 解析数据
- this._resolveData();
- this.data = null; // 释放原始数据的存储空间
- this.newResolvedData = true;
- // 替换 Infinity (替换为当前的尾值,来免除对 Infinity 在 JSON 序列化时需单独处理)
- if (!utils.isFinity(this.insertUnmatched)) {
- // 替换默认值
- this.insertUnmatched = this.index << 1;
- // 若启用了插入二级引导,且设定为尾值,则替换。以保证二级引导插入末尾的优先级
- if (utils.isNumber(this.insertSubGroup) && this.insertUnmatched - this.insertSubGroup === 2) {
- [this.insertSubGroup, this.insertUnmatched] = [this.insertUnmatched, this.insertSubGroup];
- }
- }
- }
- /** 数据参数初始化 */
- _init() {
- this.index = 0;
- this.exactOrderMap = {};
- this.regexOrderList = [];
- this.insertUnmatched = Infinity;
- this.insertSubGroup = null;
- this._defaultTextBuffer = null;
- this._priority = StaffMapList._defaultRegexPriority;
- }
- /**
- * 表示未经`initialize`,或是空缺设置,将关闭脚本的职位排序。
- * 空缺设置有两种独立开启途径:
- * 1. `EnableState = "allDisable"`
- * 2. `StaffMapListJSON = "[]"`
- */
- isNull() { return this.index === 0; }
- /**
- * 判断职位是否默认折叠
- * @param {string} job
- */
- isFoldedJob(job) {
- if (!this.foldable) return false;
- const index = this.exactOrderMap[job];
- const r###lt = (index & 1) === 1; // 奇数代表默认折叠
- if (!this.hasFolded && r###lt) this.hasFolded = true;
- return r###lt;
- }
- /** 保存自定义的数据 */
- saveData(jsonStr, parsedData) {
- this.isDefault = false;
- Store.set(Key.STAFF_MAP_LIST_KEY, jsonStr, this.subType);
- C.log(jsonStr);
- C.log(`${SCRIPT_NAME}:保存自定义 ${this.subType}StaffMapList 数据`);
- this.initialize(parsedData, true);
- if (Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY)) this.inheritPreMatches();
- this.saveResolvedData();
- }
- /** 恢复默认数据的设置 */
- resetData() {
- this.isDefault = true;
- Store.remove(Key.STAFF_MAP_LIST_KEY, this.subType);
- C.log(`${SCRIPT_NAME}:恢复默认 ${this.subType}StaffMapList 数据`);
- this.initialize(null, true); // 传入null表强制重新解析
- if (Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY)) this.inheritPreMatches();
- this.saveResolvedData();
- }
- /** 继承历史的匹配记录 */
- inheritPreMatches() {
- this.newResolvedData = true;
- this.newlyMatchedCount = 0;
- // 获取历史的匹配记录
- const jsonStr = Store.get(Key.JOB_ORDER_MAP_KEY, this.subType);
- if (!jsonStr) return;
- let preMatches;
- try {
- /** @type {jobOrderMapType} */
- const parsedMap = JSON.parse(jsonStr);
- preMatches = parsedMap.exact;
- } catch { return; }
- // 不进行排序,仅遍历匹配
- const sorter = new BaseStaffSorter(this, preMatches);
- sorter.scan();
- sorter.printRegexMatchLog();
- sorter.printUnmatchLog();
- if (this.newlyMatchedCount) {
- C.log(`${SCRIPT_NAME}:从 ${this.subType} 历史匹配记录中继承 ${this.newlyMatchedCount} 项匹配`);
- }
- }
- /**
- * 组合并保存解析数据`jobOrderMap`
- * @param {boolean} [inSubject=false] - 是否为条目界面操作
- */
- saveResolvedData(inSubject = false) {
- // 空缺设置
- if (this.isNull()) {
- if (this.sortEnableState !== SortEnableState.ALL_DISABLED) {
- Store.remove(Key.JOB_ORDER_MAP_KEY, this.subType);
- } return;
- }
- // 解析数据未更新
- if (!this.newlyMatchedCount && !this.newResolvedData) return;
- // 组合解析数据
- this._sortExactOrderMap(inSubject || !Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY));
- const jobOrderMap = {
- 'exact': this.exactOrderMap,
- 'regex': this.regexOrderList,
- 'insert': { [StaffMapList._insertUnmatchedPref]: this.insertUnmatched }
- };
- if (utils.isNumber(this.insertSubGroup)) {
- jobOrderMap.insert[StaffMapList._insertSubGroupPref] = this.insertSubGroup;
- }
- // JSON 序列化后进行存储
- const jsonStr = RegexInJSON.stringify(jobOrderMap);
- Store.set(Key.JOB_ORDER_MAP_KEY, jsonStr, this.subType);
- C.log(`${SCRIPT_NAME}:保存 ${this.subType}JobOrderMap 解析缓存`);
- }
- /** 恢复默认配置 */
- _setDefault() {
- // 该类型条目未有默认设置
- if (!StaffMapList._defaultLazyData[this.subType]) this.data = [];
- // 懒加载默认设置
- else this.data = StaffMapList._defaultLazyData[this.subType]();
- }
- /**
- * 尝试载入自定义的数据,并作初步解析
- * @param {any[] | null} [parsedData=null]
- */
- _loadData(parsedData = null) {
- const jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType);
- if (!jsonStr) return null; // 键值为空,表示用户启用默认设置
- if (!parsedData) parsedData = RegexInJSON.parse(jsonStr);
- if (!parsedData) {
- // 通过UI进行的配置一般不可能发生
- C.error(
- `${SCRIPT_NAME}:自定义 ${this.subType}StaffMapList 解析失败,将使用脚本默认的数据`
- );
- return false;
- }
- /* 修复外层重复嵌套 `[]` 的形式,例如 [["", [true, ""], ""]]
- * 同时区分形如 [[true, "", ""]] 此类不需要降维的情形,
- * 忽略存在的漏洞:形如 [[true, "", [true, ""], ""]] 将无法降维 */
- if (
- parsedData.length === 1 &&
- utils.isArray(parsedData[0]) &&
- !utils.isBoolean(parsedData[0][0])
- ) {
- parsedData = parsedData[0];
- }
- this.isDefault = false;
- this.data = parsedData;
- return true;
- }
- /** 完全解析数据,拆解为`jobOrderMap` */
- _resolveData() {
- for (let item of this.data) {
- let isFolded = false;
- if (utils.isArray(item)) {
- if (!item.length) continue;
- // 对数组进行完全展平,提高对非标多维数组的兼容性
- item = item.flat(Infinity);
- /* 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效
- * 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效 */
- if (utils.isBoolean(item[0])) {
- // 始终解析折叠信息,是否最终执行通过 foldable 进行限制
- if (item[0]) isFolded = true;
- item.shift(); // 移除第一个元素,替代 slice(1)
- }
- this._processMatchers(isFolded, ...item);
- } else {
- this._processMatchers(isFolded, item);
- }
- }
- this.regexOrderList.sort((a, b) => a[2] - b[2]); // 对正则匹配进行优先级排序
- if(!this.isNull()) C.debug(`经过解析初始化 ${this.subType}StaffMapList`);
- }
- /** 尝试载入存储的解析数据`jobOrderMap` */
- _loadResolvedData() {
- const jsonStr = Store.get(Key.JOB_ORDER_MAP_KEY, this.subType);
- if (!jsonStr) return null; // 键值为空,表示存储数据为空
- try {
- /** @type {jobOrderMapType} */
- const parsedMap = JSON.parse(jsonStr);
- this.exactOrderMap = parsedMap.exact;
- this.regexOrderList = parsedMap.regex;
- // 活化正则表达式 (改为适时活化)
- // for (const item of this.regexOrderList) {
- // const match = item[0].match(RegexInJSON.regexPattern);
- // item[0] = RegExp(match[1], match[2]);
- // }
- this.insertUnmatched = parsedMap.insert[StaffMapList._insertUnmatchedPref];
- this.insertSubGroup = parsedMap.insert[StaffMapList._insertSubGroupPref];
- if (!utils.isNumber(this.insertUnmatched)) {
- throw new Error(`插入匹配的序列号 ${this.insertUnmatched} 无效`); // 必需的数据
- }
- } catch (e) {
- C.error(
- `${SCRIPT_NAME}:${this.type}JobOrderMap 缓存数据损坏,将对 staffMapList 重新进行解析 - ${e}`
- );
- return false;
- }
- this.regexOrderList ??= []; // 允许缺损
- this.index = Infinity; // 无实义,仅表完成初始化,作用于 isNull
- C.debug(`经过读取缓存初始化 ${this.subType}StaffMapList`);
- return true;
- }
- /**
- * 处理一组匹配,用于`_resolveData`,
- * 使得`jobOrderMap.number`同时表示序列号与折叠状态
- * @param {boolean} isFolded
- * @param {...MatchJob} matchers
- */
- _processMatchers(isFolded, ...matchers) {
- for (const matcher of matchers) {
- const matcherType = StaffMapList.getMatcherType(matcher);
- if (matcherType === 0 || matcher === '') continue;
- // 获取正则匹配的优先级
- if (matcherType === 3) {
- this._priority = matcher;
- continue;
- };
- // 生成序列号
- let _index = this.index++ << 1;
- if (isFolded) _index++; // 默认折叠则为奇数,相否则为偶数
- // 构建正则表达式引用列表
- if (matcherType === 2) {
- this.regexOrderList.push([matcher, _index, this._priority]);
- this._priority = StaffMapList._defaultRegexPriority;
- continue;
- }
- // 仅剩余 matcherType === 1
- const prefixType = StaffMapList.getPrefixType(matcher);
- switch (prefixType) {
- // 精确匹配,构建哈希表
- case 0:
- this.exactOrderMap[matcher] = _index;
- break;
- // 插入匹配,未被匹配职位信息
- case 1:
- this.insertUnmatched = _index;
- C.debug(`insertUnmatched: "${matcher}", index: ${_index}}`);
- break;
- // 插入匹配,二级职位引导信息
- case 2:
- _index = _index & ~1; // 消除折叠性
- this.insertSubGroup = _index;
- C.debug(`insertSubGroup: "${matcher}", index: ${_index}}`);
- break;
- }
- }
- }
- /**
- * 对精确映射表按序列号进行排序
- * @param {boolean} [useMerge=false] - 是否使用归并排序
- */
- _sortExactOrderMap(useMerge = false) {
- if (this.newlyMatchedCount === 0) return;
- let arr = Object.entries(this.exactOrderMap);
- const compareFn = (a, b) => a[1] - b[1];
- if (useMerge) {
- // 1. 对无序部分排序后,再进行归并排序
- const sortedLength = arr.length - this.newlyMatchedCount;
- const unsortedPart = arr
- .slice(sortedLength)
- // .filter((value) => value[1] !== this.insertUnmatched) // 过滤未被匹配的职位
- .sort(compareFn);
- arr = utils.mergeSorted(arr, unsortedPart, sortedLength, this.newlyMatchedCount, compareFn);
- } else {
- // 2. 整体排序 (在继承匹配记录时,无序部分占比高)
- arr.sort(compareFn);
- }
- this.exactOrderMap = Object.fromEntries(arr);
- }
- /**
- * 将数据转化为格式化文本 (有别于`StaffMapListJSON`)
- * 用于设置内的显示与编辑,自定义数据与默认数据二者格式化有别
- * @returns {string} 格式化文本
- */
- formatToText(useDefault) {
- let jsonStr = null;
- if (!useDefault) {
- jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType);
- this.isDefault = jsonStr === null; // useDefault 不能改变 isDefault
- }
- // 自定义数据
- if (jsonStr) return jsonStr.slice(1, -1); // 消除首尾的 `[]`
- // 读取缓存的默认数据
- else if (this._defaultTextBuffer) return this._defaultTextBuffer;
- // 将默认数据转化为格式化文本
- this._setDefault();
- const text = RegexInJSON.stringify(this.data, 1)
- .replace(/(null,\n )|(\n\s+)/g, (match, g1, g2) => {
- if (g1) return '\n';
- if (g2) return ' ';
- return match;
- })
- .slice(3, -2); // 消除首部 `[ \n` 与尾部 `\n]`
- // 使得 `[ `->`[` 同时 ` ]`->`]`
- /* const text = RegexInJSON.stringify(this.data, 1).replace(
- /(null,)|(?<!\[)(\n\s+)(?!])|(\[\s+)|(\s+],)/g,
- (match, g1, g2, g3, g4) => {
- if (g1) return '\n';
- if (g2) return ' ';
- if (g3) return '[';
- if (g4) return '],';
- return match;
- }).slice(3, -2); */
- this._defaultTextBuffer = text;
- this.data = null; // 释放原始数据的存储空间
- return text;
- }
- /**
- * 获取该插入匹配的前缀类型
- * @param {string} matcher
- * @returns {0 | 1 | 2} 0-无,1-插入未被匹配,2-插入二级引导
- */
- static getPrefixType(matcher) {
- if (matcher.startsWith(StaffMapList._insertUnmatchedPref))
- return 1;
- if (matcher.startsWith(StaffMapList._insertSubGroupPref))
- return 2;
- return 0;
- }
- /**
- * 获取该匹配的类型
- * @param {MatchJob | any} matcher
- * @returns {0 | 1 | 2 | 3} 0-无效类型,1-字符串,2-正则式,3-数字
- */
- static getMatcherType(matcher) {
- return utils.isString(matcher) ? 1 : utils.isRegExp(matcher) ? 2 : utils.isNumber(matcher) ? 3 : 0;
- }
- }
- /**
- * 基类,职位排序的核心逻辑。
- * 可被拓展用于不同的场景:网页`infobox`职位信息、职位名称序列、API`infobox`职位信息。
- *
- * 经算法重构,并修改`StaffMapList`的解析数据的结构,
- * 总复杂度由原本的 `O(k+m + nk·T)` 变为 `O(k+m+n + nk·t + klogk)`,
- * 借助缓存匹配记录,最高可达到 `O(k+m+n + klogk)`,其中:
- * - `k`为`li`元素的个数
- * - `m`为精确匹配规则的数量
- * - `n`为正则匹配规则的数量
- * - `t/T`为正则表达式平均匹配时间
- *
- * 其中的匹配逻辑`nk·T`实现为`for(n+m){for(k not M){T}}`,`nk·t`实现为`for(k not M){for(n){t}}`,
- * 因此对总复杂度的影响`T >> t`,同时借助该循环方式的改变,实现了精确匹配绝对优先于正则匹配
- */
- class BaseStaffSorter {
- /**
- * @type {Object<string, any> | Iterable<string>}
- * 原始数据,元素内需包含待匹配职位名称
- */
- rawData;
- /** @type {StaffMapList} 职位排序与折叠设置 */
- staffMapList;
- /** @type {Array<string>} 职位名称的排序结果 */
- sortedJobs;
- /** @type {Map<RegExp, Array<string>>} 被正则匹配的职位名称 */
- regexMatchedJobs;
- /** @type {Array<string>} 未被匹配的职位名称 */
- unmatchedJobs;
- /**
- * 构造函数,子类可细化`rawData`的类型定义,
- * 并自行对其初始化,且需在其后调用`_initSetData`函数
- */
- constructor(staffMapList, rawData = null) {
- this.staffMapList = staffMapList;
- this.regexMatchedJobs = new Map();
- this.unmatchedJobs = [];
- if (rawData) {
- this.rawData = rawData;
- this._initSortedJobs();
- }
- }
- /** 初始化排序结果 */
- _initSortedJobs() {
- if (utils.isArray(this.rawData) || utils.isSet(this.rawData)) {
- this.sortedJobs = Array.from(this.rawData);
- } else {
- this.sortedJobs = Object.keys(this.rawData);
- }
- }
- /**
- * 获取匹配后的序列号,
- * 被正则匹配与未被匹配的职位,将所得的序列号写入解析缓存`jobOrderMap`
- * @param {string} job - 数据集合
- * @return {number} 返回用于`Array.sort`的序列号
- */
- getMatchIndex(job) {
- let index;
- // 1.精确匹配,或命中缓存 (优先)
- index = this.staffMapList.exactOrderMap[job];
- if (index !== undefined) {
- return index;
- }
- // 新的匹配记录,需写入`Store`
- this.staffMapList.newlyMatchedCount++;
- // 2.正则匹配
- for (const item of this.staffMapList.regexOrderList) {
- if (utils.isString(item[0])) {
- // 懒活化正则表达式
- const match = item[0].match(RegexInJSON.regexPattern);
- item[0] = RegExp(match[1], match[2]);
- }
- if (!item[0].test(job)) continue;
- index = item[1];
- this.staffMapList.exactOrderMap[job] = index;
- this._addRegexMatchLog(item[0], job);
- return index;
- }
- // 3.未被匹配
- index = this.staffMapList.insertUnmatched;
- // this._addUnmatchLog(job); // 推迟记录操作,以确保无论未被匹配职位是否已被缓存的,均被记录
- this.staffMapList.exactOrderMap[job] = index;
- return index;
- }
- /** 进行匹配排序 */
- sort() {
- this.sortedJobs.sort((a, b) => this.getMatchIndex(a) - this.getMatchIndex(b));
- }
- /** 进行匹配遍历,并记录未被匹配的职位 */
- scan() {
- const insertUnmatched = this.staffMapList.insertUnmatched;
- for (const job of this.sortedJobs) {
- const index = this.getMatchIndex(job, false);
- if (index === insertUnmatched) this._addUnmatchLog(job);
- }
- }
- /**
- * 记录一条被正则匹配的职位信息
- * @param {RegExp} regex
- * @param {string} job
- */
- _addRegexMatchLog(regex, job) {
- // C.log(`${SCRIPT_NAME}:使用正则表达式 ${regex} 成功匹配 "${job}"`);
- if (this.regexMatchedJobs.has(regex)) {
- this.regexMatchedJobs.get(regex).push(job);
- } else {
- this.regexMatchedJobs.set(regex, [job]);
- }
- }
- printRegexMatchLog() {
- if (!this.regexMatchedJobs.size) return;
- C.log(`${SCRIPT_NAME}:${this.staffMapList.subType} 中被正则匹配到的职位`,
- [...this.regexMatchedJobs]
- .map(([regex, jobs]) => [regex, jobs.join(',')])
- );
- }
- /**
- * 记录一条未被匹配的职位信息
- * @param {string} job
- */
- _addUnmatchLog(job) {
- this.unmatchedJobs.push(job);
- }
- printUnmatchLog() {
- if (!this.unmatchedJobs.length) return;
- C.log(`${SCRIPT_NAME}:${this.staffMapList.subType} 中未被匹配到的职位`, this.unmatchedJobs);
- }
- }
- /**
- * 实现网页`infobox`职位信息的排序与折叠,
- * `sub_group`及属其所有的`sub_container`将被视为一个整体进行排序
- */
- class InfoboxStaffSorter extends BaseStaffSorter {
- /**
- * @type {Object<string, HTMLElement | Array<HTMLElement>>}
- * 原始职位信息字典 (细化基类的数据格式)
- */
- rawData;
- /**
- * @param {HTMLElement} ul - `infobox`
- * @param {StaffMapList} staffMapList - 职位排序与折叠设置
- * @param {Object<string, HTMLElement | Array<HTMLElement>>} infoboxDict - 职位信息字典
- * @param {string} subjectId - 条目`ID`
- */
- constructor(ul, staffMapList, infoboxDict, subjectId) {
- super(staffMapList);
- this.rawData = infoboxDict;
- this._initSortedJobs();
- /** `infobox` */
- this.ul = ul;
- /** `infobox`临时片段 */
- this.fragment = document.createDocumentFragment();
- /** 当前条目的`ID` */
- this.subjectId = subjectId;
- /** 启用插入二级职位引导信息 */
- this.needInsertSubGroup = utils.isNumber(this.staffMapList.insertSubGroup);
- }
- /**
- * 获取匹配后的序列号,
- * 在原有的基础上增加优先插入二级职位引导的功能
- */
- getMatchIndex(job) {
- if (this.needInsertSubGroup && utils.isArray(this.rawData[job])) {
- return this.staffMapList.insertSubGroup;
- }
- return super.getMatchIndex(job);
- }
- sort() {
- // 进行匹配排序
- super.sort();
- // 执行排序结果
- let foldedJobs = [];
- let preUnfolded = null;
- for (const job of this.sortedJobs) {
- const li = this.rawData[job];
- // 1. sub_group 及属其所有的 sub_container 组成的序列
- if (utils.isArray(li)) {
- if (foldedJobs.length) dealFoldsList(foldedJobs, li[0], preUnfolded, false);
- preUnfolded = null;
- this.fragment.append(...li);
- lastGroup = li;
- continue;
- }
- // 2. 普通职位信息
- if (this.staffMapList.isFoldedJob(job)) {
- li.classList.add('folded', 'foldable');
- // 加入队列
- foldedJobs.push(job);
- } else {
- // 未被折叠
- if (foldedJobs.length) {
- dealFoldsList(foldedJobs, li, preUnfolded, li.classList.contains('refoldable'));
- }
- preUnfolded = li;
- }
- this.fragment.appendChild(li);
- }
- // 尾部元素折叠的情形
- dealEndWithFold(this.ul, foldedJobs, preUnfolded);
- // 一次性更新 DOM
- this.ul.append(...this.fragment.childNodes);
- }
- }
- /** 主函数入口 */
- async function main() {
- const urlPatterns = [
- /^\/(subject)\/(\d+)$/,
- /^\/(character)\/(\d+)$/,
- /^\/(person)\/(\d+)$/,
- /^\/(settings)\/privacy$/,
- ];
- Store.initialize();
- for (const pattern of urlPatterns) {
- const match = window.location.pathname.match(pattern);
- if (!match) continue;
- const [, patternType, subId] = match;
- if (patternType === 'settings') handlerSettings();
- else await handlerSubject(patternType, subId); // 传入条目类型与条目ID
- break;
- }
- Store.postProcessConfigUpdate();
- }
- /** 处理设置 */
- function handlerSettings() {
- loadSettingStyle();
- const ui = buildSettingUI({ id: 'staff_sorting' });
- document.getElementById('columnA').appendChild(ui);
- // 支持 url.hash = ID 进行导引
- if (location.hash.slice(1) === 'staff_sorting') {
- ui.scrollIntoView({ behavior: 'smooth' });
- }
- }
- /** 处理条目 */
- async function handlerSubject(subType, subId) {
- if (SubjectType.needPrase(subType))
- subType = SubjectType.parse(getSubjectType());
- if (!subType) return; // 不支持该类型条目
- loadStaffStyle();
- const ul = document.querySelector('#infobox');
- C.time(`StaffMapList`);
- const staffMapList = new StaffMapList(subType);
- staffMapList.initialize();
- C.timeEnd(`StaffMapList`);
- let sorter = null;
- // 延迟执行,提高对其他修改 infobox 信息的脚本的兼容性
- await delay(SORTING_DELAY);
- if (!staffMapList.isNull()) {
- // 1.实行自定义的职位顺序
- const infoboxDict = getInfoboxDict(ul);
- C.time(`StaffSorting`);
- sorter = new InfoboxStaffSorter(ul, staffMapList, infoboxDict, subId);
- sorter.sort();
- C.timeEnd(`StaffSorting`);
- hasFolded = staffMapList.hasFolded;
- } else {
- // 2.实行网页原有的职位顺序
- scanInfobox(ul);
- C.log(`${SCRIPT_NAME}:实行网页原有的职位顺序`);
- }
- JobStyle.setStaffStyleProperty();
- addFoldButtonListener(ul);
- changeExpandToDoubleButton(ul);
- dealLastGroup(ul);
- if (sorter) {
- // 打印匹配记录
- sorter.scan(); // 扫描未被匹配的职位
- sorter.printRegexMatchLog();
- sorter.printUnmatchLog();
- // 保存匹配记录
- staffMapList.saveResolvedData(true);
- }
- }
- /**
- * 巧妙地使用非常便捷的方法,获取当前条目的类型
- * 源自 https://bangumi.tv/dev/app/2723/gadget/1242
- */
- function getSubjectType() {
- const href = document.querySelector('#navMenuNeue .focus').getAttribute('href');
- return href.split('/')[1];
- }
- /**
- * 从`span.tip`元素中获取职位名称
- * @param {HTMLElement} tip
- * @returns {string}
- */
- function getJobFromTip(tip) {
- return tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
- }
- /**
- * 获取一个对象来存储`infobox`中的职位信息。
- * 并对职位信息进行二次折叠,
- * 同时将`sub_group`及属其所有的`sub_container`打包为一个序列作为字典的键值
- * @param {HTMLElement} ul - `infobox`
- * @returns {Object<string, HTMLElement | Array<HTMLElement>>} 返回职位信息字典,键值为`DOM`或者`DOM`序列
- */
- function getInfoboxDict(ul) {
- const staffDict = {};
- const lis = ul.querySelectorAll(':scope > li');
- lis.forEach((li) => {
- const tip = li.querySelector('span.tip');
- if (!tip) return;
- let job = getJobFromTip(tip);
- if (li.classList.contains('sub_group')) {
- // 新的小组
- staffDict[job] = [li];
- } else if (li.classList.contains('sub_container')
- && li.hasAttribute('attr-info-group')) {
- // 整合进组
- job = li.getAttribute('attr-info-group');
- if (staffDict[job]) staffDict[job].push(li);
- else staffDict[job] = [li];
- } else {
- // 普通元素
- staffDict[job] = li;
- // 为了正确计算元素高度,需使其 display
- li.classList.remove('folded');
- refoldJob(li, tip);
- // li.folded 属性已经失效无需还原
- }
- });
- return staffDict;
- }
- /**
- * 遍历`infobox中的职位信息,
- * 为网页原有的`folded`类别添加`foldable`便签,用于实现切换,
- * 忽略属于`sub_group`的`sub_container`,
- * 并对职位信息进行二次折叠
- * @param {HTMLElement} ul - `infobox`
- */
- function scanInfobox(ul) {
- const lis = ul.querySelectorAll(':scope > li');
- let foldedJobs = [];
- let preUnfolded = null;
- lis.forEach(li => {
- // 获取 lastGroup
- if (li.classList.contains('sub_group')) {
- if (foldedJobs.length) dealFoldsList(foldedJobs, li, preUnfolded, false);
- preUnfolded = null;
- lastGroup = [li];
- } else if (li.classList.contains('sub_container') && li.hasAttribute('attr-info-group')) {
- lastGroup.push(li);
- }
- const tip = li.querySelector('span.tip');
- if (!tip) return;
- const job = getJobFromTip(tip);
- const flag = li.classList.contains('folded') && !li.hasAttribute('attr-info-group');
- // 为了正确计算元素高度,需先使其 display
- if (flag) li.classList.remove('folded');
- refoldJob(li, tip);
- /* 特殊用法:当 StaffMapList = "[]" 空缺设置,同时 SortEnableState = "partialDisable"
- * 将实行网页原有的职位顺序,同时禁止其折叠 */
- if (Store.get(Key.ENABLE_STATE_KEY) === SortEnableState.PARTIAL_ENABLED) return;
- if (flag) {
- // 被折叠
- if (!hasFolded) hasFolded = true;
- li.classList.add('folded', 'foldable');
- // 加入队列
- foldedJobs.push(job);
- } else {
- // 未被折叠
- if (foldedJobs.length) {
- dealFoldsList(foldedJobs, li, preUnfolded, li.classList.contains('refoldable'));
- }
- preUnfolded = li;
- }
- });
- // 尾部元素折叠的情形
- dealEndWithFold(ul, foldedJobs, preUnfolded);
- }
- /**
- * 处理有效的折叠队列。
- * 为未被折叠的当前元素或先前元素,添加该折叠队列的控制按钮。
- * @param {Array<string>} foldedJobs - 被折叠的职位名称序列
- * @param {HTMLElement} curUnfolded - 当前未被折叠的元素
- * @param {HTMLElement | null} preUnfolded - 先前的第一个未被折叠的元素
- * @param {boolean} position - `true`为先前元素添加,`false`为当前元素添加
- */
- function dealFoldsList(foldedJobs, curUnfolded, preUnfolded, position) {
- const aTitle = '+ ' + foldedJobs.join(',');
- if (position && preUnfolded) {
- addFoldsListButton(preUnfolded, aTitle, 'bottom');
- } else {
- addFoldsListButton(curUnfolded, aTitle, 'top');
- }
- foldedJobs.length = 0; // 使用 = [] 方法将无法引用返回
- }
- /**
- * 处理`infobox`以被折叠元素序列结尾的情景
- * @param {HTMLElement} ul - `infobox`
- * @param {Array<string>} foldedJobs - 被折叠的职位名称序列
- * @param {HTMLElement} preUnfolded - 先前的第一个未被折叠的元素
- */
- function dealEndWithFold(ul, foldedJobs, preUnfolded) {
- if (foldedJobs.length && preUnfolded) {
- const aTitle = '+ ' + foldedJobs.join(',');
- addFoldsListButton(preUnfolded, aTitle, 'bottom', 'end_with_fold');
- ul.classList.add('padding_bottom');
- endWithFold = true;
- }
- }
- /**
- * 添加折叠队列的控制按钮
- * @param {HTMLElement} li - 被添加按钮的元素
- * @param {string} aTitle - 超链接的标题,即被折叠的职位名称序列
- * @param {'top' | 'bottom'} position - 按钮的方位
- * @param {string | undefined} tip - 特殊类名标记
- */
- function addFoldsListButton(li, aTitle, position, tip) {
- const span = createElement('span', { class: `folds_list expand_${position}` });
- if (tip) span.classList.add(tip);
- const plusIcon = createElement('a', { class: 'staff_sorting_icon' });
- plusIcon.innerHTML = ICON.PLUS;
- plusIcon.title = aTitle;
- span.appendChild(plusIcon);
- li.appendChild(span);
- }
- /**
- * 对超出限制行数的职位信息进行二次折叠,并添加开关。
- * 实现动态不定摘要的类似于`summary`的功能。
- * 过滤`别名`等不定行高的`infobox`信息
- * @param {HTMLElement} li - 职位信息根节点
- * @param {HTMLElement} tip - 职位名称节点
- */
- function refoldJob(li, tip) {
- if (Store.get(Key.REFOLD_THRESHOLD_KEY) === Key.REFOLD_THRESHOLD_DISABLED) return;
- if (li.classList.contains('sub_container') || li.classList.contains('sub_group')) return;
- if (!JobStyle.compStyle) JobStyle.initialize(li);
- const lineCnt = getLineCnt(li);
- const refoldThr = Store.get(Key.REFOLD_THRESHOLD_KEY);
- if (lineCnt <= refoldThr) return;
- // 添加头部开关状态图标
- const prefIcon = createElement('i', { class: 'staff_sorting_icon' });
- prefIcon.innerHTML = ICON.TRIANGLE_RIGHT;
- /* 尝试使用<symbol><use>模板或直接使用JS构建实例的方法均失败...
- * 最终改为直接修改innerHTML */
- const switchPos = Store.get(Key.REFOLD_SWITCH_POSITION_KEY);
- if (switchPos == RefoldSwitchPosition.RIGHT) {
- updat###bElements(tip, prefIcon, 'append');
- } else if (switchPos == RefoldSwitchPosition.LEFT) {
- updat###bElements(tip, prefIcon, 'prepend');
- }
- tip.classList.add('switch');
- // 添加尾部折叠图标
- const suffIcon = createElement('i', { class: 'staff_sorting_icon' });
- const sideTip = createElement('span', {class: 'tip side'}, suffIcon);
- suffIcon.innerHTML = ICON.TRIANGLE_UP;
- li.appendChild(sideTip);
- // 记录被折叠的行数,由于 span{clear: right} 防止其换行,需先渲染并重新计算行数
- const refoldLine = getLineCnt(li) - refoldThr;
- // C.debug(getJobFromTip(tip), refoldLine);
- sideTipLineThr ??= getSideTipThr(); // 小于阈值的将被隐藏
- if (refoldLine >= sideTipLineThr) sideTip.dataset.refoldLine = refoldLine;
- // else delete sideTip.dataset.refoldLine;
- // 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入)
- li.classList.add('refoldable', 'refolded');
- // const nest = nestElementWithChildren(li, 'div', {class: 'refoldable refolded'});
- /* 尝试不修改 DOM 结构仅通过添加样式达到完备的折叠效果,
- * 难点在于处理溢出到 li.padding-bottom 区域的信息
- * 最终通过施加多层遮蔽效果实现,故不再需要内嵌一层新的 div 元素 */
- }
- /**
- * 为多个折叠类型按钮绑定开关事件,
- * 包含一次连续折叠、二次折叠。
- * 采用`事件委托`形式绑定事件 (事件冒泡机制)
- * @param {HTMLElement} ul - `infobox`
- */
- function addFoldButtonListener(ul) {
- /* 检查点击的元素是否是开关本身 span 或其子元素 icon
- * 使用 .closest('.cls') 替代 classList.contains('cls')
- * 使得子元素也能响应点击事件 */
- ul.addEventListener('click', (event) => {
- /** @type {HTMLElement} 被点击的目标 */
- const target = event.target;
- let button;
- // 1.一次连续折叠
- button = target.closest('.folds_list');
- if (button && ul.contains(button)) {
- // 1.1一次连续折叠的前部开关
- if (button.classList.contains('expand_top')) return onFoldsList(button, 'previous');
- // 1.2一次连续折叠的后部开关
- if (button.classList.contains('expand_bottom')) {
- if (button.classList.contains('end_with_fold')) {
- ul.classList.remove('padding_bottom');
- }
- return onFoldsList(button, 'next');
- }
- return;
- }
- // 2.二次折叠
- if (Store.get(Key.REFOLD_THRESHOLD_KEY) === 0) return;
- // 2.1首部二次折叠开关
- button = target.closest('.switch');
- if (button && ul.contains(button)) return onPrefRefold(button);
- // 2.2尾部二次折叠开关
- button = target.closest('.side');
- if (button && ul.contains(button)) return onSuffRefold(button);
- });
- /**
- * 一次连续折叠的前部开关
- * @param {HTMLElement} button
- * @param {'previous' | 'next'} direction
- */
- function onFoldsList(button, direction) {
- const li = button.parentElement;
- const foldsList = getSiblings(li, direction, isFoldable);
- foldsList.forEach(removeFolded);
- button.classList.add('hidden');
- }
- function isFoldable(li) { return li.classList.contains('foldable'); }
- function removeFolded(li) { li.classList.remove('folded'); }
- /**
- * 首部折叠二次开关
- * @param {HTMLElement} prefTip
- */
- function onPrefRefold(prefTip) {
- // 职位名称或开关状态图标被点击了
- const li = prefTip.parentElement;
- if (li.classList.contains('refolded')) {
- li.classList.remove('refolded');
- prefTip.querySelector('i').innerHTML = ICON.TRIANGLE_DOWN;
- } else {
- li.classList.add('refolded');
- prefTip.querySelector('i').innerHTML = ICON.TRIANGLE_RIGHT;
- }
- }
- /**
- * 尾部折叠二次开关
- * @param {HTMLElement} prefTip
- */
- function onSuffRefold(suffTip) {
- const li = suffTip.parentElement;
- // 滚轮将自动上移被折叠的距离,以确保折叠后的内容不会让用户迷失上下文
- const rectBefore = li.getBoundingClientRect();
- // 更改折叠状态
- li.classList.add('refolded');
- // 等待下一帧,让浏览器完成渲染
- requestAnimationFrame(() => {
- const rectAfter = li.getBoundingClientRect();
- /* 尝试通过 suffTip.dataset.refoldLine 计算高度变化
- * 会与理想值有 ~0.5px 的随机偏差,故改用获取元素窗口的高度变化 */
- const distance = rectAfter.top - rectBefore.top + rectAfter.height - rectBefore.height;
- // C.debug( `\n` +
- // `heightBefore: \t${rectBefore.height},\nheightAfter: \t${rectAfter.height},\n` +
- // `topAfter: \t${rectAfter.top},\ntopBefore: \t${rectBefore.top},\ndistance: \t${distance},\n` +
- // `byRefoldLine: \t${suffTip.dataset.refoldLine * JobStyle.lineHeight}`
- // );
- /* 需考虑 li.top 的前后变化,且不要使用 scrollTo
- * 因为部分浏览器对于超出视口的 li 元素进行折叠时,会自主进行防迷失优化,
- * 此时 distance 的计算机结果将会是 0 */
- window.scrollBy({ top: distance, behavior: 'instant' });
- });
- // 修改首部开关的图标
- li.firstChild.querySelector('i').innerHTML = ICON.TRIANGLE_RIGHT;
- }
- /* 在 mousedown 阶段阻止用户拖动或双击时的默认选中行为。
- * 由于 span.switch 本质仍然是内容段落的一部分,
- * 不通过 user-select: none 这钟粗暴的方法禁止用户的一切选中行为
- * 而是采用温和的方法阻止部分情形下对该区域的选中行为 */
- ul.addEventListener('mousedown', (event) => {
- if (event.target.closest('.switch')) event.preventDefault();
- });
- }
- /**
- * 处理最后一组`sub_group`,若为`infobox`末尾元素,则为其添加标签。
- * 以优化样式,当其非末尾元素时,添加边界以区分`sub_container > li`与普通`li`
- * @param {HTMLElement} ul - `infobox`
- */
- function dealLastGroup(ul) {
- if (!lastGroup || ul.lastElementChild !== lastGroup[lastGroup.length - 1]) return;
- lastGroup.forEach((li) => {
- if (li.classList.contains('sub_container'))
- li.classList.add('last_group');
- })
- }
- /**
- * 获取固定行高`#infobox.li`元素显示的行数
- * 经测试,职员信息除了`8px`的`padding`还有`<1px`的`border`因为不影响行数计算忽略
- */
- function getLineCnt(el, padding = 8, border = 0) {
- const height = el.getBoundingClientRect().height - padding - border;
- return ~~(height / JobStyle.lineHeight);
- }
- /**
- * 根据页面视口高度,计算尾部折叠图标的激活行数阈值
- * 对于二次折叠区域较小,不予显示
- */
- function getSideTipThr() {
- const threshold = ~~(getViewportHeight() / JobStyle.lineHeight * sideTipRate);
- C.log(`${SCRIPT_NAME}:`, {'sideTipLineThreshold': threshold});
- return threshold;
- }
- /**
- * 将原本存在的`更多制作人员`一次性按钮,转绑新事件,并改为永久性左右双开关。
- * 使用网页原有的`folded`元素类别,实现对立于`StaffSorter`功能。
- * 通过`hasFolded`判断,若需要则进行添加,不需要则删除。
- * 双按钮中:
- * 1. 左开关拥有切换功能,用于常规场景;
- * 2. 右开关不能进行切换,用于辅助连续折叠功能。
- * @param {HTMLElement} ul - `infobox`
- <div class="infobox_expand">
- <a>更多制作人员 +</a>
- <a>更多制作人员 -</a>
- </div>
- */
- function changeExpandToDoubleButton(ul) {
- const buttonName = '更多制作人员';
- const buttonValue = { on: `${buttonName} +`, off: `${buttonName} -` };
- let buttonCntr = document.querySelector('#infobox + .infobox_expand'); // 无法实现 :scope +
- let expandLink;
- if (!hasFolded) {
- // 无必要,不进行事件绑定与可能的添加,并将原有的开关隐藏
- if (buttonCntr) {
- buttonCntr.style.display = 'none';
- C.log(`${SCRIPT_NAME} - 将原有的 '${buttonName}' 隐藏`);
- } return;
- }
- // 检查原展开按钮
- if (!buttonCntr) {
- expandLink = createElement('a', null, buttonValue.on);
- buttonCntr = createElement('div', { class: 'infobox_expand' }, expandLink);
- ul.parentElement.appendChild(buttonCntr);
- C.log(`${SCRIPT_NAME}:添加原不存在的 '${buttonName}' 按钮`);
- } else {
- expandLink = buttonCntr.firstChild;
- expandLink.removeAttribute('href');
- }
- // 添加折叠按钮
- const collapseLink = createElement('a', null, buttonValue.off);
- buttonCntr.appendChild(collapseLink);
- // 展开事件监听 (可能继承自网页原有的按钮)
- expandLink.addEventListener('click', function (event) {
- event.stopImmediatePropagation(); // 阻止其他事件的触发
- const foldedLis = ul.querySelectorAll('.foldable');
- foldedLis.forEach(li => li.classList.remove('folded'));
- // 隐藏连续折叠前后的展开按钮
- ul.querySelectorAll('span.folds_list').forEach(button => button.classList.add('hidden'));
- if (endWithFold) ul.classList.remove('padding_bottom');
- // 切换开关位置
- if (expandLink === buttonCntr.firstChild) buttonCntr.appendChild(expandLink);
- }, { capture: true }); // 使事件处理函数在捕获阶段运行
- // 折叠事件监听
- collapseLink.addEventListener('click', () => {
- const foldedLis = ul.querySelectorAll('.foldable');
- foldedLis.forEach(li => li.classList.add('folded'));
- // 显示连续折叠前后的展开按钮
- ul.querySelectorAll('span.folds_list').forEach(button => button.classList.remove('hidden'));
- if (endWithFold) ul.classList.add('padding_bottom');
- // 切换开关位置
- if (collapseLink === buttonCntr.firstChild) buttonCntr.appendChild(collapseLink);
- });
- }
- /**
- * 创建用户设置`UI`界面
- * 仿照`#columnA`中的同类元素进行构建,使用原有的结构与样式
- <table class="settings">
- <tbody>
- <tr>
- <td>
- <h2 class="subtitle">条目职位排序 · 默认折叠</h2>
- <div class="right_inline"><p class="tip_j">默认设置版本号</p></div>
- </td>
- </tr>
- <!-- 此处添加子模块 -->
- </tbody>
- </table>
- */
- function buildSettingUI(mainStyle) {
- const dataVersion = createElement('p', { class: 'tip_j' }, `数据 v${CURRENT_DATA_VERSION}`);
- const mainTitle = createElement('tr', null, [
- createElement('td', { class: 'maintitle' }, [
- createElement('h2', { class: 'subtitle' }, '条目职位排序 · 默认折叠'),
- createElement('div', {class: 'right_inline'}, dataVersion)
- ])
- ]);
- const lineLimitBlock = buildLineLimitBlock();
- const inheritPreMatchBlock = buildInheritPreMatchBlock();
- const refoldSwitchPosBlock = buildRefoldSwitchPosBlock();
- const subjectBlocks = SubjectType.getAll(true).map(sub => buildSubjectBlock(sub));
- const ui = createElement('div', mainStyle, [
- createElement('table', { class: 'settings' }, [
- createElement('tbody', null, [
- mainTitle,
- lineLimitBlock,
- refoldSwitchPosBlock,
- inheritPreMatchBlock,
- ...subjectBlocks,
- ])
- ])
- ]);
- // 隐藏功能,双击版本号触发版本数据强制刷新 (主要作为调试工具)
- dataVersion.addEventListener('dblclick', () => {
- Store.remove(Key.DATA_VERSION);
- location.reload();
- });
- return ui;
- }
- /**
- * 创建职位信息二次折叠的行高限制设置界面
- <tr class="line_limit_block">
- <td>
- <h2 class="subtitle">职位信息高度 限制</h2>
- <div class="right_inline">
- <fieldset class="num_input_cntr">...</fieldset>
- <div class="toggle">...</div>
- </div>
- </td>
- </tr>
- */
- function buildLineLimitBlock() {
- const subTitle = createElement('h2', { class: 'subtitle' }, '职位信息高度 限制');
- // 搭建滑动开关
- const [toggle, toggleCntr] = buildToggleSlider('refold_switch');
- // 搭建整数步进输入器
- const intInput = new IntInputStepper('refold_threshold_input', '行数');
- intInput.build();
- // 搭建外部框架
- const block = createElement('tr', { class: 'line_limit_block' }, [
- createElement('td', null, [
- subTitle,
- createElement('div', { class: 'right_inline' }, [
- intInput.root, toggleCntr
- ])
- ])
- ]);
- // 初始化 (此处无需关心Key._subType)
- toggle.checked = Store.get(Key.REFOLD_THRESHOLD_KEY) !== Key.REFOLD_THRESHOLD_DISABLED;
- intInput.num = Store.get(Key.REFOLD_THRESHOLD_KEY);
- if (!toggle.checked) {
- intInput.display = false;
- block.classList.add('turn_off');
- }
- // 绑定事件
- function setRefloadThreshold(num) {
- // 与缓存进行对比,防止无效写入
- if (num === Store.get(Key.REFOLD_THRESHOLD_KEY)) return;
- Store.set(Key.REFOLD_THRESHOLD_KEY, num, null, true);
- }
- toggle.addEventListener('click', () => {
- if (toggle.checked) {
- intInput.display = true;
- block.classList.remove('turn_off');
- setRefloadThreshold(intInput.num); // 使用 DOM 中可能的暂存数据
- } else {
- intInput.display = false;
- block.classList.add('turn_off');
- setRefloadThreshold(Key.REFOLD_THRESHOLD_DISABLED);
- }
- });
- intInput.onNumChange = setRefloadThreshold;
- return block;
- }
- /**
- * 创建二次折叠的开关位置设置界面
- <tr class="refold_switch_pos_block">
- <td>
- <h2 class="subtitle">· 折叠开关图标的位置</h2>
- <div class="right_inline">
- <p class="tip_j"><!-- message --></p>
- <div class="tri_state_selector">...</div>
- </div>
- </td>
- </tr>
- */
- function buildRefoldSwitchPosBlock() {
- const subTitle = createElement('h2', { class: 'subtitle' }, '· 折叠开关图标的位置');
- // 搭建滑动开关
- const selector = new TriStateSlider('refold_switch_position', RefoldSwitchPosition.getAll());
- const selectorMsgBox = createElement('p', { class: 'tip_j' });
- const selectorField = createElement('div', {class: 'right_inline'}, [
- selectorMsgBox, selector.root
- ]);
- selector.build();
- // 搭建外部框架
- const block = createElement('tr', { class: 'refold_switch_pos_block' }, [
- createElement('td', null, [
- subTitle, selectorField
- ])
- ]);
- // 初始化并绑定事件
- selector.state = Store.get(Key.REFOLD_SWITCH_POSITION_KEY);
- setSelectorMsgBox(selector.state);
- selector.onStateChange = (newState) => {
- setSelectorMsgBox(newState);
- Store.set(Key.REFOLD_SWITCH_POSITION_KEY, newState, null, true);
- };
- return block;
- function setSelectorMsgBox(state) {
- switch (state) {
- case RefoldSwitchPosition.NONE:
- setMessage(selectorMsgBox, '隐藏开关图标'); break;
- case RefoldSwitchPosition.LEFT:
- setMessage(selectorMsgBox, '位于职位名称左边'); break;
- case RefoldSwitchPosition.RIGHT:
- setMessage(selectorMsgBox, '位于职位名称右边'); break;
- }
- }
- }
- /**
- * 创建继承历史匹配记录设置界面
- <tr class="inherit_prematch_block">
- <td>
- <h2 class="subtitle">继承历史匹配记录</h2>
- <div class="right_inline">
- <p class="tip_j"><!-- message --></p>
- <div class="toggle">...</div>
- </div>
- </td>
- </tr>
- */
- function buildInheritPreMatchBlock() {
- const subTitle = createElement('h2', { class: 'subtitle' }, '继承历史匹配记录');
- // 搭建滑动开关
- const [toggle, toggleCntr] = buildToggleSlider('inherit_switch');
- const toggleMsgBox = createElement('p', { class: 'tip_j' });
- // 搭建外部框架
- const block = createElement('tr', { class: 'inherit_prematch_block' }, [
- createElement('td', null, [
- subTitle,
- createElement('div', {class: 'right_inline'}, [
- toggleMsgBox, toggleCntr
- ])
- ])
- ]);
- // 初始化并绑定事件
- toggle.checked = Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY);
- toggleOnClick();
- toggle.addEventListener('click', toggleOnClick);
- return block;
- function toggleOnClick() {
- if (toggle.checked) {
- setMessage(toggleMsgBox, '设置变更时将继承');
- Store.set(Key.INHERIT_PREVIOUS_MATCHES_KEY, true, null, true);
- } else {
- setMessage(toggleMsgBox, '设置变更时不继承');
- Store.set(Key.INHERIT_PREVIOUS_MATCHES_KEY, false, null, true);
- }
- }
- }
- /**
- * 创建`staffMapList`文本内容编辑界面
- * 对于`textarea`,`button`等控件仍然使用原有的结构与样式
- <tr class="subject_staff_block">
- <td>
- <details open="">
- <summary>
- <h2 class="subtitle"><!-- subject type --></h2>
- <div class="right_inline">
- <p class="tip_j"><!-- message --></p>
- <div class="tri_state_selector">...</div>
- </div>
- </summary>
- <div class="staffMapList_editor">...</div>
- </details>
- </td>
- </tr>
- */
- function buildSubjectBlock(subTypeObj) {
- const subType = subTypeObj.en;
- // 设置信息,editor 与 matchLog 公有
- const staffMapList = new StaffMapList(subType);
- // 搭建标题
- const subTitle = createElement('h2', { class: 'subtitle' });
- // 搭建滑动开关
- const selector = new TriStateSlider(`${subType}_subject_enable`, SortEnableState.getAll());
- const selectorMsgBox = createElement('p', { class: 'tip_j' });
- const selectorField = createElement('div', {class: 'right_inline hidden'}, [
- selectorMsgBox, selector.root
- ]);
- selector.build();
- // 定义编辑器,暂不构建
- const editor = new StaffMapListEditor(subType, staffMapList);
- // 搭建展开容器
- const detail = createElement('details', null, [
- createElement('summary', null, [
- subTitle, selectorField
- ]),
- editor.root,
- ])
- // 搭建外部结构
- const block = createElement('tr', {class: 'subject_staff_block'}, [
- createElement('td', null, detail)
- ]);
- // 初始化
- subTitle.textContent = `${subTypeObj.zh}条目`;
- detail.open = Store.get(Key.BLOCK_OPEN_KEY, subType);
- selector.state = Store.get(Key.ENABLE_STATE_KEY, subType);
- setSelectorMsgBox(selector.state);
- blockOnOpen();
- // 绑定事件
- selector.onStateChange = (newState) => {
- setSelectorMsgBox(newState);
- Store.set(Key.ENABLE_STATE_KEY, newState, subType, true);
- };
- detail.addEventListener('toggle', blockOnOpen); // 无需上下文环境
- return block;
- function setSelectorMsgBox(state) {
- switch (state) {
- case SortEnableState.ALL_DISABLED:
- setMessage(selectorMsgBox, '禁用设置,但仍可编辑保存'); break;
- case SortEnableState.PARTIAL_ENABLED:
- setMessage(selectorMsgBox, '仅启用排序,禁用折叠'); break;
- case SortEnableState.ALL_ENABLED:
- setMessage(selectorMsgBox, '启用自定义 / 默认设置'); break;
- }
- }
- function blockOnOpen() {
- if (detail.open) {
- // 在第一次展开时构建
- if (!editor.built) editor.build();
- selectorField.classList.remove('hidden');
- } else {
- selectorField.classList.add('hidden');
- }
- Store.set(Key.BLOCK_OPEN_KEY, detail.open, subType, true);
- }
- }
- /**
- * `staffMapList`编辑器,并对数据进行自主管理
- <div class="staffMapList_editor">
- <div class="markItUp">
- <textarea class="quick markItUpEditor hasEditor codeHighlight" name="staff_map_list">
- <!-- staffMapListText -->
- </textarea>
- </div>
- <div>
- <input class="inputBtn" type="submit" name="submit_context" value="保存">
- <input class="inputBtn" type="submit" name="reset_context" value="恢复默认">
- <p class="tip_j" style="display: inline;">
- <!-- message -->
- </p>
- </div>
- <!-- margin-right 为移动端预留的 mainpage 滑动空间 -->
- </div>
- */
- class StaffMapListEditor {
- static _editorCls = 'staffMapList_editor';
- /**
- * @param {string} subType - 条目类型
- * @param {StaffMapList} staffMapList
- */
- constructor(subType, staffMapList) {
- this.subType = subType;
- this.staffMapList = staffMapList;
- this.root = createElement('div', { class: StaffMapListEditor._editorCls });
- this.textArea = null; // 输入文本框
- this.resetBtn = null; // 提交按钮
- this.submitBtn = null; // 重置按钮
- this.editorMsgBox = null; // 简易提示框
- this.isDefault = null; // 标记是否为默认数据
- this.hasInputed = null; // 文本框内容是否被改变且未被保存
- this.preInheritState = Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY);
- this.built = false; // 标记是否已经初始化
- }
- build() {
- if (this.built) return; // 防止重复构建
- // 构建元素结构
- this.textArea = createElement('textarea', {
- class: 'quick markItUpEditor hasEditor codeHighlight', name: 'staff_map_list', id: `${this.subType}_staff_map_list`
- });
- this.submitBtn = createElement('input', {
- class: 'inputBtn', type: 'submit', name: 'submit_context', value: '保存'
- });
- this.resetBtn = createElement('input', {
- class: 'inputBtn', type: 'submit', name: 'reset_context', value: '恢复默认'
- });
- this.editorMsgBox = createElement('p', { class: 'tip_j'});
- this.root.append(
- createElement('div', { class: 'markItUp' }, this.textArea),
- createElement('div', null, [this.submitBtn, this.resetBtn, this.editorMsgBox])
- );
- // 初始化状态
- const text = this.staffMapList.formatToText(false);
- this.textArea.value = text;
- this.isDefault = this.staffMapList.isDefault;
- this.hasInputed = false;
- if (text.trim() === '') setMessage(this.editorMsgBox, '现为设置空缺', 0); // 网页实行原有的职位顺序与折叠
- else if (this.isDefault) setMessage(this.editorMsgBox, '现为默认设置', 0); // 初始化时,提醒用户已为默认设置
- else setMessage(this.editorMsgBox, '现为自定义设置', 0);
- // 绑定事件
- this.textArea.addEventListener('input', this._onInput.bind(this));
- this.resetBtn.addEventListener('click', this._onReset.bind(this));
- this.submitBtn.addEventListener('click', this._onSubmit.bind(this));
- this.built = true;
- }
- _onInput() {
- if (this.isDefault) this.isDefault = false;
- if (!this.hasInputed) this.hasInputed = true;
- // C.debug("IS INPUTTING");
- }
- async _onReset() {
- if (this.isDefault) return setMessage(this.editorMsgBox, '已为默认内容');
- await trySetText(
- this.textArea, this.editorMsgBox, this.staffMapList.formatToText(true),
- '已恢复默认内容', false
- );
- // 需进行同步等待,由于 setText 可能会触发 input 事件
- this.isDefault = true;
- this.hasInputed = false;
- }
- async _onSubmit() {
- // 判断是否为重置后未对默认内容进行修改
- if (this.isDefault) {
- if (this.staffMapList.isDefault && !this._onInheritStateChange()) {
- setMessage(this.editorMsgBox, '已为默认设置');
- } else {
- // 由自定义改为默认设置,或继承状态发生改变
- this.staffMapList.resetData();
- setMessage(this.editorMsgBox, '保存成功!恢复默认设置');
- }
- this.hasInputed = false;
- return;
- }
- if (!this.hasInputed && !this._onInheritStateChange()) {
- setMessage(this.editorMsgBox, '未作修改');
- return;
- }
- const [modifiedData, isModified, curCursorPos] = StaffMapListEditor.modifyText(this.textArea);
- // 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除
- const savedData = `[${modifiedData}]`;
- const parsedData = RegexInJSON.parse(savedData);
- // 数据解析失败
- if (!parsedData) return setMessage(this.editorMsgBox, '保存失败!格式存在错误');
- // 保存数据
- this.staffMapList.saveData(savedData, parsedData);
- // 页面显示
- if (modifiedData.trim() === "") setMessage(this.editorMsgBox, '保存成功!空缺设置');
- else if (isModified) {
- await trySetText(
- this.textArea, this.editorMsgBox, modifiedData,
- '保存成功!并自动纠错', true, curCursorPos
- );
- } else setMessage(this.editorMsgBox, '保存成功!');
- this.hasInputed = false;
- }
- _onInheritStateChange() {
- const curInheritState = Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY);
- if (this.preInheritState !== curInheritState) {
- this.preInheritState = curInheritState;
- return true;
- } else return false;
- }
- /**
- * 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足`JSON`格式
- * 并计算文本修改后,光标的适宜位置
- * 已基本兼容`JavaScript`格式的文本数据,实现格式转化
- * `group2`与`group4`致使正则表达式中不允许出现`/'"`三种字符
- */
- static modifyText(textArea) {
- const preCursorPos = getTextAreaPos(textArea).cursorPos;
- let curCursorPos = preCursorPos;
- let flags = new Array(6).fill(false);
- const rslt = textArea.value.replace(
- /(,\s*)+(?=]|$)|(?<=\[|^)(\s*,)+|(,\s*)+(?=,)|(['‘’“”])|(?<!['"‘“])(\/[^/'"‘’“”]+\/[gimsuy]*)(?!['"’”])|([,、])/g,
- (match, g1, g2, g3, g4, g5, g6, offset) => {
- isTriggered(0, '删除序列末尾元素后的 `,` 逗号', g1);
- isTriggered(1, '删除序列首位元素前的 `,` 逗号', g2);
- isTriggered(2, '删除连续重复的 `,` 逗号', g3);
- isTriggered(3, '将非半角单引号的引号替换', g4);
- isTriggered(4, '将正则表达式以双引号包裹', g5);
- isTriggered(5, '将全角逗号顿号变为半角逗号', g6);
- if (booleanOr(g1, g2, g3)) {
- let diff = preCursorPos - offset;
- if (diff > 0) curCursorPos -= Math.min(diff, match.length);
- return '';
- }
- if (g4) return '"';
- if (g5) {
- if (offset < preCursorPos && preCursorPos < offset + match.length) curCursorPos += 1;
- else if (preCursorPos >= offset + match.length) curCursorPos += 2;
- return `"${match}"`;
- }
- if (g6) return ',';
- return match;
- });
- return [rslt, booleanOr(...flags), curCursorPos];
- function isTriggered(index, msg, ...groups) {
- if (!flags[index] && booleanOr(...groups)) {
- C.log(`${SCRIPT_NAME}:触发自动纠错 - ${msg}`);
- flags[index] = true;
- }
- }
- function booleanOr(...values) {
- return values.reduce((acc, val) => acc || val, false);
- }
- }
- }
- /**
- * 整数步进输入器,
- * 不使用`input.type: 'number'`而是自我搭建相关控制
- <fieldset class="num_input_cntr">
- <span class="text">行数</span>
- <input class="inputtext input_num" type="text" maxlength="2">
- <div class="num_ctrs">
- <div><svg>...</svg></div>
- <div><svg>...</svg></div>
- </div>
- </fieldset>
- */
- class IntInputStepper {
- static default = Key.REFOLD_THRESHOLD_DEFAULT;
- // 所用样式的类名
- static _fieldCls = 'num_input_cntr';
- static _inputCls = 'inputtext input_num';
- static _ctrsCls = 'num_ctrs';
- /**
- * @type {(newNum: int) => void | null}
- * 回调函数,当数据变化时被调用
- */
- onNumChange = null;
- constructor(id, labelName, initNum = IntInputStepper.default) {
- this.root = createElement('fieldset', { class: IntInputStepper._fieldCls });
- this.numInput = null;
- this.incBtn = null;
- this.decBtn = null;
- this.id = id;
- this.labelName = labelName;
- this.initNum = initNum;
- this.minNum = {int: 1, str: '1'};
- this.maxDigits = 2;
- }
- set num(num) {
- if(!num) num = IntInputStepper.default;
- this.numInput.value = String(num);
- }
- get num() {
- return Number(this.numInput.value);
- }
- /** @param {boolean} flag */
- set display(flag) {
- this.root.style.display = flag ? 'flex' : 'none';
- }
- build() {
- // 构建元素结构
- this.numInput = createElement('input', {
- class: IntInputStepper._inputCls, type: 'text', maxlength: this.maxDigits, id: this.id
- });
- this.incBtn = createElement('div', { name: 'inc_btn' });
- this.decBtn = createElement('div', { name: 'dec_btn' });
- this.incBtn.innerHTML = ICON.TRIANGLE_UP;
- this.decBtn.innerHTML = ICON.TRIANGLE_DOWN;
- this.root.append(
- createElement('span', { class: 'text' }, this.labelName),
- this.numInput,
- createElement('div', { class: IntInputStepper._ctrsCls }, [this.incBtn, this.decBtn])
- );
- // 初始化状态并绑定事件
- this.num = this.initNum;
- this.numInput.addEventListener('input', this._onInput.bind(this));
- this.numInput.addEventListener('keydown', this._onKeyDown.bind(this));
- this.incBtn.addEventListener('click', this._onInc.bind(this));
- this.decBtn.addEventListener('click', this._onDec.bind(this));
- }
- /** 限制输入为正整数 */
- _onInput() {
- let value = this.numInput.value.replace(/[^0-9]/g, '');
- if (value === '' || parseInt(value) === 0) value = this.minNum.str;
- this.numInput.value = value;
- if (this.onNumChange) this.onNumChange(this.num);
- }
- /** 限制键盘输入行为,禁止非数字键输入 */
- _onKeyDown(event) {
- if (!/^[0-9]$/.test(event.key) && event.key !== 'Backspace'
- && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')
- event.preventDefault();
- if (event.key === 'ArrowUp') this._onInc();
- else if (event.key === 'ArrowDown') this._onDec();
- }
- /** 步增,可按钮或键盘触发 */
- _onInc() {
- let value = this.num;
- this.num = value + 1;
- if (this.onNumChange) this.onNumChange(this.num);
- }
- /** 步减,可按钮或键盘触发 */
- _onDec() {
- let value = this.num;
- if (value > this.minNum.int) this.num = value - 1;
- if (this.onNumChange) this.onNumChange(this.num);
- }
- }
- /**
- * 三态滑动选择器
- <div class="tri_state_selector">
- <input type="radio" name="_subject_enable_group" value="allDisable" class="radio_input">
- <label class="radio_label"></label>
- <input type="radio" name="_subject_enable_group" value="partialEnable" class="radio_input">
- <label class="radio_label"></label>
- <input type="radio" name="_subject_enable_group" value="allEnable" class="radio_input">
- <label class="radio_label"></label>
- <div class="select_slider">
- <div class="select_indicator"></div>
- </div>
- </div>
- */
- class TriStateSlider {
- // 所用样式的类名
- static _selectorCls = 'tri_state_selector';
- static _radioCls = 'radio_input';
- static _labelCls = 'radio_label';
- static _sliderCls = 'select_slider';
- static _indicatorCls = 'select_indicator';
- /** 待选状态 */
- states = [
- "0", // 灰
- "1", // 红
- "2", // 蓝
- ];
- /**
- * @type {(newState: string) => void | null}
- * 回调函数,当状态变化时被调用
- */
- onStateChange = null;
- constructor(idPref, states) {
- this.root = createElement('div', { class: TriStateSlider._selectorCls });
- this.radios = {};
- this.idPref = idPref;
- this.states = states.slice(0, 3);
- this.initState = this.states[2];
- this._stateHis = {pre: null, pre2: null};
- }
- set state(state) {
- if (!state || !this.states.includes(state))
- state = this.states[2];
- this.initState = state;
- this._initStateHis();
- this.radios[state].checked = true;
- }
- get state() {
- for (const [state, radio] of Object.entries(this.radios)) {
- if (radio.checked) return state;
- }
- return this.initState;
- }
- /**
- * 构造`DOM`树,并绑定事件
- */
- build() {
- // 构建单选格,radio 本体将通过样式隐藏
- this.states.forEach((state) => {
- const radioId = `${this.idPref}_${state}`;
- const radio = createElement('input', {
- type: 'radio', name: `${this.idPref}_group`, id: radioId,
- value: state, class: TriStateSlider._radioCls
- });
- const label = createElement('label', { htmlFor: radioId, class: TriStateSlider._labelCls });
- this.radios[state] = radio;
- this.root.append(radio, label);
- });
- // 构建滑动外观
- this.root.append(
- createElement('div', { class: TriStateSlider._sliderCls },
- createElement('div', { class: TriStateSlider._indicatorCls })
- ));
- // 初始化状态并绑定事件
- this.radios[this.initState].checked = true;
- // 1) 箭头函数每次事件触发时,都会创建一个新的匿名函数,影响性能
- // this.selector.addEventListener('click', (event) => this._onClick(event));
- // 2) 事件监听器的回调函数本身会改变 this,使得它从指向类的实例对象,变为指向事件触发的元素
- // this.selector.addEventListener('click', this._onClick);
- // 3) 使用绑定后的函数
- this.root.addEventListener('click', this._onClick.bind(this));
- }
- _initStateHis() {
- this._stateHis.pre = this.initState;
- // 设定历史状态,使得无需在 _onClick 为重复点击初始状态单独处理
- this._stateHis.pre2 = this.initState === this.states[1]
- ? this.states[2] : this.states[1]; // ((1,3) 2)->(2 3)
- }
- /**
- * 采用事件委托的形式处理点击事件,
- * 将原本的`radio`操作体验处理为`ToggleSlider`手感
- */
- _onClick(event) {
- if (!event.target.classList.contains('radio_input')) return;
- let curState = event.target.value;
- // 现在与过去互异,正常不处理;现在与过去的过去互异,模拟 Toggle
- if (curState === this._stateHis.pre && curState !== this._stateHis.pre2) {
- this.radios[this._stateHis.pre2].checked = true;
- curState = this._stateHis.pre2;
- }
- this._stateHis.pre2 = this._stateHis.pre;
- this._stateHis.pre = curState;
- // 使用回调函数通知外部
- if (this.onStateChange) this.onStateChange(curState);
- }
- }
- /**
- * 创建一个滑动开关
- * @param {string} sliderId - 开关的`ID`
- * @returns {[HTMLElement, HTMLElement]} 返回`开关`与`开关容器`构成的数组
- <div class="toggle">
- <input class="toggle_input" type="checkbox" id="refold_switch">
- <label class="toggle_slider" for="refold_switch"></label>
- </div>
- */
- function buildToggleSlider(sliderId) {
- const toggle = createElement('input', { class: 'toggle_input', type: 'checkbox', id: sliderId });
- const toggleCntr = createElement('div', { class: 'toggle' },
- [toggle, createElement('label', { class: 'toggle_slider', htmlFor: sliderId })]
- );
- return [toggle, toggleCntr];
- }
- /**
- * 优先尝试使用`execCommand`方法改写文本框,使得改写前的用户历史记录不被浏览器清除
- * (虽然`execCommand`方法已被弃用...但仍然是实现该功能最便捷的途径)
- */
- async function trySetText(textArea, msgBox, text, msg, isRestore, setCursorPos = null, transTime = 100) {
- let {scrollVert, cursorPos} = getTextAreaPos(textArea);
- try {
- setMessage(msgBox);
- await clearAndSetTextarea(textArea, text, transTime);
- setMessage(msgBox, `${msg},可快捷键撤销`, 0);
- } catch {
- textArea.value = '';
- await delay(transTime);
- textArea.value = text;
- setMessage(msgBox, msg, 0);
- C.log(`${SCRIPT_NAME}:浏览器不支持 execCommand 方法,改为直接重置文本框,将无法通过快捷键撤销重置`)
- }
- if (isRestore) {
- setCursorPos ??= cursorPos; // 可以使用外部计算获取的光标位置
- restorePos();
- }
- /**
- * 恢复滚动位置和光标位置
- */
- function restorePos() {
- const currentTextLen = textArea.value.length;
- if (setCursorPos > currentTextLen) setCursorPos = currentTextLen;
- textArea.scrollTop = Math.min(scrollVert, textArea.scrollHeight);
- // textArea.scrollLeft = Math.min(scrollHoriz, textArea.scrollWidth - textArea.clientWidth);
- textArea.setSelectionRange(setCursorPos, setCursorPos);
- }
- }
- /**
- * 获取文本框的滚动位置和光标位置
- */
- function getTextAreaPos(textArea) {
- return {
- scrollVert: textArea.scrollTop,
- scrollHoriz: textArea.scrollLeft,
- cursorPos: textArea.selectionStart
- };
- }
- async function clearAndSetTextarea(textarea, newText, timeout = 100) {
- textarea.focus();
- // 全选文本框内容并删除
- textarea.setSelectionRange(0, textarea.value.length);
- document.execCommand('delete');
- // 延迟一段时间后,插入新的内容
- await delay(timeout);
- document.execCommand('insertText', false, newText);
- }
- async function setMessage(container, message, timeout = 100) {
- container.style.display = 'none';
- if (!message) return; // 无信息输入,则隐藏
- // 隐藏一段时间后,展现新内容
- if (timeout) await delay(timeout);
- container.textContent = message;
- container.style.display = 'inline';
- }
- /**
- * 获取当前页面的视口高度
- */
- function getViewportHeight() {
- return document.documentElement.clientHeight || document.body.clientHeight;
- }
- /**
- * 获取该元素前后所有连续的符合要求的兄弟节点
- * @param {HTMLElement} element - 元素
- * @param {'both' | 'previous' | 'next'} direction - 捕获的方向
- * @param {(el: HTMLElement) => boolean} [condition=() => true]
- * - 兄弟节点的条件要求,一旦结果为否将终止该方向的捕获
- * @returns {Array<HTMLElement>}
- */
- function getSiblings(element, direction, condition = () => true) {
- let siblings = [];
- if (direction === 'both' || direction === 'previous') {
- let sibling = element.previousElementSibling;
- while (sibling && condition(sibling)) {
- siblings.push(sibling);
- sibling = sibling.previousElementSibling;
- }
- }
- if (direction === 'both' || direction === 'next') {
- let sibling = element.nextElementSibling;
- while (sibling && condition(sibling)) {
- siblings.push(sibling);
- sibling = sibling.nextElementSibling;
- }
- }
- return siblings;
- }
- /**
- * 创建元素实例
- * @param {string} tagName - 类名
- * @param {Object | undefined} options - 属性
- * @param {Array<HTMLElement | string> | undefined} subElements - 子元素
- * @param {Object<string, Function> | undefined} eventHandlers - 绑定的事件
- */
- function createElement(tagName, options, subElements, eventHandlers) {
- const element = document.createElement(tagName);
- if (options) {
- for (const opt of Object.keys(options)) {
- if (opt === 'class') element.className = options[opt];
- else if (['maxlength', 'open'].includes(opt)) {
- element.setAttribute(opt, options[opt]);
- } else if (opt === 'dataset' || opt === 'style') {
- for (const key of Object.keys(options[opt])) {
- element[opt][key] = options[opt][key];
- }
- } else element[opt] = options[opt];
- }
- }
- if (subElements) updat###bElements(element, subElements);
- if (eventHandlers) {
- for (const e of Object.keys(eventHandlers)) {
- element.addEventListener(e, eventHandlers[e]);
- }
- }
- return element;
- }
- /**
- * 更新子元素的内容
- * @param {HTMLElement} parent - 父元素
- * @param {Array<HTMLElement | string> | HTMLElement | string | undefined} subElements - 要插入的子元素
- * @param {'append' | 'prepend' | 'replace'} [actionType='append'] - 操作类型,可以是以下之一:
- * - `prepend` 将元素插入到父元素的首位
- * - `append` 将元素插入到父元素的末尾
- * - `replace` 清空父元素内容并插入元素
- */
- function updat###bElements(parent, subElements, actionType = 'append') {
- if (actionType === 'replace') parent.innerHTML = '';
- if (!subElements) return parent;
- if (!utils.isArray(subElements)) subElements = [subElements];
- subElements = subElements.map(e => typeof e === 'string' ? document.createTextNode(e) : e);
- switch (actionType) {
- case 'append':
- case 'replace':
- parent.append(...subElements);
- break;
- case 'prepend':
- parent.prepend(...subElements);
- break;
- default:
- throw new Error(`'${actionType}' is invalid action type of updateElements!`);
- }
- return parent;
- }
- /**
- * 使用闭包定义防抖动函数模板。
- * 若为立即执行,将先执行首次触发,再延迟执行最后一次触发
- * @param {Function} func - 回调函数
- * @param {boolean} [immediate=false] - 是否先立即执行
- */
- function debounce(func, immediate = false, delay = DEBOUNCE_DELAY) {
- let timer = null;
- return function (...args) {
- const context = this; // 保存调用时的上下文
- const callNow = immediate && !timer;
- if (timer) clearTimeout(timer);
- // 设置新的定时器
- timer = setTimeout(() => {
- timer = null;
- if (!immediate) func.apply(context, args); // 延时执行
- }, delay);
- if (callNow) func.apply(context, args); // 立即执行
- };
- }
- /** 异步延迟 */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
- /** 基本工具函数集 */
- const utils = {
- isObject: (value) => value !== null && typeof value === 'object',
- isArray: Array.isArray,
- isSet: (value) => value instanceof Set,
- isRegExp: (value) => value instanceof RegExp,
- isString: (value) => typeof value === 'string',
- isBoolean: (value) => typeof value === 'boolean',
- isFunction: (value) => typeof value === 'function',
- isNumber: (value) => typeof value === 'number' && !isNaN(value),
- isFinity: (num) => Number.isFinite(num),
- /**
- * 过滤对象中的方法,只返回对象的枚举值
- * @param {Object} obj - 需要过滤的对象
- * @param {(value: any) => boolean} [filterFn=value => typeof value !== 'function']
- * - 过滤函数 (默认过滤掉函数类型)
- * @returns {Array} 过滤后的枚举值数组
- */
- filterEnumValues: (
- obj, filterFn = (value) => typeof value !== 'function'
- ) => Object.values(obj).filter(filterFn),
- /**
- * 归并排序
- * @param {Array} arr1 - 第一个有序数组
- * @param {Array} arr2 - 第二个有序数组
- * @param {number} [len1=arr1.length] - 第一个数组的归并长度
- * @param {number} [len2=arr2.length] - 第二个数组的归并长度
- * @param {(a: any, b: any) => number} [compareFn=(a, b) => a - b] - 自定义比较函数
- * @returns {Array} 归并后的有序数组
- */
- mergeSorted: (
- arr1, arr2, len1 = arr1.length, len2 = arr2.length, compareFn = (a, b) => a - b
- ) => {
- if (len1 > arr1.length) len1 = arr1.length;
- if (len2 > arr2.length) len2 = arr2.length;
- const sortedArray = [];
- let i = 0;
- let j = 0;
- while (i < len1 && j < len2) {
- if (compareFn(arr1[i], arr2[j]) <= 0) sortedArray.push(arr1[i++]);
- else sortedArray.push(arr2[j++]);
- }
- while (i < len1) sortedArray.push(arr1[i++]);
- while (j < len2) sortedArray.push(arr2[j++]);
- return sortedArray;
- },
- };
- /**
- * `infobox.li`职位人员信息的计算样式
- */
- const JobStyle = {
- compStyle: null,
- // fontSize: null, // num
- lineHeight: null, // num
- borderBottom: null, // px
- paddingBottom: null, // px
- initialize(el) {
- this.compStyle = window.getComputedStyle(el); // 通常不会返回 em % normal 类别的数据
- // this.fontSize = parseFloat(this.compStyle.fontSize);
- this.lineHeight = parseFloat(this.compStyle.lineHeight);
- this.borderBottom = this.compStyle.borderBottomWidth;
- this.paddingBottom = this.compStyle.paddingBottom;
- C.log(`${SCRIPT_NAME}:`, {
- 'lineHeight': `${this.lineHeight}px`,
- 'borderBottom': this.borderBottom,
- 'paddingBottom': this.paddingBottom,
- });
- },
- /** 设置职位排序的样式参数 */
- setStaffStyleProperty() {
- if (!JobStyle.compStyle) return;
- document.documentElement.style.setProperty('--job-line-height', `${this.lineHeight}px`);
- document.documentElement.style.setProperty('--job-border-bottom', this.borderBottom);
- document.documentElement.style.setProperty('--job-padding-bottom', this.paddingBottom);
- },
- }
- /**
- * 动态载入职位排序的默认样式。
- * 通过先于`DOM`操作载入样式再设置参数的方式,加快解析与渲染。
- */
- function loadStaffStyle() {
- const style = createElement('style', {class: 'staff_sorting'});
- style.innerHTML = `
- :root {
- --refold-threshold: ${Store.get(Key.REFOLD_THRESHOLD_KEY)};
- --job-line-height: 18px;
- --job-border-bottom: 0.64px;
- --job-padding-bottom: 4px;
- }
- /* 删除与前继元素重复的边线 */
- #infobox li.sub_container li.sub_section:first-child,
- #infobox li.sub_group,
- html[data-theme='dark'] ul#infobox li.sub_group {
- border-top: none; !important
- }
- /* 优化小组样式 */
- #infobox li:not(.last_group)[attr-info-group] {
- border-bottom: none;
- }
- #infobox li:not(.last_group)[attr-info-group] > ul {
- border-bottom: 3px solid #fafafa;
- }
- html[data-theme='dark'] #infobox li:not(.last_group)[attr-info-group] > ul {
- border-bottom: 3px solid #3d3d3f;
- }
- /* 部分情形下隐藏页面右小角的悬浮窗口 */
- #dock.hidden {
- display: none;
- }
- /* 防止图标可能污染爬取 infobox 数据的脚本 */
- .staff_sorting_icon {
- display: none;
- }
- #infobox .staff_sorting_icon {
- display: inline;
- }
- /* 公用 */
- #infobox span.tip.switch:hover i,
- #infobox span.tip.side:hover i,
- #infobox span.folds_list:hover {
- color: #2ea6ff;
- }
- /* 职位信息一次折叠 */
- #infobox li.foldable {
- background-color: transparent;
- transition: background-color 1s ease;
- }
- #infobox li.foldable.folded {
- display: list-item;
- background-color: grey;
- visibility: hidden; /* 不移除元素,只是隐藏 */
- height: 0;
- padding: 0;
- margin: 0;
- border: none;
- }
- /* 更多制作人员 */
- #infobox + .infobox_expand {
- display: grid;
- grid-template-columns: 1fr 1fr;
- border-bottom: 1px solid #EEE; /* 同原有的border-top */
- }
- html[data-theme='dark'] #infobox + .infobox_expand {
- border-bottom: 1px solid #444;
- }
- #infobox + .infobox_expand > a {
- cursor: pointer;
- }
- #infobox + .infobox_expand > a:first-child {
- border-right: 2px solid #EEE;
- }
- html[data-theme='dark'] #infobox + .infobox_expand > a:first-child {
- border-right: 2px solid #2d2e2f;
- }
- /* 职位信息一次折叠队列展开 */
- #infobox.padding_bottom {
- padding-bottom: 10px;
- }
- #infobox li {
- position: relative;
- }
- #infobox span.folds_list {
- position: absolute;
- width: 32px;
- height: 8px;
- right: -8px;
- cursor: pointer;
- }
- #infobox span.folds_list.hidden {
- display: none;
- }
- #infobox span.folds_list:hover {
- height: 16px;
- }
- #infobox span.folds_list.expand_top {
- top: calc(-4px - var(--job-border-bottom) / 2);
- }
- #infobox span.folds_list.expand_top:hover {
- top: calc(-8px - var(--job-border-bottom) / 2);
- }
- #infobox span.folds_list.expand_bottom {
- bottom: calc(-4px - var(--job-border-bottom) / 2);
- }
- #infobox span.folds_list.expand_bottom:hover {
- bottom: calc(-8px - var(--job-border-bottom) / 2);
- }
- #infobox span.folds_list a,
- #infobox span.folds_list svg {
- position: absolute;
- height: 100%;
- top: 0;
- }
- #infobox span.folds_list a {
- width: 100%;
- left: 0;
- }
- #infobox span.folds_list svg {
- width: auto;
- right: 0;
- }
- #infobox span.folds_list:hover svg {
- right: -4px;
- }
- /* 职位信息二次折叠 */
- #infobox li.refoldable {
- display: inline-block; /* 使其容#.tip.side */
- overflow: visible;
- height: auto;
- }
- #infobox li.refolded {
- display: block;
- overflow: hidden;
- height: calc(var(--refold-threshold) * var(--job-line-height));
- /* 由下至上进行遮蔽 */
- -webkit-mask-image:
- linear-gradient(black, black), /* 显现 border-bottom */
- linear-gradient(transparent, transparent), /* 隐藏溢出到 padding-bottom 区域的信息 */
- linear-gradient(160deg, black 10%, transparent 90%), /* 修饰最后一行人员信息 */
- linear-gradient(black, black); /* 显现其余的人员信息 */
- mask-image:
- linear-gradient(black, black),
- linear-gradient(transparent, transparent),
- linear-gradient(160deg, black 10%, transparent 90%),
- linear-gradient(black, black);
- -webkit-mask-size:
- 100% var(--job-border-bottom),
- 100% var(--job-padding-bottom),
- 100% var(--job-line-height),
- 100% 100%;
- mask-size:
- 100% var(--job-border-bottom),
- 100% var(--job-padding-bottom),
- 100% var(--job-line-height),
- 100% 100%;
- -webkit-mask-position:
- 0 100%,
- 0 calc(100% - var(--job-border-bottom)),
- 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)),
- 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom) - var(--job-line-height));
- mask-position:
- 0 100%,
- 0 calc(100% - var(--job-border-bottom)),
- 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)),
- 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom) - var(--job-line-height));
- -webkit-mask-repeat: no-repeat;
- mask-repeat: no-repeat;
- -webkit-mask-composite: source-over;
- mask-composite: add;
- }
- #infobox .tip.switch,
- #infobox .tip.side {
- cursor: pointer;
- }
- #infobox .tip.switch:hover {
- color: #000;
- }
- html[data-theme='dark'] #infobox .tip.switch:hover {
- color: #FFF;
- }
- #infobox .tip.side {
- display: none;
- float: right; /* 将其推到尾行右侧 */
- clear: right; /* 如果尾行放不下,则换到新行 */
- margin: 0 5px;
- }
- #infobox .tip.side[data-refold-line] {
- display: inline-block;
- }
- `;
- document.head.appendChild(style);
- }
- /** 载入设置界面的样式 */
- function loadSettingStyle() {
- const style = createElement('style', {class: 'staff_sorting'});
- // 使用CSS变量提高对代码的复用性
- style.innerHTML = `
- :root {
- --state-selector-size: 22px;
- --state-selector-step: 19px;
- }
- /* 设置界面的样式 */
- #staff_sorting > .settings {
- margin-left: 5px;
- }
- #staff_sorting .right_inline {
- display: flex;
- float: right;
- align-items: center;
- }
- #staff_sorting .right_inline .tip_j {
- margin-right: 15px;
- }
- #staff_sorting .hidden {
- display: none;
- }
- #staff_sorting h2 {
- display: inline-block;
- }
- #staff_sorting [class*='_block'] h2 {
- font-size: 16px;
- }
- /* 主标题 */
- #staff_sorting .maintitle .right_inline {
- height: 25px;
- }
- #staff_sorting .maintitle .tip_j {
- font-size: 13px;
- margin-right: 6px;
- }
- /* 二次折叠的开关位置设置 */
- #staff_sorting .line_limit_block.turn_off ~ .refold_switch_pos_block {
- display: none;
- }
- /* 各类型条目的职位设置模块 */
- .subject_staff_block h2,
- .subject_staff_block summary::marker {
- cursor: pointer;
- }
- .subject_staff_block .tip_j {
- display: none;
- margin: 0 5px;
- }
- .subject_staff_block .staffMapList_editor {
- padding-right: 10%;
- margin-bottom: 5px;
- }
- .staffMapList_editor .inputBtn {
- margin-right: 5px;
- }
- .staffMapList_editor textarea {
- font-size: 15px;
- line-height: 21px;
- }
- /* 数字输入框与控制器 */
- .num_input_cntr {
- display: flex;
- float: left;
- align-items: center;
- gap: 5px;
- margin-right: 30px;
- }
- .num_input_cntr .text {
- font-size: 14px;
- margin-right: 2px;
- }
- .inputtext.input_num {
- width: 30px;
- height: 11px;
- text-align: center;
- font-size: 15px;
- }
- .num_ctrs {
- display: flex;
- flex-direction: column;
- background-color: white;
- border: 1px solid #d9d9d9;
- border-radius: 4px;
- box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
- gap: 0;
- }
- html[data-theme="dark"] .num_ctrs {
- background-color: black;
- border: 1px solid #757575;
- }
- .num_ctrs div {
- display: flex;
- text-align: center;
- width: 12px;
- height: 6.5px;
- padding: 2px;
- cursor: pointer;
- }
- .num_ctrs div:first-child {
- border-radius: 3px 3px 0 0;
- }
- .num_ctrs div:last-child {
- border-radius: 0 0 3px 3px;
- }
- .num_ctrs div svg {
- width: 100%;
- height: 100%;
- }
- .num_ctrs div:active {
- background-color: #2ea6ff;
- }
- /* 滑动开关 */
- .toggle {
- position: relative;
- width: 44px;
- height: 22px;
- display: block;
- float: right;
- }
- .toggle_input {
- display: none;
- }
- .toggle_slider {
- position: absolute;
- cursor: pointer;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: #eaeaea;
- border-radius: 22px;
- box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.2);
- transition: background-color 0.2s ease-in;
- }
- html[data-theme="dark"] .toggle_slider {
- background-color: #9a9a9a;
- }
- .toggle_slider::before {
- content: "";
- position: absolute;
- height: 16px;
- width: 16px;
- left: 3px;
- bottom: 3px;
- background-color: white;
- border-radius: 50%;
- box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3);
- transition: transform 0.2s ease-in;
- }
- .toggle_input:checked + .toggle_slider {
- background-color: #72b6e3;
- }
- html[data-theme="dark"] .toggle_input:checked + .toggle_slider {
- background-color: #3072dc;
- }
- .toggle_input:checked + .toggle_slider::before {
- transform: translateX(22px);
- }
- /* 滑动选择器公有 */
- .tri_state_selector {
- position: relative;
- height: var(--state-selector-size);
- display: inline-block;
- }
- .radio_input {
- position: absolute;
- opacity: 0;
- z-index: 2;
- }
- .select_slider {
- position: relative;
- width: 100%;
- height: 100%;
- background-color: #eaeaea;
- border-radius: var(--state-selector-size);
- box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
- z-index: 1;
- overflow: hidden;
- transition: background-color 0.2s ease-in;
- }
- html[data-theme="dark"] .select_slider {
- background-color: #9a9a9a;
- }
- .select_indicator {
- position: absolute;
- width: calc(var(--state-selector-size) - 4px);
- height: calc(var(--state-selector-size) - 4px);
- top: 2px;
- left: 2px;
- background-color: white;
- border-radius: 50%;
- box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3);
- z-index: 1;
- transition: transform 0.2s ease-in;
- }
- .radio_label {
- position: absolute;
- width: var(--state-selector-step);
- height: 100%;
- top: 0;
- cursor: pointer;
- z-index: 3;
- }
- /* 三态滑动选择器 */
- .tri_state_selector {
- width: calc(
- var(--state-selector-size) + var(--state-selector-step) * 2
- );
- }
- label.radio_label:nth-of-type(1) {
- left: 0;
- }
- label.radio_label:nth-of-type(2) {
- left: var(--state-selector-step);
- }
- label.radio_label:nth-of-type(3) {
- width: var(--state-selector-size);
- left: calc(var(--state-selector-step) * 2);
- }
- input.radio_input:nth-of-type(2):checked ~ .select_slider {
- background-color: #f47a88;
- }
- input.radio_input:nth-of-type(3):checked ~ .select_slider {
- background-color: #72b6e3;
- }
- html[data-theme="dark"] input.radio_input:nth-of-type(2):checked ~ .select_slider {
- background-color: #ff668a;
- }
- html[data-theme="dark"] input.radio_input:nth-of-type(3):checked ~ .select_slider {
- background-color: #3072dc;
- }
- input.radio_input:nth-of-type(1):checked ~ .select_slider .select_indicator {
- transform: translateX(0);
- }
- input.radio_input:nth-of-type(2):checked ~ .select_slider .select_indicator {
- transform: translateX(var(--state-selector-step));
- }
- input.radio_input:nth-of-type(3):checked ~ .select_slider .select_indicator {
- transform: translateX(calc(var(--state-selector-step) * 2));
- }
- .select_slider::after {
- content: "";
- position: absolute;
- width: calc(var(--state-selector-size) + var(--state-selector-step));
- height: var(--state-selector-size);
- left: var(--state-selector-step);
- border-radius: calc(var(--state-selector-size) / 2);
- box-shadow: 0 0 3px rgba(0, 0, 0, 0.1), inset 0 0 6px rgba(0, 0, 0, 0.3);
- transition: transform 0.2s ease-in-out;
- }
- input.radio_input:nth-of-type(1):checked ~ .select_slider::after {
- transform: translateX(calc(0px - var(--state-selector-step)));
- }
- `;
- document.head.appendChild(style);
- }
- main();
- })();