UserscriptAPIMessage

https://gitee.com/liangjiancang/userscript/tree/master/lib/UserscriptAPI

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/432000/1095149/UserscriptAPIMessage.js

/**
 * UserscriptAPIMessage
 *
 * 依赖于 `UserscriptAPI`,`UserscriptAPIDom`。
 * @version 1.3.1.20220918
 * @author Laster2800
 * @see {@link https://gitee.com/liangjiancang/userscript/tree/master/lib/UserscriptAPI UserscriptAPI}
 */
class UserscriptAPIMessage {
  /**
   * @param {UserscriptAPI} api `UserscriptAPI`
   */
  constructor(api) {
    this.api = api

    api.initModuleStyle(`
      .${api.options.id}-infobox {
        z-index: 100000000;
        background-color: #000000bf;
        font-size: 16px;
        max-width: 24em;
        min-width: 2em;
        color: white;
        padding: 0.5em 1em;
        border-radius: 9.6px;
        opacity: 0;
        transition: opacity ${api.options.fadeTime}ms ease-in-out;
        pointer-events: none;
        text-align: justify;
      }

      .${api.options.id}-infobox .hover-info {
        display: flex;
        align-items: center;
        gap: 1em;
      }

      .${api.options.id}-dialog {
        z-index: 90000000;
        background-color: white;
        font-size: 17px;
        min-width: 18em;
        max-width: 35em;
        border-radius: 4px;
        opacity: 0;
        box-shadow: #000000aa 0px 3px 6px;
        transition: opacity 150ms cubic-bezier(0.68, -0.55, 0.27, 1.55);
      }
      .${api.options.id}-dialog .gm-header {
        padding: 0.5em 1em 0.4em;
        border-bottom: 1px solid #d5d5d5;
      }
      .${api.options.id}-dialog .gm-body {
        padding: 0.8em 1em;
      }
      .${api.options.id}-dialog .gm-bottom {
        padding: 0 1em 0.6em;
        text-align: right;
      }
      .${api.options.id}-dialog .gm-content {
        line-height: 1.6em;
      }
      .${api.options.id}-dialog button.gm-interactive {
        font-size: 0.9em;
        padding: 0.1em 0.6em;
        margin-left: 0.8em;
        cursor: pointer;
        background-color: white;
        border: 1px solid #909090;
        border-radius: 2px;
      }
      .${api.options.id}-dialog button.gm-interactive:hover,
      .${api.options.id}-dialog button.gm-interactive:focus {
        background-color: #ebebeb;
      }
      .${api.options.id}-dialog input.gm-interactive {
        outline: none;
        width: calc(100% - 12px);
        margin-top: 0.6em;
        padding: 4px 6px;
        border: 1px solid #909090;
        border-radius: 2px;
      }
      .${api.options.id}-dialog textarea.gm-interactive {
        outline: none;
        width: calc(100% - 2em);
        margin: 0.6em 0 -0.4em;
        padding: 1em;
        resize: none;
        border: 1px solid #909090;
        border-radius: 2px;
      }
      .${api.options.id}-dialog textarea.gm-interactive::-webkit-scrollbar {
        width: 6px;
        height: 6px;
        background-color: transparent;
      }
      .${api.options.id}-dialog textarea.gm-interactive::-webkit-scrollbar-thumb {
        border-radius: 3px;
        background-color: #0000002b;
      }
      .${api.options.id}-dialog textarea.gm-interactive::-webkit-scrollbar-corner {
        background-color: transparent;
      }
    `)
  }

