Brazen Framework - View Layer

View layer for the Brazen userscripts framework

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.sleazyfork.org/scripts/416104/1847306/Brazen%20Framework%20-%20View%20Layer.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Brazen Framework - View Layer
// @namespace    brazenvoid
// @version      3.1.1
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  View layer for the Brazen user scripts framework
// @grant        GM_addStyle
// ==/UserScript==

/**
 * @function GM_addStyle
 * @param {string} style
 */
GM_addStyle(
  `@keyframes brazen-fade{from{opacity:0}to{opacity:1}}#bv-resizer{align-items:center;background-color:white;border-radius:3px;box-shadow:3px 3px 10px rgba(0,0,0,0.5);cursor:ew-resize;display:flex;height:18px;justify-content:center;position:absolute;right:10px;transition:background 0.3s ease;width:25px;z-index:5}#bv-resizer:hover{background-color:lightgrey}#bv-resizer-icon{color:black;font-size:100%;pointer-events:none}#bv-ui{backdrop-filter:blur(10px);background:rgba(255,255,255,0.1);border-bottom-right-radius:8px;border-left:0;border-top-right-radius:8px;bottom:5vh;box-shadow:0 2px 8px rgba(0,0,0,0.5);box-sizing:border-box;opacity:0.3;overflow:auto;top:5vh;z-index:1001}#bv-ui:hover{opacity:1}#restore-settings.bv-input{margin-bottom:1rem}.show-settings.bv-section{border:2px solid white;border-radius:5px;box-shadow:0 0 20px white;padding:8px;top:5vh}.bv-actions{display:inline-flex;justify-content:center;padding:0 0.25rem;text-align:center}.bv-actions .bv-button{width:auto}.bv-bg-colour{background-color:#4f535b}.bv-border-primary{border:1px solid black}.bv-break{margin:0.5rem 0}.bv-button{background-color:revert;padding:0.5rem 1rem;width:100%}.bv-flex-column{flex-direction:column}.bv-font-primary{color:white}.bv-font-secondary{color:black}.bv-group{align-items:center;display:flex;min-height:20px}.bv-group+.bv-group{margin-top:1rem}.bv-group.bv-range-group,.bv-group.bv-text-group{align-items:center}.bv-group.bv-range-group>input{width:75px}.bv-group.bv-range-group>input+input{margin-left:5px}.bv-group.bv-textarea-group{align-items:start;flex-direction:column;overflow:hidden}.bv-group.bv-textarea-group>textarea.bv-input{margin-top:0.5rem;resize:vertical;width:100%}input.bv-input,select.bv-input,textarea.bv-input{box-sizing:border-box;margin:0;padding:0.5rem}.bv-input.bv-checkbox-radio{margin-right:5px;scale:2}.bv-input.bv-text{width:100%}.bv-label{flex-grow:1;text-align:start}.bv-label.bv-text+.bv-input.bv-text{width:40%}.bv-section,#bv-ui,.bv-modal-dialog,.show-settings.bv-section{--bv-font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-family:var(--bv-font-family)}.bv-section:is(button,input,select,textarea,.bv-button,.bv-tab-button,.bv-label,.bv-title,.bv-token-btn,.bv-bookmark-link),#bv-ui:is(button,input,select,textarea,.bv-button,.bv-tab-button,.bv-label,.bv-title,.bv-token-btn,.bv-bookmark-link),.bv-modal-dialog:is(button,input,select,textarea,.bv-button,.bv-modal-title,h3,p,span),.show-settings.bv-section:is(button,input){font-family:var(--bv-font-family)}.bv-section{display:flex;flex-direction:column;font-size:1rem;font-weight:normal;left:0;padding:1rem;position:fixed;z-index:1000}.bv-section>.bv-bottom-section{display:flex;flex-direction:column;margin-top:auto}.bv-section>div+div{margin-top:1rem}.bv-section hr{border:1px solid white;margin:1rem 0}.bv-section button+button{margin-left:0.25rem}.bv-section .bv-title{display:block;height:20px;margin-bottom:1rem;text-align:center;width:100%}.bv-show-settings{border:0;font-size:0.7rem;height:90vh;left:0;margin:0;padding:0;position:fixed;top:5vh;width:0.2vw;writing-mode:sideways-lr;z-index:999}.bv-show-settings .bv-title{display:block;height:20px;width:100%}.bv-tab-button{background-color:inherit;border-bottom:0;border-top-left-radius:3px;border-top-right-radius:3px;cursor:pointer;outline:none;padding:0.5rem 0.75rem;transition:0.3s}.bv-tab-button.bv-active,.bv-tab-button:hover{background-color:white;color:black}.bv-tab-panel{animation:brazen-fade 1s;display:none;flex-direction:column;padding:1rem}.bv-tab-panel.bv-active{display:flex}.bv-tabs-nav{display:flex;flex-wrap:wrap;overflow:hidden}.bv-bookmarks-group{display:flex;flex-direction:column;gap:0.25rem;min-width:0;width:100%}.bv-bookmark-columns{column-gap:0.25rem;display:grid;grid-template-columns:minmax(0,1fr) var(--bv-bookmark-action-size,1.5rem);min-width:0;row-gap:0.25rem;scrollbar-gutter:stable;width:100%}.bv-bookmark-toolbar{align-items:center;display:grid;grid-column:1 / -1;grid-template-columns:subgrid}.bv-bookmark-scroll{display:grid;grid-auto-rows:min-content;grid-column:1 / -1;grid-template-columns:subgrid;max-height:14rem;overflow-y:auto;row-gap:0.25rem}.bv-bookmark-list{display:contents}.bv-bookmark-row{align-items:center;display:grid;grid-column:1 / -1;grid-template-columns:subgrid}.bv-bookmark-search{box-sizing:border-box;grid-column:1;height:var(--bv-bookmark-action-size,1.5rem);margin-top:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;width:auto}.bv-bookmark-search.bv-input.bv-text{width:auto}.bv-bookmark-sort{box-sizing:border-box;grid-column:2;height:var(--bv-bookmark-action-size,1.5rem);margin:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;padding:0.25rem 0.5rem;width:var(--bv-bookmark-action-size,1.5rem)}.bv-bookmark-link{box-sizing:border-box;display:block;grid-column:1;height:var(--bv-bookmark-action-size,1.5rem);line-height:var(--bv-bookmark-action-size,1.5rem);margin:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;overflow:hidden;padding:0 0.38rem;text-align:left;text-overflow:ellipsis;white-space:nowrap;width:auto}.bv-bookmark-remove{align-items:center;box-sizing:border-box;display:inline-flex;grid-column:2;height:var(--bv-bookmark-action-size,1.5rem);justify-content:center;line-height:1;margin:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;padding:0.25rem 0.5rem;width:var(--bv-bookmark-action-size,1.5rem)}.bv-bookmark-empty{font-size:0.85rem;grid-column:1 / -1;margin-top:0.15rem}.bv-bookmark-toolbar button+button,.bv-bookmark-row button+button{margin-left:0}@supports not (grid-template-columns:subgrid){.bv-bookmark-columns{scrollbar-gutter:auto}.bv-bookmark-toolbar,.bv-bookmark-row{column-gap:0.25rem;display:grid;grid-column:1 / -1;grid-template-columns:minmax(0,1fr) var(--bv-bookmark-action-size,1.5rem)}.bv-bookmark-scroll{display:block}.bv-bookmark-list{display:flex;flex-direction:column;gap:0.25rem}.bv-bookmark-toolbar{box-sizing:border-box;padding-inline-end:var(--bv-bookmark-scroll-inset,0px)}}.bv-modal-overlay{align-items:center;background:rgba(0,0,0,0.55);display:flex;inset:0;justify-content:center;position:fixed;z-index:10002}.bv-modal-dialog{background:#4f535b;border:1px solid black;border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.5);color:white;max-height:80vh;max-width:90vw;min-width:280px;overflow:auto;padding:1rem}.bv-modal-header{align-items:center;display:flex;gap:0.5rem;justify-content:space-between;margin-bottom:0.75rem}.bv-modal-title{font-size:1rem;font-weight:500;margin:0}.bv-modal-close{padding:0.25rem 0.5rem;width:auto}.bv-modal-dialog .bv-button{background-color:#3a3f47;border:1px solid #222;color:#fff;cursor:pointer;width:auto}.bv-modal-dialog .bv-button:hover{background-color:#4a505a}.bv-modal-body ul:not(.bv-compliance-rule-list){margin:0 0 1rem;padding-left:1.25rem}.bv-modal-body h3{font-size:0.9rem;margin:0 0 0.35rem}.bv-compliance-rule-list{list-style:none;margin:0 0 1rem;padding:0}.bv-compliance-rule-row{align-items:center;display:flex;gap:0.5rem;justify-content:space-between;margin-bottom:0.35rem}.bv-compliance-rule-label{flex:1 1 auto;min-width:0}.bv-compliance-rule-remove{flex:0 0 auto;padding:0.2rem 0.45rem;width:auto}.bv-tag-actions-slot{display:inline-flex;position:relative}.bv-tag-actions-slot .tag-count[hidden]{display:none}.bv-tag-actions{align-items:center;display:inline-flex;gap:0.15rem}.bv-tag-actions[hidden]{display:none}`)

