Toc Bar

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

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

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