  /**
   * @typedef infoOptions
   * @property {(infobox: HTMLElement) => void} [onOpened] 信息打开后的回调
   * @property {(infobox: HTMLElement) => void} [onClosed] 信息关闭后的回调
   * @property {boolean} [autoClose=true] 是否自动关闭信息,配合 `ms` 使用
   * @property {number} [ms=1500] 显示时间(单位:ms,不含渐显/渐隐时间)
   * @property {boolean} [html=false] 是否将 `msg` 理解为 HTML
   * @property {string} [width] 信息框的宽度;缺省时根据内容决定,但有最小宽度和最大宽度的限制,设为 `auto` 可解除限制
   * @property {{top: string, left: string}} [position] 信息框的位置,必须带单位或以百分号结尾;不设置该项时,相当于设置为 `{ top: '80%', left: '50%' }`
   */
  /**
   * 创建信息
   * @param {string} msg 信息
   * @param {infoOptions | number} [options] 选项 / 显示时间(单位:ms,不含渐显/渐隐时间)
   * @return {HTMLElement} 信息框元素
   */
  info(msg, options) {
    const { api } = this
    if (typeof options === 'number') {
      options = { ms: options }
    }
    options = {
      autoClose: true,
      ms: 1500,
      position: { top: '85%' },
      ...options,
    }

    const infobox = document.createElement('div')
    infobox.className = `${api.options.id}-infobox`
    if (options.width) {
      infobox.style.minWidth = 'auto'
      infobox.style.maxWidth = 'none'
      infobox.style.width = options.width
    }
    if (options.html) {
      infobox.innerHTML = msg
    } else {
      infobox.textContent = msg
    }
    document.body.append(infobox)
    api.dom.setPosition(infobox, options.position)

    api.dom.fade(true, infobox, () => {
      options.onOpened?.(infobox)
      if (options.autoClose) {
        setTimeout(() => {
          this.close(infobox, options.onClosed)
        }, options.ms)
      }
    })
    return infobox
  }

  /**
   * 创建悬浮信息
   *
   * 后续可通过启动元素上的 `hoverInfo` 属性修改悬浮信息设置,也可再次在启动元素上调用该方法修改。
   * @param {HTMLElement} el 启动元素
   * @param {string} msg 信息
   * @param {string} [flag] 标志信息
   * @param {Object} [options] 选项
   * @param {string} [options.flagSize='1.8em'] 标志大小
   * @param {string} [options.width] 信息框的宽度;缺省时根据内容决定,但有最小宽度和最大宽度的限制,设为 `auto` 可解除限制
   * @param {{top: string, left: string}} [options.position] 信息框的位置,不设置该项时,沿用 `api.message.infobox()` 的默认设置
   * @param {() => boolean} [options.disabled] 用于获取是否禁用信息的函数
   */
  hoverInfo(el, msg, flag, options) {
    const created = el.hoverInfo
    el.hoverInfo = { msg, flag, options: { flagSize: '1.8em', ...options } }
    if (!created) {
      /** @type {MutationObserver} */
      let ob = null
      el.addEventListener('mouseenter', () => {
        const opt = el.hoverInfo
        if (opt.options.disabled?.()) return
        const htmlMsg = `
          <div class="hover-info">
            ${opt.flag ? `<div style="font-size:${opt.options.flagSize};line-height:${opt.options.flagSize}">${opt.flag}</div>` : ''}
            <div>${opt.msg}</div>
          </div>
        `
        el.infobox = this.info(htmlMsg, { ...opt.options, html: true, autoClose: false })
        // 避免 el 被移除后悬浮信息无法关闭
        ob ??= new MutationObserver((records => {
          for (const record of records) {
            for (const node of record.removedNodes) {
              if (node === el || node.contains(el)) {
                this.close(el.infobox)
                ob.disconnect()
                ob = null
                return
              }
            }
          }
        }))
        ob.observe(document, { childList: true, subtree: true })
      })
      el.addEventListener('mouseleave', () => {
        this.close(el.infobox)
        ob?.disconnect()
      })
    }
  }

