popup-inject

向网页中插入一个侧边按钮和一个弹窗

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/473443/1374764/popup-inject.js

// @name            popup-inject
// @name:zh         弹窗注入
// @description     Insert a sidebar button and a popup window into the webpage.
// @description:zh  向网页中插入一个侧边按钮和一个弹窗。
// @namespace       https://github.com/pansong291/
// @version         1.0.9
// @author          paso
// @license         Apache-2.0

/**
 * @typedef {object} PopupInjectConfig
 * @property {string} namespace
 * @property {string} [actionName] 侧边按钮文案
 * @property {string} [collapse] 折叠 <length-percentage>
 * @property {string} [location] 顶部位置 <length-percentage>
 * @property {string} [content] DOMString
 * @property {string} [style] StyleString
 * @property {VoidFunction} [onPopShow]
 * @property {VoidFunction} [onPopHide]
 */
/**
 * @typedef {object} PopupInjectResult
 * @property {{
 *    container: HTMLElement,
 *    stickyBar: HTMLElement,
 *    mask: HTMLElement,
 *    popup: HTMLElement
 * }} elem
 * @property {{
 *    createElement: CreateElementFunction,
 *    excludeClick: ExcludeClickFuction,
 *    leftKey: LeftKeyFunction<Function>,
 *    getNumber: GetNumberFunction
 * }} func
 */
/**
 * @typedef {(tag: string, attrs?: Record<string, string>, children?: string | (Node | string)[]) => HTMLElement} CreateElementFunction
 */
/**
 * @typedef {(included: HTMLElement, excluded: HTMLElement, onClick?: EventListener) => void} ExcludeClickFuction
 */
/**
 * @template {Function} T
 * @typedef {(fn: T) => T} LeftKeyFunction
 */
/**
 * @typedef {(str?: string) => number | undefined} GetNumberFunction
 */
