// ==UserScript==
// @name 班固米-条目职位自定义排序与折叠
// @namespace https://github.com/weiduhuo/scripts
// @version 1.2.0-1.0
// @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 = '班固米-职位排序组件';
/* 职位的排序列表 jobOrder 与默认折叠的职位 foldableJobs 的合并信息
* 数据类型的标准样式:
* Array[ String | RegExp | Array[Boolean, String | RegExp, ...]]
* 其中 Boolean 表示子序列内的职位是否默认折叠,缺损值为 False,需位于子序列的首位才有意义
* (其中 `,,` 表示插入 null 元素,用于输出格式化文本时标记换行 )
*/
const staffMapList = [,
"中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"], ,
"放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/, ,
,
"原作", "原案", "人物原案", "原作插图", [true, "原作协力"], ,
"团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督", ,
"系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/], ,
"分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"], ,
"人物设定", ,
,
"总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/, ,
"主动画师", "主要动画师", [true, "构图"], [true, "原画"], [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, "特别鸣谢", /鸣谢|Thanks/], ,
,
"动画制作", [true, /制作|製作/], ,
"别名", /.+名$/, ,
"发行", "官方网站", "在线播放平台", "链接", "播放电视台", "其他电视台", "配给", /配(?!音)|連載|番組|版|播放(?!结束)/, ,
"播放结束", "结束", ,
,
"其他", /其他/, ,
"===此处插入未被匹配的职位===", ,
"Copyright",
];
// 职位的排序列表
const jobOrder = [];
// 默认折叠的职位
const foldableJobs = [];
// 默认值的格式化文本的缓存
let mapListTextBuffer = null;
// 是否对职位信息进行了折叠 (依此判断 `更多制作人员` 开关的必要性)
let hasFolded = false;
if (location.pathname.startsWith('/subject/')) {
trySortStaff();
}
else if (location.pathname === '/settings/privacy') {
const ui = buildSettingUI({ id: 'staff-sorting' });
document.getElementById('columnA').appendChild(ui);
if (location.hash.slice(1) === 'staff-sorting') {
ui.scrollIntoView({ behavior: 'smooth' });
}
}
function trySortStaff() {
isTargetMediaType().then(result => {
if (!result) {
// console.log(`${SCRIPT_NAME}:此页面条目不是动画类别,脚本不会继续执行。`);
return;
} else {
loadMapList();
if (staffMapList.length) {
sortStaff();
} else {
addFoldableTag();
console.log(`${SCRIPT_NAME}:自定义 staffMapList 数值为空,不进行职位排序`)
}
changeToToggleButton();
}
}).catch(error => {
console.error(`${SCRIPT_NAME}:API查询错误 - ${error}`);
});
}
function sortStaff() {
// 职位信息字典
const staffDict = getStaffDict();
// 清空原始的 staff 列表
const ul = document.querySelector('#infobox');
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);
console.log(`${SCRIPT_NAME}:激活将未能匹配职位插入指定位置`)
} else return
// 4.其余情形均忽略(且对于意外类型不报错)
} else return;
// 添加职位,并判断是否默认折叠
matchingRoles.forEach(role => {
const li = document.createElement('li');
li.innerHTML = 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;
}
});
});
// 将剩余的职位(不在排序列表中的)按原顺序添加到待插入位置
Object.keys(staffDict).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)}`);
}
async function isTargetMediaType() {
const smallTag = document.querySelector('h1.nameSingle > small.grey');
if (smallTag) {
// 优先通过网页内容判断
const text = smallTag.innerText.trim();
return ['TV', 'WEB', '剧场版', 'OVA'].includes(text);
} else {
// 通过API查询
const urlParts = location.href.split('/');
const subjectID = urlParts[urlParts.length - 1];
const response = await fetch(`https://api.bgm.tv/v0/subjects/${subjectID}`);
const subject = await response.json();
return subject.type === 2; // 判断是否为动画类别
}
}
// 获取一个字典来存储网页中的职位信息
function getStaffDict() {
const staffDict = {};
const lis = document.querySelectorAll('#infobox > li');
lis.forEach(li => {
const tip = li.querySelector('span.tip');
if (tip) {
const role = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
staffDict[role] = li.innerHTML;
}
});
return staffDict;
}
// 为网页原有的 `folded` 类别添加 `foldable` 便签,用于实现切换
function addFoldableTag() {
const lis = document.querySelectorAll('#infobox > li');
lis.forEach(li => {
if (li.classList.contains('folded')) {
li.classList.add('foldable');
if (!hasFolded) hasFolded = true;
}
});
}
/* 将原本存在的 `更多制作人员` 一次性按钮,转绑新事件,并改为永久性开关
* 使用网页原有的 `folded` 元素类别,实现对立于 sortStaff 功能
* 添加不存在的 `更多制作人员` 按钮,否则一些职位信息将不可见
<div class="infobox_expand">
<a href="javascript:void(0)">更多制作人员 +</a>
<!-- href 属性需保留,不然鼠标悬浮效果会失效 -->
</div>
*/
function changeToToggleButton() {
const buttonValue = { on: '更多制作人员 +', off: '更多制作人员 -' };
const parent = document.querySelector('.infobox_container');
let moreLink = parent.querySelector('.infobox_expand a');
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]);
parent.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" style="margin-left: 5px">
<colgroup>
<col style="width: 90%" />
<!-- 右侧留出的空间,是为了移动端可以同时选择滑动 textarea Or mainpage -->
<col />
</colgroup>
<tbody>
<tr>
<td colspan="2">
<h2 class="subtitle">条目职位排序 · 默认折叠的职位</h2>
</td>
</tr>
<!-- 此处添加子模块 -->
</tbody>
</table>
*/
function buildSettingUI(mainStyle) {
const mainTitle = createElement('tr', null, [
createElement('td', { colSpan: '2' }, [
createElement('h2', { class: 'subtitle' }, '条目职位排序 · 默认折叠的职位')
])
]);
const animeBlock = buildAnimeBlock();
const ui = createElement('div', mainStyle, [
createElement('table',
{ class: 'settings', style: { marginLeft: '5px' } }, [
createElement('colgroup', null, [
createElement('col', { style: { width: '90%' } }),
createElement('col'),
]),
createElement('tbody', null, [
mainTitle, animeBlock
// 可拓展其他类型条目的模块
])
])
]);
return ui;
}
/* 创建 staffMapList 文本内容编辑界面
* 对于 textarea, button 等控件仍然使用原有的结构与样式
<tr>
<td>
<h2 class="subtitle">动画条目</h2>
<div>
<div class="markItUp">
<textarea class="quick markItUpEditor hasEditor codeHighlight"
id="staff_map_list" name="staff_map_list" style="line-height: 21px">
<!-- staffMapListText -->
</textarea>
</div>
<input class="inputBtn" type="submit" name="submit_context" value="保存" style="margin-right: 5px" />
<input class="inputBtn" type="submit" name="reset_context" value="恢复默认" style="margin-right: 10px" />
<p class="tip_j" style="display: none"><!-- message --></p>
</div>
</td>
<td><!-- 为移动端预留的 mainpage 滑动空间 --></td>
</tr>
*/
function buildAnimeBlock() {
// 搭建标题
const subTitle = createElement('h2', { class: 'subtitle' }, '动画条目');
// 搭建简易提示框
const msgCntr = createElement('p', { class: 'tip_j', style: { display: 'none' } });
// 搭建文本框
let hasInputted = false;
let {text, isDefault} = getMapListText(false);
if (isDefault) setMessage(msgCntr, '现为默认设置'); // 初始化时,提醒用户已为默认设置
const textArea = createElement('textarea', {
class: 'quick markItUpEditor hasEditor codeHighlight', id: 'staff_map_list', name: 'staff_map_list',
style: { fontSize: '13x', lineHeight: '21px' }
},
text, {
input: () => {
if (!hasInputted) hasInputted = true;
if (isDefault) isDefault = false;
// console.log("IS INPUTTING");
}
});
// 搭建提交按钮
const submitBtn = createElement('input',
{ class: 'inputBtn', type: 'submit', name: 'submit_context', value: '保存', style: { marginRight: '5px' } }, null, {
click: () => {
// 判断是否为重置后未对默认内容进行修改
if (isDefault && !hasInputted) {
resetMapList();
setMessage(msgCntr, '保存成功!恢复默认设置');
// 恢复初始状态
hasInputted = false;
return;
}
const [modifiedData, isModified] = modifyMapListJSON(textArea.value);
// 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除
const savedDate = `[${modifiedData}]`;
const parsedData = parseMapListJSON(savedDate);
if (parsedData) {
// 保存数据
saveMapListText(savedDate);
// 页面显示
if (isModified) trySetText(textArea, msgCntr, modifiedData, '保存成功!并自动纠错', true);
else setMessage(msgCntr, '保存成功!');
} else setMessage(msgCntr, '保存失败!格式存在错误');
// 恢复初始状态
hasInputted = false;
}
});
// 搭建重置按钮
const resetBtn = createElement('input',
{ class: 'inputBtn', type: 'submit', name: 'reset_context', value: '恢复默认', style: { marginRight: '10px' } }, null, {
click: async () => {
if (isDefault) {
setMessage(msgCntr, '已为默认内容');
return;
}
await trySetText(textArea, msgCntr, getMapListText(true).text, '已恢复默认内容', false);
// 需进行同步等待,由于 setText 可能会触发 input 事件
isDefault = true;
hasInputted = false;
}
});
// 搭建外部结构
const textCntr = createElement('div', { class: 'markItUp' }, [textArea]);
const animeBlock = createElement('tr', null, [
createElement('td', null, [
subTitle,
// 可拓展折叠效果
createElement('div', null, [textCntr, submitBtn, resetBtn, msgCntr])
]),
createElement('td')
]);
return animeBlock;
}
/* 优先尝试使用 execCommand 方法改写文本框,使得改写前的用户历史记录不被浏览器清除
* (虽然 execCommand 方法已被弃用...但仍然是实现该功能最便捷的途径)
*/
async function trySetText(textArea, msgCntr, text, msg, isRestore, transTime = 100) {
let [scrollVert, scrollHoriz, cursorPos] = savePos();
try {
setMessage(msgCntr);
await clearAndSetTextarea(textArea, text, transTime);
setMessage(msgCntr, `${msg},可快捷键撤销`, 0);
} catch (e) {
textArea.value = '';
await new Promise(resolve => setTimeout(resolve, transTime));
textArea.value = text;
setMessage(msgCntr, msg, 0);
console.log(`${SCRIPT_NAME}:浏览器不支持 execCommand 方法,改为直接重置文本框,将无法通过快捷键撤销重置`)
}
if (isRestore) restorePos();
// 保存滚动位置和光标位置
function savePos() {
return [textArea.scrollTop, textArea.scrollLeft, textArea.selectionStart];
}
// 恢复滚动位置和光标位置
function restorePos() {
const currentTextLen = textArea.value.length;
if (cursorPos > currentTextLen) cursorPos = currentTextLen;
textArea.scrollTop = Math.min(scrollVert, textArea.scrollHeight);
// textArea.scrollLeft = Math.min(scrollHoriz, textArea.scrollWidth - textArea.clientWidth);
textArea.setSelectionRange(cursorPos, cursorPos);
}
}
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 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 格式
* 已基本兼容 JS 格式的文本数据,实现格式转化
* group2 与 group4 致使正则表达式中不允许出现 [/'"] 三种字符
*/
function modifyMapListJSON(text) {
let flags = new Array(10).fill(false);
const rslt = text.replace(
/(,\s*(?=]|$))|(,\s*)+(?=,)|(')|(?<!'|")(\/[^\/'"]+\/[gimsuy]*)(?!'|")/g, (match, g1, g2, g3, g4) => {
isTriggered(0, '删除序列末尾元素后的 `,` 逗号', g1);
isTriggered(2, '删除连续重复的 `,` 逗号', g2);
isTriggered(1, '将单引号替换为双引号', g3);
isTriggered(3, '将正则表达式以双引号包裹', g4);
if (g1 || g2) return '';
if (g3) return '"';
if (g4) return `"${match}"`;
return match;
});
return [rslt, booleanOr(...flags)];
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);
}
}
/* 初步解析 staffMapList JSON 字符串
* 仅检查:
* 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 createElement(tagName, options, subElements, eventHandlers) {
const element = document.createElement(tagName);
if (options) {
for (let opt in options) {
if (opt === 'dataset' || opt === 'style') {
for (let key in options[opt]) {
element[opt][key] = options[opt][key];
}
} else if (opt === 'class') {
element.className = options[opt];
} else {
element[opt] = options[opt];
}
}
}
if (subElements) {
updateSubElements(element, subElements);
}
if (eventHandlers) {
for (let e in eventHandlers) {
element.addEventListener(e, eventHandlers[e]);
}
}
return element;
}
function updateSubElements(parent, subElements, isReplace = false) {
if (isReplace) parent.innerHTML = '';
if (!subElements) return parent;
if (typeof subElements === 'string') subElements = [subElements];
for (let e of subElements) {
parent.appendChild(typeof e === 'string' ? document.createTextNode(e) : e);
}
return parent;
}
})();