Greasy Fork 支持简体中文。

Toc Bar

A floating table of content widget

目前為 2020-10-12 提交的版本,檢視 最新版本

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