Toc Bar, auto-generating table of content

A floating table of content widget

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name              Toc Bar, auto-generating table of content
// @name:zh-CN        Toc Bar, 自动生成文章大纲。知乎、微信公众号等阅读好伴侣
// @author            hikerpig
// @namespace         https://github.com/hikerpig
// @license           MIT
// @description       A floating table of content widget
// @description:zh-CN 自动生成文章大纲目录,在页面右侧展示一个浮动的组件。覆盖常用在线阅读资讯站(技术向)。github/medium/MDN/掘金/简书等
// @version           1.9.6
// @match             *://www.jianshu.com/p/*
// @match             *://cdn2.jianshu.io/p/*
// @match             *://zhuanlan.zhihu.com/p/*
// @match             *://www.zhihu.com/pub/reader/*
// @match             *://mp.weixin.qq.com/s*
// @match             *://cnodejs.org/topic/*
// @match             *://*zcfy.cc/article/*
// @match             *://juejin.cn/post/*
// @match             *://juejin.cn/book/*
// @match             *://dev.to/*/*
// @exclude           *://dev.to/settings/*
// @match             *://web.dev/*
// @match             *://medium.com/*
// @exclude           *://medium.com/media/*
// @match             *://itnext.io/*
// @match             *://python-patterns.guide/*
// @match             *://www.mysqltutorial.org/*
// @match             *://en.wikipedia.org/*
// @match             *://vuejs.org/*
// @match             *://docs.python.org/*
// @match             *://packaging.python.org/*
// @match             *://*.readthedocs.io/*
// @match             *://docs.djangoproject.com/*
// @match             *://www.cnblogs.com/*
// @match             *://bigsearcher.com/*
// @match             *://ffmpeg.org/*
// @match             *://www.ruanyifeng.com/*
// @match             *://stackoverflow.blog/*
// @match             *://realpython.com/*
// @match             *://www.infoq.cn/article/*
// @match             *://towardsdatascience.com/*
// @match             *://hackernoon.com/*
// @match             *://css-tricks.com/*
// @match             *://www.smashingmagazine.com/*/*
// @match             *://distill.pub/*
// @match             *://github.com/*/*
// @match             *://github.com/*/issues/*
// @match             *://developer.mozilla.org/*/docs/*
// @match             *://learning.oreilly.com/library/view/*
// @match             *://developer.chrome.com/extensions/*
// @match             *://app.getpocket.com/read/*
// @match             *://indepth.dev/posts/*
// @match             *://gitlab.com/*
// @run-at            document-idle
// @grant             GM_getResourceText
// @grant             GM_addStyle
// @grant             GM_setValue
// @grant             GM_getValue
// @require           https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.18.2/tocbot.min.js
// @icon              https://raw.githubusercontent.com/hikerpig/toc-bar-userscript/master/toc-logo.svg
// @homepageURL       https://github.com/hikerpig/toc-bar-userscript
// ==/UserScript==

