// ==UserScript==
// @name 班固米-条目职位自定义排序与折叠
// @namespace https://github.com/weiduhuo/scripts
// @version 1.3.2-1.3
// @description 对所有类型条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置
// @author weiduhuo
// @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
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_NAME = '班固米-职位排序组件';
const INTERFACE_NAME = '班固米-职位排序接口';
const CURRENT_DATA_VERSION = '1.3';
/** 禁止console.debug */
console.debug = function () {};
/** 排序延迟时间 */
const SORTING_DELAY = 50;
/** 防抖延迟时间 */
const DEBOUNCE_DELAY = 500;
/** 非设置模式下接口延迟时间 */
const INTERFACE_DELAY = 1000;
/** URL 相对路径 */
const pathname = window.location.pathname;
/** 是否对职位信息进行了折叠,忽略网页自身`sub_group`的折叠 (依此判断 `更多制作人员` 开关的必要性) */
let hasFolded = false;
/** 尾部折叠图标的激活阈值相对于视口高度的系数 */
const sideTipRate = 0.25;
/**
* @type {number} 尾部折叠图标的激活行数阈值
*/
let sideTipLineThr = null;
/**
* @type {Array<HTMLElement> | null} 最后一组`sub_group`的数据包
*/
let lastGroup = null;
/**
* 图标,已在`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>
`,
};
/**
* 枚举所支持的条目类型
*/
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 filterEnumValues(this);
else return 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 EnableState = {
/** 启用全部功能 */
ALL_ENABLED: "allEnable",
/** 启用部分功能,仅排序不折叠 */
PARTIAL_ENABLED: "partialEnable",
/** 全部功能禁用 */
ALL_DISABLED: "allDisable",
/**
* @returns {Array<string>}
*/
getAll() {
return filterEnumValues(this);
},
parse(value) {
if (this.getAll().includes(value)) return value;
return null;
},
};
/**
* 管理`localStorage`的键名与初值。
* 键值分为全局配置与各类型条目配置、简单类型与复杂类型
*/
const Key = {
/** 键名前缀 */
_KEY_PREF: 'BangumiStaffSorting',
/** 数据版本 */
DATA_VERSION: '_dataVersion__',
/** 排序接口 */
_INTERFACE: 'Interface',
/** 共享注册表 */
SHARED_REGISTER: '_sharedRegister_',
/** 共享注册表上锁 */
LOCK_KEY: '_lock_',
/** 超过此行数的职位信息将被二次折叠*/
REFOLD_THRESHOLD_KEY: 'refoldThreshold',
REFOLD_THRESHOLD_DEFAULT: 4,
REFOLD_THRESHOLD_DISABLED: 0,
/** 各类型条目模块的展开状态 */
BLOCK_OPEN_KEY: 'blockOpen',
BLOCK_OPEN_DEFAULT: false,
/** 各类型条目的功能启用状态 */
ENABLE_STATE_KEY: 'EnableState',
ENABLE_STATE_DEFAULT: EnableState.ALL_ENABLED,
/** 各类型条目的自定义排序与折叠 (复杂类型) */
STAFF_MAP_LIST_KEY: 'StaffMapList',
/** 各类型条目的排序预匹配数据 Array<string> (归为简单类型,目前仅音乐条目启用) */
PRE_MATCHED_DATA: 'PreMatchedData',
/** 当前使用的键值的所属条目类型 (可即时切换) */
_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}`;
},
makeInterfaceKey(key, type = null) {
if (this.isGlobalData(key)) return `${this._KEY_PREF}${this._INTERFACE}_${key}_`;
else return `${this._KEY_PREF}${this._INTERFACE}_${type}${key}`;
},
setSubType(type) {
if (type && SubjectType.getAll().includes(type)) this._subType = type;
},
isComplexData(key) {
return [this.STAFF_MAP_LIST_KEY].includes(key);
},
isGlobalData(key) {
return [
this.REFOLD_THRESHOLD_KEY, this.DATA_VERSION, this.SHARED_REGISTER, this.LOCK_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'].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.set(Key.DATA_VERSION, CURRENT_DATA_VERSION);
}
}
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)) {
value = JSON.stringify(value);
this._cache[fullKey] = value; // 同步到缓存
}
localStorage.setItem(fullKey, value);
}
static get(key, type = null) {
Key.setSubType(type);
const fullKey = Key.makeKey(key);
// 简单数据类型,命中缓存
if (!Key.isComplexData() && Store._isCacheHit(fullKey)) {
// console.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);
}
}
/**
* `StaffMapList`的`JSON`格式化字符串。
* 最短的有效字符串为`"[]"`,其表示设置空缺。
*/
const StaffMapListJSON = {
/**
* 解析`staffMapListJSON`字符串。
* 用于初步解析与有效性检测,
* 更进一步的解析,将在`StaffMapList`中进行。
* 仅检查:
* 1. 是否满足`JSON`格式
* 2. 是否为数组类型
* 3. 字符串样式的正则表达式,是否满足规定格式
* @returns {Array | null} `StaffMapList`数据或空值
*/
parse(text) {
let parsedData;
try {
parsedData = JSON.parse(text, this._reviver);
} catch (e) {
console.error(`${SCRIPT_NAME}:staffMapList 解析失败 - ${e}`);
return null;
}
if (!Array.isArray(parsedData)) {
console.error(`${SCRIPT_NAME}:staffMapList 类型错误 - 非数组类型`);
return null;
}
return parsedData;
},
/** 将`StaffMapList`转为`JSON`格式化字符串 */
stringify(data) {
return JSON.stringify(data, this._replacer, 1);
},
/** 解析`JSON`字符串中的正则表达式 */
_reviver(key, value) {
if (typeof value === 'string' && value.startsWith('/')) {
const regexParttern = /^\/(.+)\/([gimsuy]*)$/;
const match = value.match(regexParttern);
if (match) {
try {
return new RegExp(match[1], match[2]);
} catch (e) {
throw new Error(`正则表达式 "${value}" 非法 - ${e}`);
}
} else throw new Error(`正则表达式 "${value}" 不符合 ${regexParttern} 格式`);
}
return value;
},
/** 将正则表达式转化为字符串,以满足`JSON`格式 */
_replacer(key, value) {
if (value instanceof RegExp) return value.toString();
return value;
},
}
/**
* 职位排序与折叠设置,
* 是职位的排序列表`jobOrder`与默认折叠的职位`foldableJobs`信息的组合
*/
class StaffMapList {
/**
* @typedef {string | RegExp} MatchJob - 匹配职位名称
* @typedef {[MatchJob | [boolean | MatchJob, ...MatchJob[]]]} StaffMapListType
* 其中`boolean`表示子序列内的职位是否默认折叠,缺损值为`False`,需位于子序列的首位才有意义
* (默认配置中`,,`表示在`JSON`数组中插入`null`元素,用于输出格式化文本时标记换行)
*/
/** 懒加载的默认配置 */
static _defaultLazyData = {
[SubjectType.ANIME.en]: () => [,
"中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"],,
"放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/,,
,
"原作", "原案", "人物原案", "人物设定", "原作插图", [true, "原作协力"],,
"团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督",,
"系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/],,
"分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"],,
,
"总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/,,
"主动画师", "主要动画师", [true, "构图"], [false, "原画"], [true, "第二原画", "补间动画"], "数码绘图", /(原画|动画|動画)(?!制|检查)/,,
"动画检查", [true, /动画检查/],,
,
"设定", "背景设定", "道具设计", /(?<!色彩|美术|美術)(设|設)(?!.*制)/, /Design|デザイン|Motion Graphic|モーショングラフィック/,,
"色彩设计", [false, "色彩指定", "色指定"], [true, "上色", /(?<!角)色/],,
"美术监督", /(美术|美術).*导演/, [false, "美术设计"], "概念美术", "视觉概念", "概念艺术",,
[false, "背景美术"], [true, /景/], /ART WORK|美术|美術|艺术|工艺|创意|绘制/,,
,
"3DCG 导演", "CG 导演", /CG.*导演/, "3DCG", [false, /2D|3D|CG|コンピュータ/], "模型监督", "绑骨监督", [/建模|模型|动作|表情|骨/],,
"摄影监督", "副摄影监督", "后期监督", [true, "摄影", "特效", "照明", "特技", /摄影|モニター|特效|动效|合成|拍|Effect|技术/],,
"現像", /タイトル|标题|字幕/,,
[false, "剪辑", "编集"], [true, /(?<!音.*)剪辑/], "编辑", [true, "场记"],,
"监修", /监修|監修/, "顾问", /顾问/,,
,
"音响监督", [true, "音响", "音响制作", "音效", "拟音", /音响/], "录音", [true, "录音助理", "混声", /录音|声/],,
"配音", "主演", "キャスティング", [true, /配音|((?<!歌)演出)/],,
"音乐", "音乐制作", [true, "音乐制作人", "音乐助理"], [true, /音/],,
"主题歌演出", [true, "主题歌作词", "主题歌作曲", "主题歌编曲"], "插入歌演出",,
[true, "插入歌作词", "插入歌作曲", "插入歌编曲"], [true, "选曲"], [true, /曲|歌/],,
,
"企画", [true, "企画协力"], "企划制作人", /企画|企划|出品|策划/, "监制", /监制/,,
"执行制片人", "总制片人", "制片人", "总制片", "制片", [true, "副制片人", "联合制片人", "助理制片人", /(?<!动画|動画)制片/],,
[true, /行政|审/, "责任编辑"], [true, /法务/], [true, "宣传", /宣传|宣伝|広報/], /市场|运营|衍生/,,
"制作", "製作", [true, "制作著作"],,
"动画制片人", [true, "制作管理", "制作统筹", "制作主任", "制作助理"],,
[true, "设定制作"], [true, "计划管理", "制作进行", "制作进行协力"],,
"制作协调", "制作协力", "制作助手", "协力", /协力|協力/, [true, /取材/], [true, "特别鸣谢", /鸣谢|Thanks/],,
,
"动画制作", [true, /制作|製作/],,
"别名", /.+名$/,,
"发行", "官方网站", "在线播放平台", "链接", "播放电视台", "其他电视台", "配给", /配(?!音)|連載|番組|版|播放(?!结束)/,,
"播放结束", "结束",,
,
"其他", /其他/,,
[false, "===此处插入未被匹配的职位==="],,
"Copyright",
],
[SubjectType.MUSIC.en]: () => [,
"制作人",,
"艺术家", "作词", "作曲", "编曲",,
"脚本", "声乐", "乐器", "混音", "母带制作",,
"插图", "原作", "出版方", "厂牌",
],
};
/** @type {StaffMapListType} 主数据 */
data = [];
/** @type {Array<MatchJob>} 职位的排序列表 */
jobOrder = [];
/** @type {Set<MatchJob>} 默认折叠的职位,EnableState = "particalDisable" 时,内容为空 */
foldableJobs = new Set();
/** 所属条目类型(不可变更)*/
subType = null;
/** 是否为默认数据 */
isDefault = null;
/** 是否具备折叠功能 */
foldable = false;
/** 默认配置格式化文本的缓存 */
_defaultTextBuffer = null;
constructor(subType) {
this.subType = subType; // 小心 Store._subType 被其他模块切换
}
/**
* 依据`EnableState`进行初始化,使其具备职位匹配的能力。
* 若仅为获取`StaffMapList`格式化字符串,则不需要执行本初始化。
* @param {boolean} [foldable=false] - 是否开启折叠功能 (默认关闭)
* @param {boolean} [forced=false] - 是否开启强制模式 (默认关闭),即`EnableState`检查
*/
initialize(foldable = false, forced = false) {
Key.setSubType(this.subType);
if (!forced && Store.get(Key.ENABLE_STATE_KEY) === EnableState.ALL_DISABLED)
return;
if (!this._loadData()) {
this._setDefault();
this.isDefault = true;
}
this._resolveData(foldable);
this.foldable = foldable && this.foldableJobs.size;
}
/**
* 空缺设置,将关闭脚本的职位排序。
* 有两种独立开启途径:
* 1. `EnableState = "allDisable"`
* 2. `StaffMapListJSON = "[]"`
*/
isNull() {
return this.data.length === 0;
}
/** 保存自定义的数据 */
saveData(jsonStr) {
this.isDefault = false;;
Store.set(Key.STAFF_MAP_LIST_KEY, jsonStr, this.subType);
console.log(jsonStr);
console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`);
}
/** 恢复默认数据的设置 */
resetData() {
this.isDefault = true;
Store.remove(Key.STAFF_MAP_LIST_KEY, this.subType);
console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`);
}
/** 使用懒加载恢复默认配置 */
_setDefault() {
if (!StaffMapList._defaultLazyData[this.subType])
this.data = []; // 该类型条目未有默认设置
else this.data = StaffMapList._defaultLazyData[this.subType]();
}
/** 尝试载入自定义的数据,并作初步解析 */
_loadData() {
const jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType);
if (!jsonStr) return null; // 键值为空,表示用户启用默认设置
let parsedData = StaffMapListJSON.parse(jsonStr);
if (!parsedData) {
// 通过UI进行的配置一般不可能发生
console.error(
`${SCRIPT_NAME}:自定义 staffMapList 解析失败,将使用脚本默认的数据`
);
return false;
}
/* 修复外层重复嵌套 `[]` 的形式,例如 [["", [true, ""], ""]]
* 同时区分形如 [[true, "", ""]] 此类不需要降维的情形,
* 忽略存在的漏洞:形如 [[true, "", [true, ""], ""]] 将无法降维 */
if (
parsedData.length === 1 &&
Array.isArray(parsedData[0]) &&
typeof parsedData[0][0] !== "boolean"
) {
parsedData = parsedData[0];
}
this.isDefault = false;
this.data = parsedData;
return true;
}
/** 完全解析数据,拆解为`jobOrder`与`foldableJobs` */
_resolveData(foldable) {
foldable = foldable &&
Store.get(Key.ENABLE_STATE_KEY, this.subType) === EnableState.ALL_ENABLED;
for (let item of this.data) {
if (Array.isArray(item) && item.length) {
// 对数组进行完全展平,提高对非标多层数组的兼容性
item = item.flat(Infinity);
/* 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效
* 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效 */
if (typeof item[0] === "boolean") {
// 可以使用 EnableState 仅启用排序,禁用折叠
if (item[0] && foldable) {
item.forEach((value, index) => { if (index) this.foldableJobs.add(value) });
// 替代 this.foldableJobs.push(...item.slice(1));
}
item.shift(); // 移除第一个元素,替代 slice(1)
}
this.jobOrder.push(...item);
} else if (typeof item !== "undefined") {
this.jobOrder.push(item);
}
}
}
/**
* 将数据转化为格式化文本 (有别于`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 = StaffMapListJSON.stringify(this.data)
.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 = StaffMapListJSON.stringify(this.data).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;
return text;
}
}
/**
* 基类,职位排序的核心公共逻辑,拥有多个虚拟函数需子类实现。
* 可被拓展用于不同的场景:网页`infobox`职位信息、职位名称序列、API`infobox`职位信息
*/
class BaseStaffSorter {
/** @type {StaffMapList} 职位排序与折叠设置 */
staffMapList;
/** @type {Object<string, any> | Iterable<any>} 原始数据,元素内需包含待匹配职位名称 */
rawData;
/** @type {Set<string>} 待匹配职位名称的集合 */
_setData;
/** 排序的结果 */
sortedData;
/**
* 构造函数,子类可细化`rawData`的类型定义,
* 并自行对其初始化,且需在其后调用`_initSetData`函数
*/
constructor(staffMapList, rawData = null) {
this.staffMapList = staffMapList;
if (rawData) {
this.rawData = rawData; // 接受其引用,不对其删改,改为操作 _setData
this._initSetData();
}
/** 未被匹配职位的待插入位置 */
this._afterInsert = null;
/** 激活待插入位置 */
this._insertTag = false;
/** 待插入信息的折叠状态 */
this._insertFold = false;
}
/** 依据`rawData`初始化`_setData` */
_initSetData() {
if (typeof this.rawData === 'object') this._setData = new Set(Object.keys(this.rawData));
else this._setData = new Set(this.rawData);
}
/**
* 进行匹配,可静态调用或者绑定实例对象。
* 绑定实例时,将调用`this._logRegexMatch`并修改`this._insertTag`与`this._insertFold`
* @param {MatchJob} matcher - 匹配职位名称的字符串或正则表达式
* @param {Set<string> | Map<string, any>} data - 数据集合
* @returns {Array<string> | null} 成功则返回数据序列,失败则返回空
*/
static match(matcher, data) {
const instance = this instanceof BaseStaffSorter ? this : null;
const matchedJobs = [];
// 1.正则匹配
if (matcher instanceof RegExp) {
for (const job of data.keys()) if (matcher.test(job)) matchedJobs.push(job);
// 替代 matchedJobs.push(...Object.keys(this.rawData).filter(key => item.test(key)));
if (matchedJobs.length) {
if (instance) instance._logRegexMatch(matcher, matchedJobs);
return matchedJobs;
}
} else if (typeof matcher === 'string' && matcher) {
// 2.精确匹配
if (data.has(matcher)) {
matchedJobs.push(matcher);
return matchedJobs;
// 3.特殊关键字处理
} else if (matcher.startsWith('==') && instance) {
// 激活待插入位置
instance._insertTag = true;
instance._insertFold = this.staffMapList.foldableJobs.has(matcher);
console.debug(`insertMatcher: "${matcher}", insetFold: ${instance._insertFold}`);
}
}
return null; // 其余情形均忽略 (且对于意外类型不报错)
}
/** 进行匹配排序 */
sort() {
for (const matcher of this.staffMapList.jobOrder) {
// 进行匹配
const matchedJobs = BaseStaffSorter.match.call(this, matcher, this._setData); // 使用 call 绑定实例
if (!matchedJobs) continue;
// 进行排序
for (const job of matchedJobs) {
this._processMatchedJob(job, matcher);
// 保存待插入位置
if (this._insertTag) {
this._processSaveInsert(job, matcher);
this._insertTag = false;
}
// 删除已被匹配排序的职位名称
this._setData.delete(job);
}
}
if (this._setData.size === 0) return;
// 将剩余未被匹配的职位按原顺序添加到待插入位置
this._processUnmatchedJobs();
// 进行相关记录
this._logUnmatch(Array.from(this._setData.keys()));
if (this._afterInsert != null) this._logInsert();
}
/**
* 对该条被匹配的职位信息进行排序处理
* @param {string} job - 被匹配的职位名称
* @param {MatchJob} matcher - 匹配职位名称的字符串或正则表达式
*/
_processMatchedJob(job, matcher) { throw new Error('VirtualMethod'), job, matcher }
/** 保存未被匹配的职位信息的待插入位置 */
_processSaveInsert(job) { throw new Error('VirtualMethod'), job }
/** 对该条未被匹配的职位信息进行处理 */
_processUnmatchedJobs() {
// for (const job of this._setData.keys()) if (this._afterInsert) { job } else { job }
throw new Error('VirtualMethod');
}
/**
* 记录被正则匹配的职位信息
* @param {RegExp} reg
* @param {Array<string>} matchedJobs
*/
_logRegexMatch(reg, matchedJobs) {
console.log(`${SCRIPT_NAME}:使用正则表达式 ${reg} 成功匹配 {${matchedJobs}}`);
}
/**
* 记录未被匹配的职位信息
* @param {Array<string>} unmatchedJobs
*/
_logUnmatch(unmatchedJobs) {
console.log(`${SCRIPT_NAME}:未被匹配到的职位 {${unmatchedJobs}}`);
}
/** 记录插入信息 */
_logInsert() {
console.log(`${SCRIPT_NAME}:激活将未被匹配职位插入指定位置`);
}
}
/**
* 实现网页`infobox`职位信息的排序与折叠,
* `sub_group`及属其所有的`sub_container`将被视为一个整体进行排序
*/
class HtmlStaffSorter extends BaseStaffSorter {
/** @type {Object<string, HTMLElement | Array<HTMLElement>>} 原始职位信息字典 */
rawData;
/**
* @param {HTMLElement} ul - `infobox`
* @param {StaffMapList} staffMapList - 职位排序与折叠设置
* @param {Object<string, HTMLElement | Array<HTMLElement>>} staffDict - 职位信息字典
*/
constructor(ul, staffMapList, staffDict) {
super(staffMapList);
this.rawData = staffDict;
this._initSetData();
/** `infobox` */
this.ul = ul;
}
_processMatchedJob(job, matcher) {
const li = this.rawData[job];
// sub_group 及属其所有的 sub_container 组成的序列
if (Array.isArray(li)) {
this.ul.append(...li);
lastGroup = li;
// 普通职位信息
} else {
if (this.staffMapList.foldable && this.staffMapList.foldableJobs.has(matcher)) {
if (!hasFolded) hasFolded = true;
li.classList.add('folded', 'foldable');
}
this.ul.appendChild(li);
}
}
_processSaveInsert(job) {
const li = this.rawData[job];
this._afterInsert = Array.isArray(li) ? li[0] : li;
}
_processUnmatchedJobs() {
for (const job of this._setData.keys()) {
const li = this.rawData[job];
const isGroup = Array.isArray(li);
if (isGroup) lastGroup = li;
else if (this._insertFold) {
if (!hasFolded) hasFolded = true;
li.classList.add('folded', 'foldable');
}
if (this._afterInsert) {
if (isGroup) li.forEach(node => this.ul.insertBefore(node, this._afterInsert));
else this.ul.insertBefore(li, this._afterInsert);
} else {
// 未设置待插入位置,则默认插入到末尾,且默认不折叠
if (isGroup) this.ul.append(...li);
else this.ul.appendChild(li);
}
}
}
_logInsert() {
console.debug(`liAfterInsert: ${this._afterInsert.innerText}`);
super._logInsert();
}
}
/**
* 实现接口传递来的职位信息的排序
*/
class InterfaceStaffSorter extends BaseStaffSorter {
/**
* @typedef {string[] | { key: string; [key: string]: any }[]} AppData
* @type {AppData} 包含职位名称的原始序列
* 当序列元素的类型为对象时,规定`key`的键值存储职位名称
*/
rawData;
/**
* @type {Map<string, number>}
* 为了输出排序索引,将`Set`类型升格为`Map`类型
* (基类除`_initSetData`方法外,均可完全兼容)
*/
_setData;
/** @type {string[]} 原始序列类型的排序结果 */
sortedData;
/** @type {number[]} 排序结果的索引 */
sortedIndex;
/**
* @param {StaffMapList} staffMapList - 职位排序与折叠设置
* @param {string[] | { key: string; [key: string]: any }[]} rawData - 原始数据
*/
constructor(staffMapList, rawData) {
super(staffMapList);
this.rawData = rawData;
this._initSetData();
this.sortedData = [];
this.sortedIndex = [];
}
/** 依据`rawData`初始化`_setData`,并对其做合法性检验 */
_initSetData() {
if (!Array.isArray(this.rawData) || !this.rawData.length)
throw new Error('传入接口的数据类型应为非空数组');
this._setData = new Map();
for (const [index, item] of this.rawData.entries()) {
if (typeof item === 'string') {
this._setData.set(item, index);
} else if (typeof item === 'object' && typeof item.key === 'string') {
this._setData.set(item.key, index);
} else {
throw new Error(`传入接口的数据的数组元素 ${JSON.stringify(item)} 类型不符合规范`);
}
}
}
_processMatchedJob(job) {
const index = this._setData.get(job);
this.sortedData.push(this.rawData[index]);
this.sortedIndex.push(index);
}
_processSaveInsert(job) {
this._afterInsert = this.sortedData.length - 1;
console.debug(`IndexAfterInsert: ${this._afterInsert}, job: ${job}`);
}
_processUnmatchedJobs() {
const numatchedIndex = Array.from(this._setData.keys()).map((job) => this._setData.get(job));
const numatchedData = numatchedIndex.map((index) => this.rawData[index]);
if (this._afterInsert != null) {
const start = this._afterInsert;
this.sortedData.splice(start, 0, ...numatchedData)
this.sortedIndex.splice(start, 0, ...numatchedIndex)
} else {
this.sortedData.push(...numatchedData);
this.sortedIndex.push(...numatchedIndex)
}
}
_logUnmatch(unmatchedJobs) {
console.debug(`unmatchedJobs: {${unmatchedJobs}}`);
}
_logRegexMatch() {}
_logInsert() {}
}
/**
* 实现基于`localStorage`的异步通信接口,并对传入的数据进行排序。
* 有两种工作模式:
* 1. 非设置模式,后于主任务异步执行一次 (`@match`所匹配的所有页面均首先开启该模式)
* 2. 设置模式,伴随事件监听器同步执行 (仅在设置页面发生)
*/
class SortingInterface {
/** 共享注册表的键名 */
static registerKey = Key.makeInterfaceKey(Key.SHARED_REGISTER);
/** 共享注册表上锁的键名 */
// static lockKey = Key.makeInterfaceKey(Key.LOCK_KEY);
/** 是否为设置模式 */
static settingMode = false;
/** @type {Object<string, string[]>} 分组任务队列,以`SubjectType`为组别名 */
static _tasksByType = {};
/** 缓存应用传递的有效数据,仅在设置模式中开启 */
static _appCache = {};
/** 注册表应用计数器 */
static appCount = 0;
/** 有效应用的有效更新次数计数器 */
static validAppCount = 0;
/** 初始化,仅检查`sharedRegister`是否缺损或被污染 */
static initialize() {
SortingInterface._tasksByType = {};
SortingInterface._appCache = {};
SortingInterface._parseRegister();
}
/** 非设置模式下,单次异步执行接口任务 */
static runAsyncTask() {
setTimeout(() => {
SortingInterface._processRegister();
console.debug("tasksByType:", SortingInterface._tasksByType);
SubjectType.getAll().forEach((type) => {
SortingInterface.processTask(type, null, Store.updateRequired);
});
if (SortingInterface.appCount) {
console.log(
`${INTERFACE_NAME}:共发现 ${SortingInterface.appCount} 个接入应用,` +
`并执行 ${SortingInterface.validAppCount} 次有效更新任务`
);
}
}, INTERFACE_DELAY);
}
/**
* 处理该条目类型的接口任务
* @param {StaffMapList} [staffMapList=null]
* - 在设置模式下其由`StaffMapListEditor`传入,非设置模式下则自行定义
* @param {boolean} [forced=false]
* - 在设置模式下或版本更新下将为强制,不对原有的排序结果状态进行检查,强制写入
*/
static processTask(type, staffMapList = null, forced = false) {
const tasks = SortingInterface._tasksByType[type];
if (!tasks) return;
staffMapList ??= new StaffMapList(type);
// 任何模式均需初始化,使其加载当前最新数据,并强制激活排序能力
staffMapList.initialize(false, true);
for (const appName of tasks) {
const appKey = Key.makeInterfaceKey(appName, type);
let appValue = null;
// 设置模式下尝试读取缓存
if (SortingInterface.settingMode && appKey in SortingInterface._appCache) {
appValue = SortingInterface._appCache[appKey];
// console.debug(`Hit cache: ${appKey}`, appValue);
} else {
appValue = SortingInterface._parseAppValue(appKey);
if (SortingInterface.settingMode) SortingInterface._appCache[appKey] = appValue;
}
if (!appValue) continue;
// 判断有无更新必要
if (!forced && SortingInterface._parseSortedValue(appKey, appValue.version)) continue;
try {
// 尝试进行更新
const sorted = SortingInterface.calcSortedValue(appValue, staffMapList);
localStorage.setItem(`${appKey}_sorted`, JSON.stringify(sorted));
console.log(`${INTERFACE_NAME}:${appKey}_sorted 数据更新`, sorted);
SortingInterface.validAppCount++;
} catch (e) {
SortingInterface._appCache[appKey] = null; // 剔除数据
console.error(`${INTERFACE_NAME}:${appKey}.data: 解析失败 - ${e}`);
}
}
}
/**
* 进行排序并包装结果
* @param {{ data: AppData, version: any }} appValue
* @param {StaffMapList} staffMapList
* @returns {{ data: AppData, index: number[], version: any }}
*/
static calcSortedValue(appValue, staffMapList) {
if (staffMapList.isNull()) {
// staffMapList 空缺设置,即原排序顺序
const size = appValue.data.length;
return {
data: appValue.data,
index: Array.from({ length: size }, (_, i) => i),
version: appValue.version,
};
}
const sorter = new InterfaceStaffSorter(staffMapList, appValue.data);
sorter.sort();
return {
data: sorter.sortedData,
index: sorter.sortedIndex,
version: appValue.version,
};
}
/** 解析原有的排序结果,判断是否有更新的必要 */
static _parseSortedValue(appKey, preVer) {
const value = localStorage.getItem(`${appKey}_sorted`);
if (value === null) return false; // 新的应用任务
try {
const sortedValue = JSON.parse(value);
if (sortedValue.version === preVer)
return true; // 当且仅当版本号完全相同时
else return false;
} catch {
return false;
}
}
/**
* 解析应用传递的数值,在设置模式中将进行缓存,形如
* `{'data': AppData, 'version': '1.0'}`
* 对`AppData`的详细解析将在`InterfaceStaffSorter`中进行
* @returns {{data: AppData, version: any} | null}
*/
static _parseAppValue(appKey) {
const value = localStorage.getItem(appKey);
if (value === null) {
console.error(`${INTERFACE_NAME}:${appKey} 键值为空`);
return null;
}
let appValue;
try {
appValue = JSON.parse(value);
} catch (e) {
console.error(`${INTERFACE_NAME}:${appKey}: ${value} 解析失败 - ${e}`);
return null;
}
if (typeof appValue !== "object") {
console.error(`${INTERFACE_NAME}:${appKey}: ${value} 非对象类型`);
return null;
} else if (!appValue.data || !appValue.version) {
console.error(
`${INTERFACE_NAME}:${appKey}: ${value} 缺失'data'或'version'的有效键值`
);
return null;
}
return appValue;
}
/**
* 拆解共享的应用注册表信息为分组任务队列
* 将 {A:a, B:a, C:b} => {a:[A,B], b:[C]}
* 同时检查`SubjectType`是否有效
*/
static _processRegister() {
// SortingInterface.acquireLock(); // 上锁
const register = SortingInterface._parseRegister();
// SortingInterface.releaseLock(); // 解锁
if (!register) return;
SortingInterface._tasksByType = Object.entries(register).reduce(
(acc, [key, value]) => {
SortingInterface.appCount++;
if (!SubjectType.parse(value)) {
console.error(`${INTERFACE_NAME}:${Key.SHARED_REGISTER}.${key}: ${value} 写入的条目类型无效,` +
`不予处理,类型应为 {${SubjectType.getAll()}} 之一`);
return acc;
}
if (!acc[value]) acc[value] = [];
acc[value].push(key);
return acc;
},
{}
);
}
/**
* 初步解析共享的应用注册表,确保其是可读可写的
* 注册表形如 {'app01': 'music', 'app02': 'anime'}
*/
static _parseRegister() {
const value = localStorage.getItem(SortingInterface.registerKey);
if (value === null) return SortingInterface._resetRegister();
let register;
try {
register = JSON.parse(value);
} catch (e) {
console.error(
`${INTERFACE_NAME}:${Key.SHARED_REGISTER}: ${value} 解析失败,可能遭到污染,将进行重置 - ${e}`
);
return SortingInterface._resetRegister();
}
if (typeof register !== "object") {
console.error(
`${INTERFACE_NAME}:${Key.SHARED_REGISTER}: ${value} 非对象类型,可能遭到污染,将进行重置`
);
return SortingInterface._resetRegister();
}
return register;
}
/** 重置注册表 */
static _resetRegister() {
localStorage.setItem(SortingInterface.registerKey, "{}");
console.log(`${INTERFACE_NAME}:${Key.SHARED_REGISTER} 初始化`);
return null;
}
/* 上锁机制,暂不启用
// `lockKey = 'BangumiStaffSortingInterface__lock__'`
static acquireLock() {
const now = Date.now();
if (SortingInterface.getLockState(now)) return;
// 如果锁被占用,使用 setTimeout 延迟检查
return new Promise((resolve) => {
function checkLock() {
if (SortingInterface.getLockState(now)) resolve(true);
else setTimeout(checkLock, 100);
}
checkLock();
});
}
static getLockState(now) {
const lockTimestamp = localStorage.getItem(SortingInterface.lockKey);
// 检查是否可以获得锁,如果 lockTimestamp 不存在或超时
if (!lockTimestamp || now - parseInt(lockTimestamp) > LOCK_TIMEOUT) {
localStorage.setItem(SortingInterface.lockKey, now.toString());
return true;
} else return false;
}
static releaseLock() {
localStorage.removeItem(SortingInterface.lockKey);
}
*/
}
/** 匹配相应 URL 类型的函数入口 */
const urlPatterns = [
{ type: 'subject', regex: /^\/subject\/\d+$/, handler: handlerSubject },
{ type: 'character', regex: /^\/character\/\d+$/, handler: handlerSubject },
{ type: 'person', regex: /^\/person\/\d+$/, handler: handlerSubject },
{ type: 'settings', regex: /^\/settings\/privacy$/, handler: handlerSettings },
];
/** 主函数入口 */
function main() {
Store.initialize();
SortingInterface.initialize();
let patternType = null;
for (const pattern of urlPatterns) {
if (pattern.regex.test(pathname)) {
patternType = pattern.type;
pattern.handler(patternType);
break;
}
}
SortingInterface.runAsyncTask();
}
/** 处理设置 */
function handlerSettings() {
SortingInterface.settingMode = true; // 开启接口的设置模式
const ui = buildSettingUI({ id: 'staff_sorting' });
document.getElementById('columnA').appendChild(ui);
loadSettingStyle();
// 支持 url.hash = ID 进行导引
if (location.hash.slice(1) === 'staff_sorting') {
ui.scrollIntoView({ behavior: 'smooth' });
}
}
/** 处理条目 */
function handlerSubject(subType) {
if (SubjectType.needPrase(subType))
subType = SubjectType.parse(getSubjectType());
if (!subType) return; // 不支持该类型条目
const ul = document.querySelector('#infobox');
const staffMapList = new StaffMapList(subType);
staffMapList.initialize(true);
if (!staffMapList.isNull()) {
// 实行自定义的职位顺序
const staffDict = getStaffDict(ul);
// 延迟执行,提高对修改 infobox 信息的其他脚本的兼容性
setTimeout(() => {
const sorter = new HtmlStaffSorter(ul, staffMapList, staffDict);
sorter.sort();
// 依赖 sortStaff 解析得到的数据
dealLastGroup(ul);
changeExpandToToggleButton(ul);
}, SORTING_DELAY);
} else {
// 实行网页原有的职位顺序
addFoldableTag(ul);
dealLastGroup(ul);
changeExpandToToggleButton(ul);
console.log(`${SCRIPT_NAME}:实行网页原有的职位顺序`);
}
loadStaffStyle();
addRefoldToggleButton(ul);
}
/**
* 巧妙地使用非常便捷的方法,获取当前条目的类型
* 源自 https://bangumi.tv/dev/app/2723/gadget/1242
*/
function getSubjectType() {
const href = document.querySelector("#navMenuNeue .focus").getAttribute("href");
return href.split("/")[1];
}
/**
* 获取一个对象来存储网页中的职位信息。
* 并对职位信息进行二次折叠,
* 同时将`sub_group`及属其所有的`sub_container`打包为一个序列作为字典的键值
* @param {HTMLElement} ul - `infobox`
* @returns {Object<string, HTMLElement | Array<HTMLElement>>} 返回职位信息字典,键值为`DOM`或者`DOM`序列
*/
function getStaffDict(ul) {
const staffDict = {};
const lis = ul.querySelectorAll(":scope > li");
lis.forEach((li) => {
const tip = li.querySelector("span.tip");
if (!tip) return;
let job = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
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");
refoldStaff(li, tip);
// li.folded 属性已经失效无需还原
}
});
return staffDict;
}
/**
* 为网页原有的`folded`类别添加`foldable`便签,用于实现切换,
* 忽略属于`sub_group`的`sub_container`,
* 并对职位信息进行二次折叠
* @param {HTMLElement} ul - `infobox`
*/
function addFoldableTag(ul) {
const lis = ul.querySelectorAll(':scope > li');
lis.forEach(li => {
const flag = li.classList.contains('folded') && !li.hasAttribute("attr-info-group");
if (flag) {
if (!hasFolded) hasFolded = true;
// 为了正确计算元素高度,需先使其 display
li.classList.remove('folded');
}
const tip = li.querySelector('span.tip');
if (tip) refoldStaff(li, tip);
/* 特殊用法 StaffMapListJSON = "[]" 同时 EnableState = "partialDisable"
* 将实行网页原有的职位顺序,同时禁止其折叠 */
if (flag && Store.get(Key.ENABLE_STATE_KEY) !== EnableState.PARTIAL_ENABLED)
li.classList.add('folded', 'foldable');
// 获取 lastGroup
if (li.classList.contains("sub_group")) lastGroup = [li];
else if (li.classList.contains("sub_container")
&& li.hasAttribute("attr-info-group")) lastGroup.push(li);
});
if (Store.get(Key.ENABLE_STATE_KEY) === EnableState.PARTIAL_ENABLED)
hasFolded = false;
}
/**
* 对超出限制行数的职位信息进行二次折叠,并添加开关。
* 实现动态不定摘要的类似于`summary`的功能。
* 过滤`别名`等不定行高的`infobox`信息
* @param {HTMLElement} li - 职位信息根节点
* @param {HTMLElement} tip - 职位名称节点
*/
function refoldStaff(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; // 过滤不定行高的 infobox 信息
if (!JobStyle.compStyle) JobStyle.initialize(li);
const lineCnt = getLineCnt(li);
const refoldThr = Store.get(Key.REFOLD_THRESHOLD_KEY);
if (lineCnt <= refoldThr) return;
// 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入)
li.classList.add('refoldable', 'refolded');
// const nest = nestElementWithChildren(li, 'div', {class: 'refoldable refolded'});
/* 尝试不修改 DOM 结构仅通过添加样式达到完备的折叠效果,
* 难点在于处理溢出到 li.padding-bottom 区域的信息
* 最终通过施加多层遮蔽效果实现,故不再需要内嵌一层新的 div 元素 */
// 添加头部开关状态图标
const prefIcon = createElement('i', { class: 'staff_sorting_icon' });
prefIcon.innerHTML = ICON.TRIANGLE_RIGHT;
/* 尝试使用<symbol><use>模板或直接使用JS构建实例的方法均失败...
* 最终改为直接修改innerHTML */
updateSubElements(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;
sideTipLineThr ??= getSideTipThr(); // 小于阈值的将被隐藏
if (refoldLine >= sideTipLineThr) sideTip.dataset.refoldLine = refoldLine;
// else delete sideTip.dataset.refoldLine;
}
/**
* 为二次折叠按钮绑定开关事件,
* 采用`事件委托`形式绑定事件 (事件冒泡机制)
* @param {HTMLElement} ul - `infobox`
*/
function addRefoldToggleButton(ul) {
if (Store.get(Key.REFOLD_THRESHOLD_KEY) === 0) return;
/* 检查点击的元素是否是开关本身 span 或其子元素 icon
* 使用 .closest('.cls') 替代 classList.contains('cls')
* 使得子元素也能响应点击事件 */
ul.addEventListener('click', (event) => {
/** @type {HTMLElement} 被点击的目标 */
const target = event.target;
// 1. 首部开关
const prefTip = target.closest('.switch');
if (prefTip && ul.contains(prefTip)){
// 职位名称或开关状态图标被点击了
const parent = prefTip.parentElement;
if (parent.classList.contains('refolded')) {
parent.classList.remove('refolded');
prefTip.firstChild.innerHTML = ICON.TRIANGLE_DOWN;
} else {
parent.classList.add('refolded');
prefTip.firstChild.innerHTML = ICON.TRIANGLE_RIGHT;
}
return;
}
// 2. 尾部开关
const suffTip = target.closest('.side');
if (!suffTip || !ul.contains(suffTip)) return;
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;
// console.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.firstChild.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);
console.log(`${SCRIPT_NAME}:sideTipLineThreshold:${threshold}`);
return threshold;
}
/**
* 将原本存在的`更多制作人员+`一次性按钮,转绑新事件,并改为永久性开关
* 使用网页原有的`folded`元素类别,实现对立于`sortStaff`功能
* 添加不存在的`更多制作人员+`按钮,否则一些职位信息将永不可见
* @param {HTMLElement} ul - `infobox`
<div class="infobox_expand">
<a href="javascript:void(0)">更多制作人员 +</a>
<!-- href 属性需保留,不然鼠标悬浮效果会失效 -->
</div>
*/
function changeExpandToToggleButton(ul) {
const buttonValue = { on: '更多制作人员 +', off: '更多制作人员 -' };
let moreLink = document.querySelector('#infobox + .infobox_expand a'); // 无法实现 :scope +
if (!hasFolded) {
// 无必要,不进行事件绑定与可能的添加,并将原有的开关隐藏
if (moreLink) {
moreLink.style.display = 'none';
console.log(`${SCRIPT_NAME} - 将原有的 '${buttonValue.on}' 隐藏`);
}
return;
}
if (!moreLink) {
moreLink = createElement('a', { href: 'javascript:void(0)' }, buttonValue.on);
const expand = createElement('div', { class: 'infobox_expand' }, [moreLink]);
ul.parentElement.appendChild(expand);
console.log(`${SCRIPT_NAME}:添加原不存在的 '${buttonValue.on}' 按钮`);
}
moreLink.addEventListener('click', function (event) {
event.stopImmediatePropagation(); // 阻止其他事件的触发
const foldedLis = document.querySelectorAll('.foldable');
const isHidden = moreLink.innerText == buttonValue.on;
foldedLis.forEach(li => {
if (isHidden) li.classList.remove('folded');
else li.classList.add('folded');
});
moreLink.innerText = isHidden ? buttonValue.off : buttonValue.on;
}, { capture: true }); // 使事件处理函数在捕获阶段运行
}
/**
* 创建用户设置`UI`界面
* 仿照`#columnA`中的同类元素进行构建,使用原有的结构与样式
<table class="settings">
<tbody>
<tr>
<td>
<h2 class="subtitle">条目职位排序 · 默认折叠的职位</h2>
</td>
</tr>
<!-- 此处添加子模块 -->
</tbody>
</table>
*/
function buildSettingUI(mainStyle) {
const mainTitle = createElement('tr', null, [
createElement('td', null, [
createElement('h2', { class: 'subtitle' }, '条目职位排序 · 默认折叠的职位')
])
]);
const lineLimitBlock = buildLineLimitBlock();
const subjectBlocks = SubjectType.getAll(true).map(sub => buildSubjectBlock(sub));
const ui = createElement('div', mainStyle, [
createElement('table', { class: 'settings' }, [
createElement('tbody', null, [
mainTitle, lineLimitBlock, ...subjectBlocks
])
])
]);
return ui;
}
/**
* 创建职位信息二次折叠的行高限制设置界面
<tr>
<td class="line_limit_block">
<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', null, [
createElement('td', { class: 'line_limit_block' }, [
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;
// 绑定事件
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;
setRefloadThreshold(intInput.num); // 使用 DOM 中可能的暂存数据
} else {
intInput.display = false;
setRefloadThreshold(Key.REFOLD_THRESHOLD_DISABLED);
}
});
intInput.onNumChange = setRefloadThreshold;
return block;
}
/**
* 创建`staffMapList`文本内容编辑界面
* 对于`textarea`,`button`等控件仍然使用原有的结构与样式
<tr>
<td class="subject_staff_block">
<details open="">
<summary>
<h2 class="subtitle"><!-- subject type --></h2>
<div class="right_inline">
<p class="tip_j" style="display: inline;"><!-- 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;
// 搭建标题
const subTitle = createElement('h2', { class: 'subtitle' });
// 搭建滑动开关
const selector = new TriStateSlider(`${subTypeObj.en}_subject_enable`);
const selectorMsgBox = createElement('p', { class: 'tip_j' });
const selectorField = createElement('div', {class: 'right_inline hidden'}, [
selectorMsgBox, selector.root
]);
selector.build();
// 定义编辑器,暂不构建
const editor = new StaffMapListEditor(subTypeObj.en);
// 搭建展开容器
const detail = createElement('details', null, [
createElement('summary', null, [
subTitle, selectorField
]),
editor.root
])
// 搭建外部结构
const block = createElement('tr', null, [
createElement('td', {class: 'subject_staff_block'}, 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 EnableState.ALL_DISABLED:
setMessage(selectorMsgBox, '禁用设置,但仍可编辑保存'); break;
case EnableState.PARTIAL_ENABLED:
setMessage(selectorMsgBox, '仅启用排序,禁用折叠'); break;
case EnableState.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';
constructor(subType) {
this.subType = subType;
this.staffMapList = new StaffMapList(subType);
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.built = false; // 标记是否已经初始化
}
build() {
if (this.built) return; // 防止重复构建
// 构建元素结构
this.textArea = createElement('textarea', {
class: 'quick markItUpEditor hasEditor codeHighlight', name: '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;
// console.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) {
setMessage(this.editorMsgBox, '已为默认设置');
} else {
// 由自定义改为默认设置
this.staffMapList.resetData();
setMessage(this.editorMsgBox, '保存成功!恢复默认设置');
// 对该条目类型的接口任务进行处理
SortingInterface.processTask(this.subType, this.staffMapList, true);
}
this.hasInputed = false;
return;
}
if (!this.hasInputed) {
setMessage(this.editorMsgBox, '未作修改');
return;
}
const [modifiedData, isModified, curCursorPos] = StaffMapListEditor.modifyText(this.textArea);
// 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除
const savedDate = `[${modifiedData}]`;
const parsedData = StaffMapListJSON.parse(savedDate);
// 数据解析失败
if (!parsedData) return setMessage(this.editorMsgBox, '保存失败!格式存在错误');
// 保存数据
this.staffMapList.saveData(savedDate);
// 页面显示
if (modifiedData.trim() === "") setMessage(this.editorMsgBox, '保存成功!空缺设置');
else if (isModified) {
await trySetText(
this.textArea, this.editorMsgBox, modifiedData,
'保存成功!并自动纠错', true, curCursorPos
);
} else setMessage(this.editorMsgBox, '保存成功!');
// 对该条目类型的接口任务进行处理
SortingInterface.processTask(this.subType, this.staffMapList, true);
this.hasInputed = 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)) {
console.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" id="refold_threshold_input">
<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 states = [
EnableState.ALL_DISABLED, // 1
EnableState.PARTIAL_ENABLED, // 2
EnableState.ALL_ENABLED // 3
];
static default = Key.ENABLE_STATE_DEFAULT;
// 所用样式的类名
static _selectorCls = 'tri_state_selector';
static _radioCls = 'radio_input';
static _labelCls = 'radio_label';
static _sliderCls = 'select_slider';
static _indicatorCls = 'select_indicator';
/**
* @type {(newState: string) => void | null}
* 回调函数,当状态变化时被调用
*/
onStateChange = null;
constructor(idPref, initState = TriStateSlider.default) {
this.root = createElement('div', { class: TriStateSlider._selectorCls });
this.radios = {};
this.idPref = idPref;
this.initState = initState;
this._stateHis = {pre: null, pre2: null};
this._initStateHis();
}
set state(state) {
if (!state || !TriStateSlider.states.includes(state))
state = TriStateSlider.default;
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 本体将通过样式隐藏
TriStateSlider.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 === TriStateSlider.states[1]
? TriStateSlider.states[2] : TriStateSlider.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 new Promise(resolve => setTimeout(resolve, transTime));
textArea.value = text;
setMessage(msgBox, msg, 0);
console.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 new Promise(resolve => setTimeout(resolve, timeout));
document.execCommand('insertText', false, newText);
}
async function setMessage(container, message, timeout = 100) {
container.style.display = 'none';
if (!message) return; // 无信息输入,则隐藏
// 隐藏一段时间后,展现新内容
if (timeout) await new Promise(resolve => setTimeout(resolve, timeout));
container.textContent = message;
container.style.display = 'inline';
}
/**
* 获取当前页面的视口高度
*/
function getViewportHeight() {
return document.documentElement.clientHeight || document.body.clientHeight;
}
/**
* 创建元素实例
* @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 (let opt in options) {
if (opt === 'class') element.className = options[opt];
else if (['maxlength'].includes(opt)) element.setAttribute(opt, options[opt]);
else if (opt === 'dataset' || opt === 'style') {
for (let key in options[opt]) {
element[opt][key] = options[opt][key];
}
} else element[opt] = options[opt];
}
}
if (subElements) updateSubElements(element, subElements);
if (eventHandlers) {
for (let e in 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 updateSubElements(parent, subElements, actionType = 'append') {
if (actionType === 'replace') parent.innerHTML = '';
if (!subElements) return parent;
if (!Array.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); // 立即执行
};
}
/**
* 过滤对象中的方法,只返回对象的枚举值
* @param {Object} obj - 需要过滤的对象
* @param {(value: any) => boolean} [filterFn = value => typeof value !== 'function'] - 可选的过滤函数
* @returns {Array} 过滤后的枚举值数组
*/
function filterEnumValues(obj, filterFn = value => typeof value !== 'function') {
return Object.values(obj).filter(filterFn);
}
/**
* `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;
console.log(
`${SCRIPT_NAME}:lineHeight:${this.lineHeight}px, ` +
`borderBottom:${this.borderBottom}, paddingBottom:${this.paddingBottom}`
);
},
}
/**
* 动态载入职位排序的样式,
* 依据的职位信息行高`jobLineHeight`与设置的限制行数`maxRefoldLines`
*/
function loadStaffStyle() {
const style = createElement('style', {class: 'staff_sorting'});
// 使用CSS变量,以便未来拓展监听窗口布局变化
style.innerHTML = `
:root {
--refold-threshold: ${Store.get(Key.REFOLD_THRESHOLD_KEY)};
--job-line-height: ${JobStyle.lineHeight}px; /* 18px */
--job-border-bottom: ${JobStyle.borderBottom}; /* 0.64px */
--job-padding-bottom: ${JobStyle.paddingBottom}; /* 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;
}
/* 防止图标可能污染爬取 infobox 数据的脚本 */
.staff_sorting_icon {
display: none;
}
#infobox .staff_sorting_icon {
display: inline;
}
/* 职位信息二次折叠 */
#infobox li.refoldable {
display: inline-block; /* 使其容纳.tip.side */
height: auto;
overflow: visible;
}
#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% calc(100% - var(--job-line-height) - var(--job-padding-bottom) - var(--job-border-bottom));
mask-size:
100% var(--job-border-bottom),
100% var(--job-padding-bottom),
100% var(--job-line-height),
100% calc(100% - var(--job-line-height) - var(--job-padding-bottom) - var(--job-border-bottom));
-webkit-mask-position:
0 100%,
0 calc(100% - var(--job-border-bottom)),
0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)),
0 0;
mask-position:
0 100%,
0 calc(100% - var(--job-border-bottom)),
0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)),
0 0;
-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.switch:hover i,
#infobox .tip.side:hover i {
color: #2ea6ff;
}
#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 {
--tri-state-selector-size: 22px;
--tri-state-selector-step: 19px;
}
/* 设置界面的样式 */
#staff_sorting > .settings {
margin-left: 5px;
}
#staff_sorting .right_inline {
height: 22px;
float: right;
display: flex;
align-items: center;
}
#staff_sorting .right_inline.hidden {
display: none;
}
.line_limit_block h2 {
font-size: 16px;
display: inline-block;
}
/* 各类型条目的职位设置模块 */
.subject_staff_block h2,
.subject_staff_block summary::marker {
font-size: 16px;
display: inline-block;
cursor: pointer;
}
.subject_staff_block .staffMapList_editor {
padding-right: 10%;
margin-bottom: 5px;
}
.subject_staff_block textarea {
font-size: 15px;
line-height: 21px;
}
.subject_staff_block .inputBtn {
margin-right: 5px;
}
.subject_staff_block .tip_j {
display: none;
margin: 0 5px;
}
.subject_staff_block .right_inline .tip_j {
display: none;
margin-right: 15px;
}
/* 滑动开关 */
.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);
}
/* 数字输入框与控制器 */
.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: 12px;
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: 7px;
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;
}
/* 三态滑动选择器 */
.tri_state_selector {
position: relative;
width: calc(
var(--tri-state-selector-size) + var(--tri-state-selector-step) * 2
);
height: var(--tri-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(--tri-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(--tri-state-selector-size) - 4px);
height: calc(var(--tri-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(--tri-state-selector-step);
height: 100%;
top: 0;
cursor: pointer;
z-index: 3;
}
label.radio_label:nth-of-type(1) {
left: 0;
}
label.radio_label:nth-of-type(2) {
left: var(--tri-state-selector-step);
}
label.radio_label:nth-of-type(3) {
width: var(--tri-state-selector-size);
left: calc(var(--tri-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(--tri-state-selector-step));
}
input.radio_input:nth-of-type(3):checked ~ .select_slider .select_indicator {
transform: translateX(calc(var(--tri-state-selector-step) * 2));
}
.select_slider::after {
content: "";
position: absolute;
width: calc(var(--tri-state-selector-size) + var(--tri-state-selector-step));
height: var(--tri-state-selector-size);
left: var(--tri-state-selector-step);
border-radius: calc(var(--tri-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(--tri-state-selector-step)));
}
`;
document.head.appendChild(style);
}
main();
})();