Toc Bar, 文章大纲

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

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

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