Toc Bar, 文章大纲

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

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

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