;(function () {
  'use strict'
  const version = 'v1.0.9'

  /**
   * @type CreateElementFunction
   */
  const createElement = (tag, attrs, children) => {
    const el = document.createElement(tag)
    if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v))
    if (Array.isArray(children)) {
      el.append.apply(el, children)
    } else if (typeof children === 'string') {
      el.innerHTML = children
    }
    return el
  }

  /**
   * @type ExcludeClickFuction
   */
  const excludeClick = (included, excluded, onClick) => {
    const _data = {
      excludeDown: false,
      inIncluded: false,
      inExcluded: false
    }
    excluded.addEventListener('mousedown', () => (_data.excludeDown = true))
    excluded.addEventListener('mouseup', () => (_data.excludeDown = false))
    excluded.addEventListener('mouseenter', () => (_data.inExcluded = true))
    excluded.addEventListener('mouseleave', () => (_data.inExcluded = false))
    included.addEventListener('mouseenter', () => (_data.inIncluded = true))
    included.addEventListener('mouseleave', () => (_data.inIncluded = false))
    included.addEventListener('click', (e) => {
      if (_data.inIncluded && !_data.inExcluded) {
        if (_data.excludeDown) {
          _data.excludeDown = false
        } else {
          onClick?.(e)
        }
      }
    })
  }

  /**
   * @type LeftKeyFunction<Function>
   */
  const leftKey = (fn) => {
    return (...args) => {
      const key = args?.[0]?.button
      if (key === 0 || key === void 0) {
        fn.apply(this, args)
      }
    }
  }

  /**
   * @type GetNumberFunction
   */
  const getNumber = (str) => {
    const mArr = str?.match(/\d+(\.\d*)?|\.\d+/)
    return mArr?.length ? parseFloat(mArr[0]) : void 0
  }

  /**
   * @param {string} originStyleContent
   * @param {string} ancestor
   * @returns {string}
   */
  const addCSSAncestor = (originStyleContent, ancestor) => {
    originStyleContent = '}' + originStyleContent
    return originStyleContent.replaceAll(/}([^{}]+?){/g, (_, p1) => {
      return `}\n${p1.trim().split(',').map(it => `${ancestor} ${it}`).join(', ')} {`
    }).substring(1)
  }

  /**
   * @param {HTMLElement} el
   * @param {(e: MouseEvent, d: WithDragData) => void} [onMove]
   * @param {(e: MouseEvent, d: WithDragData) => void} [onClick]
   */
  const withDrag = (el, onMove, onClick) => {
    /**
     * @typedef {{innerOffsetY: number, outerHeight: number, justClick: boolean}} WithDragData
     */
    const _data = {
      outerHeight: 0,
      innerOffsetY: 0,
      justClick: false
    }

    const onElMouseMove = (e) => {
      _data.justClick = false
      onMove?.(e, _data)
    }

    const onElMouseUp = leftKey(() => {
      document.removeEventListener('mousemove', onElMouseMove)
      document.removeEventListener('mouseup', onElMouseUp)
    })

    el.addEventListener(
      'mousedown',
      leftKey((e) => {
        _data.justClick = true
        const elComputedStyle = window.getComputedStyle(el)
        _data.innerOffsetY = e.pageY - getNumber(elComputedStyle.top)
        _data.outerHeight =
          el.clientHeight + getNumber(elComputedStyle.borderTopWidth) + getNumber(elComputedStyle.borderBottomWidth)
        document.addEventListener('mousemove', onElMouseMove)
        document.addEventListener('mouseup', onElMouseUp)
      })
    )

    el.addEventListener(
      'mouseup',
      leftKey((e) => {
        if (_data.justClick) {
          onClick?.(e, _data)
          _data.justClick = false
        }
        onElMouseUp()
        e.stopPropagation()
      })
    )
  }

  /**
   * @param {PopupInjectConfig} config
   * @returns {string}
   */
  const getBaseStyle = (config) => `
<style>
  :not(svg *) {
      align-content: revert;
      align-items: revert;
      align-self: revert;
      animation: revert;
      background: revert;
      border: revert;
      border-radius: revert;
      box-shadow: revert;
      box-sizing: border-box;
      color: inherit;
      cursor: inherit;
      display: revert;
      flex: revert;
      float: revert;
      font: inherit;
      height: revert;
      inset: revert;
      justify-content: revert;
      justify-items: revert;
      justify-self: revert;
      letter-spacing: inherit;
      list-style: inherit;
      margin: revert;
      mask: revert;
      max-height: revert;
      max-width: revert;
      min-height: revert;
      min-width: revert;
      offset: revert;
      opacity: revert;
      outline: revert;
      overflow: revert;
      overscroll-behavior: revert;
      padding: revert;
      pointer-events: inherit;
      position: revert;
      text-align: inherit;
      text-shadow: inherit;
      text-transform: inherit;
      transform: revert;
      transition: revert;
      user-select: revert;
      visibility: inherit;
      width: revert;
      z-index: revert;
  }
  *::before, *::after {
      content: none;
  }
  *::-webkit-scrollbar {
      width: 8px;
      height: 8px;
  }
  *::-webkit-scrollbar-thumb {
      border-radius: 4px;
      background-color: rgba(0, 0, 0, 0.5);
  }
  *::-webkit-scrollbar-track {
      border-radius: 4px;
      background-color: transparent;
  }
  .flex {
      display: flex;
      flex-direction: row;
      align-items: stretch;
      justify-content: flex-start;
  }
  .flex.col {
      flex-direction: column;
  }
  .container {
      all: revert;
      color: black;
      font-size: 14px;
      line-height: 1.5;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
      font-style: normal;
      font-weight: normal;
  }
  .monospace {
      font-family: v-mono, "JetBrains Mono", Consolas, SFMono-Regular, Menlo, Courier, v-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, monospace, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  }
  .sticky-bar {
      position: fixed;
      top: ${config.location};
      left: 0;
      transform: translateX(calc(12px - ${config.collapse}));
      z-index: 99999999;
      background: #3d7fff;
      color: white;
      padding: 4px 12px 4px 6px;
      cursor: pointer;
      user-select: none;
      border-radius: 0 12px 12px 0;
      box-shadow: 0 2px 4px 1px #0006;
      transition: transform 0.5s ease;
  }
  .sticky-bar:hover {
      transform: none;
  }
  .mask {
      position: fixed;
      inset: 0;
      padding: 24px;
      overflow: auto;
      z-index: 99999999;
      background-color: rgba(0, 0, 0, 0.4);
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 0;
      pointer-events: none;
      transition: opacity .6s;
  }
  .container.open .mask {
      opacity: 1;
      pointer-events: all;
  }
  .popup {
      position: relative;
      margin: auto;
      padding: 16px;
      background: #f0f2f5;
      border-radius: 2px;
      box-shadow: 0 1px 12px 2px rgba(0, 0, 0, 0.4);
      transform: scale(0);
      transition: transform .3s;
  }
  .container.open .popup {
      transform: scale(1);
  }
  label {
      user-select: none;
  }
  textarea {
      resize: vertical;
  }
  .input, .button {
      height: 32px;
      transition: all 0.3s, height 0s;
  }
  .button {
      user-select: none;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 4px 16px;
      color: #fff;
      border: none;
      border-radius: 2px;
      background: #3d7fff;
      text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);
      box-shadow: 0 2px 0 rgba(0, 0, 0, 0.05);
  }
  .button:hover, .button:focus {
      border-color: #669eff;
      background: #669eff;
  }
  .button:active {
      border-color: #295ed9;
      background: #295ed9;
  }
  .input {
      padding: 4px 8px;
      background: white;
      border: 1px solid #d9d9d9;
      border-radius: 2px;
  }
  .input:hover, .input:focus {
      border-color: #669eff;
  }
  .input:focus-visible {
      outline: none;
  }
  .input:focus {
      box-shadow: 0 0 0 2px rgba(61, 127, 255, 0.2);
  }
  ${config.style}
</style>`

  /**
   * @param {PopupInjectConfig} config
   * @param {(value: PopupInjectResult) => void} resolve
   */
  const _injectHtml = (config, resolve) => {
    const anchorId = 'x' + Math.floor(Math.random() * 100_000_000).toString(16)
    const styleContent = addCSSAncestor(getBaseStyle(config).replaceAll(/<\/?style>/g, ''), `#${anchorId}`)
    document.head.insertAdjacentHTML('beforeend', `<style data-namespace='${config.namespace}'>${styleContent}</style>`)
    const stickyBar = createElement('div', { class: 'sticky-bar' }, config.actionName)
    const popup = createElement('div', { class: 'popup flex col' }, config.content)
    const mask = createElement('div', { class: 'mask' }, [popup])
    const container = createElement('div', { class: 'container' }, [stickyBar, mask])
    const anchor = createElement('div', { id: anchorId, 'data-namespace': config.namespace, 'data-version': version }, [container])

    excludeClick(mask, popup, () => {
      container.classList.remove('open')
      config.onPopHide?.()
    })

    withDrag(
      stickyBar,
      (e, d) => {
        requestAnimationFrame(() => {
          const height = document.documentElement.clientHeight - d.outerHeight
          const newTop = e.pageY - d.innerOffsetY
          if (newTop <= 0) stickyBar.style.top = '0'
          else if (newTop > height) stickyBar.style.top = `${height}px`
          else stickyBar.style.top = `${newTop}px`
        })
      },
      () => {
        container.classList.add('open')
        config.onPopShow?.()
      }
    )

    document.body.append(anchor)
    // ---- other code
    resolve?.({
      elem: {
        container, stickyBar, mask, popup
      },
      func: {
        createElement, excludeClick, leftKey, getNumber
      }
    })
  }

  /**
   * @param {PopupInjectConfig} config
   * @returns {PopupInjectConfig}
   */
  const _checkConfig = (config) => {
    if (!config) throw new Error('config is required. you should call window.paso.injectPopup(config)')
    if (!config.namespace) throw new Error('config.namespace is required and it cannot be empty.')
    if (!/^[-\w]+$/.test(config.namespace)) throw new Error('config.namespace must match the regex /^[-\\w]+$/.')
    return config
  }

  if (!window.paso || !(window.paso instanceof Object)) window.paso = {}
  /**
   * @param {PopupInjectConfig} config
   * @returns {Promise<PopupInjectResult>}
   */
  window.paso.injectPopup = (config) => {
    const _config = Object.assign(
      {
        namespace: '',
        actionName: 'Action',
        collapse: '100%',
        location: '25%',
        content: '<label>Hello World</label>',
        style: '',
        onPopShow() {
        },
        onPopHide() {
        }
      },
      _checkConfig(config)
    )
    return new Promise((resolve) => _injectHtml(_config, resolve))
  }
})()