// ==UserScript==
// @name 班固米-条目职位自定义排序与折叠
// @namespace https://github.com/weiduhuo/scripts
// @version 1.3.1-1.2
// @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 SORTING_DELAY = 50;
// 防抖延迟时间
const DEBOUNCE_DELAY = 500;
// URL 相对路径
const PATHNAME = window.location.pathname;
// 是否对职位信息进行了折叠,忽略网页自身`sub_group`的折叠 (依此判断 `更多制作人员` 开关的必要性)
let hasFolded = false;
// 职位人员信息的行距
let jobLineHeight = null;
// 尾部折叠图标的激活行数阈值
const sideTipRate = 0.25;
let sideTipLineThr = null;
/**
* @type {Array.<HTMLElement> | null}
* 最后一组`sub_group`的数据包
*/
let lastGroup = null;
/**
* 图标
*/
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: '人物'},
getAll(isObj = false) {
if (isObj) return filterEnumValues(this);
else return filterEnumValues(this).map(item => item.en);
},
prase(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",
getAll() {
return filterEnumValues(this);
},
prase(value) {
if (this.getAll().includes(value)) return value;
return null;
},
};
/**
* 管理`localStorage`的键名与初值。
* 键值分为全局配置与各类型条目配置、简单类型与复杂类型
*/
const Key = {
// 键名前缀
_KEY_PREF: 'BangumiStaffSorting',
// 超过此行数的职位信息将被二次折叠
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',
// 当前使用的键值的所属条目类型 (可即时切换)
_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 && SubjectType.getAll().includes(type)) this._subType = type;
},
isComplexData(key) {
return [this.STAFF_MAP_LIST_KEY].includes(key);
},
isGlobalData(key) {
return [this.REFOLD_THRESHOLD_KEY].includes(key);
}
}
/**
* 配置存储,提供`localStorage`的接口。
* 仅对简单数据类型进行解析、编码、缓存,复杂数据类型放权给外部
*/
class Store {
// 数据缓存,仅对简单类型的键值
static _cache = {};
// 定义静态防抖逻辑的占位 (忽略短时间内改变多对键值的极端情况)
static debouncedSet;
// 为缺损的配置进行初始化
static initialize() {
// 全局配置初始化
['REFOLD_THRESHOLD'].forEach((key) => Store._setDefault(key));
// 局部配置初始化
SubjectType.getAll().forEach((type) => {
['BLOCK_OPEN', 'ENABLE_STATE'].forEach((key) => Store._setDefault(key, type));
});
// 缓存初始化
this._cache = {};
// 动态绑定防抖逻辑,确保 this 指向 Store
this.debouncedSet = debounce(this._set.bind(this));
}
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 {
/**
* 懒加载的默认配置
* 数据基本类型:`
data = [Job | [boolean | Job, ...Job[]]]
Job = string | RegExp
* `其中`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, /制作|製作/],,
"别名", /.+名$/,,
"发行", "官方网站", "在线播放平台", "链接", "播放电视台", "其他电视台", "配给", /配(?!音)|連載|番組|版|播放(?!结束)/,,
"播放结束", "结束",,
,
"其他", /其他/,,
"===此处插入未被匹配的职位===",,
"Copyright",
],
// [SubjectType.BOOK.en]: () => [],
};
// 构造函数
constructor(subType) {
// 所属条目类型(不可变更)
this.subType = subType; // 小心 Store._subType 被设置的其他模块切换
// 数据
this.data = [];
// 职位的排序列表
this.jobOrder = [];
// 默认折叠的职位,EnableState = "particalDisable" 时,值为空
this.foldableJobs = [];
// 是否为默认数据
this.isDefault = null;
// 默认配置格式化文本的缓存
this._defaultTextBuffer = null;
}
/**
* 依据`EnableState`进行初始化,使其具备职位匹配的能力。
* 若仅为获取`StaffMapList`格式化字符串,则不需要执行本初始化。
*/
initialize() {
Key.setSubType(this.subType);
if (Store.get(Key.ENABLE_STATE_KEY) === EnableState.ALL_DISABLED)
return;
if (!this._loadData()) this._setDefault();
this._resolveData();
}
/**
* 空缺设置,将关闭脚本的职位排序。
* 有两种独立开启途径:
* 1. `EnableState = "allDisable"`
* 2. `StaffMapListJSON = "[]"`
*/
isNull() {
return this.data.length === 0;
}
// 保存自定义的数据
saveData(jsonStr) {
Store.set(Key.STAFF_MAP_LIST_KEY, jsonStr, this.subType);
console.log(jsonStr);
console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`);
}
// 恢复默认数据的设置
resetData() {
Store.remove(Key.STAFF_MAP_LIST_KEY, this.subType);
console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`);
}
// 使用懒加载恢复默认配置
_setDefault() {
this.isDefault = true;
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() {
this.jobOrder = [];
this.foldableJobs = [];
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] && Store.get(Key.ENABLE_STATE_KEY, this.subType) ===
EnableState.ALL_ENABLED) {
this.foldableJobs.push(...item.slice(1));
}
this.jobOrder.push(...item.slice(1));
} else {
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;
// 自定义数据
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;
}
}
// 匹配相应 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();
for (const pattern of urlPatterns) {
if (pattern.regex.test(PATHNAME)) {
pattern.handler(pattern.type);
break;
}
}
}
// 处理设置
function handlerSettings() {
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.prase(getSubjectType());
if (!subType) return; // 不支持该类型条目
const ul = document.querySelector('#infobox');
const staffMapList = new StaffMapList(subType);
staffMapList.initialize();
if (!staffMapList.isNull()) {
// 实行自定义的职位顺序
const staffDict = getStaffDict(ul);
// 延迟执行,提高对修改 infobox 信息的其他脚本的兼容性
setTimeout(() => {
sortStaff(ul, staffMapList, staffDict);
// 依赖 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`
* @param {StaffMapList} staffMapList - 职位信息设置
* @param {Object.<string, HTMLElement|Array.<HTMLElement>>} staffDict - 职位信息字典
*/
function sortStaff(ul, staffMapList, staffDict) {
// 未能匹配职位的待插入位置
let liAfterIntsert = null;
let insterTag = false;
let insertFold = false;
/* 无需清空原始的`staff`列表 ul.innerHTML = '';
* appendChild() 会将元素从原先的位置移动到新的位置 */
// 按照预定顺序添加到 DOM
for (const item of staffMapList.jobOrder) {
const matchingRoles = [];
// 1.正则匹配
if (item instanceof RegExp) {
matchingRoles.push(...Object.keys(staffDict).filter(key => item.test(key)));
if (matchingRoles.length) {
console.log(`${SCRIPT_NAME}:使用正则表达式 "${item}" 成功匹配 {${matchingRoles}}`);
} else continue;
} else if (typeof item === 'string') {
// 2.精确匹配
if (item && item in staffDict) {
matchingRoles.push(item);
// 3.特殊关键字处理
} else if (item.startsWith('==')) {
// 激活待插入位置
insterTag = true;
insertFold = staffMapList.foldableJobs.includes(item);
} else continue
// 4.其余情形均忽略 (且对于意外类型不报错)
} else continue;
// 添加职位,并判断是否默认折叠
matchingRoles.forEach((role) => {
const li = staffDict[role];
if (Array.isArray(li)) {
// 将`sub_group`及属其所有的`sub_container`视为一个整体进行排序
ul.append(...li);
lastGroup = li;
} else {
if (typeof item === 'string' && staffMapList.foldableJobs.includes(role)
|| item instanceof RegExp && staffMapList.foldableJobs.includes(item)) {
if (!hasFolded) hasFolded = true;
li.classList.add('folded', 'foldable');
}
ul.appendChild(li);
}
delete staffDict[role]; // 从字典中删除已处理的职位
// 保存待插入位置
if (insterTag) {
liAfterIntsert = Array.isArray(li) ? li[0] : li;
console.debug(liAfterIntsert.innerHTML);
insterTag = false;
}
});
}
// 将剩余未被匹配的职位按原顺序添加到待插入位置
const unmatchedJobs = Object.keys(staffDict);
if (unmatchedJobs.length === 0) return;
unmatchedJobs.forEach(role => {
const li = staffDict[role];
const isGroup = Array.isArray(li);
if (isGroup) lastGroup = li;
else if (insertFold) {
if (!hasFolded) hasFolded = true;
li.classList.add('folded', 'foldable');
}
if (liAfterIntsert) {
if (isGroup) li.forEach(node => ul.insertBefore(node, liAfterIntsert));
else ul.insertBefore(li, liAfterIntsert);
} else {
// 未设置待插入位置,则默认插入到末尾,且默认不折叠
if (isGroup) ul.append(...li);
else ul.appendChild(li);
}
});
dealUnmatched(staffDict);
if (liAfterIntsert) console.log(`${SCRIPT_NAME}:激活将未能匹配职位插入指定位置`);
}
/**
* 获取一个对象来存储网页中的职位信息。
* 并对职位信息进行二次折叠,
* 同时将`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 role = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
if (li.classList.contains("sub_group")) {
// 新的小组
staffDict[role] = [li];
} else if (li.classList.contains("sub_container")
&& li.hasAttribute("attr-info-group")) {
// 整合进组
role = li.getAttribute("attr-info-group");
if (staffDict[role]) staffDict[role].push(li);
else staffDict[role] = [li];
} else {
// 普通元素
staffDict[role] = 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 信息
const lineCnt = getLineCnt(li);
const refoldThr = Store.get(Key.REFOLD_THRESHOLD_KEY);
if (lineCnt <= refoldThr) return;
// 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入)
const nest = nestElementWithChildren(li, 'div', {class: 'refoldable refolded'});
// 尝试不修改 DOM 结构仅通过样式添加折叠效果,但未果,故改为内嵌一层新的 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;
nest.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) => {
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 div = suffTip.parentElement; // li > div
let [pre, cur] = [null, null];
pre = div.getBoundingClientRect().height;
// 更改折叠状态
div.classList.add('refolded');
// 修改首部开关的图标
div.firstChild.firstChild.innerHTML = ICON.TRIANGLE_RIGHT;
// 滚轮自动上移被折叠的距离,以确保折叠后的内容不会让用户迷失上下文
cur = div.getBoundingClientRect().height;
/* 尝试通过 sideTip.dataset.refoldLine 计算滚动数值
* 但滚动结果与理想情况有 ~0.5px 的随机偏差,
* 故改用计算元素窗口的高度变化 */
window.scrollBy({
top: cur - pre,
behavior: 'instant'
});
// console.debug(`Pre: ${pre}\nCur: ${cur}\nDiff: ${cur - pre}`);
});
/* 在 mousedown 阶段阻止用户拖动或双击时的默认选中行为。
* 由于 span.switch 本质仍然是内容段落的一部分,
* 不通过 user-select: none 这钟粗暴的方法禁止用户的一切选中行为
* 而是采用温和的方法阻止部分情形下对该区域的选中行为 */
ul.addEventListener('mousedown', (event) => {
if (event.target.closest('.switch')) event.preventDefault();
});
}
/**
* 处理未被匹配的职位信息,待扩展
* @param {Object.<string, HTMLElement>} staffDict
*/
async function dealUnmatched(staffDict) {
console.log(
`${SCRIPT_NAME}:未能匹配到的职位`,
Object.values(staffDict)
.map((v) =>
`{\n ${(v instanceof HTMLElement
? v.innerText
: v[0].innerText // 对于 [sub_group, sub_container,...] 只打印首元素
).trim()}\n}`
).join(",")
);
}
/**
* 处理最后一组`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');
})
}
/**
* 获取固定行高元素显示的行数
* 经测试,职员信息除了`8px`的`padding`还有`<1px`的`border`因为不影响行数计算忽略
*/
function getLineCnt(el, padding = 8, border = 0) {
jobLineHeight ??= getLineHeight(el);
const height = el.getBoundingClientRect().height - padding - border;
return ~~(height / jobLineHeight);
}
/**
* 根据页面视口高度,计算尾部折叠图标的激活行数阈值
* 对于二次折叠区域较小,不予显示
*/
function getSideTipThr() {
const threshold = ~~(getViewportHeight() / jobLineHeight * 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.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.built = false; // 标记是否已经初始化
}
async 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;
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;
// 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;
}
_onSubmit() {
// 判断是否为重置后未对默认内容进行修改
if (this.isDefault) {
this.staffMapList.resetData();
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) trySetText(
this.textArea, this.editorMsgBox, modifiedData,
'保存成功!并自动纠错', true, curCursorPos
);
else setMessage(this.editorMsgBox, '保存成功!');
}
/**
* 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足`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 getLineHeight(el) {
// 获取元素的计算样式
const compStyle = window.getComputedStyle(el); // 不会返回 em 单位的数据
const fontSize = parseFloat(compStyle.fontSize);
let lineHeight = compStyle.lineHeight;
console.log(`${SCRIPT_NAME}:fontSize:${fontSize}px, lineHeight:${lineHeight}`);
// normal,估算为 1.2 倍
if (lineHeight === 'normal') return fontSize * 1.2;
// 百分比,如 150%
if (lineHeight.endsWith('%')) return fontSize * parseFloat(lineHeight) / 100;
// 像素单位,如 18px
if (lineHeight.endsWith('px')) return parseFloat(lineHeight);
// 倍数,如 1.5
return fontSize * parseFloat(lineHeight);
}
/**
* 获取当前页面的视口高度
*/
function getViewportHeight() {
return document.documentElement.clientHeight || document.body.clientHeight;
}
/**
* 在子元素的外层与父元素间嵌套一层元素
* @returns {HTMLElement} 新建的中间层元素
*/
function nestElementWithChildren(parent, newTagName, options) {
const newElement = createElement(newTagName, options, Array.from(parent.childNodes));
parent.innerHTML = '';
parent.appendChild(newElement);
return newElement;
}
/**
* 查找某个节点在某条件下的下方最近兄弟节点
* @param {HTMLElement} node - 当前节点
* @param {(value: HTMLElement) => boolean} condition - 判断条件函数
* @returns {HTMLElement|null} - 满足条件的最近兄弟节点,若不存在则返回 null
*/
// function findNextSibling(node, condition) {
// let nextSibling = node.nextElementSibling;
// while (nextSibling) {
// if (condition(nextSibling)) return nextSibling;
// nextSibling = nextSibling.nextElementSibling;
// }
// return null;
// }
/**
* 创建元素实例
* @param {string} tagName - 类名
* @param {object} options - 属性
* @param {Array.<HTMLElement|string>|undefined} subElements - 子元素
* @param {object.<string, Function>} 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);
}
/**
* 动态载入职位排序的样式,
* 依据的职位信息行高`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: ${jobLineHeight}px;
}
/* 删除与前继元素重复的边线 */
#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 div.refoldable {
display: inline-block; /* 使其容纳.tip.side */
height: auto;
overflow: visible;
}
#infobox div.refolded {
display: block;
height: calc(
var(--refold-threshold) * var(--job-line-height)
);
overflow: hidden;
-webkit-mask-image:
linear-gradient(160deg, black 10%, transparent 90%),
linear-gradient(black, black);
mask-image:
linear-gradient(160deg, black 10%, transparent 90%),
linear-gradient(black, black);
-webkit-mask-size:
100% var(--job-line-height),
100% calc(100% - var(--job-line-height));
mask-size:
100% var(--job-line-height),
100% calc(100% - var(--job-line-height));
-webkit-mask-position:
0 100%,
0 0;
mask-position:
0 100%,
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();
})();