Render part of HTML for Bangumi BBCode extension
// ==UserScript==
// @name Bangumi BBCode Extension
// @name:zh-CN Bangumi BBCode 扩展
// @namespace https://bgm.tv/
// @version 0.1.2
// @description Render part of HTML for Bangumi BBCode extension
// @description:zh-CN 为 Bangumi BBCode 扩展部分Html渲染
// @author Liebessprache
// @match https://bgm.tv/group/topic/*
// @match https://bangumi.tv/group/topic/*
// @match https://chii.in/group/topic/*
// @match https://bgm.tv/subject/topic/*
// @match https://bangumi.tv/subject/topic/*
// @match https://chii.in/subject/topic/*
// @match https://bgm.tv/ep/*
// @match https://bangumi.tv/ep/*
// @match https://chii.in/ep/*
// @match https://bgm.tv/blog/*
// @match https://bangumi.tv/blog/*
// @match https://chii.in/blog/*
// @match https://bgm.tv/index/*/comments
// @match https://bangumi.tv/index/*/comments
// @match https://chii.in/index/*/comments
// @match https://bgm.tv/settings
// @match https://bangumi.tv/settings
// @match https://chii.in/settings
// @match https://bgm.tv/user/*
// @match https://bangumi.tv/user/*
// @match https://chii.in/user/*
// @grant none
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const PROCESSED_ATTR = 'data-bgm-bbtable-processed';
const CONTENT_SELECTORS = [
'#entry_content',
'.topic_content',
'.blog_main',
'.reply_content',
'.cmt_sub_content',
'.postContent',
'.comment_content',
'#user_home',
'#intro',
'.intro',
'.bio',
'.user_box .text',
'.user_box blockquote',
'.user_box .quote'
];
const SKIP_SELECTOR = [
'textarea',
'input',
'select',
'option',
'button',
'script',
'style',
'template',
'pre',
'code',
'[contenteditable="true"]',
'.CodeMirror',
'.editor',
'.reply_form',
'.quickpost',
'#reply_form',
'#editTopicForm',
'#eden_tpc_form',
'#commentForm'
].join(',');
const SITE_INTERACTIVE_SELECTOR = [
'a[data-like-type]',
'a.item[href^="/like?"]',
'[data-like-type]',
'.like_grid',
'.actions',
'.action',
'.tools'
].join(',');
const STYLE = `
.bgm-bbtable-wrap {
max-width: 100%;
margin: 14px 0;
overflow-x: auto;
scrollbar-gutter: stable;
}
.bgm-bbtable {
width: max-content;
max-width: 100%;
border-collapse: collapse;
border-spacing: 0;
background: transparent;
color: inherit;
font-size: 14px;
line-height: 1.55;
}
.bgm-bbtable.bgm-bbtable-wide {
width: 100%;
min-width: 860px;
table-layout: fixed;
}
.bgm-bbtable.bgm-bbtable-cols-5,
.bgm-bbtable.bgm-bbtable-cols-6,
.bgm-bbtable.bgm-bbtable-cols-7,
.bgm-bbtable.bgm-bbtable-cols-8 {
min-width: 980px;
}
.bgm-bbtable th,
.bgm-bbtable td {
min-width: 0;
border: 1px solid rgba(128, 128, 128, 0.32);
padding: 9px 14px;
text-align: center;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: normal;
}
.bgm-bbtable th {
background: rgba(128, 128, 128, 0.12);
color: inherit;
font-weight: 700;
white-space: nowrap;
}
.bgm-bbtable tr td {
background: transparent;
}
.bgm-bbtable tr.bgm-bbtable-data-even td {
background: rgba(128, 128, 128, 0.06);
}
.bgm-bbtable tr:hover td {
background: rgba(37, 99, 166, 0.10);
}
.bgm-bbtable a {
color: #2563a6;
text-decoration: none;
overflow-wrap: anywhere;
}
.bgm-bbtable a:hover {
color: #174c82;
text-decoration: underline;
}
.bgm-bbtable img {
max-width: min(100%, 260px);
height: auto;
vertical-align: middle;
}
.bgm-bbtable ul,
.bgm-bbtable ol {
margin: 0.25em 0;
padding-left: 0;
list-style-position: inside;
}
.bgm-bbtable li {
margin: 0.15em 0;
}
.bgm-bbtable pre {
max-width: 100%;
margin: 0.2em 0;
padding: 8px 10px;
overflow-x: auto;
white-space: pre;
border: 1px solid rgba(128, 128, 128, 0.32);
border-radius: 4px;
background: rgba(128, 128, 128, 0.06);
box-sizing: border-box;
text-align: left;
}
.bgm-bbtable code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.bgm-bbdetails {
margin: 0.85em 0;
border: 1px solid rgba(128, 128, 128, 0.32);
background: transparent;
}
.bgm-bbdetails summary {
cursor: pointer;
padding: 8px 12px;
background: rgba(128, 128, 128, 0.12);
color: inherit;
font-weight: 700;
user-select: none;
}
.bgm-bbdetails-body {
padding: 10px 12px;
}
.bgm-bbhr {
border: 0;
border-top: 1px solid rgba(128, 128, 128, 0.32);
margin: 0.9em 0;
}
.bgm-bbkbd {
display: inline-block;
min-width: 1.8em;
padding: 1px 6px;
border: 1px solid rgba(128, 128, 128, 0.42);
border-bottom-width: 2px;
background: rgba(128, 128, 128, 0.08);
color: inherit;
font: 12px/1.45 Consolas, "Cascadia Mono", monospace;
text-align: center;
vertical-align: baseline;
}
.bgm-bbtool-btn {
position: relative !important;
width: 24px !important;
height: 24px !important;
overflow: visible !important;
flex-shrink: 0;
}
.bgm-bbtool-btn > a {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 24px !important;
height: 24px !important;
box-sizing: border-box;
padding: 0 !important;
margin: 0 !important;
line-height: 24px !important;
overflow: visible !important;
text-indent: 0 !important;
background-image: none !important;
color: currentColor !important;
opacity: 1;
}
.bgm-bbtool-btn > a:hover {
opacity: 0.72;
}
.bgm-bbtool-btn svg {
display: block;
width: 16px;
height: 16px;
fill: currentColor;
pointer-events: none;
}
.bgm-bbtool-picker {
display: none;
position: absolute;
left: 0;
top: 26px;
z-index: 10000;
width: 190px;
padding: 10px;
border: 1px solid rgba(128, 128, 128, 0.42);
background: Canvas;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
color: CanvasText;
text-align: left;
}
.bgm-bbtool-picker.is-open {
display: block;
}
.bgm-bbtool-picker-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
}
.bgm-bbtool-picker-grid {
display: grid;
grid-template-columns: repeat(8, 18px);
gap: 3px;
}
.bgm-bbtool-picker-cell {
width: 18px;
height: 18px;
padding: 0;
border: 1px solid rgba(128, 128, 128, 0.62);
background: transparent;
cursor: pointer;
}
.bgm-bbtool-picker-cell.is-active {
border-color: #2563a6;
background: #dcecff;
}
.bgm-bbtool-picker-status {
margin-top: 8px;
color: #66707c;
font-size: 12px;
}
html[data-theme="dark"] .bgm-bbtable,
html[data-theme="dark"] .bgm-bbdetails,
html[data-theme="dark"] .bgm-bbkbd,
html.dark .bgm-bbtable,
html.dark .bgm-bbdetails,
html.dark .bgm-bbkbd,
body.dark .bgm-bbtable,
body.dark .bgm-bbdetails,
body.dark .bgm-bbkbd {
color: #f4f6f8;
}
@media (prefers-color-scheme: dark) {
.bgm-bbtable,
.bgm-bbdetails,
.bgm-bbkbd {
color: #f4f6f8;
}
}
`;
const ICONS = {
table: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M3.2 3.4h13.6v13.2H3.2V3.4Zm1.5 1.5v2.7h3.4V4.9H4.7Zm4.9 0v2.7h3.4V4.9H9.6Zm4.9 0v2.7h.8V4.9h-.8ZM4.7 9.1v2.5h3.4V9.1H4.7Zm4.9 0v2.5h3.4V9.1H9.6Zm4.9 0v2.5h.8V9.1h-.8ZM4.7 13.1v2h3.4v-2H4.7Zm4.9 0v2h3.4v-2H9.6Zm4.9 0v2h.8v-2h-.8Z"/></svg>',
details: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4 4.5h12v2H4v-2Zm0 4h12v1.6H4V8.5Zm0 3.6h8.6v1.6H4v-1.6Z"/><path d="m14.2 12.1 1.9 1.9-1.9 1.9-1.1-1.1.8-.8-.8-.8 1.1-1.1Z"/></svg>',
hr: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M3.5 9.1h13v1.8h-13V9.1Z"/></svg>',
kbd: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M3.2 6.2c0-.88.72-1.6 1.6-1.6h10.4c.88 0 1.6.72 1.6 1.6v7.6c0 .88-.72 1.6-1.6 1.6H4.8c-.88 0-1.6-.72-1.6-1.6V6.2Zm1.6.1v7.4h10.4V6.3H4.8Z"/><path d="M6.1 8h1.8v1.7H6.1V8Zm3 0h1.8v1.7H9.1V8Zm3 0h1.8v1.7h-1.8V8ZM6.1 11h7.8v1.5H6.1V11Z"/></svg>',
sub: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.2 5.6h2.1l2 3 2-3h2.1L9.5 9.8l3 4.5h-2.1l-2.1-3.2-2.1 3.2H4.1l3-4.5-2.9-4.2Z"/><path d="M13.7 13.2h1.7c.42 0 .76.1 1.01.31.25.2.38.48.38.84 0 .24-.07.46-.2.66-.13.19-.37.42-.7.69l-.68.56h1.7v1.05h-3.35v-.85l1.53-1.31c.19-.16.31-.29.38-.39.07-.1.1-.2.1-.31 0-.13-.05-.23-.14-.3-.1-.08-.23-.11-.41-.11h-1.32v-1.03Z"/></svg>',
sup: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.2 8.2h2.1l2 3 2-3h2.1l-2.9 4.2 3 4.5h-2.1l-2.1-3.2-2.1 3.2H4.1l3-4.5-2.9-4.2Z"/><path d="M13.7 2.9h1.7c.42 0 .76.1 1.01.31.25.2.38.48.38.84 0 .24-.07.46-.2.66-.13.19-.37.42-.7.69l-.68.56h1.7V7h-3.35v-.85l1.53-1.31c.19-.16.31-.29.38-.39.07-.1.1-.2.1-.31 0-.13-.05-.23-.14-.3-.1-.08-.23-.11-.41-.11h-1.32V2.9Z"/></svg>'
};
function addStyle(css) {
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
function isEditableOrUnsafe(element) {
return Boolean(element.closest(SKIP_SELECTOR));
}
function hasSiteInteractiveControls(element) {
return Boolean(element.querySelector(SITE_INTERACTIVE_SELECTOR));
}
function getCandidateElements(root) {
const selector = CONTENT_SELECTORS.join(',');
const elements = [];
if (root.nodeType === Node.ELEMENT_NODE && root.matches(selector)) {
elements.push(root);
}
if (root.querySelectorAll) {
elements.push(...root.querySelectorAll(selector));
if (/^\/user\/[^/]+\/?$/.test(location.pathname)) {
elements.push(...Array.from(root.querySelectorAll('#columnA blockquote, #columnA .text, #columnA .tip, #columnA .inner, #columnA div'))
.filter(el => {
const text = el.textContent || '';
return containsSupportedMarkup(text) && !el.querySelector(CONTENT_SELECTORS.join(',')) && !hasSiteInteractiveControls(el);
}));
}
}
return Array.from(new Set(elements));
}
function trimHtmlEdges(html) {
return html
.replace(/^(?:\s| |<br\s*\/?>)+/gi, '')
.replace(/(?:\s| |<br\s*\/?>)+$/gi, '');
}
function escapeAttribute(value) {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function stripTags(value) {
return String(value).replace(/<[^>]*>/g, '').trim();
}
function isSafeUrl(url) {
const normalized = stripTags(url).replace(/&/g, '&').trim();
return /^(https?:\/\/|\/(?!\/)|#)/i.test(normalized);
}
function normalizeLooseBBCode(html) {
let out = html;
// Tolerate common mistakes in test posts, e.g. </td>, </td], or escaped variants.
out = out.replace(/<\s*\/\s*(table|tr|th|td|details|kbd)\s*(?:>|\])/gi, (_, tagName) => `[/${tagName.toLowerCase()}]`);
out = out.replace(/<\s*\/\s*(table|tr|th|td|details|kbd)\s*(?:>|\])/gi, (_, tagName) => `[/${tagName.toLowerCase()}]`);
// Some preview components convert spaces inside raw BBCode tags to .
out = out.replace(/\[(\/?)(table|tr|th|td|details|kbd|sub|sup)((?:(?:\s| | | |=)[^\]]*)?)\]/gi, (match, slash, tagName, attrs) => {
const normalizedAttrs = String(attrs || '').replace(/(?: | | )+/gi, ' ');
return `[${slash}${tagName.toLowerCase()}${normalizedAttrs}]`;
});
return out;
}
function clampSpan(value) {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n) || n < 1) return null;
return Math.min(n, 20);
}
function parseCellAttributes(rawAttrs) {
const attrs = {
attrHtml: '',
styleHtml: '',
colspan: 1
};
const attrText = String(rawAttrs || '');
const attrPattern = /([a-z]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/gi;
const safeAlign = new Set(['left', 'center', 'right']);
const safeValign = new Set(['top', 'middle', 'bottom']);
const styles = [];
let match;
while ((match = attrPattern.exec(attrText))) {
const key = match[1].toLowerCase();
const value = (match[2] || match[3] || match[4] || '').toLowerCase();
if (key === 'colspan') {
const span = clampSpan(value);
if (span) {
attrs.colspan = span;
attrs.attrHtml += ` colspan="${span}"`;
}
} else if (key === 'rowspan') {
const span = clampSpan(value);
if (span) attrs.attrHtml += ` rowspan="${span}"`;
} else if (key === 'align' && safeAlign.has(value)) {
styles.push(`text-align:${value}`);
} else if (key === 'valign' && safeValign.has(value)) {
styles.push(`vertical-align:${value}`);
}
}
if (styles.length) attrs.styleHtml = ` style="${styles.join(';')}"`;
return attrs;
}
function renderInlineBBCode(html) {
let out = html;
out = out.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '<strong>$1</strong>');
out = out.replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '<em>$1</em>');
out = out.replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '<u>$1</u>');
out = out.replace(/\[s\]([\s\S]*?)\[\/s\]/gi, '<del>$1</del>');
out = out.replace(/\[kbd\]([\s\S]*?)\[\/kbd\]/gi, '<kbd class="bgm-bbkbd">$1</kbd>');
out = out.replace(/\[sub\]([\s\S]*?)\[\/sub\]/gi, '<sub>$1</sub>');
out = out.replace(/\[sup\]([\s\S]*?)\[\/sup\]/gi, '<sup>$1</sup>');
out = out.replace(/<sub>([\s\S]*?)<\/sub>/gi, '<sub>$1</sub>');
out = out.replace(/<sup>([\s\S]*?)<\/sup>/gi, '<sup>$1</sup>');
out = out.replace(/\[url=([^\]]+)\]([\s\S]*?)\[\/url\]/gi, (match, rawUrl, label) => {
if (!isSafeUrl(rawUrl)) return match;
return `<a href="${escapeAttribute(stripTags(rawUrl))}" rel="nofollow noreferrer">${label}</a>`;
});
out = out.replace(/\[url\]([\s\S]*?)\[\/url\]/gi, (match, rawUrl) => {
if (!isSafeUrl(rawUrl)) return match;
const url = stripTags(rawUrl);
return `<a href="${escapeAttribute(url)}" rel="nofollow noreferrer">${url}</a>`;
});
return out;
}
function estimateTextWidth(html) {
const text = stripTags(html)
.replace(/\[[^\]]+\]/g, '')
.replace(/ /gi, ' ')
.replace(/&/gi, '&')
.trim();
let width = 0;
for (const char of text) {
width += char.charCodeAt(0) > 255 ? 2 : 1;
}
if (/\[code\b|<pre\b|<code\b/i.test(html)) width *= 1.35;
if (/https?:\/\/|www\./i.test(text)) width *= 1.2;
return Math.max(4, Math.min(52, Math.ceil(width)));
}
function renderColGroup(columnWeights) {
if (!columnWeights.length) return '';
const weights = columnWeights.map(weight => Math.max(6, Math.min(52, weight || 6)));
const total = weights.reduce((sum, weight) => sum + weight, 0);
const widths = weights.map(weight => Math.max(7, (weight / total) * 100));
const normalizedTotal = widths.reduce((sum, width) => sum + width, 0);
return `<colgroup>${widths.map(width => `<col style="width:${((width / normalizedTotal) * 100).toFixed(2)}%">`).join('')}</colgroup>`;
}
function parseCells(rowHtml) {
const cells = [];
const cellPattern = /\[(th|td)((?:\s+[^\]]*)?|=[^\]]*)?\]([\s\S]*?)\[\/\1\]/gi;
let match;
while ((match = cellPattern.exec(rowHtml))) {
const tagName = match[1].toLowerCase();
const attrs = parseCellAttributes(match[2]);
const rawContent = match[3];
const content = renderInlineBBCode(trimHtmlEdges(rawContent));
cells.push({
html: `<${tagName}${attrs.attrHtml}${attrs.styleHtml}>${content}</${tagName}>`,
isHeader: tagName === 'th',
colspan: attrs.colspan,
weight: estimateTextWidth(rawContent)
});
}
return cells;
}
function renderTable(tableBodyHtml) {
const rows = [];
const rowPattern = /\[tr(?:=[^\]]*)?\]([\s\S]*?)\[\/tr\]/gi;
let match;
let dataRowIndex = 0;
let maxColumnCount = 0;
const columnWeights = [];
while ((match = rowPattern.exec(tableBodyHtml))) {
const cells = parseCells(match[1]);
if (cells.length) {
const columnCount = cells.reduce((sum, cell) => sum + cell.colspan, 0);
maxColumnCount = Math.max(maxColumnCount, columnCount);
let columnIndex = 0;
cells.forEach((cell, index) => {
const weight = Math.max(4, cell.weight / cell.colspan);
for (let i = 0; i < cell.colspan; i += 1) {
columnWeights[columnIndex + i] = Math.max(columnWeights[columnIndex + i] || 0, weight);
}
columnIndex += cell.colspan;
});
const isHeaderRow = cells.some(cell => cell.isHeader);
const rowClass = !isHeaderRow && dataRowIndex++ % 2 === 1 ? ' class="bgm-bbtable-data-even"' : '';
rows.push(`<tr${rowClass}>${cells.map(cell => cell.html).join('')}</tr>`);
}
}
if (!rows.length) return null;
const wideClass = maxColumnCount >= 4 ? ' bgm-bbtable-wide' : '';
const columnClass = ` bgm-bbtable-cols-${Math.min(maxColumnCount, 8)}`;
const colGroup = maxColumnCount >= 4 ? renderColGroup(columnWeights.slice(0, maxColumnCount)) : '';
return `<div class="bgm-bbtable-wrap"><table class="bgm-bbtable${wideClass}${columnClass}">${colGroup}<tbody>${rows.join('')}</tbody></table></div>`;
}
function transformTables(html) {
let changed = false;
const tablePattern = /\[table(?:\s+[^\]]*|=[^\]]*)?\]([\s\S]*?)\[\/table\]/gi;
const output = html.replace(tablePattern, (match, tableBodyHtml) => {
const rendered = renderTable(tableBodyHtml);
if (!rendered) return match;
changed = true;
return rendered;
});
return { changed, output };
}
function transformDetails(html) {
let changed = false;
const output = html.replace(/\[details(?:=([^\]]+))?\]([\s\S]*?)\[\/details\]/gi, (match, rawSummary, rawBody) => {
const summary = rawSummary ? stripTags(rawSummary).replace(/&/g, '&').trim() : '详情';
const safeSummary = escapeAttribute(summary || '详情');
const body = renderInlineBBCode(trimHtmlEdges(rawBody));
changed = true;
return `<details class="bgm-bbdetails"><summary>${safeSummary}</summary><div class="bgm-bbdetails-body">${body}</div></details>`;
});
return { changed, output };
}
function transformStandaloneTags(html) {
let changed = false;
let output = html.replace(/\[hr\s*\/?\]/gi, () => {
changed = true;
return '<hr class="bgm-bbhr">';
});
output = renderInlineBBCode(output);
if (output !== html) changed = true;
return { changed, output };
}
function transformContent(html) {
let changed = false;
let output = normalizeLooseBBCode(html);
if (output !== html) changed = true;
[transformTables, transformDetails, transformStandaloneTags].forEach(transform => {
const result = transform(output);
output = result.output;
changed = changed || result.changed;
});
return { changed, output };
}
function containsSupportedMarkup(text) {
return /\[table(?:\s[^\]]*|=[^\]]*)?\][\s\S]*?\[\/table\]/i.test(text)
|| /\[details(?:=[^\]]+)?\][\s\S]*?\[\/details\]/i.test(text)
|| /\[hr\s*\/?\]/i.test(text)
|| /\[kbd\][\s\S]*?\[\/kbd\]/i.test(text)
|| /\[(sub|sup)\][\s\S]*?\[\/\1\]/i.test(text)
|| /<\s*(sub|sup)\s*>[\s\S]*?<\s*\/\s*\1\s*>/i.test(text);
}
function processElement(element) {
if (element.getAttribute(PROCESSED_ATTR) === '1') return;
if (isEditableOrUnsafe(element)) return;
if (hasSiteInteractiveControls(element)) return;
const text = element.textContent || '';
if (!containsSupportedMarkup(text)) return;
const result = transformContent(element.innerHTML);
if (!result.changed || result.output === element.innerHTML) return;
element.innerHTML = result.output;
element.setAttribute(PROCESSED_ATTR, '1');
}
function processRoot(root) {
getCandidateElements(root).forEach(processElement);
}
function dispatchEditorEvents(textarea) {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
}
function insertIntoTextarea(textarea, text) {
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
const insertAt = Math.max(start, end);
textarea.setRangeText(text, insertAt, insertAt, 'end');
textarea.focus();
dispatchEditorEvents(textarea);
}
function wrapTextareaSelection(textarea, before, after, fallback) {
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
const selected = textarea.value.slice(start, end);
const body = selected || fallback;
textarea.setRangeText(`${before}${body}${after}`, start, end, 'end');
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + body.length);
dispatchEditorEvents(textarea);
}
function buildBBCodeTable(rows, cols) {
const rowCount = Math.max(1, Math.min(8, rows));
const colCount = Math.max(1, Math.min(8, cols));
const lines = ['[table]'];
for (let row = 0; row < rowCount; row += 1) {
lines.push('[tr]');
for (let col = 0; col < colCount; col += 1) {
const tagName = row === 0 ? 'th' : 'td';
const label = row === 0 ? `标题${col + 1}` : `单元格${row}-${col + 1}`;
lines.push(`[${tagName}]${label}[/${tagName}]`);
}
lines.push('[/tr]');
}
lines.push('[/table]');
return lines.join('\n');
}
function createToolbarButton(className, title, icon) {
const li = document.createElement('li');
li.className = `markItUpButton tool_ico bgm-bbtool-btn ${className}`;
li.setAttribute('data-bgm-bbtool', 'true');
const button = document.createElement('a');
button.href = '#';
button.role = 'button';
button.title = title;
button.setAttribute('aria-label', title);
button.innerHTML = icon;
li.append(button);
return li;
}
function bindInsertButton(button, textarea, action) {
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
action(textarea);
});
}
function closeTablePickers(except = null) {
document.querySelectorAll('.bgm-bbtool-picker.is-open').forEach(picker => {
if (picker !== except) picker.classList.remove('is-open');
});
}
function createTablePickerButton(textarea) {
const li = createToolbarButton('bgm-bbtool-table', '插入表格', ICONS.table);
const button = li.querySelector('a');
const picker = document.createElement('div');
picker.className = 'bgm-bbtool-picker';
const title = document.createElement('div');
title.className = 'bgm-bbtool-picker-title';
title.textContent = '插入表格';
picker.append(title);
const grid = document.createElement('div');
grid.className = 'bgm-bbtool-picker-grid';
picker.append(grid);
const status = document.createElement('div');
status.className = 'bgm-bbtool-picker-status';
status.textContent = '选择行列';
picker.append(status);
const cells = [];
const setActive = (rows, cols) => {
cells.forEach(cell => {
const active = Number(cell.dataset.row) <= rows && Number(cell.dataset.col) <= cols;
cell.classList.toggle('is-active', active);
});
status.textContent = `${rows} x ${cols} 表格`;
};
for (let row = 1; row <= 8; row += 1) {
for (let col = 1; col <= 8; col += 1) {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'bgm-bbtool-picker-cell';
cell.dataset.row = String(row);
cell.dataset.col = String(col);
cell.setAttribute('aria-label', `${row} 行 ${col} 列`);
cell.addEventListener('mouseenter', () => setActive(row, col));
cell.addEventListener('focus', () => setActive(row, col));
cell.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
insertIntoTextarea(textarea, buildBBCodeTable(row, col));
picker.classList.remove('is-open');
});
cells.push(cell);
grid.append(cell);
}
}
picker.addEventListener('mouseleave', () => {
cells.forEach(cell => cell.classList.remove('is-active'));
status.textContent = '选择行列';
});
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const willOpen = !picker.classList.contains('is-open');
closeTablePickers(picker);
picker.classList.toggle('is-open', willOpen);
});
li.append(picker);
return li;
}
function addBBCodeToolbarButtons(toolbar, textarea) {
if (toolbar.querySelector('[data-bgm-bbtool="true"]')) return;
const cleanBtn = Array.from(toolbar.children).find(child => child.classList?.contains('tool_clean'));
const insertionPoint = cleanBtn || null;
const buttons = [
createTablePickerButton(textarea),
createToolbarButton('bgm-bbtool-details', '插入 details 折叠块', ICONS.details),
createToolbarButton('bgm-bbtool-hr', '插入分隔线 [hr]', ICONS.hr),
createToolbarButton('bgm-bbtool-kbd', '插入键帽 [kbd]', ICONS.kbd),
createToolbarButton('bgm-bbtool-sub', '插入下标 <sub>', ICONS.sub),
createToolbarButton('bgm-bbtool-sup', '插入上标 <sup>', ICONS.sup)
];
bindInsertButton(buttons[1].querySelector('a'), textarea, currentTextarea => {
wrapTextareaSelection(currentTextarea, '[details=标题]\n', '\n[/details]', '内容');
});
bindInsertButton(buttons[2].querySelector('a'), textarea, currentTextarea => {
insertIntoTextarea(currentTextarea, '[hr]');
});
bindInsertButton(buttons[3].querySelector('a'), textarea, currentTextarea => {
wrapTextareaSelection(currentTextarea, '[kbd]', '[/kbd]', 'Ctrl');
});
bindInsertButton(buttons[4].querySelector('a'), textarea, currentTextarea => {
wrapTextareaSelection(currentTextarea, '<sub>', '</sub>', '2');
});
bindInsertButton(buttons[5].querySelector('a'), textarea, currentTextarea => {
wrapTextareaSelection(currentTextarea, '<sup>', '</sup>', '2');
});
buttons.forEach(item => toolbar.insertBefore(item, insertionPoint));
}
function enhanceMarkItUpHeader(header) {
if (header.dataset.bgmBbtoolEnhanced === 'true') return;
const markItUp = header.closest('.markItUp');
const textarea = markItUp?.querySelector('textarea') || header.parentElement?.querySelector('textarea');
const toolbar = header.querySelector('ul') || header.firstElementChild;
if (!textarea || !toolbar) return;
header.dataset.bgmBbtoolEnhanced = 'true';
addBBCodeToolbarButtons(toolbar, textarea);
}
function enhanceEditorToolbars(root = document) {
root.querySelectorAll?.('.markItUpHeader:not([data-bgm-bbtool-enhanced])').forEach(enhanceMarkItUpHeader);
}
function processPreviewElement(element) {
const text = element.textContent || '';
if (!containsSupportedMarkup(text)) return;
const result = transformContent(element.innerHTML);
if (!result.changed || result.output === element.innerHTML) return;
element.innerHTML = result.output;
}
function processPreviewRoot(root = document) {
if (root.nodeType === Node.ELEMENT_NODE && root.matches?.('.bbcodePreview')) {
processPreviewElement(root);
}
root.querySelectorAll?.('.bbcodePreview').forEach(processPreviewElement);
}
function debounce(fn, delay) {
let timer = null;
return function debounced() {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn();
}, delay);
};
}
function observePage() {
const scheduleProcess = debounce(() => processRoot(document.body), 120);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const previewTarget = mutation.target.nodeType === Node.ELEMENT_NODE
? (mutation.target.matches?.('.bbcodePreview') ? mutation.target : mutation.target.closest?.('.bbcodePreview'))
: null;
if (previewTarget) processPreviewElement(previewTarget);
if (!mutation.addedNodes.length) continue;
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.closest && node.closest('.bgm-bbtable-wrap')) continue;
processRoot(node);
enhanceEditorToolbars(node);
processPreviewRoot(node);
}
scheduleProcess();
break;
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function init() {
addStyle(STYLE);
processRoot(document.body);
processPreviewRoot(document.body);
enhanceEditorToolbars();
document.addEventListener('click', () => closeTablePickers(), true);
observePage();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();