(function () {
  /**
   * @typedef {Object} SiteSetting
   * @property {string} contentSelector
   * @property {string} siteName
   * @property {Object} style
   * @property {Number} scrollSmoothOffset
   * @property {Number} initialTop
   * @property {Number} headingsOffset
   * @property {() => Boolean} shouldShow
   * @property {(ele) => HTMLElement} findHeaderId
   * @property {(e) => void} onClick
   * @property {(tocBar: TocBar) => void} onInit
   */

  /** @type {{[key: string]: Partial<SiteSetting>}} */
  const SITE_SETTINGS = {
    jianshu: {
      contentSelector: '.ouvJEz',
      style: {
        top: '55px',
        color: '#ea6f5a',
      },
    },
    'zhuanlan.zhihu.com': {
      contentSelector: 'article',
      scrollSmoothOffset: -52,
      shouldShow() {
        return location.pathname.startsWith('/p/')
      },
    },
    'www.zhihu.com': {
      contentSelector: '.reader-chapter-content',
      scrollSmoothOffset: -52,
    },
    zcfy: {
      contentSelector: '.markdown-body',
    },
    qq: {
      contentSelector: '.rich_media_content',
    },
    'juejin.cn': function() {
      let contentSelector = '.article' // post
      if (/\/book\//.test(location.pathname)) {
        contentSelector = '.book-body'
      }
      return {
        contentSelector,
      }
    },
    'dev.to': {
      contentSelector: 'article',
      scrollSmoothOffset: -56,
      shouldShow() {
        return ['/search', '/top/'].every(s => !location.pathname.startsWith(s))
      },
    },
    'medium.com': {
      contentSelector: 'article'
    },
    'docs.djangoproject.com': {
      contentSelector: '#docs-content'
    },
    'hackernoon.com': {
      contentSelector: 'main',
      scrollSmoothOffset: -80,
    },
    'towardsdatascience.com': {
      contentSelector: 'article'
    },
    'css-tricks.com': {
      contentSelector: 'main'
    },
    'distill.pub': {
      contentSelector: 'body'
    },
    'smashingmagazine': {
      contentSelector: 'article'
    },
    'web.dev': {
      contentSelector: '#content'
    },
    'python-patterns.guide': {
      contentSelector: '.section',
    },
    'www.mysqltutorial.org': {
      contentSelector: 'article',
    },
    'github.com': function () {
      const README_SEL = '.entry-content'
      const WIKI_CONTENT_SEL = '#wiki-body'
      const ISSUE_CONTENT_SEL = '.comment .comment-body'

      const matchedSel = [README_SEL, ISSUE_CONTENT_SEL, WIKI_CONTENT_SEL].find((sel) => {
        const c = document.querySelector(sel)
        if (c) {
          return true
        }
      })

      if (!matchedSel) {
        return {
          contentSelect: false,
        }
      }

      const isIssueDetail = /\/issues\//.test(location.pathname)
      const ISSUE_DETAIL_HEADING_OFFSET = 60

      /** Ugly hack for github issues */
      const onClick = isIssueDetail ? function (e) {
        const href = e.target.getAttribute('href')
        const header = document.body.querySelector(href)
        if (header) {
          const rect = header.getBoundingClientRect()
          const currentWindowScrollTop = document.documentElement.scrollTop
          const scrollY = rect.y + currentWindowScrollTop - ISSUE_DETAIL_HEADING_OFFSET

          window.scrollTo(0, scrollY)

          location.hash = href

          e.preventDefault()
          e.stopPropagation()
        }
      }: null

      return {
        siteName: 'github.com',
        contentSelector: matchedSel,
        hasInnerContainers: isIssueDetail ? true: false,
        scrollSmoothOffset: isIssueDetail ? -ISSUE_DETAIL_HEADING_OFFSET: 0,
        headingsOffset: isIssueDetail ? ISSUE_DETAIL_HEADING_OFFSET: 0,
        initialTop: 500,
        onClick,
        findHeaderId(ele) {
          let id
          let anchor = ele.querySelector('.anchor')
          if (anchor) id = anchor.getAttribute('id')

          if (!anchor) {
            anchor = ele.querySelector('a')
            if (anchor) id = anchor.hash.replace(/^#/, '')
          }
          return id
        },
      }
    },
    'developer.mozilla.org': {
      contentSelector: '#content',
      onInit() {
        setTimeout(() => {
          tocbot.refresh()
        }, 2000)
      }
    },
    'learning.oreilly.com': {
      contentSelector: '#sbo-rt-content'
    },
    'developer.chrome.com': {
      contentSelector: 'article'
    },
    'www.infoq.cn': {
      contentSelector: '.article-main',
      scrollSmoothOffset: -107
    },
    'app.getpocket.com': {
      contentSelector: '[role=main]',
    },
    'indepth.dev': {
      contentSelector: '.content',
    },
    'gitlab.com': {
      contentSelector:  '.file-content',
      scrollSmoothOffset: -40
    },
    'docs.celeryproject.org': {
      contentSelector: '[role=main]',
    },
    'docs.python.org': {
      contentSelector: '[role=main]',
    },
    'packaging.python.org': {
      contentSelector: '[role=main]',
    },
    'readthedocs.io': {
      contentSelector: '[role=main]',
    },
    'bigsearcher.com': {
      contentSelector: 'body',
    },
    'ffmpeg.org': {
      contentSelector: '#page-content-wrapper',
    },
    'www.ruanyifeng.com': {
      contentSelector: 'article',
    },
    'realpython.com': {
      contentSelector: '.main-content',
    },
    'en.wikipedia.org': {
      contentSelector: '#content',
    },
    'www.cnblogs.com': {
      contentSelector: '#main',
    },
    'stackoverflow.blog': {
      contentSelector: 'article',
    },
    'vuejs.org': {
      contentSelector: 'main > div',
    },
  }

  function getSiteInfo() {
    let siteName
    if (SITE_SETTINGS[location.hostname]) {
      siteName = location.hostname;
    } else if (location.hostname.indexOf('readthedocs.io') > -1) {
      siteName = 'readthedocs.io';
    } else {
      const match = location.href.match(
        /([\d\w]+)\.(com|cn|net|org|im|io|cc|site|tv)/i
      )
      siteName = match ? match[1] : null
    }
    if (siteName && SITE_SETTINGS[siteName]) {
      return {
        siteName,
        siteSetting: SITE_SETTINGS[siteName],
      }
    }
  }

  function getPageTocOptions() {
    let siteInfo = getSiteInfo()
    if (siteInfo) {
      if (typeof siteInfo.siteSetting === 'function') {
        return siteInfo.siteSetting()
      }

      let siteSetting = { ...siteInfo.siteSetting }
      if (siteSetting.shouldShow && !siteSetting.shouldShow()) {
        return
      }
      if (typeof siteSetting.contentSelector === 'function') {
        const contentSelector = siteSetting.contentSelector()
        if (!contentSelector) return
        siteSetting = {...siteSetting, contentSelector}
      }
      if (typeof siteSetting.scrollSmoothOffset === 'function') {
        siteSetting.scrollSmoothOffset = siteSetting.scrollSmoothOffset()
      }

      console.log('[toc-bar] found site info for', siteInfo.siteName)
      return siteSetting
    }
  }

  function guessThemeColor() {
    const meta = document.head.querySelector('meta[name="theme-color"]')
    if (meta) {
      return meta.getAttribute('content')
    }
  }

  /**
   * @param {String} content
   * @return {String}
   */
  function doContentHash(content) {
    const val = content.split('').reduce((prevHash, currVal) => (((prevHash << 5) - prevHash) + currVal.charCodeAt(0))|0, 0);
    return val.toString(32)
  }

  const POSITION_STORAGE = {
    cache: null,
    checkCache() {
      if (!POSITION_STORAGE.cache) {
        POSITION_STORAGE.cache = GM_getValue('tocbar-positions', {})
      }
    },
    get(k) {
      k = k || location.host
      POSITION_STORAGE.checkCache()
      return POSITION_STORAGE.cache[k]
    },
    set(k, position) {
      k = k || location.host
      POSITION_STORAGE.checkCache()
      POSITION_STORAGE.cache[k] = position
      GM_setValue('tocbar-positions', POSITION_STORAGE.cache)
    },
  }

  function isEmpty(input) {
    if (input) {
      return Object.keys(input).length === 0
    }
    return true
  }

  /** 宽度,也用于计算拖动时的最小 right */
  const TOC_BAR_WIDTH = 340

  const TOC_BAR_DEFAULT_ACTIVE_COLOR = '#54BC4B';

  // ---------------- TocBar ----------------------
  const TOC_BAR_STYLE = `
.toc-bar {
  --toc-bar-active-color: ${TOC_BAR_DEFAULT_ACTIVE_COLOR};
  --toc-bar-text-color: #333;
  --toc-bar-background-color: #FEFEFE;

  position: fixed;
  z-index: 9000;
  right: 5px;
  top: 80px;
  width: ${TOC_BAR_WIDTH}px;
  font-size: 14px;
  box-sizing: border-box;
  padding: 0 10px 10px 0;
  box-shadow: 0 1px 3px #DDD;
  border-radius: 4px;
  transition: width 0.2s ease;
  color: var(--toc-bar-text-color);
  background: var(--toc-bar-background-color);

  user-select:none;
  -moz-user-select:none;
  -webkit-user-select: none;
  -ms-user-select: none;
}

.toc-bar[colorscheme="dark"] {
  --toc-bar-text-color: #fafafa;
  --toc-bar-background-color: #333;
}
.toc-bar[colorscheme="dark"] svg {
  fill: var(--toc-bar-text-color);
  stroke: var(--toc-bar-text-color);
}

.toc-bar.toc-bar--collapsed {
  width: 30px;
  height: 30px;
  padding: 0;
  overflow: hidden;
}

.toc-bar--collapsed .toc {
  display: none;
}

.toc-bar--collapsed .hidden-when-collapsed {
  display: none;
}

.toc-bar__header {
  font-weight: bold;
  padding-bottom: 5px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  cursor: move;
}

.toc-bar__refresh {
  position: relative;
  top: -2px;
}

.toc-bar__icon-btn {
  height: 1em;
  width: 1em;
  cursor: pointer;
  transition: transform 0.2s ease;
}

.toc-bar__icon-btn:hover {
  opacity: 0.7;
}

.toc-bar__icon-btn svg {
  max-width: 100%;
  max-height: 100%;
  vertical-align: top;
}

.toc-bar__actions {
  align-items: center;
}
.toc-bar__actions .toc-bar__icon-btn {
  margin-left: 1em;
}

.toc-bar__scheme {
  transform: translateY(-1px) scale(1.1);
}

.toc-bar__header-left {
  align-items: center;
}

.toc-bar__toggle {
  cursor: pointer;
  padding: 8px 8px;
  box-sizing: content-box;
  transition: transform 0.2s ease;
}

.toc-bar__title {
  margin-left: 5px;
}

.toc-bar a.toc-link {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: block;
  line-height: 1.6;
}

.flex {
  display: flex;
}

/* tocbot related */
.toc-bar__toc {
  max-height: 80vh;
  overflow-y: auto;
}

.toc-list-item > a:hover {
  text-decoration: underline;
}

.toc-list {
  padding-inline-start: 0;
}

.toc-bar__toc > .toc-list {
  margin: 0;
  overflow: hidden;
  position: relative;
  padding-left: 5px;
}

.toc-bar__toc>.toc-list li {
  list-style: none;
  padding-left: 8px;
  position: static;
}

a.toc-link {
  color: currentColor;
  height: 100%;
}

.is-collapsible {
  max-height: 1000px;
  overflow: hidden;
  transition: all 300ms ease-in-out;
}

.is-collapsed {
  max-height: 0;
}

.is-position-fixed {
  position: fixed !important;
  top: 0;
}

.is-active-link {
  font-weight: 700;
}

.toc-link::before {
  background-color: var(--toc-bar-background-color);
  content: ' ';
  display: inline-block;
  height: inherit;
  left: 0;
  margin-top: -1px;
  position: absolute;
  width: 2px;
}

.is-active-link::before {
  background-color: var(--toc-bar-active-color);
}

.toc-list-item,
.toc-link {
  font-size: 1em; /* reset font size */
}


@media print {
  .toc-bar__no-print { display: none !important; }
}
/* end tocbot related */
`

  const TOCBOT_CONTAINTER_CLASS = 'toc-bar__toc'

  const DARKMODE_KEY = 'tocbar-darkmode'

  /**
   * @typedef {Object} TocBarOptions
   * @property {String} [siteName]
   * @property {Number} [initialTop]
   */

  /**
   * @class
   * @param {TocBarOptions} options
   */
  function TocBar(options={}) {
    this.options = options

    // inject style
    GM_addStyle(TOC_BAR_STYLE)

    this.element = document.createElement('div')
    this.element.id = 'toc-bar'
    this.element.classList.add('toc-bar', 'toc-bar__no-print')
    document.body.appendChild(this.element)

    /** @type {Boolean} */
    this.visible = true

    this.initHeader()

    // create a container tocbot
    const tocElement = document.createElement('div')
    this.tocElement = tocElement
    tocElement.classList.add(TOCBOT_CONTAINTER_CLASS)
    this.element.appendChild(tocElement)

    POSITION_STORAGE.checkCache()
    const cachedPosition = POSITION_STORAGE.get(options.siteName)
    if (!isEmpty(cachedPosition)) {
      this.element.style.top = `${Math.max(0, cachedPosition.top)}px`
      this.element.style.right = `${cachedPosition.right}px`
    } else if (options.hasOwnProperty('initialTop')) {
      this.element.style.top = `${options.initialTop}px`
    }

    if (GM_getValue('tocbar-hidden', false)) {
      this.toggle(false)
    }

    const isDark = Boolean(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
    /** @type {Boolean} */
    this.isDarkMode = isDark

    if (GM_getValue(DARKMODE_KEY, false)) {
      this.toggleScheme(true)
    }
  }

  const REFRESH_ICON = `<svg t="1593614403764" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5002" width="200" height="200"><path d="M918 702.8 918 702.8c45.6-98.8 52-206 26-303.6-30-112.4-104-212.8-211.6-273.6L780 23.2l-270.8 70.8 121.2 252.4 50-107.6c72.8 44.4 122.8 114.4 144 192.8 18.8 70.8 14.4 147.6-18.8 219.6-42 91.2-120.8 153.6-210.8 177.6-13.2 3.6-26.4 6-39.6 8l56 115.6c5.2-1.2 10.4-2.4 16-4C750.8 915.2 860 828.8 918 702.8L918 702.8M343.2 793.2c-74-44.4-124.8-114.8-146-194-18.8-70.8-14.4-147.6 18.8-219.6 42-91.2 120.8-153.6 210.8-177.6 14.8-4 30-6.8 45.6-8.8l-55.6-116c-7.2 1.6-14.8 3.2-22 5.2-124 33.2-233.6 119.6-291.2 245.6-45.6 98.8-52 206-26 303.2l0 0.4c30.4 113.2 105.2 214 213.6 274.8l-45.2 98 270.4-72-122-252L343.2 793.2 343.2 793.2M343.2 793.2 343.2 793.2z" p-id="5003"></path></svg>`

  const TOC_ICON = `
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
    viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
<g>
  <g>
    <path d="M835.2,45.9H105.2v166.8l93.2,61.5h115.8H356h30.6v-82.8H134.2v-24.9h286.2v107.6h32.2V141.6H134.2V118h672.1v23.6H486.4
      v132.5h32V166.5h287.8v24.9H553.8v82.8h114.1H693h225.6V114.5L835.2,45.9z M806.2,93.2H134.2V67.2h672.1v26.1H806.2z"/>
    <polygon points="449.3,1008.2 668,1008.2 668,268.9 553.8,268.9 553.8,925.4 518.4,925.4 518.4,268.9 486.4,268.9 486.4,925.4
      452.6,925.4 452.6,268.9 420.4,268.9 420.4,925.4 386.6,925.4 386.6,268.9 356,268.9 356,946.7 		"/>
  </g>
</g>
</svg>
`

  const LIGHT_ICON = `
  <?xml version="1.0" encoding="iso-8859-1"?>
  <!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
  <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  <svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
     viewBox="0 0 181.328 181.328" style="enable-background:new 0 0 181.328 181.328;" xml:space="preserve" style="transform: translateY(-1px);">
  <g>
    <path d="M118.473,46.308V14.833c0-4.142-3.358-7.5-7.5-7.5H70.357c-4.142,0-7.5,3.358-7.5,7.5v31.474
      C51.621,54.767,44.34,68.214,44.34,83.331c0,25.543,20.781,46.324,46.324,46.324s46.324-20.781,46.324-46.324
      C136.988,68.215,129.708,54.769,118.473,46.308z M77.857,22.333h25.615v16.489c-4.071-1.174-8.365-1.815-12.809-1.815
      c-4.443,0-8.736,0.642-12.807,1.814V22.333z M90.664,114.655c-17.273,0-31.324-14.052-31.324-31.324
      c0-17.272,14.052-31.324,31.324-31.324s31.324,14.052,31.324,31.324C121.988,100.604,107.937,114.655,90.664,114.655z"/>
    <path d="M40.595,83.331c0-4.142-3.358-7.5-7.5-7.5H7.5c-4.142,0-7.5,3.358-7.5,7.5c0,4.142,3.358,7.5,7.5,7.5h25.595
      C37.237,90.831,40.595,87.473,40.595,83.331z"/>
    <path d="M173.828,75.831h-25.595c-4.142,0-7.5,3.358-7.5,7.5c0,4.142,3.358,7.5,7.5,7.5h25.595c4.142,0,7.5-3.358,7.5-7.5
      C181.328,79.189,177.97,75.831,173.828,75.831z"/>
    <path d="M44.654,47.926c1.464,1.465,3.384,2.197,5.303,2.197c1.919,0,3.839-0.732,5.303-2.197c2.929-2.929,2.929-7.678,0-10.606
      L37.162,19.222c-2.929-2.93-7.678-2.929-10.606,0c-2.929,2.929-2.929,7.678,0,10.606L44.654,47.926z"/>
    <path d="M136.674,118.735c-2.93-2.929-7.678-2.928-10.607,0c-2.929,2.929-2.928,7.678,0,10.607l18.1,18.098
      c1.465,1.464,3.384,2.196,5.303,2.196c1.919,0,3.839-0.732,5.304-2.197c2.929-2.929,2.928-7.678,0-10.607L136.674,118.735z"/>
    <path d="M44.654,118.736l-18.099,18.098c-2.929,2.929-2.929,7.677,0,10.607c1.464,1.465,3.384,2.197,5.303,2.197
      c1.919,0,3.839-0.732,5.303-2.197l18.099-18.098c2.929-2.929,2.929-7.677,0-10.606C52.332,115.807,47.583,115.807,44.654,118.736z"
      />
    <path d="M131.371,50.123c1.919,0,3.839-0.732,5.303-2.196l18.1-18.098c2.929-2.929,2.929-7.678,0-10.607
      c-2.929-2.928-7.678-2.929-10.607-0.001l-18.1,18.098c-2.929,2.929-2.929,7.678,0,10.607
      C127.532,49.391,129.452,50.123,131.371,50.123z"/>
    <path d="M90.664,133.4c-4.142,0-7.5,3.358-7.5,7.5v25.595c0,4.142,3.358,7.5,7.5,7.5c4.142,0,7.5-3.358,7.5-7.5V140.9
      C98.164,136.758,94.806,133.4,90.664,133.4z"/>
  </g>
  </svg>
`

  TocBar.prototype = {
    /**
     * @method TocBar
     */
    initHeader() {
      const header = document.createElement('div')
      header.classList.add('toc-bar__header')
      header.innerHTML = `
    <div class="flex toc-bar__header-left">
      <div class="toc-bar__toggle toc-bar__icon-btn" title="Toggle TOC Bar">
        ${TOC_ICON}
      </div>
      <div class="toc-bar__title hidden-when-collapsed">TOC Bar</div>
    </div>
    <div class="toc-bar__actions flex hidden-when-collapsed">
      <div class="toc-bar__scheme toc-bar__icon-btn" title="Toggle Light/Dark Mode">
        ${LIGHT_ICON}
      </div>
      <div class="toc-bar__refresh toc-bar__icon-btn" title="Refresh TOC">
        ${REFRESH_ICON}
      </div>
    </div>
    `
      const toggleElement = header.querySelector('.toc-bar__toggle')
      toggleElement.addEventListener('click', () => {
        this.toggle()
        GM_setValue('tocbar-hidden', !this.visible)
      })
      this.logoSvg = toggleElement.querySelector('svg')

      const refreshElement = header.querySelector('.toc-bar__refresh')
      refreshElement.addEventListener('click', () => {
        try {
          tocbot.refresh()
        } catch (error) {
          console.warn('error in tocbot.refresh', error)
        }
      })

      const toggleSchemeElement = header.querySelector('.toc-bar__scheme')
      toggleSchemeElement.addEventListener('click', () => {
        this.toggleScheme()
      })
      // ---------------- header drag ----------------------
      const dragState = {
        startMouseX: 0,
        startMouseY: 0,
        startPositionX: 0,
        startPositionY: 0,
        startElementDisToRight: 0,
        isDragging: false,
        curRight: 0,
        curTop: 0,
      }

      const onMouseMove = (e) => {
        if (!dragState.isDragging) return
        const deltaX = e.pageX - dragState.startMouseX
        const deltaY = e.pageY - dragState.startMouseY
        // 要换算为 right 数字
        const newRight = Math.max(30 - TOC_BAR_WIDTH, dragState.startElementDisToRight - deltaX)
        const newTop = Math.max(0, dragState.startPositionY + deltaY)
        Object.assign(dragState, {
          curTop: newTop,
          curRight: newRight,
        })
        // console.table({ newRight, newTop})
        this.element.style.right = `${newRight}px`
        this.element.style.top = `${newTop}px`
      }

      const onMouseUp = () => {
        Object.assign(dragState, {
          isDragging: false,
        })
        document.body.removeEventListener('mousemove', onMouseMove)
        document.body.removeEventListener('mouseup', onMouseUp)

        POSITION_STORAGE.set(this.options.siteName, {
          top: dragState.curTop,
          right: dragState.curRight,
        })
      }

      header.addEventListener('mousedown', (e) => {
        if (e.target === toggleElement) return
        const bbox = this.element.getBoundingClientRect()
        Object.assign(dragState, {
          isDragging: true,
          startMouseX: e.pageX,
          startMouseY: e.pageY,
          startPositionX: bbox.x,
          startPositionY: bbox.y,
          startElementDisToRight: document.body.clientWidth - bbox.right,
        })
        document.body.addEventListener('mousemove', onMouseMove)
        document.body.addEventListener('mouseup', onMouseUp)
      })
      // ----------------end header drag -------------------

      this.element.appendChild(header)
    },
    /**
     * @method TocBar
     * @param {SiteSetting} options
     */
    initTocbot(options) {
      const me = this

      /**
       * records for existing ids to prevent id conflict (when there are headings of same content)
       * @type {Object} {[key: string]: number}
       **/
      this._tocContentCountCache = {}

      const tocbotOptions = Object.assign(
        {},
        {
          tocSelector: `.${TOCBOT_CONTAINTER_CLASS}`,
          scrollSmoothOffset: options.scrollSmoothOffset || 0,
          headingObjectCallback(obj, ele) {
            // if there is no id on the header element, add one that derived from hash of header title
            // remove ¶ and # notation in headers text
            obj.textContent = obj.textContent.replace(/¶|#/g, '');
            if (!ele.id) {
              let newId
              if (options.findHeaderId) {
                newId = options.findHeaderId(ele)
              }
              if (!newId) {
                newId = me.generateHeaderId(obj, ele)
                ele.setAttribute('id', newId)
              }
              if (newId) obj.id = newId
            }
            return obj
          },
          headingSelector: 'h1, h2, h3, h4, h5',
          collapseDepth: 4,
        },
        options
      )
      // console.log('tocbotOptions', tocbotOptions);
      try {
        tocbot.init(tocbotOptions)
        if (options.onInit) {
          options.onInit(this)
        }
      } catch (error) {
        console.warn('error in tocbot.init', error)
      }
    },
    generateHeaderId(obj, ele) {
      const hash = doContentHash(obj.textContent)
      let count = 1
      let resultHash = hash
      if (this._tocContentCountCache[hash]) {
        count = this._tocContentCountCache[hash] + 1
        resultHash = doContentHash(`${hash}-${count}`)
      }
      this._tocContentCountCache[hash] = count
      return `tocbar-${resultHash}`
    },
    /**
     * @method TocBar
     */
    toggle(shouldShow = !this.visible) {
      const HIDDEN_CLASS = 'toc-bar--collapsed'
      const LOGO_HIDDEN_CLASS = 'toc-logo--collapsed'
      if (shouldShow) {
        this.element.classList.remove(HIDDEN_CLASS)
        this.logoSvg && this.logoSvg.classList.remove(LOGO_HIDDEN_CLASS)
      } else {
        this.element.classList.add(HIDDEN_CLASS)
        this.logoSvg && this.logoSvg.classList.add(LOGO_HIDDEN_CLASS)

        const right = parseInt(this.element.style.right)
        if (right && right < 0) {
          this.element.style.right = "0px"
          const cachedPosition = POSITION_STORAGE.cache
          if (!isEmpty(cachedPosition)) {
            POSITION_STORAGE.set(null, {...cachedPosition, right: 0 })
          }
        }
      }
      this.visible = shouldShow
    },
    /**
     * Toggle light/dark scheme
     * @method TocBar
     */
    toggleScheme(isDark) {
      const isDarkMode = typeof isDark === 'undefined' ? !this.isDarkMode: isDark
      this.element.setAttribute('colorscheme', isDarkMode ? 'dark': 'light')
      console.log('[toc-bar] toggle scheme', isDarkMode)
      this.isDarkMode = isDarkMode

      GM_setValue(DARKMODE_KEY, isDarkMode)
      this.refreshStyle()
    },
    refreshStyle() {
      const themeColor = guessThemeColor()
      if (themeColor && !this.isDarkMode) {
        this.element.style.setProperty('--toc-bar-active-color', themeColor);
      } else if (this.isDarkMode) {
        this.element.style.setProperty('--toc-bar-active-color', TOC_BAR_DEFAULT_ACTIVE_COLOR);
      }
    },
  }
  // ----------------end TocBar -------------------

  function main() {
    const options = getPageTocOptions()

    if (options) {
      const tocBar = new TocBar(options)
      tocBar.initTocbot(options)
      tocBar.refreshStyle()
    }
  }

  main()
})()