Toc Bar, auto-generating table of content

A floating table of content widget

  1. // ==UserScript==
  2. // @name Toc Bar, auto-generating table of content
  3. // @name:zh-CN Toc Bar, 自动生成文章大纲。知乎、微信公众号等阅读好伴侣
  4. // @author hikerpig
  5. // @namespace https://github.com/hikerpig
  6. // @license MIT
  7. // @description A floating table of content widget
  8. // @description:zh-CN 自动生成文章大纲目录,在页面右侧展示一个浮动的组件。覆盖常用在线阅读资讯站(技术向)。github/medium/MDN/掘金/简书等
  9. // @version 1.9.6
  10. // @match *://www.jianshu.com/p/*
  11. // @match *://cdn2.jianshu.io/p/*
  12. // @match *://zhuanlan.zhihu.com/p/*
  13. // @match *://www.zhihu.com/pub/reader/*
  14. // @match *://mp.weixin.qq.com/s*
  15. // @match *://cnodejs.org/topic/*
  16. // @match *://*zcfy.cc/article/*
  17. // @match *://juejin.cn/post/*
  18. // @match *://juejin.cn/book/*
  19. // @match *://dev.to/*/*
  20. // @exclude *://dev.to/settings/*
  21. // @match *://web.dev/*
  22. // @match *://medium.com/*
  23. // @exclude *://medium.com/media/*
  24. // @match *://itnext.io/*
  25. // @match *://python-patterns.guide/*
  26. // @match *://www.mysqltutorial.org/*
  27. // @match *://en.wikipedia.org/*
  28. // @match *://vuejs.org/*
  29. // @match *://docs.python.org/*
  30. // @match *://packaging.python.org/*
  31. // @match *://*.readthedocs.io/*
  32. // @match *://docs.djangoproject.com/*
  33. // @match *://www.cnblogs.com/*
  34. // @match *://bigsearcher.com/*
  35. // @match *://ffmpeg.org/*
  36. // @match *://www.ruanyifeng.com/*
  37. // @match *://stackoverflow.blog/*
  38. // @match *://realpython.com/*
  39. // @match *://www.infoq.cn/article/*
  40. // @match *://towardsdatascience.com/*
  41. // @match *://hackernoon.com/*
  42. // @match *://css-tricks.com/*
  43. // @match *://www.smashingmagazine.com/*/*
  44. // @match *://distill.pub/*
  45. // @match *://github.com/*/*
  46. // @match *://github.com/*/issues/*
  47. // @match *://developer.mozilla.org/*/docs/*
  48. // @match *://learning.oreilly.com/library/view/*
  49. // @match *://developer.chrome.com/extensions/*
  50. // @match *://app.getpocket.com/read/*
  51. // @match *://indepth.dev/posts/*
  52. // @match *://gitlab.com/*
  53. // @run-at document-idle
  54. // @grant GM_getResourceText
  55. // @grant GM_addStyle
  56. // @grant GM_setValue
  57. // @grant GM_getValue
  58. // @require https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.18.2/tocbot.min.js
  59. // @icon https://raw.githubusercontent.com/hikerpig/toc-bar-userscript/master/toc-logo.svg
  60. // @homepageURL https://github.com/hikerpig/toc-bar-userscript
  61. // ==/UserScript==
  62.  
  63. (function () {
  64. /**
  65. * @typedef {Object} SiteSetting
  66. * @property {string} contentSelector
  67. * @property {string} siteName
  68. * @property {Object} style
  69. * @property {Number} scrollSmoothOffset
  70. * @property {Number} initialTop
  71. * @property {Number} headingsOffset
  72. * @property {() => Boolean} shouldShow
  73. * @property {(ele) => HTMLElement} findHeaderId
  74. * @property {(e) => void} onClick
  75. * @property {(tocBar: TocBar) => void} onInit
  76. */
  77.  
  78. /** @type {{[key: string]: Partial<SiteSetting>}} */
  79. const SITE_SETTINGS = {
  80. jianshu: {
  81. contentSelector: '.ouvJEz',
  82. style: {
  83. top: '55px',
  84. color: '#ea6f5a',
  85. },
  86. },
  87. 'zhuanlan.zhihu.com': {
  88. contentSelector: 'article',
  89. scrollSmoothOffset: -52,
  90. shouldShow() {
  91. return location.pathname.startsWith('/p/')
  92. },
  93. },
  94. 'www.zhihu.com': {
  95. contentSelector: '.reader-chapter-content',
  96. scrollSmoothOffset: -52,
  97. },
  98. zcfy: {
  99. contentSelector: '.markdown-body',
  100. },
  101. qq: {
  102. contentSelector: '.rich_media_content',
  103. },
  104. 'juejin.cn': function() {
  105. let contentSelector = '.article' // post
  106. if (/\/book\//.test(location.pathname)) {
  107. contentSelector = '.book-body'
  108. }
  109. return {
  110. contentSelector,
  111. }
  112. },
  113. 'dev.to': {
  114. contentSelector: 'article',
  115. scrollSmoothOffset: -56,
  116. shouldShow() {
  117. return ['/search', '/top/'].every(s => !location.pathname.startsWith(s))
  118. },
  119. },
  120. 'medium.com': {
  121. contentSelector: 'article'
  122. },
  123. 'docs.djangoproject.com': {
  124. contentSelector: '#docs-content'
  125. },
  126. 'hackernoon.com': {
  127. contentSelector: 'main',
  128. scrollSmoothOffset: -80,
  129. },
  130. 'towardsdatascience.com': {
  131. contentSelector: 'article'
  132. },
  133. 'css-tricks.com': {
  134. contentSelector: 'main'
  135. },
  136. 'distill.pub': {
  137. contentSelector: 'body'
  138. },
  139. 'smashingmagazine': {
  140. contentSelector: 'article'
  141. },
  142. 'web.dev': {
  143. contentSelector: '#content'
  144. },
  145. 'python-patterns.guide': {
  146. contentSelector: '.section',
  147. },
  148. 'www.mysqltutorial.org': {
  149. contentSelector: 'article',
  150. },
  151. 'github.com': function () {
  152. const README_SEL = '.entry-content'
  153. const WIKI_CONTENT_SEL = '#wiki-body'
  154. const ISSUE_CONTENT_SEL = '.comment .comment-body'
  155.  
  156. const matchedSel = [README_SEL, ISSUE_CONTENT_SEL, WIKI_CONTENT_SEL].find((sel) => {
  157. const c = document.querySelector(sel)
  158. if (c) {
  159. return true
  160. }
  161. })
  162.  
  163. if (!matchedSel) {
  164. return {
  165. contentSelect: false,
  166. }
  167. }
  168.  
  169. const isIssueDetail = /\/issues\//.test(location.pathname)
  170. const ISSUE_DETAIL_HEADING_OFFSET = 60
  171.  
  172. /** Ugly hack for github issues */
  173. const onClick = isIssueDetail ? function (e) {
  174. const href = e.target.getAttribute('href')
  175. const header = document.body.querySelector(href)
  176. if (header) {
  177. const rect = header.getBoundingClientRect()
  178. const currentWindowScrollTop = document.documentElement.scrollTop
  179. const scrollY = rect.y + currentWindowScrollTop - ISSUE_DETAIL_HEADING_OFFSET
  180.  
  181. window.scrollTo(0, scrollY)
  182.  
  183. location.hash = href
  184.  
  185. e.preventDefault()
  186. e.stopPropagation()
  187. }
  188. }: null
  189.  
  190. return {
  191. siteName: 'github.com',
  192. contentSelector: matchedSel,
  193. hasInnerContainers: isIssueDetail ? true: false,
  194. scrollSmoothOffset: isIssueDetail ? -ISSUE_DETAIL_HEADING_OFFSET: 0,
  195. headingsOffset: isIssueDetail ? ISSUE_DETAIL_HEADING_OFFSET: 0,
  196. initialTop: 500,
  197. onClick,
  198. findHeaderId(ele) {
  199. let id
  200. let anchor = ele.querySelector('.anchor')
  201. if (anchor) id = anchor.getAttribute('id')
  202.  
  203. if (!anchor) {
  204. anchor = ele.querySelector('a')
  205. if (anchor) id = anchor.hash.replace(/^#/, '')
  206. }
  207. return id
  208. },
  209. }
  210. },
  211. 'developer.mozilla.org': {
  212. contentSelector: '#content',
  213. onInit() {
  214. setTimeout(() => {
  215. tocbot.refresh()
  216. }, 2000)
  217. }
  218. },
  219. 'learning.oreilly.com': {
  220. contentSelector: '#sbo-rt-content'
  221. },
  222. 'developer.chrome.com': {
  223. contentSelector: 'article'
  224. },
  225. 'www.infoq.cn': {
  226. contentSelector: '.article-main',
  227. scrollSmoothOffset: -107
  228. },
  229. 'app.getpocket.com': {
  230. contentSelector: '[role=main]',
  231. },
  232. 'indepth.dev': {
  233. contentSelector: '.content',
  234. },
  235. 'gitlab.com': {
  236. contentSelector: '.file-content',
  237. scrollSmoothOffset: -40
  238. },
  239. 'docs.celeryproject.org': {
  240. contentSelector: '[role=main]',
  241. },
  242. 'docs.python.org': {
  243. contentSelector: '[role=main]',
  244. },
  245. 'packaging.python.org': {
  246. contentSelector: '[role=main]',
  247. },
  248. 'readthedocs.io': {
  249. contentSelector: '[role=main]',
  250. },
  251. 'bigsearcher.com': {
  252. contentSelector: 'body',
  253. },
  254. 'ffmpeg.org': {
  255. contentSelector: '#page-content-wrapper',
  256. },
  257. 'www.ruanyifeng.com': {
  258. contentSelector: 'article',
  259. },
  260. 'realpython.com': {
  261. contentSelector: '.main-content',
  262. },
  263. 'en.wikipedia.org': {
  264. contentSelector: '#content',
  265. },
  266. 'www.cnblogs.com': {
  267. contentSelector: '#main',
  268. },
  269. 'stackoverflow.blog': {
  270. contentSelector: 'article',
  271. },
  272. 'vuejs.org': {
  273. contentSelector: 'main > div',
  274. },
  275. }
  276.  
  277. function getSiteInfo() {
  278. let siteName
  279. if (SITE_SETTINGS[location.hostname]) {
  280. siteName = location.hostname;
  281. } else if (location.hostname.indexOf('readthedocs.io') > -1) {
  282. siteName = 'readthedocs.io';
  283. } else {
  284. const match = location.href.match(
  285. /([\d\w]+)\.(com|cn|net|org|im|io|cc|site|tv)/i
  286. )
  287. siteName = match ? match[1] : null
  288. }
  289. if (siteName && SITE_SETTINGS[siteName]) {
  290. return {
  291. siteName,
  292. siteSetting: SITE_SETTINGS[siteName],
  293. }
  294. }
  295. }
  296.  
  297. function getPageTocOptions() {
  298. let siteInfo = getSiteInfo()
  299. if (siteInfo) {
  300. if (typeof siteInfo.siteSetting === 'function') {
  301. return siteInfo.siteSetting()
  302. }
  303.  
  304. let siteSetting = { ...siteInfo.siteSetting }
  305. if (siteSetting.shouldShow && !siteSetting.shouldShow()) {
  306. return
  307. }
  308. if (typeof siteSetting.contentSelector === 'function') {
  309. const contentSelector = siteSetting.contentSelector()
  310. if (!contentSelector) return
  311. siteSetting = {...siteSetting, contentSelector}
  312. }
  313. if (typeof siteSetting.scrollSmoothOffset === 'function') {
  314. siteSetting.scrollSmoothOffset = siteSetting.scrollSmoothOffset()
  315. }
  316.  
  317. console.log('[toc-bar] found site info for', siteInfo.siteName)
  318. return siteSetting
  319. }
  320. }
  321.  
  322. function guessThemeColor() {
  323. const meta = document.head.querySelector('meta[name="theme-color"]')
  324. if (meta) {
  325. return meta.getAttribute('content')
  326. }
  327. }
  328.  
  329. /**
  330. * @param {String} content
  331. * @return {String}
  332. */
  333. function doContentHash(content) {
  334. const val = content.split('').reduce((prevHash, currVal) => (((prevHash << 5) - prevHash) + currVal.charCodeAt(0))|0, 0);
  335. return val.toString(32)
  336. }
  337.  
  338. const POSITION_STORAGE = {
  339. cache: null,
  340. checkCache() {
  341. if (!POSITION_STORAGE.cache) {
  342. POSITION_STORAGE.cache = GM_getValue('tocbar-positions', {})
  343. }
  344. },
  345. get(k) {
  346. k = k || location.host
  347. POSITION_STORAGE.checkCache()
  348. return POSITION_STORAGE.cache[k]
  349. },
  350. set(k, position) {
  351. k = k || location.host
  352. POSITION_STORAGE.checkCache()
  353. POSITION_STORAGE.cache[k] = position
  354. GM_setValue('tocbar-positions', POSITION_STORAGE.cache)
  355. },
  356. }
  357.  
  358. function isEmpty(input) {
  359. if (input) {
  360. return Object.keys(input).length === 0
  361. }
  362. return true
  363. }
  364.  
  365. /** 宽度,也用于计算拖动时的最小 right */
  366. const TOC_BAR_WIDTH = 340
  367.  
  368. const TOC_BAR_DEFAULT_ACTIVE_COLOR = '#54BC4B';
  369.  
  370. // ---------------- TocBar ----------------------
  371. const TOC_BAR_STYLE = `
  372. .toc-bar {
  373. --toc-bar-active-color: ${TOC_BAR_DEFAULT_ACTIVE_COLOR};
  374. --toc-bar-text-color: #333;
  375. --toc-bar-background-color: #FEFEFE;
  376.  
  377. position: fixed;
  378. z-index: 9000;
  379. right: 5px;
  380. top: 80px;
  381. width: ${TOC_BAR_WIDTH}px;
  382. font-size: 14px;
  383. box-sizing: border-box;
  384. padding: 0 10px 10px 0;
  385. box-shadow: 0 1px 3px #DDD;
  386. border-radius: 4px;
  387. transition: width 0.2s ease;
  388. color: var(--toc-bar-text-color);
  389. background: var(--toc-bar-background-color);
  390.  
  391. user-select:none;
  392. -moz-user-select:none;
  393. -webkit-user-select: none;
  394. -ms-user-select: none;
  395. }
  396.  
  397. .toc-bar[colorscheme="dark"] {
  398. --toc-bar-text-color: #fafafa;
  399. --toc-bar-background-color: #333;
  400. }
  401. .toc-bar[colorscheme="dark"] svg {
  402. fill: var(--toc-bar-text-color);
  403. stroke: var(--toc-bar-text-color);
  404. }
  405.  
  406. .toc-bar.toc-bar--collapsed {
  407. width: 30px;
  408. height: 30px;
  409. padding: 0;
  410. overflow: hidden;
  411. }
  412.  
  413. .toc-bar--collapsed .toc {
  414. display: none;
  415. }
  416.  
  417. .toc-bar--collapsed .hidden-when-collapsed {
  418. display: none;
  419. }
  420.  
  421. .toc-bar__header {
  422. font-weight: bold;
  423. padding-bottom: 5px;
  424. display: flex;
  425. justify-content: space-between;
  426. align-items: center;
  427. cursor: move;
  428. }
  429.  
  430. .toc-bar__refresh {
  431. position: relative;
  432. top: -2px;
  433. }
  434.  
  435. .toc-bar__icon-btn {
  436. height: 1em;
  437. width: 1em;
  438. cursor: pointer;
  439. transition: transform 0.2s ease;
  440. }
  441.  
  442. .toc-bar__icon-btn:hover {
  443. opacity: 0.7;
  444. }
  445.  
  446. .toc-bar__icon-btn svg {
  447. max-width: 100%;
  448. max-height: 100%;
  449. vertical-align: top;
  450. }
  451.  
  452. .toc-bar__actions {
  453. align-items: center;
  454. }
  455. .toc-bar__actions .toc-bar__icon-btn {
  456. margin-left: 1em;
  457. }
  458.  
  459. .toc-bar__scheme {
  460. transform: translateY(-1px) scale(1.1);
  461. }
  462.  
  463. .toc-bar__header-left {
  464. align-items: center;
  465. }
  466.  
  467. .toc-bar__toggle {
  468. cursor: pointer;
  469. padding: 8px 8px;
  470. box-sizing: content-box;
  471. transition: transform 0.2s ease;
  472. }
  473.  
  474. .toc-bar__title {
  475. margin-left: 5px;
  476. }
  477.  
  478. .toc-bar a.toc-link {
  479. overflow: hidden;
  480. text-overflow: ellipsis;
  481. white-space: nowrap;
  482. display: block;
  483. line-height: 1.6;
  484. }
  485.  
  486. .flex {
  487. display: flex;
  488. }
  489.  
  490. /* tocbot related */
  491. .toc-bar__toc {
  492. max-height: 80vh;
  493. overflow-y: auto;
  494. }
  495.  
  496. .toc-list-item > a:hover {
  497. text-decoration: underline;
  498. }
  499.  
  500. .toc-list {
  501. padding-inline-start: 0;
  502. }
  503.  
  504. .toc-bar__toc > .toc-list {
  505. margin: 0;
  506. overflow: hidden;
  507. position: relative;
  508. padding-left: 5px;
  509. }
  510.  
  511. .toc-bar__toc>.toc-list li {
  512. list-style: none;
  513. padding-left: 8px;
  514. position: static;
  515. }
  516.  
  517. a.toc-link {
  518. color: currentColor;
  519. height: 100%;
  520. }
  521.  
  522. .is-collapsible {
  523. max-height: 1000px;
  524. overflow: hidden;
  525. transition: all 300ms ease-in-out;
  526. }
  527.  
  528. .is-collapsed {
  529. max-height: 0;
  530. }
  531.  
  532. .is-position-fixed {
  533. position: fixed !important;
  534. top: 0;
  535. }
  536.  
  537. .is-active-link {
  538. font-weight: 700;
  539. }
  540.  
  541. .toc-link::before {
  542. background-color: var(--toc-bar-background-color);
  543. content: ' ';
  544. display: inline-block;
  545. height: inherit;
  546. left: 0;
  547. margin-top: -1px;
  548. position: absolute;
  549. width: 2px;
  550. }
  551.  
  552. .is-active-link::before {
  553. background-color: var(--toc-bar-active-color);
  554. }
  555.  
  556. .toc-list-item,
  557. .toc-link {
  558. font-size: 1em; /* reset font size */
  559. }
  560.  
  561.  
  562. @media print {
  563. .toc-bar__no-print { display: none !important; }
  564. }
  565. /* end tocbot related */
  566. `
  567.  
  568. const TOCBOT_CONTAINTER_CLASS = 'toc-bar__toc'
  569.  
  570. const DARKMODE_KEY = 'tocbar-darkmode'
  571.  
  572. /**
  573. * @typedef {Object} TocBarOptions
  574. * @property {String} [siteName]
  575. * @property {Number} [initialTop]
  576. */
  577.  
  578. /**
  579. * @class
  580. * @param {TocBarOptions} options
  581. */
  582. function TocBar(options={}) {
  583. this.options = options
  584.  
  585. // inject style
  586. GM_addStyle(TOC_BAR_STYLE)
  587.  
  588. this.element = document.createElement('div')
  589. this.element.id = 'toc-bar'
  590. this.element.classList.add('toc-bar', 'toc-bar__no-print')
  591. document.body.appendChild(this.element)
  592.  
  593. /** @type {Boolean} */
  594. this.visible = true
  595.  
  596. this.initHeader()
  597.  
  598. // create a container tocbot
  599. const tocElement = document.createElement('div')
  600. this.tocElement = tocElement
  601. tocElement.classList.add(TOCBOT_CONTAINTER_CLASS)
  602. this.element.appendChild(tocElement)
  603.  
  604. POSITION_STORAGE.checkCache()
  605. const cachedPosition = POSITION_STORAGE.get(options.siteName)
  606. if (!isEmpty(cachedPosition)) {
  607. this.element.style.top = `${Math.max(0, cachedPosition.top)}px`
  608. this.element.style.right = `${cachedPosition.right}px`
  609. } else if (options.hasOwnProperty('initialTop')) {
  610. this.element.style.top = `${options.initialTop}px`
  611. }
  612.  
  613. if (GM_getValue('tocbar-hidden', false)) {
  614. this.toggle(false)
  615. }
  616.  
  617. const isDark = Boolean(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
  618. /** @type {Boolean} */
  619. this.isDarkMode = isDark
  620.  
  621. if (GM_getValue(DARKMODE_KEY, false)) {
  622. this.toggleScheme(true)
  623. }
  624. }
  625.  
  626. 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>`
  627.  
  628. const TOC_ICON = `
  629. <?xml version="1.0" encoding="utf-8"?>
  630. <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
  631. viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
  632. <g>
  633. <g>
  634. <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
  635. v132.5h32V166.5h287.8v24.9H553.8v82.8h114.1H693h225.6V114.5L835.2,45.9z M806.2,93.2H134.2V67.2h672.1v26.1H806.2z"/>
  636. <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
  637. 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 "/>
  638. </g>
  639. </g>
  640. </svg>
  641. `
  642.  
  643. const LIGHT_ICON = `
  644. <?xml version="1.0" encoding="iso-8859-1"?>
  645. <!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
  646. <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  647. <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"
  648. viewBox="0 0 181.328 181.328" style="enable-background:new 0 0 181.328 181.328;" xml:space="preserve" style="transform: translateY(-1px);">
  649. <g>
  650. <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
  651. 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
  652. 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
  653. 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
  654. 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"/>
  655. <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
  656. C37.237,90.831,40.595,87.473,40.595,83.331z"/>
  657. <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
  658. C181.328,79.189,177.97,75.831,173.828,75.831z"/>
  659. <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
  660. 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"/>
  661. <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
  662. 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"/>
  663. <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
  664. 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"
  665. />
  666. <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
  667. 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
  668. C127.532,49.391,129.452,50.123,131.371,50.123z"/>
  669. <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
  670. C98.164,136.758,94.806,133.4,90.664,133.4z"/>
  671. </g>
  672. </svg>
  673. `
  674.  
  675. TocBar.prototype = {
  676. /**
  677. * @method TocBar
  678. */
  679. initHeader() {
  680. const header = document.createElement('div')
  681. header.classList.add('toc-bar__header')
  682. header.innerHTML = `
  683. <div class="flex toc-bar__header-left">
  684. <div class="toc-bar__toggle toc-bar__icon-btn" title="Toggle TOC Bar">
  685. ${TOC_ICON}
  686. </div>
  687. <div class="toc-bar__title hidden-when-collapsed">TOC Bar</div>
  688. </div>
  689. <div class="toc-bar__actions flex hidden-when-collapsed">
  690. <div class="toc-bar__scheme toc-bar__icon-btn" title="Toggle Light/Dark Mode">
  691. ${LIGHT_ICON}
  692. </div>
  693. <div class="toc-bar__refresh toc-bar__icon-btn" title="Refresh TOC">
  694. ${REFRESH_ICON}
  695. </div>
  696. </div>
  697. `
  698. const toggleElement = header.querySelector('.toc-bar__toggle')
  699. toggleElement.addEventListener('click', () => {
  700. this.toggle()
  701. GM_setValue('tocbar-hidden', !this.visible)
  702. })
  703. this.logoSvg = toggleElement.querySelector('svg')
  704.  
  705. const refreshElement = header.querySelector('.toc-bar__refresh')
  706. refreshElement.addEventListener('click', () => {
  707. try {
  708. tocbot.refresh()
  709. } catch (error) {
  710. console.warn('error in tocbot.refresh', error)
  711. }
  712. })
  713.  
  714. const toggleSchemeElement = header.querySelector('.toc-bar__scheme')
  715. toggleSchemeElement.addEventListener('click', () => {
  716. this.toggleScheme()
  717. })
  718. // ---------------- header drag ----------------------
  719. const dragState = {
  720. startMouseX: 0,
  721. startMouseY: 0,
  722. startPositionX: 0,
  723. startPositionY: 0,
  724. startElementDisToRight: 0,
  725. isDragging: false,
  726. curRight: 0,
  727. curTop: 0,
  728. }
  729.  
  730. const onMouseMove = (e) => {
  731. if (!dragState.isDragging) return
  732. const deltaX = e.pageX - dragState.startMouseX
  733. const deltaY = e.pageY - dragState.startMouseY
  734. // 要换算为 right 数字
  735. const newRight = Math.max(30 - TOC_BAR_WIDTH, dragState.startElementDisToRight - deltaX)
  736. const newTop = Math.max(0, dragState.startPositionY + deltaY)
  737. Object.assign(dragState, {
  738. curTop: newTop,
  739. curRight: newRight,
  740. })
  741. // console.table({ newRight, newTop})
  742. this.element.style.right = `${newRight}px`
  743. this.element.style.top = `${newTop}px`
  744. }
  745.  
  746. const onMouseUp = () => {
  747. Object.assign(dragState, {
  748. isDragging: false,
  749. })
  750. document.body.removeEventListener('mousemove', onMouseMove)
  751. document.body.removeEventListener('mouseup', onMouseUp)
  752.  
  753. POSITION_STORAGE.set(this.options.siteName, {
  754. top: dragState.curTop,
  755. right: dragState.curRight,
  756. })
  757. }
  758.  
  759. header.addEventListener('mousedown', (e) => {
  760. if (e.target === toggleElement) return
  761. const bbox = this.element.getBoundingClientRect()
  762. Object.assign(dragState, {
  763. isDragging: true,
  764. startMouseX: e.pageX,
  765. startMouseY: e.pageY,
  766. startPositionX: bbox.x,
  767. startPositionY: bbox.y,
  768. startElementDisToRight: document.body.clientWidth - bbox.right,
  769. })
  770. document.body.addEventListener('mousemove', onMouseMove)
  771. document.body.addEventListener('mouseup', onMouseUp)
  772. })
  773. // ----------------end header drag -------------------
  774.  
  775. this.element.appendChild(header)
  776. },
  777. /**
  778. * @method TocBar
  779. * @param {SiteSetting} options
  780. */
  781. initTocbot(options) {
  782. const me = this
  783.  
  784. /**
  785. * records for existing ids to prevent id conflict (when there are headings of same content)
  786. * @type {Object} {[key: string]: number}
  787. **/
  788. this._tocContentCountCache = {}
  789.  
  790. const tocbotOptions = Object.assign(
  791. {},
  792. {
  793. tocSelector: `.${TOCBOT_CONTAINTER_CLASS}`,
  794. scrollSmoothOffset: options.scrollSmoothOffset || 0,
  795. headingObjectCallback(obj, ele) {
  796. // if there is no id on the header element, add one that derived from hash of header title
  797. // remove ¶ and # notation in headers text
  798. obj.textContent = obj.textContent.replace(/¶|#/g, '');
  799. if (!ele.id) {
  800. let newId
  801. if (options.findHeaderId) {
  802. newId = options.findHeaderId(ele)
  803. }
  804. if (!newId) {
  805. newId = me.generateHeaderId(obj, ele)
  806. ele.setAttribute('id', newId)
  807. }
  808. if (newId) obj.id = newId
  809. }
  810. return obj
  811. },
  812. headingSelector: 'h1, h2, h3, h4, h5',
  813. collapseDepth: 4,
  814. },
  815. options
  816. )
  817. // console.log('tocbotOptions', tocbotOptions);
  818. try {
  819. tocbot.init(tocbotOptions)
  820. if (options.onInit) {
  821. options.onInit(this)
  822. }
  823. } catch (error) {
  824. console.warn('error in tocbot.init', error)
  825. }
  826. },
  827. generateHeaderId(obj, ele) {
  828. const hash = doContentHash(obj.textContent)
  829. let count = 1
  830. let resultHash = hash
  831. if (this._tocContentCountCache[hash]) {
  832. count = this._tocContentCountCache[hash] + 1
  833. resultHash = doContentHash(`${hash}-${count}`)
  834. }
  835. this._tocContentCountCache[hash] = count
  836. return `tocbar-${resultHash}`
  837. },
  838. /**
  839. * @method TocBar
  840. */
  841. toggle(shouldShow = !this.visible) {
  842. const HIDDEN_CLASS = 'toc-bar--collapsed'
  843. const LOGO_HIDDEN_CLASS = 'toc-logo--collapsed'
  844. if (shouldShow) {
  845. this.element.classList.remove(HIDDEN_CLASS)
  846. this.logoSvg && this.logoSvg.classList.remove(LOGO_HIDDEN_CLASS)
  847. } else {
  848. this.element.classList.add(HIDDEN_CLASS)
  849. this.logoSvg && this.logoSvg.classList.add(LOGO_HIDDEN_CLASS)
  850.  
  851. const right = parseInt(this.element.style.right)
  852. if (right && right < 0) {
  853. this.element.style.right = "0px"
  854. const cachedPosition = POSITION_STORAGE.cache
  855. if (!isEmpty(cachedPosition)) {
  856. POSITION_STORAGE.set(null, {...cachedPosition, right: 0 })
  857. }
  858. }
  859. }
  860. this.visible = shouldShow
  861. },
  862. /**
  863. * Toggle light/dark scheme
  864. * @method TocBar
  865. */
  866. toggleScheme(isDark) {
  867. const isDarkMode = typeof isDark === 'undefined' ? !this.isDarkMode: isDark
  868. this.element.setAttribute('colorscheme', isDarkMode ? 'dark': 'light')
  869. console.log('[toc-bar] toggle scheme', isDarkMode)
  870. this.isDarkMode = isDarkMode
  871.  
  872. GM_setValue(DARKMODE_KEY, isDarkMode)
  873. this.refreshStyle()
  874. },
  875. refreshStyle() {
  876. const themeColor = guessThemeColor()
  877. if (themeColor && !this.isDarkMode) {
  878. this.element.style.setProperty('--toc-bar-active-color', themeColor);
  879. } else if (this.isDarkMode) {
  880. this.element.style.setProperty('--toc-bar-active-color', TOC_BAR_DEFAULT_ACTIVE_COLOR);
  881. }
  882. },
  883. }
  884. // ----------------end TocBar -------------------
  885.  
  886. function main() {
  887. const options = getPageTocOptions()
  888.  
  889. if (options) {
  890. const tocBar = new TocBar(options)
  891. tocBar.initTocbot(options)
  892. tocBar.refreshStyle()
  893. }
  894. }
  895.  
  896. main()
  897. })()