Toc Bar, 自动生成文章大纲。知乎、微信公众号等阅读好伴侣

自动生成文章大纲目录,在页面右侧展示一个浮动的组件。覆盖常用在线阅读资讯站(技术向)。github/medium/MDN/掘金/简书等

目前为 2021-02-04 提交的版本,查看 最新版本

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