Toc Bar, 文章大纲

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

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

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