// ==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 // @downloadURL none // ==/UserScript== (function () { 'use strict'; const SCRIPT_NAME = '班固米-职位排序组件'; // 图标 const ICON = { // 三角形顶点向右,可表展开按键 TRIANGLE_RIGHT: ` `, // 三角形顶点向下,可表折叠按键 TRIANGLE_DOWN: ` `, // 三角形顶点向上,可表折叠按键 TRIANGLE_UP: ` `, }; // 条目类型 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, /动画检查/], , , "设定", "背景设定", "道具设计", /(? { 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; /* 尝试使用模板或直接使用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`功能 * 添加不存在的`更多制作人员+`按钮,否则一些职位信息将永不可见 */ 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`中的同类元素进行构建,使用原有的结构与样式

条目职位排序 · 默认折叠的职位

*/ 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`等控件仍然使用原有的结构与样式

*/ 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,)|(? { // 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*)+(?=,)|(')|(? { 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.|undefined} subElements - 子元素 * @param {object.} 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|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(); })();