  /**
   * @typedef DialogElement
   * @property {0 | 1 | 2 | 3 | 4} state 状态(初始 | 开启中 | 打开 | 关闭中 | 关闭)
   * @property {HTMLElement[]} interactives 交互元素
   * @property {(callback?: () => void) => void} open 打开对话框
   * @property {(callback?: () => void) => void} close 关闭对话框
   */
  /**
   * 创建对话框
   * @param {string} msg 信息
   * @param {Object} [options] 选项
   * @param {boolean} [options.html] 信息是否为 HTML
   * @param {string} [options.title=api.options.label] 标题
   * @param {boolean} [options.titleHtml] 标题是否为 HTML
   * @param {boolean} [options.lineInput] 是否添加单行输入框
   * @param {boolean} [options.boxInput] 是否添加多行输入框
   * @param {string[]} [options.buttons] 对话框按钮文本
   * @param {string} [options.width] 对话框宽度,不设置的情况下根据内容决定,但有最小宽度和最大宽度的限制
   * @param {{top: string, left: string}} [options.position] 信息框的位置,必须带单位或以百分号结尾;不设置该项时绝对居中
   * @returns {HTMLElement & DialogElement} 对话框元素
   */
  dialog(msg, options) {
    const { api } = this
    options = {
      title: api.options.label,
      position: {
        top: '50%',
        left: '50%',
      },
      ...options,
    }

    const dialog = document.createElement('div')
    dialog.className = `${api.options.id}-dialog`
    if (options.width) {
      dialog.style.minWidth = 'auto'
      dialog.style.maxWidth = 'none'
      dialog.style.width = options.width
    }

    let bottomHtml = ''
    if (options.buttons) {
      for (const button of options.buttons) {
        bottomHtml += `<button class="gm-interactive">${button}</button>`
      }
      if (bottomHtml) {
        bottomHtml = `<div class="gm-bottom">${bottomHtml}</div>`
      }
    }
    dialog.innerHTML = `
      ${options.title ? '<div class="gm-header"></div>' : ''}
      <div class="gm-body">
        <div class="gm-content"></div>
        ${options.lineInput ? '<input type="text" class="gm-interactive">' : ''}
        ${options.boxInput ? '<textarea class="gm-interactive"></textarea>' : ''}
      </div>
      ${bottomHtml}
    `
    if (options.title) {
      const header = dialog.querySelector('.gm-header')
      if (options.titleHtml) {
        header.innerHTML = options.title
      } else {
        header.textContent = options.title
      }
    }
    const content = dialog.querySelector('.gm-content')
    if (options.html) {
      content.innerHTML = msg
    } else {
      content.textContent = msg
    }
    dialog.interactives = dialog.querySelectorAll('.gm-interactive')
    document.body.append(dialog)

    dialog.state = 0
    dialog.fadeOutNoInteractive = true
    dialog.open = callback => {
      dialog.state = 1
      api.dom.setPosition(dialog, options.position)
      api.dom.fade(true, dialog, () => {
        dialog.state = 2
        callback?.()
      })
    }
    dialog.close = callback => {
      dialog.state = 3
      this.close(dialog, () => {
        dialog.state = 4
        callback?.()
      })
    }
    return dialog
  }

  /**
   * 创建提醒对话框
   *
   * 没有引入 `message` 模块时,`UserscriptAPI` 会创建一个基础方法作为本方法的替代。
   * @param {string} msg 信息
   * @param {Object} [options] 选项
   * @param {boolean} [options.primitive] 使用原生组件
   * @param {boolean} [options.html] 信息是否为 HTML
   * @param {string} [options.title=api.options.label] 标题
   * @param {boolean} [options.titleHtml] 标题是否为 HTML
   * @param {string} [options.width] 对话框宽度,不设置的情况下根据内容决定,但有最小宽度和最大宽度的限制
   * @param {{top: string, left: string}} [options.position] 信息框的位置,不设置该项时绝对居中
   * @param {string[]} [options.btnText] 按钮文本
   * @param {Object} [ret] 方法通过该对象将必要数据返回
   * @returns {Promise<void>} 用户输入
   */
  alert(msg, options, ret) {
    return new Promise(resolve => {
      let primitive = !document.body || options?.primitive
      if (!primitive) {
        try {
          const btnText = []
          btnText[0] = options?.btnText?.[0] ?? '确定'
          const dialog = this.dialog(msg, {
            ...options,
            buttons: btnText,
          })
          const confirm = dialog.interactives[0]
          confirm.focus({ preventScroll: true })
          confirm.addEventListener('click', () => {
            dialog.close()
            resolve()
          })
          dialog.open()
          if (ret) {
            ret.dialog = dialog
          }
        } catch { // not true error
          primitive = true
        }
      }
      if (primitive) {
        const { label } = this.api.options
        if (options?.html) {
          const el = document.createElement('div')
          el.innerHTML = msg
          msg = el.textContent
        }
        resolve(alert(`${label ? `${label}\n\n` : ''}${msg}`))
      }
    })
  }

