Bangumi BBCode Extension

Render part of HTML for Bangumi BBCode extension

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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|&nbsp;|<br\s*\/?>)+/gi, '')
      .replace(/(?:\s|&nbsp;|<br\s*\/?>)+$/gi, '');
  }

  function escapeAttribute(value) {
    return String(value)
      .replace(/&/g, '&amp;')
      .replace(/"/g, '&quot;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }

  function stripTags(value) {
    return String(value).replace(/<[^>]*>/g, '').trim();
  }

  function isSafeUrl(url) {
    const normalized = stripTags(url).replace(/&amp;/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(/&lt;\s*\/\s*(table|tr|th|td|details|kbd)\s*(?:&gt;|\])/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 &nbsp;.
    out = out.replace(/\[(\/?)(table|tr|th|td|details|kbd|sub|sup)((?:(?:\s|&nbsp;|&#160;|&#xa0;|=)[^\]]*)?)\]/gi, (match, slash, tagName, attrs) => {
      const normalizedAttrs = String(attrs || '').replace(/(?:&nbsp;|&#160;|&#xa0;)+/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(/&lt;sub&gt;([\s\S]*?)&lt;\/sub&gt;/gi, '<sub>$1</sub>');
    out = out.replace(/&lt;sup&gt;([\s\S]*?)&lt;\/sup&gt;/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(/&nbsp;/gi, ' ')
      .replace(/&amp;/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(/&amp;/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();
  }
})();