class BrazenViewLayer
{
/**
 * @type {boolean}
 * @private
 */
_resizing = false

/**
 * @type {JQuery}
 * @private
 */
_section = null

/**
 * @type {SelectorGenerator}
 * @private
 */
_selectorGenerator

/**
 * @type {string}
 * @private
 */
_selectorPrefix

/**
 * @param {string} selectorPrefix
 */
constructor(selectorPrefix)
{
  this._selectorGenerator = new SelectorGenerator(selectorPrefix)
  this._selectorPrefix = selectorPrefix
}

/**
 * @param {JQuery} nodes
 */
static appendToBody(nodes)
{
  $('body').append(nodes)
}

/**
 * @param {JQuery[]} children
 * @return {JQuery}
 */
createBottomSection(children)
{
  return $('<div class="bv-bottom-section">').append(...children)
}

/**
 * @return {JQuery}
 */
createBreakSeparator()
{
  return $('<br class="bv-break"/>')
}

/**
 * @return {JQuery}
 */
createContainer()
{
  this._section = $('<section class="bv-section bv-font-primary">')
  return this._section
}

/**
 * @param {JQuery|JQuery[]} children
 * @param {string} wrapperClasses
 * @return {JQuery}
 */
createFormActions(children, wrapperClasses = '')
{
  return $('<div class="bv-actions"/>').addClass(wrapperClasses).append(children)
}

/**
 * @param {string} caption
 * @param {JQuery.EventHandler} onClick
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormButton(caption, hoverHelp, onClick)
{
  return $('<button class="bv-button">').attr('title', hoverHelp).text(caption).on('click', onClick)
}

createFormCheckBoxesGroupSection(label, keyValuePairs, hoverHelp)
{
  let section = this.createFormSection(label).addClass('bv-checkboxes-group').attr('title', hoverHelp)
  for (const element of keyValuePairs) {
    section.append(
        this.createFormGroup().append([
          this.createFormGroupLabel(element[0], 'checkbox'),
          this.createFormGroupInput('checkbox').attr('data-value', element[1]),
        ]),
    )
  }
  return section
}

/**
 * @return {JQuery}
 */
createFormGroup()
{
  return $('<div class="bv-group"/>')
}

/**
 * @param {string} id
 * @param {Array} keyValuePairs
 *
 * @return {JQuery}
 */
createFormGroupDropdown(id, keyValuePairs)
{
  let dropdown = $('<select>').attr('id', id).addClass('bv-input')

  for (let i = 0; i < keyValuePairs.length; i++) {
    dropdown.append($('<option>').attr('value', keyValuePairs[i][0]).text(keyValuePairs[i][1]).prop('selected', (i === 0)))
  }
  return dropdown
}

/**
 * @param {string} type
 *
 * @return {JQuery}
 */
createFormGroupInput(type)
{
  let input = $('<input class="bv-input">').attr('type', type)
  switch (type) {
    case 'number':
    case 'text':
      input.addClass('bv-text')
      break

    case 'radio':
    case 'checkbox':
      input.addClass('bv-checkbox-radio')
      break
  }
  return input
}

/**
 * @param {string} label
 * @param {string} inputType
 * @return {JQuery}
 */
createFormGroupLabel(label, inputType = '')
{
  let labelFormGroup = $('<label class="bv-label">').text(label)
  if (inputType !== '') {
    switch (inputType) {
      case 'number':
      case 'text':
        labelFormGroup.addClass('bv-text')
        labelFormGroup.text(labelFormGroup.text() + ': ')
        break
      case 'radio':
      case 'checkbox':
        labelFormGroup.addClass('bv-checkbox-radio')
        break
    }
  }
  return labelFormGroup
}

/**
 * @param {string} statisticType
 * @return {JQuery}
 */
createFormGroupStatLabel(statisticType)
{
  return $('<label class="bv-stat-label">').attr('id', this._selectorGenerator.getStatLabelSelector(statisticType)).text('0')
}

/**
 * @param {string} label
 * @param {string} inputType
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormInputGroup(label, inputType, hoverHelp = '')
{
  return this.createFormGroup().attr('title', hoverHelp).append([
    this.createFormGroupLabel(label, inputType),
    this.createFormGroupInput(inputType),
  ])
}

createFormRadiosGroupSection(label, keyValuePairs, hoverHelp)
{
  let section = this.createFormSection(label).addClass('bv-radios-group').attr('title', hoverHelp)
  for (let i = 0; i < keyValuePairs.length; i++) {
    section.append(
        this.createFormGroup().append([
          this.createFormGroupLabel(keyValuePairs[i][0], 'radio'),
          this.createFormGroupInput('radio').prop('checked', i === 0).attr('data-value', keyValuePairs[i][1]).on('change', (event) => {
            $(event.currentTarget).parents('.bv-radios-group').first().find('input').each((index, element) => {
              if (!element.isSameNode(event.currentTarget)) {
                $(element).prop('checked', false)
              }
            })
          }),
        ]),
    )
  }
  return section
}

/**
 * @param {string} label
 * @param {string} inputsType
 * @param {number} minimum
 * @param {number} maximum
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormRangeInputGroup(label, inputsType, minimum, maximum, hoverHelp)
{
  return this.createFormGroup().addClass('bv-range-group').attr('title', hoverHelp).append([
    this.createFormGroupLabel(label, inputsType),
    this.createFormGroupInput(inputsType).attr('min', minimum).attr('max', maximum),
    this.createFormGroupInput(inputsType).attr('min', minimum).attr('max', maximum),
  ])
}

/**
 * @param {string} title
 * @return {JQuery}
 */
createFormSection(title = '')
{
  return $('<div>').append($('<label class="bv-title">').text(title))
}

/**
 * @param {string} label
 * @param {int} rows
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormTextAreaGroup(label, rows, hoverHelp = '')
{
  return this.createFormGroup().attr('title', hoverHelp).addClass('bv-textarea-group').append([
    this.createFormGroupLabel(label),
    $('<textarea class="bv-input" spellcheck="false">').attr('rows', rows),
  ])
}

/**
 * @typedef {{id: string, label: string, tags: string, url: string}} BrazenBookmarkEntry
 */

/**
 * @typedef {{
 *   getCurrentUrl?: function(): string,
 *   normalizeUrl?: function(string): string,
 *   onMatchChange: function(boolean): void,
 *   watchSelectors?: string[],
 *   isActive?: function(): boolean,
 * }} BrazenBookmarksPageMatchOptions
 */

/**
 * @typedef {{
 *   showAddButton?: boolean,
 *   addCaption?: string,
 *   addHelpText?: string,
 *   searchPlaceholder?: string,
 *   searchHelpText?: string,
 *   sortHelpText?: string,
 *   emptyMessage?: string,
 *   noMatchMessage?: string,
 *   pageMatch?: BrazenBookmarksPageMatchOptions,
 *   onAdd?: Function,
 *   onSort?: Function,
 *   onRemove?: function(string): void,
 *   onNavigate?: function(string): void,
 * }} BrazenBookmarksPanelOptions
 */

/**
 * @typedef {{
 *   element: JQuery,
 *   render: function(BrazenBookmarkEntry[]): void,
 *   getFilterQuery: function(): string,
 *   isCurrentPageBookmarked: function(string=): boolean,
 *   checkPageMatch: function(): void,
 * }} BrazenBookmarksPanel
 */

/**
 * Searchable bookmark list with add, sort, navigate, and remove controls.
 *
 * @param {BrazenBookmarksPanelOptions} options
 * @return {BrazenBookmarksPanel}
 */
createBookmarksPanel(options = {})
{
  let settings = {
    showAddButton: true,
    addCaption: 'Add Bookmark',
    addHelpText: '',
    searchPlaceholder: 'Search bookmarks…',
    searchHelpText: 'Filter bookmarks by label or tags',
    sortHelpText: 'Sort bookmarks A–Z',
    emptyMessage: 'No bookmarks yet.',
    noMatchMessage: 'No matching bookmarks.',
    onAdd: () => {},
    onSort: () => {},
    onRemove: () => {},
    onNavigate: (url) => {
      location.href = url
    },
    ...options,
  }

  let filterQuery = ''
  let lastBookmarks = []
  let listEl = $('<div class="bv-bookmark-list">')
  let emptyEl = $('<div class="bv-bookmark-empty">').text(settings.emptyMessage)
  let searchEl = $('<input type="search" class="bv-input bv-text bv-bookmark-search">').
      attr('placeholder', settings.searchPlaceholder).
      attr('title', settings.searchHelpText)
  searchEl.on('input', () => {
    filterQuery = String(searchEl.val() ?? '')
    widget.render(lastBookmarks)
  })

  let sortButton = $('<button type="button" class="bv-bookmark-sort bv-button">').
      text('⇅').
      attr('title', settings.sortHelpText).
      on('click', () => settings.onSort())

  let toolbar = $('<div class="bv-bookmark-toolbar">').append(searchEl, sortButton)
  let scrollEl = $('<div class="bv-bookmark-scroll">')
  scrollEl.append(listEl)
  let columnsEl = $('<div class="bv-bookmark-columns">')
  columnsEl.append(toolbar, emptyEl, scrollEl)

  let syncBookmarkColumnInset = () => {
    let scrollNode = scrollEl[0]
    if (!scrollNode) {
      return
    }
    columnsEl[0].style.setProperty('--bv-bookmark-scroll-inset', `${scrollNode.offsetWidth - scrollNode.clientWidth}px`)
  }

  if (typeof ResizeObserver !== 'undefined') {
    new ResizeObserver(() => syncBookmarkColumnInset()).observe(scrollEl[0])
  }

  let elementChildren = []
  if (settings.showAddButton) {
    elementChildren.push(this.createFormButton(settings.addCaption, settings.addHelpText, () => settings.onAdd()))
  }
  elementChildren.push(columnsEl)
  let element = $('<div class="bv-bookmarks-group">').append(elementChildren)

  let widget = {
    element: element,
    getFilterQuery: () => filterQuery,
    isCurrentPageBookmarked: (url) => {
      let match = settings.pageMatch
      if (!match) {
        return false
      }
      let normalize = match.normalizeUrl ?? ((value) => String(value ?? '').trim())
      let currentUrl = normalize(url ?? (match.getCurrentUrl?.() ?? location.href))
      if (!currentUrl) {
        return false
      }
      return lastBookmarks.some((bookmark) => normalize(bookmark.url) === currentUrl)
    },
    checkPageMatch: () => {
      let match = settings.pageMatch
      if (!match) {
        return
      }
      if (match.isActive && !match.isActive()) {
        match.onMatchChange(false)
        return
      }
      match.onMatchChange(widget.isCurrentPageBookmarked())
    },
    render: (bookmarks) => {
      lastBookmarks = Array.isArray(bookmarks) ? bookmarks : []
      let query = filterQuery.trim().toLowerCase()
      let visible = query ? lastBookmarks.filter((bookmark) =>
        bookmark.label.toLowerCase().includes(query) || bookmark.tags.toLowerCase().includes(query)) : lastBookmarks

      listEl.empty()
      if (lastBookmarks.length === 0) {
        emptyEl.text(settings.emptyMessage).toggle(true)
      } else if (visible.length === 0) {
        emptyEl.text(settings.noMatchMessage).toggle(true)
      } else {
        emptyEl.toggle(false)
      }

      for (let bookmark of visible) {
        let row = $('<div class="bv-bookmark-row">')
        let link = $('<button type="button" class="bv-bookmark-link">').text(bookmark.label).attr('title', bookmark.tags)
        link.on('click', () => settings.onNavigate(bookmark.url))
        let remove = $('<button type="button" class="bv-bookmark-remove">').attr('title', 'Remove bookmark').text('×')
        remove.on('click', (event) => {
          event.stopPropagation()
          settings.onRemove(bookmark.id)
        })
        row.append(link, remove)
        listEl.append(row)
      }

      syncBookmarkColumnInset()
      widget.checkPageMatch()
    },
  }

  syncBookmarkColumnInset()
  requestAnimationFrame(() => requestAnimationFrame(syncBookmarkColumnInset))

  if (settings.pageMatch) {
    let refresh = () => widget.checkPageMatch()
    for (let selector of settings.pageMatch.watchSelectors ?? []) {
      for (let element of document.querySelectorAll(selector)) {
        element.addEventListener('input', refresh)
      }
    }
    refresh()
  }

  return widget
}

createResizer()
{
  return $('<div id="bv-resizer" title="Resize"><span id="bv-resizer-icon">↔</span></div>').
      on('mousedown', (event) => {

        event.preventDefault()
        this._resizing = true

        let resizeHorizontal = (event) => {
          this._section[0].style.width = Math.max(150, event.clientX - this._section[0].getBoundingClientRect().left) + `px`
        }

        let stopResize = () => {
          removeEventListener('mousemove', resizeHorizontal)
          removeEventListener('mouseup', stopResize)
        }

        addEventListener('mousemove', resizeHorizontal)
        addEventListener('mouseup', stopResize)

      }).
      on('mouseup', (event) => {
        this._resizing = false
      })
}

createModal(title, bodyNodes)
{
  let body = $('<div class="bv-modal-body">')
  let nodes = Array.isArray(bodyNodes) ? bodyNodes : [bodyNodes]
  for (let node of nodes) {
    body.append(node instanceof $ ? node : $(node))
  }

  let dialog = $('<div class="bv-modal-dialog">').append([
    $('<div class="bv-modal-header">').append([
      $('<h2 class="bv-modal-title">').text(title),
      $('<button type="button" class="bv-modal-close bv-button" aria-label="Close">').text('×'),
    ]),
    body,
  ])

  return $('<div class="bv-modal-overlay">').append(dialog)
}

/**
 * @param {JQuery} modal
 */
showModal(modal)
{
  let $modal = $(modal)
  let hide = () => this.hideModal($modal)

  $modal.on('click', (event) => {
    if (event.target === $modal[0]) {
      hide()
    }
  })
  $modal.find('.bv-modal-close').on('click', hide)
  $(document).on('keydown.bvModal', (event) => {
    if (event.key === 'Escape') {
      hide()
    }
  })
  BrazenViewLayer.appendToBody($modal)
}

/**
 * @param {JQuery} modal
 */
hideModal(modal)
{
  $(document).off('keydown.bvModal')
  $(modal).remove()
}

/**
 * @typedef {{
 *   sidebarSelector?: string,
 *   rowSelector?: string,
 *   countSelector?: string,
 *   hideDelayMs?: number,
 *   extractTag?: function(Element): *,
 *   renderActions?: function(Element, *, Element, *): void,
 *   isBookmarked?: function(string): boolean,
 * }} TagSidebarCountEnhancementOptions
 */

/**
 * Wraps sidebar tag counts with a hover-reveal action slot.
 *
 * @param {TagSidebarCountEnhancementOptions} options
 */
enhanceTagSidebarCounts(options = {})
{
  let settings = {
    sidebarSelector: '#tag-sidebar',
    rowSelector: 'li[class*="tag-type-"]',
    countSelector: 'span.tag-count',
    hideDelayMs: 2000,
    extractTag: () => ({name: ''}),
    renderActions: () => {},
    isBookmarked: () => false,
    ...options,
  }

  let sidebar = document.querySelector(settings.sidebarSelector)
  if (!sidebar) {
    return
  }

  let reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
  let hideDelayMs = reducedMotion ? 0 : settings.hideDelayMs

  for (let row of sidebar.querySelectorAll(settings.rowSelector)) {
    let countSpan = row.querySelector(settings.countSelector)
    if (!countSpan) {
      continue
    }

    let slot = countSpan.closest('.bv-tag-actions-slot')
    let actions
    if (!slot) {
      slot = document.createElement('span')
      slot.className = 'bv-tag-actions-slot'
      countSpan.parentNode.insertBefore(slot, countSpan)
      slot.appendChild(countSpan)

      actions = document.createElement('span')
      actions.className = 'bv-tag-actions'
      actions.hidden = true
      slot.appendChild(actions)

      let hideTimer = null
      let showActions = () => {
        clearTimeout(hideTimer)
        hideTimer = null
        actions.hidden = false
        countSpan.hidden = true
        row.classList.add('bv-tag-row-active')
      }
      let hideActions = () => {
        actions.hidden = true
        countSpan.hidden = false
        row.classList.remove('bv-tag-row-active')
      }
      let scheduleHide = () => {
        clearTimeout(hideTimer)
        hideTimer = setTimeout(hideActions, hideDelayMs)
      }

      row.addEventListener('pointerenter', showActions)
      row.addEventListener('pointerleave', scheduleHide)
    } else {
      actions = slot.querySelector('.bv-tag-actions')
    }

    if (!actions) {
      continue
    }

    let tag = settings.extractTag(row)
    actions.replaceChildren()
    settings.renderActions(row, tag, actions, {isBookmarked: settings.isBookmarked})
  }
}

/**
 * @return {JQuery}
 */
createSeparator()
{
  return $('<hr/>')
}

/**
 * @return {JQuery}
 */
createSettingsHideButton()
{
  return this.createFormButton('<< Hide', '', () => this._section.css('display', 'none'))
}

/**
 * @return {JQuery}
 */
createSettingsSection()
{
  return this.createContainer().
      attr('id', 'bv-ui').
      addClass('bv-bg-colour').
      hide().
      append(this.createResizer())
}

/**
 * @param {string} caption
 * @param {JQuery} settingsSection
 *
 * @return {JQuery}
 */
createSettingsShowButton(caption, settingsSection)
{
  return $('<button class="show-settings bv-section bv-bg-colour">').
      text(caption).
      on('click', () => settingsSection.slideDown(300))
}

/**
 * @param {string} statisticsType
 * @param {string} label
 * @return {JQuery}
 */
createStatisticsFormGroup(statisticsType, label = '')
{
  return this.createFormGroup().addClass('bv-stat-group').append([
    this.createFormGroupLabel((label === '' ? statisticsType : label) + ' Filter'),
    this.createFormGroupStatLabel(statisticsType),
  ])
}

/**
 * @return {JQuery}
 */
createStatisticsTotalsGroup()
{
  return this.createFormGroup().append([
    this.createFormGroupLabel('Total'),
    this.createFormGroupStatLabel('Total'),
  ])
}

/**
 * @param {string} tabName
 * @param {boolean} isFirst
 * @return {JQuery}
 */
createTabButton(tabName, isFirst)
{
  let tabButton = $('<button class="bv-tab-button bv-border-primary">').
      text(tabName).
      on('click', (event) => {

        let button = $(event.currentTarget)
        let tabSection = button.parents('.bv-tabs-section:first')

        tabSection.find('.bv-tab-button').removeClass('bv-active bv-font-secondary').addClass('bv-font-primary')

        tabSection.find('.bv-tab-panel').removeClass('bv-active')

        button.removeClass('bv-font-primary').addClass('bv-active bv-font-secondary')

        $('#' + Utilities.toKebabCase(button.text())).addClass('bv-active')

        let root = tabSection.closest('#bv-ui')[0]
        root?.userScript?._configurationManager?.updateInterface()
      }).
      on('mouseenter', (event) => $(event.currentTarget).addClass('bv-font-secondary')).
      on('mouseleave', (event) => $(event.currentTarget).removeClass('bv-font-secondary'))

  return isFirst ? tabButton.addClass('bv-active bv-font-secondary') : tabButton.addClass('bv-font-primary')
}

/**
 * @param {string} tabName
 * @param {boolean} isFirst
 * @return {JQuery}
 */
createTabPanel(tabName, isFirst = false)
{
  let tabPanel = $('<div class="bv-tab-panel bv-border-primary">').attr('id', Utilities.toKebabCase(tabName))
  if (isFirst) {
    tabPanel.addClass('bv-active')
  }
  return tabPanel
}

/**
 * @param {string[]} tabNames
 * @param {JQuery[]} tabPanels
 * @return {JQuery}
 */
createTabsSection(tabNames, tabPanels)
{
  let tabButtons = []
  for (let i = 0; i < tabNames.length; i++) {
    tabButtons.push(this.createTabButton(tabNames[i], i === 0))
  }
  let nav = $('<div class="bv-tabs-nav">').append(tabButtons)
  return $('<div class="bv-tabs-section">').append(nav).append(...tabPanels)
}

/**
 * @param {string} title
 * @return {JQuery}
 */
createTitle(title)
{
  return $('<label class="bv-title">' + title + '</label>')
}

/**
 * @return {JQuery}
 */
getSelectedSection()
{
  return this._section
}

isSettingsPaneBeingResized()
{
  return this._resizing
}
}