  /**
   * 创建确认对话框
   *
   * 没有引入 `message` 模块时,`UserscriptAPI` 会创建一个基础方法作为本方法的替代。
   * @param {string} msg 信息
   * @param {Object} [options] 选项
   * @param {boolean} [options.primitive] 使用原生组件
   * @param {boolean} [options.html] 信息是否为 HTML
   * @param {string} [options.title=api.options.label] 标题
   * @param {boolean} [options.titleHtml] 标题是否为 HTML
   * @param {string} [options.width] 对话框宽度,不设置的情况下根据内容决定,但有最小宽度和最大宽度的限制
   * @param {{top: string, left: string}} [options.position] 信息框的位置,不设置该项时绝对居中
   * @param {string[]} [options.btnText] 按钮文本
   * @param {Object} [ret] 方法通过该对象将必要数据返回
   * @returns {Promise<boolean>} 用户输入
   */
  confirm(msg, options, ret) {
    return new Promise(resolve => {
      let primitive = !document.body || options?.primitive
      if (!primitive) {
        try {
          const btnText = []
          btnText[0] = options?.btnText?.[0] ?? '确定'
          btnText[1] = options?.btnText?.[1] ?? '取消'
          const dialog = this.dialog(msg, {
            ...options,
            buttons: btnText,
          })
          const [confirm, cancel] = dialog.interactives
          confirm.focus({ preventScroll: true })
          confirm.addEventListener('click', () => {
            dialog.close()
            resolve(true)
          })
          cancel.addEventListener('click', () => {
            dialog.close()
            resolve(false)
          })
          dialog.open()
          if (ret) {
            ret.dialog = dialog
          }
        } catch { // not true error
          primitive = true
        }
      }
      if (primitive) {
        const { label } = this.api.options
        if (options?.html) {
          const el = document.createElement('div')
          el.innerHTML = msg
          msg = el.textContent
        }
        resolve(confirm(`${label ? `${label}\n\n` : ''}${msg}`))
      }
    })
  }

  /**
   * 创建输入对话框
   *
   * 没有引入 `message` 模块时,`UserscriptAPI` 会创建一个基础方法作为本方法的替代。
   * @param {string} msg 信息
   * @param {string} [val] 默认值
   * @param {Object} [options] 选项
   * @param {boolean} [options.primitive] 使用原生组件
   * @param {boolean} [options.html] 信息是否为 HTML
   * @param {string} [options.title=api.options.label] 标题
   * @param {boolean} [options.titleHtml] 标题是否为 HTML
   * @param {string} [options.width] 对话框宽度,不设置的情况下根据内容决定,但有最小宽度和最大宽度的限制
   * @param {{top: string, left: string}} [options.position] 信息框的位置,不设置该项时绝对居中
   * @param {string[]} [options.btnText] 按钮文本
   * @param {Object} [ret] 方法通过该对象将必要数据返回
   * @returns {Promise<string>} 用户输入
   */
  prompt(msg, val, options, ret) {
    return new Promise(resolve => {
      let primitive = !document.body || options?.primitive
      if (!primitive) {
        try {
          const btnText = []
          btnText[0] = options?.btnText?.[0] ?? '确定'
          btnText[1] = options?.btnText?.[1] ?? '取消'
          const dialog = this.dialog(msg, {
            ...options,
            buttons: btnText,
            lineInput: true,
          })
          const [input, confirm, cancel] = dialog.interactives
          if (val) {
            input.value = val
            input.setSelectionRange(0, input.value.length)
          }
          input.focus({ preventScroll: true })
          input.addEventListener('keyup', e => {
            if (e.key === 'Enter') {
              confirm.dispatchEvent(new Event('click'))
            }
          })
          confirm.addEventListener('click', () => {
            dialog.close()
            resolve(input.value)
          })
          cancel.addEventListener('click', () => {
            dialog.close()
            resolve(null)
          })
          dialog.open()
          if (ret) {
            ret.dialog = dialog
          }
        } catch { // not true error
          primitive = true
        }
      }
      if (primitive) {
        const { label } = this.api.options
        if (options?.html) {
          const el = document.createElement('div')
          el.innerHTML = msg
          msg = el.textContent
        }
        resolve(prompt(`${label ? `${label}\n\n` : ''}${msg}`, val))
      }
    })
  }

  /**
   * 关闭信息元素
   * @param {HTMLElement} msgEl 信息元素
   * @param {(msgEl: HTMLElement) => void} [callback] 信息关闭后的回调
   */
  close(msgEl, callback) {
    if (msgEl) {
      this.api.dom.fade(false, msgEl, () => {
        callback?.(msgEl)
        msgEl?.remove()
      })
    }
  }
}

/* global UserscriptAPI */
// eslint-disable-next-line no-lone-blocks
{ UserscriptAPI.registerModule('message', UserscriptAPIMessage) }