// ==UserScript==
// @name 班固米-条目职位自定义排序与折叠
// @namespace https://github.com/weiduhuo/scripts
// @version 1.2.2-1.1
// @description 对[动画]条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置
// @author weiduhuo
// @match *://bgm.tv/subject/*
// @match *://bgm.tv/settings/privacy*
// @match *://bangumi.tv/subject/*
// @match *://bangumi.tv/settings/privacy*
// @match *://chii.in/subject/*
// @match *://chii.in/settings/privacy*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_NAME = '班固米-职位排序组件';
// 图标
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: 'anime',
// 待支持的类型
// BOOK: 'book', MUSIC: 'music', GAME: 'game', REAL: 'real', CHARACTER: 'character', PERSON: 'person',
getAll() {
return Object.values(this);
},
prase(value) {
if (this.getAll().includes(value)) return value;
return null;
},
// needPrase(value) {
// return value !== this.CHARACTER && value !== this.PERSON;
// },
};
/**
* 职位的排序列表`jobOrder`与默认折叠的职位`foldableJobs`的合并信息
* 基本类型:`
type = [Job | [boolean | Job, ...Job[]]]
Job = string | RegExp
* `其中`boolean`表示子序列内的职位是否默认折叠,缺损值为`False`,需位于子序列的首位才有意义
* (下文`,,`表示在`JSON`数组中插入`null`元素,用于输出格式化文本时标记换行)
*/
const staffMapList = [,
"中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"], ,
"放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/, ,
,
"原作", "原案", "人物原案", "原作插图", [true, "原作协力"], ,
"团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督", ,
"系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/], ,
"分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"], ,
"人物设定", ,
,
"总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/, ,
"主动画师", "主要动画师", [true, "构图"], [false, "原画"], [true, "第二原画", "补间动画"], "数码绘图", /(原画|动画|動画)(?!制|检查)/, ,
"动画检查", [true, /动画检查/], ,
,
"设定", "背景设定", "道具设计", /(?<!色彩|美术|美術)(设|設)(?!.*制)/, /Design|デザイン|Motion Graphic|モーショングラフィック/, ,
"色彩设计", [false, "色彩指定", "色指定"], [true, "上色", /(?<!角)色/], ,
"美术监督", /(美术|美術).*导演/, [false, "美术设计"], "概念美术", "视觉概念", "概念艺术", ,
[false, "背景美术"], [true, /景/], /ART WORK|美术|美術|艺术|工艺|创意|绘制/, ,
,
/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",
];
// 职位的排序列表
const jobOrder = [];
// 默认折叠的职位
const foldableJobs = [];
// 默认值的格式化文本的缓存
let mapListTextBuffer = null;
// 超过此行数的职位信息将被二次折叠 (现为固定值,在之后的版本用户可以自定义)
let maxRefoldLines = 4;
// 是否对职位信息进行了折叠 (依此判断 `更多制作人员` 开关的必要性)
let hasFolded = false;
// 职位信息的行距
let jobLineHeight = null;
const pathname = window.location.pathname;
// 匹配相应 URL 类型
const urlPatterns = [
{ type: 'subject', regex: /^\/subject\/\d+$/, handler: handlerSubject },
// { type: 'character', regex: /^\/character\/\d+$/, handler: trySortStaff },
// { type: 'person', regex: /^\/person\/\d+$/, handler: trySortStaff },
{ type: 'settings', regex: /^\/settings\/privacy$/, handler: handlerSettings },
];
function main() {
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 (needPrase(subType))
subType = SubjectType.prase(getSubjectType());
if (!subType) return; // 不支持该类型条目
loadMapList();
const ul = document.querySelector('#infobox');
if (staffMapList.length) {
sortStaff(ul);
} else {
addFoldableTag(ul);
console.log(`${SCRIPT_NAME}:staffMapList 设置空缺,网页实行原有的职位顺序与折叠`);
}
loadStaffStyle();
changeExpandToToggleButton(ul);
addRefoldToggleButton(ul);
}
/**
* 巧妙地使用非常便捷的方法,获取当前条目的类型
* 源自 https://bangumi.tv/dev/app/2723/gadget/1242
* 替代了下方的原有方法
*/
function getSubjectType() {
const href = document.querySelector("#navMenuNeue .focus").getAttribute("href");
return href.split("/")[1];
}
function sortStaff(ul) {
// 职位信息字典
const staffDict = getStaffDict(ul);
// 清空原始的`staff`列表
ul.innerHTML = '';
// 未能匹配职位的待插入位置
let liAfterIntsert = null;
let insterTag = false;
let insertFold = false;
// 按照预定顺序添加到 DOM
jobOrder.forEach(item => {
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 return;
} else if (typeof item === 'string') {
// 2.键值匹配
if (item && item in staffDict) {
matchingRoles.push(item);
// 3.特殊关键字处理
} else if (item.startsWith('==')) {
// 激活待插入位置
insterTag = true;
insertFold = foldableJobs.includes(item);
} else return
// 4.其余情形均忽略(且对于意外类型不报错)
} else return;
// 添加职位,并判断是否默认折叠
matchingRoles.forEach(role => {
const li = staffDict[role];
if (typeof item === 'string' && foldableJobs.includes(role)
|| item instanceof RegExp && foldableJobs.includes(item)) {
li.classList.add('folded', 'foldable');
if (!hasFolded) hasFolded = true;
}
ul.appendChild(li);
delete staffDict[role]; // 从字典中删除已处理的职位
// 保存待插入位置
if (insterTag) {
liAfterIntsert = li;
insterTag = false;
}
});
});
// 将剩余未被匹配的职位按原顺序添加到待插入位置
const unmatchedJobs = Object.keys(staffDict);
if (unmatchedJobs.length === 0) {
return;
}
unmatchedJobs.forEach(role => {
const li = document.createElement('li');
li.innerHTML = staffDict[role];
if (insertFold) li.classList.add('folded', 'foldable');
if (liAfterIntsert) ul.insertBefore(li, liAfterIntsert);
// 未设置待插入位置,则默认插入到末尾,且默认不折叠
else ul.appendChild(li);
});
console.log(`${SCRIPT_NAME}:未能匹配到的职位 ${JSON.stringify(staffDict, null, 2)}`);
if (liAfterIntsert) console.log(`${SCRIPT_NAME}:激活将未能匹配职位插入指定位置`);
}
/**
* 获取一个字典来存储网页中的职位信息,
* 并对职位信息进行二次折叠
*/
function getStaffDict(ul) {
const staffDict = {};
const lis = ul.querySelectorAll(':scope > li');
lis.forEach(li => {
const tip = li.querySelector('span.tip');
if (tip) {
const role = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
// 为了正确计算元素高度,需使其 display
li.classList.remove('folded');
refoldStaff(li, tip);
staffDict[role] = li;
// li.folded 属性已经失效无需还原
}
});
return staffDict;
}
/**
* 为网页原有的`folded`类别添加`foldable`便签,用于实现切换,
* 并对职位信息进行二次折叠
*/
function addFoldableTag(ul) {
const lis = ul.querySelectorAll(':scope > li');
lis.forEach(li => {
let flag = li.classList.contains('folded');
if (flag) {
if (!hasFolded) hasFolded = true;
// 为了正确计算元素高度,需先使其 display
li.classList.remove('folded');
}
const tip = li.querySelector('span.tip');
if (tip) refoldStaff(li, tip);
if (flag) li.classList.add('folded', 'foldable');
});
}
/**
* 对超出限制行数的职位信息进行二次折叠,并添加开关。
* 实现类似于`summary`但是动态摘要的功能。
* 过滤`别名`等不定行高的`infobox`信息
*/
function refoldStaff(li, tip) {
if (li.classList.contains('sub_container')) return; // 不定行高的 infobox 信息
const lineCnt = getLineCnt(li);
if (lineCnt <= maxRefoldLines) return;
// 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入)
nestElementWithChildren(li, 'div', {class: 'refoldable refolded'});
// 尝试不修改 DOM 结构仅通过样式添加折叠效果,但未果,故改为内嵌一层新元素
// 添加开关状态图标
const icon = createElement('i');
icon.innerHTML = ICON.TRIANGLE_RIGHT;
/* 尝试使用<symbol><use>模板或直接使用JS构建实例的方法均失败...
* 最终改为直接修改innerHTML */
updateSubElements(tip, icon, 'prepend');
tip.classList.add('switch');
}
/**
* 为二次折叠按钮绑定开关事件,
* 采用`事件委托`形式绑定事件 (事件冒泡机制)
*/
function addRefoldToggleButton(ul) {
ul.addEventListener('click', (event) => {
/* 检查点击的元素是否是开关本身或其子元素
* 使用 .closest('.switch') 替代 classList.contains('switch')
* 使得子元素也能响应点击事件 */
const tip = event.target.closest('.switch');
if (!tip || !ul.contains(tip)) return;
// 职位名称或开关状态图标被点击了
const parent = tip.parentElement;
if (parent.classList.contains('refolded')) {
parent.classList.remove('refolded')
tip.firstChild.innerHTML = ICON.TRIANGLE_DOWN;
} else {
parent.classList.add('refolded')
tip.firstChild.innerHTML = ICON.TRIANGLE_RIGHT;
}
});
/* 在 mousedown 阶段阻止用户拖动或双击时的默认选中行为。
* 由于 span.switch 本质仍然是内容段落的一部分,
* 不通过 user-select: none 这钟粗暴的方法禁止用户的一切选中行为
* 而是采用温和的方法阻止部分情形下对该区域的选中行为 */
ul.addEventListener('mousedown', (event) => {
if (event.target.closest('.switch')) event.preventDefault();
});
}
/**
* 获取固定行高元素显示的行数
* 经测试,职员信息除了`8px`的`padding`还有`0.03555px`的`border`因为不影响行数计算忽略
*/
function getLineCnt(el, padding = 8, border = 0) {
jobLineHeight ??= getLineHeight(el);
const height = el.getBoundingClientRect().height - padding - border;
return ~~(height / jobLineHeight);
}
/**
* 将原本存在的`更多制作人员+`一次性按钮,转绑新事件,并改为永久性开关
* 使用网页原有的`folded`元素类别,实现对立于`sortStaff`功能
* 添加不存在的`更多制作人员+`按钮,否则一些职位信息将永不可见
<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 animeBlock = buildAnimeBlock();
const ui = createElement('div', mainStyle, [
createElement('table',
{ class: 'settings' }, [
createElement('tbody', null, [
mainTitle,
// lineLimitBlock, // 未完成开发,暂不启用
animeBlock,
// 可拓展其他类型条目的模块
])
])
]);
return ui;
}
/**
* 创建职位信息二次折叠的行高限制设置界面
*/
function buildLineLimitBlock() {
const subTitle = createElement('h2', { class: 'subtitle' }, '职位信息高度 限制');
// 搭建滑动开关
const [toggle, toggleCntr] = buildToggleSlider('refold_switch');
// 搭建数字输入框与控制器,不使用 input.type = 'number' 而是自我搭建相关控制
const label = createElement('span', { class: 'text'}, '行数');
const inputNum = createElement('input', { class: 'inputtext input_num', type: 'text', maxlength: '2' });
const incBtn = createElement('div', { name: 'inc_btn' });
const decBtn = createElement('div', { name: 'dec_btn' });
// 搭建外部框架
const numInputCntr = createElement('fieldset', { class: 'num_input_cntr' },[
label, inputNum,
createElement('div', { class: 'num_ctrs' }, [incBtn, decBtn])
]);
const block = createElement('tr', null, [
createElement('td', { class: 'line_limit_block' }, [
subTitle, createElement('div', {class: 'right_inline'}, [numInputCntr, toggleCntr])
])
]);
// 初始化
const minNum = {int: 1, str: '1'};
toggle.checked = true;
inputNum.value = '4';
incBtn.innerHTML = ICON.TRIANGLE_UP;
decBtn.innerHTML = ICON.TRIANGLE_DOWN;
// 绑定事件
toggle.addEventListener('click', () => {
if (toggle.checked) {
numInputCntr.style.display = 'flex';
} else {
numInputCntr.style.display = 'none';
}
});
// 限制输入为正整数
inputNum.addEventListener('input', () => {
let value = inputNum.value.replace(/[^0-9]/g, '');
if (value === '' || parseInt(value) === 0) value = minNum.str;
inputNum.value = value;
});
// 限制键盘输入行为,禁止非数字键输入
inputNum.addEventListener('keydown', (event) => {
if (!/^[0-9]$/.test(event.key) && event.key !== 'Backspace'
&& event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') event.preventDefault();
});
// 增加加减按键功能
incBtn.addEventListener('click', () => {
let value = parseInt(inputNum.value) || minNum.int;
inputNum.value = value + minNum.int;
});
decBtn.addEventListener('click', () => {
let value = parseInt(inputNum.value) || minNum.int;
if (value > minNum.int) inputNum.value = value - minNum.int;
});
return block;
}
/**
* 创建`staffMapList`文本内容编辑界面
* 对于`textarea`,`button`等控件仍然使用原有的结构与样式
<tr>
<td class="subject_staff_block">
<h2 class="subtitle"><!-- subject type --></h2>
<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>
</td>
</tr>
*/
function buildAnimeBlock() {
// 搭建标题
const subTitle = createElement('h2', { class: 'subtitle' });
// 搭建滑动开关
// const selector = new TriStateSlider('anime_staff_sort');
// selector.build();
// 搭建文本框
const textArea = createElement('textarea', {
class: 'quick markItUpEditor hasEditor codeHighlight', name: 'staff_map_list'
});
// 搭建提交按钮
const submitBtn = createElement('input', {
class: 'inputBtn', type: 'submit', name: 'submit_context', value: '保存'
});
// 搭建重置按钮
const resetBtn = createElement('input', {
class: 'inputBtn', type: 'submit', name: 'reset_context', value: '恢复默认'
});
// 搭建简易提示框
const editorMsgBox = createElement('p', { class: 'tip_j'});
const selectorMsgBox = createElement('p', { class: 'tip_j' });
// 搭建外部结构
const block = createElement('tr', null, [
createElement('td', {class: 'subject_staff_block'}, [
subTitle,
// createElement('div', {class: 'right_inline'}, [selectorMsgBox, selector.root]), // 未完成开发,暂不启用
// 可拓展折叠效果
createElement('div', { class: 'staffMapList_editor'}, [
createElement('div', { class: 'markItUp' }, textArea),
createElement('div', null, [submitBtn, resetBtn, editorMsgBox])
])
])
]);
function setToggleMsgBox(state) {
switch (state) {
case '1':
setMessage(selectorMsgBox, '禁用设置,但仍可编辑保存');
break;
case '2':
setMessage(selectorMsgBox, '仅启用排序,禁用折叠');
break;
case '3':
setMessage(selectorMsgBox, '启用自定义 / 默认设置');
break;
}
}
// 初始化
let hasInputted = false;
let {text, isDefault} = getMapListText(false);
subTitle.textContent = '动画条目';
textArea.textContent = text;
// selector.state = '3';
// setToggleMsgBox(selector.state);
if (isDefault) setMessage(editorMsgBox, '现为默认设置'); // 初始化时,提醒用户已为默认设置
if (text.trim() === "") setMessage(editorMsgBox, '现为设置空缺'); // 网页实行原有的职位顺序与折叠
// 绑定事件
// selector.onStateChange = (newState) => {
// setToggleMsgBox(newState);
// };
textArea.addEventListener('input', () => {
if (!hasInputted) hasInputted = true;
if (isDefault) isDefault = false;
// console.log("IS INPUTTING");
});
resetBtn.addEventListener('click', async () => {
if (isDefault) return setMessage(editorMsgBox, '已为默认内容');
await trySetText(textArea, editorMsgBox, getMapListText(true).text, '已恢复默认内容', false);
// 需进行同步等待,由于 setText 可能会触发 input 事件
isDefault = true;
hasInputted = false;
})
submitBtn.addEventListener('click', () => {
// 判断是否为重置后未对默认内容进行修改
if (isDefault && !hasInputted) {
resetMapList();
setMessage(editorMsgBox, '保存成功!恢复默认设置');
// 恢复初始状态
hasInputted = false;
return;
}
const [modifiedData, isModified, curCursorPos] = modifyMapListJSON(textArea);
// 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除
const savedDate = `[${modifiedData}]`;
const parsedData = parseMapListJSON(savedDate);
if (parsedData) {
// 保存数据
saveMapListText(savedDate);
// 页面显示
if (modifiedData.trim() === "") setMessage(editorMsgBox, '保存成功!空缺设置');
else if (isModified) trySetText(textArea, editorMsgBox, modifiedData, '保存成功!并自动纠错', true, curCursorPos);
else setMessage(editorMsgBox, '保存成功!');
} else setMessage(editorMsgBox, '保存失败!格式存在错误');
// 恢复初始状态
hasInputted = false;
});
return block;
}
/**
* 三态滑动选择器
*/
class TriStateSlider {
// 可选状态
static states = ['1', '2', '3'];
// 所用样式的类名
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;
/**
* 构造函数
* @param {string} idPref - 选择器的`ID`前缀
* @param {'1'|'2'|'3'} [initState='1'] - 初始状态
*/
constructor(idPref, initState = '1') {
this.root = createElement('div', { class: 'tri_state_selector' });
this.radios = {};
this.idPref = idPref;
this.initState = initState;
this._stateHis = {pre: this.initState, pre2: this.initState};
this._initStateHis();
}
/**
* 设置选择器状态
* @param {'1'|'2'|'3'} state - 状态
*/
set state(state) {
this.initState = state;
this._initStateHis(state);
this.radios[state].checked = true;
}
/**
* 获取选择器当前的状态
* @returns {'1'|'2'|'3'} 当前状态
*/
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: 'radio_label' });
this.radios[state] = radio;
this.root.append(radio, label);
});
// 构建滑动外观
this.root.append(
createElement('div', { class: 'select_slider' },
createElement('div', { class: 'select_indicator' })
));
// 初始化状态并绑定事件
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;
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]} 返回`开关`与`开关容器`构成的数组
*/
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, transTime = 100) {
let {scrollVert, cursorPos} = getTextAreaPos(textArea);
try {
setMessage(msgBox);
await clearAndSetTextarea(textArea, text, transTime);
setMessage(msgBox, `${msg},可快捷键撤销`, 0);
} catch (e) {
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';
}
/**
* 载入`StaffMapList`数据,并对其作最终解析
*/
function loadMapList() {
// 读取可能的非默认设置
let jsonString = localStorage.getItem('BangumiStaffSorting_animeStaffMapList');
if (jsonString) {
let parsedData = parseMapListJSON(jsonString);
if (parsedData) {
// 修复外层重复嵌套 `[]` 的形式 (忽略存在的漏洞,形如:[[true, ["a"], "b"]] )
if (parsedData.length === 1 && Array.isArray(parsedData[0]) && typeof parsedData[0][0] !== 'boolean') {
parsedData = parsedData[0];
}
staffMapList.length = 0;
staffMapList.push(...parsedData);
} else console.log(`${SCRIPT_NAME}:自定义 staffMapList 解析失败,将使用脚本默认的数据`);
}
// 将数据拆解为 jobOrder 与 foldableJobs
staffMapList.forEach(item => {
if (Array.isArray(item) && item.length) {
// 对数组进行完全展平,提高对非标多层数组的兼容性
item = item.flat(Infinity);
// 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效
// 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效
if (typeof item[0] === 'boolean') {
if (item[0]) foldableJobs.push(...item.slice(1));
jobOrder.push(...item.slice(1));
} else {
jobOrder.push(...item);
}
} else if (typeof item !== 'undefined') {
jobOrder.push(item);
}
});
}
function resetMapList() {
localStorage.removeItem('BangumiStaffSorting_animeStaffMapList');
console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`)
}
function saveMapListText(jsonStr) {
localStorage.setItem('BangumiStaffSorting_animeStaffMapList', jsonStr);
console.log(jsonStr);
console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`);
}
function getMapListText(useDefault) {
let jsonStr = null;
if (!useDefault) {
jsonStr = localStorage.getItem('BangumiStaffSorting_animeStaffMapList');
}
const isDefault = jsonStr === null;
if (jsonStr) {
jsonStr = jsonStr.slice(1, -1); // 消除首尾的 `[]`
} else if (mapListTextBuffer) {
jsonStr = mapListTextBuffer;
} else {
// 将默认数据转化为格式化文本
jsonStr = JSON.stringify(staffMapList, regexReplacer, 1).replace(
/(null,\n )|(\n\s+)/g, (match, g1, g2) => {
if (g1) return '\n';
if (g2) return ' ';
return match;
}).slice(3, -2); // 消除首部 `[ \n` 与尾部 `\n]`
// 使得 `[ `->`[` 同时 ` ]`->`]`
// jsonStr = JSON.stringify(staffMapList, regexReplacer, 1).replace(
// /(null,)|(?<!\[)(\n\s+)(?!])|(\[\s+)|(\s+],)/g, (match, g1, g2, g3, g4) => {
// if (g1) return '\n';
// if (g2) return ' ';
// if (g3) return '[';
// if (g4) return '],';
// return match;
// }).slice(3, -2);
mapListTextBuffer = jsonStr;
}
return {text: jsonStr, isDefault: isDefault};
}
/**
* 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足`JSON`格式
* 并计算文本修改后,光标的适宜位置
* 已基本兼容`JavaScript`格式的文本数据,实现格式转化
* `group2`与`group4`致使正则表达式中不允许出现`/'"`三种字符
*/
function modifyMapListJSON(textArea) {
const preCursorPos = getTextAreaPos(textArea).cursorPos;
let curCursorPos = preCursorPos;
let flags = new Array(5).fill(false);
const rslt = textArea.value.replace(
/(,\s*(?=]|$))|(,\s*)+(?=,)|(')|(?<!'|")(\/[^\/'"]+\/[gimsuy]*)(?!'|")/g,
(match, g1, g2, g3, g4, offset) => {
isTriggered(0, '删除序列末尾元素后的 `,` 逗号', g1);
isTriggered(2, '删除连续重复的 `,` 逗号', g2);
isTriggered(1, '将单引号替换为双引号', g3);
isTriggered(3, '将正则表达式以双引号包裹', g4);
if (g1 || g2) {
let diff = preCursorPos - offset;
if (diff > 0) curCursorPos -= Math.min(diff, match.length);
return '';
}
if (g3) return '"';
if (g4) {
if (offset < preCursorPos && preCursorPos < offset + match.length) curCursorPos += 1;
else if (preCursorPos >= offset + match.length) curCursorPos += 2;
return `"${match}"`;
}
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);
}
}
/**
* 初步解析`staffMapListJSON`字符串
* 仅检查:
* 1.是否满足`JSON`格式
* 2.是否为数组类型
* 3.字符串样式的正则表达式,是否满足规定格式
* 更进一步的解析,将在`loadMapList`中进行
*/
function parseMapListJSON(text) {
let parsedData;
try {
parsedData = JSON.parse(text, regexReviver);
} catch (e) {
console.error(`${SCRIPT_NAME}:staffMapList 解析失败 - ${e}`);
return null;
}
if (!Array.isArray(parsedData)) {
console.error(`${SCRIPT_NAME}:staffMapList 类型错误 - 非数类型`);
return null;
}
return parsedData;
}
/**
* 解析`JSON`字符串中的正则表达式
*/
function regexReviver(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`格式
*/
function regexReplacer(key, value) {
if (value instanceof RegExp) return value.toString();
return value;
}
/**
* 获取元素的行高
*/
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 nestElementWithChildren(parent, newTagName, options) {
const newElement = createElement(newTagName, options, Array.from(parent.childNodes));
parent.innerHTML = '';
parent.appendChild(newElement);
}
/**
* 创建元素实例
* @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];
for (let e of subElements) {
const child = typeof e === 'string' ? document.createTextNode(e) : e;
switch (actionType) {
case "append":
case "replace":
parent.appendChild(child);
break;
case "prepend":
parent.insertBefore(child, parent.firstChild);
break;
default:
throw new Error(`'${actionType}' is invalid action type of updateElements!`);
}
}
return parent;
}
/**
* 动态载入职位排序的样式,
* 依据的职位信息行高`jobLineHeight`与设置的限制行数`maxRefoldLines`
*/
function loadStaffStyle() {
const style = createElement('style', {class: 'staff_sorting'});
style.innerHTML = `
/* 职位信息二次折叠 */
#infobox div.refoldable {
height: auto;
overflow: visible;
}
#infobox div.refolded {
height: ${maxRefoldLines * jobLineHeight}px;
overflow: clip;
mask-image:
linear-gradient(160deg, black 10%, transparent 90%),
linear-gradient(black, black);
mask-size: 100% ${jobLineHeight}px, 100% calc(100% - ${jobLineHeight}px);
mask-position: 0 100%, 0 0;
mask-repeat: no-repeat;
mask-composite: add;
}
#infobox .tip.switch {
cursor: pointer;
}
#infobox .tip.switch:hover {
color: #000;
}
html[data-theme='dark'] #infobox .tip.switch:hover {
color: #FFF;
}
#infobox .tip.switch:hover i {
color: #2ea6ff;
}
`;
document.head.appendChild(style);
}
// 载入设置界面的样式
function loadSettingStyle() {
const style = createElement('style', {class: 'staff_sorting'});
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 td[class$="block"] > h2 {
font-size: 16px;
display: inline-block;
}
#staff_sorting .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();
})();