Toc Bar, 文章大纲

在页面右侧展示一个浮动的文章大纲目录

当前为 2020-07-05 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Toc Bar
  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 在页面右侧展示一个浮动的文章大纲目录
  9. // @version 1.3.0
  10. // @match *://www.jianshu.com/p/*
  11. // @match *://cdn2.jianshu.io/p/*
  12. // @match *://zhuanlan.zhihu.com/p/*
  13. // @match *://mp.weixin.qq.com/s*
  14. // @match *://cnodejs.org/topic/*
  15. // @match *://*zcfy.cc/article/*
  16. // @match *://juejin.im/entry/*
  17. // @match *://dev.to/*
  18. // @match *://web.dev/*
  19. // @match *://medium.com/*
  20. // @match *://css-tricks.com/*
  21. // @match *://www.smashingmagazine.com/*/*
  22. // @match *://distill.pub/*
  23. // @match *://github.com/*/*
  24. // @match *://developer.mozilla.org/*/docs/*
  25. // @run-at document-idle
  26. // @grant GM_getResourceText
  27. // @grant GM_addStyle
  28. // @grant GM_setValue
  29. // @grant GM_getValue
  30. // @require https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.11.1/tocbot.min.js
  31. // @homepageURL https://github.com/hikerpig/toc-bar-userscript
  32. // ==/UserScript==
  33.  
  34. (function () {
  35. const SITE_SETTINGS = {
  36. jianshu: {
  37. contentSelector: '.ouvJEz',
  38. style: {
  39. top: '55px',
  40. color: '#ea6f5a',
  41. },
  42. },
  43. 'zhuanlan.zhihu.com': {
  44. contentSelector: 'article',
  45. scrollSmoothOffset: -52,
  46. shouldShow() {
  47. return location.pathname.startsWith('/p/')
  48. },
  49. },
  50. zcfy: {
  51. contentSelector: '.markdown-body',
  52. },
  53. qq: {
  54. contentSelector: '.rich_media_content',
  55. },
  56. 'juejin.im': {
  57. contentSelector: '.entry-public-main',
  58. },
  59. 'dev.to': {
  60. contentSelector: 'article',
  61. scrollSmoothOffset: -56,
  62. shouldShow() {
  63. return ['/search', '/top/'].every(s => !location.pathname.startsWith(s))
  64. },
  65. },
  66. 'medium.com': {
  67. contentSelector: 'article'
  68. },
  69. 'css-tricks.com': {
  70. contentSelector: 'main'
  71. },
  72. 'distill.pub': {
  73. contentSelector: 'body'
  74. },
  75. 'smashingmagazine': {
  76. contentSelector: 'article'
  77. },
  78. 'web.dev': {
  79. contentSelector: '#content'
  80. },
  81. 'github.com': {
  82. contentSelector() {
  83. const README_SEL = '#readme'
  84. const WIKI_CONTENT_SEL = '.repository-content'
  85. const matchedSel = [README_SEL, WIKI_CONTENT_SEL].find((sel) => {
  86. return !!document.querySelector(README_SEL)
  87. })
  88.  
  89. if (matchedSel) return matchedSel
  90.  
  91. return false
  92. },
  93. initialTop: 500,
  94. },
  95. 'developer.mozilla.org': {
  96. contentSelector: '#content'
  97. }
  98. }
  99.  
  100. function getSiteInfo() {
  101. let siteName
  102. if (SITE_SETTINGS[location.hostname]) {
  103. siteName = location.hostname
  104. } else {
  105. const match = location.href.match(
  106. /([\d\w]+)\.(com|cn|net|org|im|io|cc|site|tv)/i
  107. )
  108. siteName = match ? match[1] : null
  109. }
  110. if (siteName && SITE_SETTINGS[siteName]) {
  111. return {
  112. siteName,
  113. siteSetting: SITE_SETTINGS[siteName],
  114. }
  115. }
  116. }
  117.  
  118. function getPageTocOptions() {
  119. let siteInfo = getSiteInfo()
  120. if (siteInfo) {
  121. let siteSetting = siteInfo.siteSetting
  122. if (siteSetting.shouldShow && !siteSetting.shouldShow()) {
  123. return
  124. }
  125. if (typeof siteSetting.contentSelector === 'function') {
  126. const contentSelector = siteSetting.contentSelector()
  127. if (!contentSelector) return
  128. siteSetting = {...siteSetting, contentSelector}
  129. }
  130. console.log('[toc-bar] found site info for', siteInfo.siteName)
  131. return siteSetting
  132. }
  133. }
  134.  
  135.  
  136. function guessThemeColor() {
  137. const meta = document.head.querySelector('meta[name="theme-color"]')
  138. if (meta) {
  139. return meta.getAttribute('content')
  140. }
  141. }
  142.  
  143. /**
  144. * @param {String} content
  145. * @return {String}
  146. */
  147. function doContentHash(content) {
  148. const val = content.split('').reduce((prevHash, currVal) => (((prevHash << 5) - prevHash) + currVal.charCodeAt(0))|0, 0);
  149. return val.toString(32)
  150. }
  151.  
  152. const POSITION_STORAGE = {
  153. cache: null,
  154. checkCache() {
  155. if (!POSITION_STORAGE.cache) {
  156. POSITION_STORAGE.cache = GM_getValue('tocbar-positions', {})
  157. }
  158. },
  159. get(k) {
  160. k = k || location.host
  161. POSITION_STORAGE.checkCache()
  162. return POSITION_STORAGE.cache[k]
  163. },
  164. set(k, position) {
  165. k = k || location.host
  166. POSITION_STORAGE.checkCache()
  167. POSITION_STORAGE.cache[k] = position
  168. GM_setValue('tocbar-positions', POSITION_STORAGE.cache)
  169. },
  170. }
  171.  
  172. // ---------------- TocBar ----------------------
  173. const TOC_BAR_STYLE = `
  174. .toc-bar {
  175. --toc-bar-active-color: #54BC4B;
  176.  
  177. position: fixed;
  178. z-index: 9000;
  179. right: 5px;
  180. top: 80px;
  181. width: 340px;
  182. font-size: 14px;
  183. box-sizing: border-box;
  184. padding: 0 10px 10px 0;
  185. box-shadow: 0 1px 3px #DDD;
  186. border-radius: 4px;
  187. transition: width 0.2s ease;
  188. color: #333;
  189. background: #FEFEFE;
  190. }
  191.  
  192. .toc-bar.toc-bar--collapsed {
  193. width: 30px;
  194. height: 30px;
  195. padding: 0;
  196. overflow: hidden;
  197. }
  198.  
  199. .toc-bar--collapsed .toc {
  200. display: none;
  201. }
  202.  
  203. .toc-bar--collapsed .hidden-when-collapsed {
  204. display: none;
  205. }
  206.  
  207. .toc-bar__header {
  208. font-weight: bold;
  209. padding-bottom: 5px;
  210. display: flex;
  211. justify-content: space-between;
  212. align-items: center;
  213. cursor: move;
  214. }
  215.  
  216. .toc-bar__refresh {
  217. position: relative;
  218. top: -2px;
  219. }
  220.  
  221. .toc-bar__icon-btn {
  222. height: 1em;
  223. width: 1em;
  224. cursor: pointer;
  225. transition: transform 0.2s ease;
  226. }
  227.  
  228. .toc-bar__icon-btn:hover {
  229. opacity: 0.7;
  230. }
  231.  
  232. .toc-bar__icon-btn svg {
  233. max-width: 100%;
  234. max-height: 100%;
  235. vertical-align: top;
  236. }
  237.  
  238. .toc-bar__header-left {
  239. align-items: center;
  240. }
  241.  
  242. .toc-bar__toggle {
  243. cursor: pointer;
  244. padding: 8px 8px;
  245. box-sizing: content-box;
  246. transition: transform 0.2s ease;
  247. }
  248.  
  249. .toc-bar__title {
  250. margin-left: 5px;
  251. }
  252.  
  253. .toc-bar a.toc-link {
  254. overflow: hidden;
  255. text-overflow: ellipsis;
  256. white-space: nowrap;
  257. display: block;
  258. line-height: 1.6;
  259. }
  260.  
  261. .flex {
  262. display: flex;
  263. }
  264.  
  265. /* tocbot related */
  266. .toc-bar__toc {
  267. max-height: 80vh;
  268. overflow-y: scroll;
  269. }
  270.  
  271. .toc-list-item > a:hover {
  272. text-decoration: underline;
  273. }
  274.  
  275. .toc-list {
  276. padding-inline-start: 0;
  277. }
  278.  
  279. .toc-bar__toc > .toc-list {
  280. margin: 0;
  281. overflow: hidden;
  282. position: relative;
  283. padding-left: 5px;
  284. }
  285.  
  286. .toc-bar__toc>.toc-list li {
  287. list-style: none;
  288. padding-left: 8px;
  289. position: static;
  290. }
  291.  
  292. a.toc-link {
  293. color: currentColor;
  294. height: 100%;
  295. }
  296.  
  297. .is-collapsible {
  298. max-height: 1000px;
  299. overflow: hidden;
  300. transition: all 300ms ease-in-out;
  301. }
  302.  
  303. .is-collapsed {
  304. max-height: 0;
  305. }
  306.  
  307. .is-position-fixed {
  308. position: fixed !important;
  309. top: 0;
  310. }
  311.  
  312. .is-active-link {
  313. font-weight: 700;
  314. }
  315.  
  316. .toc-link::before {
  317. background-color: #EEE;
  318. content: ' ';
  319. display: inline-block;
  320. height: inherit;
  321. left: 0;
  322. margin-top: -1px;
  323. position: absolute;
  324. width: 2px;
  325. }
  326.  
  327. .is-active-link::before {
  328. background-color: var(--toc-bar-active-color);
  329. }
  330. /* end tocbot related */
  331. `
  332.  
  333. const TOCBOT_CONTAINTER_CLASS = 'toc-bar__toc'
  334.  
  335. /**
  336. * @class
  337. */
  338. function TocBar(options={}) {
  339. this.options = options
  340.  
  341. // inject style
  342. GM_addStyle(TOC_BAR_STYLE)
  343.  
  344. this.element = document.createElement('div')
  345. this.element.id = 'toc-bar'
  346. this.element.classList.add('toc-bar')
  347. document.body.appendChild(this.element)
  348.  
  349. /** @type {Boolean} */
  350. this.visible = true
  351.  
  352. this.initHeader()
  353.  
  354. // create a container tocbot
  355. const tocElement = document.createElement('div')
  356. this.tocElement = tocElement
  357. tocElement.classList.add(TOCBOT_CONTAINTER_CLASS)
  358. this.element.appendChild(tocElement)
  359.  
  360. const cachedPosition = POSITION_STORAGE.get(options.siteName)
  361. if (cachedPosition) {
  362. this.element.style.top = `${cachedPosition.top}px`
  363. this.element.style.right = `${cachedPosition.right}px`
  364. } else if (options.hasOwnProperty('initialTop')) {
  365. this.element.style.top = `${options.initialTop}px`
  366. }
  367.  
  368. if (GM_getValue('tocbar-hidden', false)) {
  369. this.toggle(false)
  370. }
  371. }
  372.  
  373. 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>`
  374.  
  375. const TOC_ICON = `
  376. <?xml version="1.0" encoding="utf-8"?>
  377. <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
  378. viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
  379. <g>
  380. <g>
  381. <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
  382. v132.5h32V166.5h287.8v24.9H553.8v82.8h114.1H693h225.6V114.5L835.2,45.9z M806.2,93.2H134.2V67.2h672.1v26.1H806.2z"/>
  383. <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
  384. 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 "/>
  385. </g>
  386. </g>
  387. </svg>
  388. `
  389.  
  390. TocBar.prototype = {
  391. /**
  392. * @method TocBar
  393. */
  394. initHeader() {
  395. const header = document.createElement('div')
  396. header.classList.add('toc-bar__header')
  397. header.innerHTML = `
  398. <div class="flex toc-bar__header-left">
  399. <div class="toc-bar__toggle toc-bar__icon-btn" title="Toggle TOC Bar">
  400. ${TOC_ICON}
  401. </div>
  402. <div class="toc-bar__title hidden-when-collapsed">TOC Bar</div>
  403. </div>
  404. <div class="toc-bar__actions hidden-when-collapsed">
  405. <div class="toc-bar__refresh toc-bar__icon-btn" title="Refresh TOC">
  406. ${REFRESH_ICON}
  407. </div>
  408. </div>
  409. `
  410. const toggleElement = header.querySelector('.toc-bar__toggle')
  411. toggleElement.addEventListener('click', () => {
  412. this.toggle()
  413. GM_setValue('tocbar-hidden', !this.visible)
  414. })
  415. this.logoSvg = toggleElement.querySelector('svg')
  416.  
  417. const refreshElement = header.querySelector('.toc-bar__refresh')
  418. refreshElement.addEventListener('click', () => {
  419. tocbot.refresh()
  420. })
  421. // ---------------- header drag ----------------------
  422. const dragState = {
  423. startMouseX: 0,
  424. startMouseY: 0,
  425. startPositionX: 0,
  426. startPositionY: 0,
  427. startElementDisToRight: 0,
  428. isDragging: false,
  429. curRight: 0,
  430. curTop: 0,
  431. }
  432.  
  433. const onMouseMove = (e) => {
  434. if (!dragState.isDragging) return
  435. const deltaX = e.pageX - dragState.startMouseX
  436. const deltaY = e.pageY - dragState.startMouseY
  437. // 要换算为 right 数字
  438. const newRight = dragState.startElementDisToRight - deltaX
  439. const newTop = dragState.startPositionY + deltaY
  440. Object.assign(dragState, {
  441. curTop: newTop,
  442. curRight: newRight,
  443. })
  444. // console.table({ newRight, newTop})
  445. this.element.style.right = `${newRight}px`
  446. this.element.style.top = `${newTop}px`
  447. }
  448.  
  449. const onMouseUp = (e) => {
  450. Object.assign(dragState, {
  451. isDragging: false,
  452. })
  453. document.body.removeEventListener('mousemove', onMouseMove)
  454. document.body.removeEventListener('mouseup', onMouseUp)
  455.  
  456. POSITION_STORAGE.set(this.options.siteName, {
  457. top: dragState.curTop,
  458. right: dragState.curRight,
  459. })
  460. }
  461.  
  462. header.addEventListener('mousedown', (e) => {
  463. if (e.target === toggleElement) return
  464. const bbox = this.element.getBoundingClientRect()
  465. Object.assign(dragState, {
  466. isDragging: true,
  467. startMouseX: e.pageX,
  468. startMouseY: e.pageY,
  469. startPositionX: bbox.x,
  470. startPositionY: bbox.y,
  471. startElementDisToRight: document.body.clientWidth - bbox.right,
  472. })
  473. document.body.addEventListener('mousemove', onMouseMove)
  474. document.body.addEventListener('mouseup', onMouseUp)
  475. })
  476. // ----------------end header drag -------------------
  477.  
  478. this.element.appendChild(header)
  479. },
  480. /**
  481. * @method TocBar
  482. */
  483. initTocbot(options) {
  484. const me = this
  485. const tocbotOptions = Object.assign(
  486. {},
  487. {
  488. tocSelector: `.${TOCBOT_CONTAINTER_CLASS}`,
  489. scrollSmoothOffset: options.scrollSmoothOffset || 0,
  490. // hasInnerContainers: true,
  491. headingObjectCallback(obj, ele) {
  492. // if there is no id on the header element, add one that derived from hash of header title
  493. if (!ele.id) {
  494. const newId = me.generateHeaderId(obj, ele)
  495. ele.setAttribute('id', newId)
  496. obj.id = newId
  497. }
  498. return obj
  499. },
  500. headingSelector: 'h1, h2, h3, h4, h5',
  501. collapseDepth: 4,
  502. },
  503. options
  504. )
  505. // console.log('tocbotOptions', tocbotOptions);
  506. tocbot.init(tocbotOptions)
  507. },
  508. generateHeaderId(obj, ele) {
  509. return `tocbar-${doContentHash(obj.textContent)}`
  510. },
  511. /**
  512. * @method TocBar
  513. */
  514. toggle(shouldShow = !this.visible) {
  515. const HIDDEN_CLASS = 'toc-bar--collapsed'
  516. const LOGO_HIDDEN_CLASS = 'toc-logo--collapsed'
  517. if (shouldShow) {
  518. this.element.classList.remove(HIDDEN_CLASS)
  519. this.logoSvg && this.logoSvg.classList.remove(LOGO_HIDDEN_CLASS)
  520. } else {
  521. this.element.classList.add(HIDDEN_CLASS)
  522. this.logoSvg && this.logoSvg.classList.add(LOGO_HIDDEN_CLASS)
  523. }
  524. this.visible = shouldShow
  525. },
  526. refreshStyle() {
  527. const themeColor = guessThemeColor()
  528. if (themeColor) {
  529. this.element.style.setProperty('--toc-bar-active-color', themeColor);
  530. }
  531. },
  532. }
  533. // ----------------end TocBar -------------------
  534.  
  535. function main() {
  536. const options = getPageTocOptions()
  537. if (options) {
  538.  
  539. const tocBar = new TocBar(options)
  540. tocBar.initTocbot(options)
  541. tocBar.refreshStyle()
  542. }
  543. }
  544.  
  545. main()
  546. })()