Toc Bar

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

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

  1. // ==UserScript==
  2. // @name Toc Bar
  3. // @author hikerpig
  4. // @namespace https://github.com/hikerpig
  5. // @license MIT
  6. // @description A floating table of content widget
  7. // @description:zh-CN 在页面右侧展示一个浮动的文章大纲目录
  8. // @version 1.1.0
  9. // @match *://www.jianshu.com/p/*
  10. // @match *://cdn2.jianshu.io/p/*
  11. // @match *://zhuanlan.zhihu.com/p/*
  12. // @match *://mp.weixin.qq.com/s*
  13. // @match *://cnodejs.org/topic/*
  14. // @match *://www.zcfy.cc/article/*
  15. // @match *://dev.to/*
  16. // @match *://web.dev/*
  17. // @match *://medium.com/*
  18. // @match *://css-tricks.com/*
  19. // @match *://www.smashingmagazine.com/*/*
  20. // @match *://distill.pub/*
  21. // @match *://github.com/*/*
  22. // @match *://developer.mozilla.org/*/docs/*
  23. // @run-at document-idle
  24. // @grant GM_getResourceText
  25. // @grant GM_addStyle
  26. // @require https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.11.1/tocbot.min.js
  27. // @homepageURL https://github.com/hikerpig/toc-bar-userscript
  28. // ==/UserScript==
  29.  
  30. (function () {
  31. const SITE_SETTINGS = {
  32. jianshu: {
  33. contentSelector: '.ouvJEz',
  34. style: {
  35. top: '55px',
  36. color: '#ea6f5a',
  37. },
  38. },
  39. 'zhuanlan.zhihu.com': {
  40. contentSelector: '.Post-RichText',
  41. shouldShow() {
  42. return location.pathname.startsWith('/p/')
  43. },
  44. },
  45. zcfy: {
  46. contentSelector: '.markdown-body',
  47. },
  48. qq: {
  49. contentSelector: '.rich_media_content',
  50. },
  51. 'dev.to': {
  52. contentSelector: 'article',
  53. scrollSmoothOffset: -56,
  54. shouldShow() {
  55. return ['/search', '/top/'].every(s => !location.pathname.startsWith(s))
  56. },
  57. },
  58. 'medium.com': {
  59. contentSelector: 'article'
  60. },
  61. 'css-tricks.com': {
  62. contentSelector: 'main'
  63. },
  64. 'distill.pub': {
  65. contentSelector: 'body'
  66. },
  67. 'smashingmagazine': {
  68. contentSelector: 'article'
  69. },
  70. 'web.dev': {
  71. contentSelector: '#content'
  72. },
  73. 'github.com': {
  74. contentSelector() {
  75. const README_SEL = '#readme'
  76. const WIKI_CONTENT_SEL = '.repository-content'
  77. const matchedSel = [README_SEL, WIKI_CONTENT_SEL].find((sel) => {
  78. return !!document.querySelector(README_SEL)
  79. })
  80.  
  81. if (matchedSel) return matchedSel
  82.  
  83. return false
  84. }
  85. },
  86. 'developer.mozilla.org': {
  87. contentSelector: '#content'
  88. }
  89. }
  90.  
  91. function getSiteInfo() {
  92. let siteName
  93. if (SITE_SETTINGS[location.hostname]) {
  94. siteName = location.hostname
  95. } else {
  96. const match = location.href.match(
  97. /([\d\w]+)\.(com|cn|net|org|im|io|cc|site|tv)/i
  98. )
  99. siteName = match ? match[1] : null
  100. }
  101. if (siteName && SITE_SETTINGS[siteName]) {
  102. return {
  103. siteName,
  104. siteSetting: SITE_SETTINGS[siteName],
  105. }
  106. }
  107. }
  108.  
  109. function getPageTocOptions() {
  110. let siteInfo = getSiteInfo()
  111. if (siteInfo) {
  112. let siteSetting = siteInfo.siteSetting
  113. if (siteSetting.shouldShow && !siteSetting.shouldShow()) {
  114. return
  115. }
  116. if (typeof siteSetting.contentSelector === 'function') {
  117. const contentSelector = siteSetting.contentSelector()
  118. if (!contentSelector) return
  119. siteSetting = {...siteSetting, contentSelector}
  120. }
  121. console.log('[toc-bar] found site info for', siteInfo.siteName)
  122. return siteSetting
  123. }
  124. }
  125.  
  126.  
  127. function guessThemeColor() {
  128. const meta = document.head.querySelector('meta[name="theme-color"]')
  129. if (meta) {
  130. return meta.getAttribute('content')
  131. }
  132. }
  133.  
  134. /**
  135. * @param {String} content
  136. * @return {String}
  137. */
  138. function doContentHash(content) {
  139. const val = content.split('').reduce((prevHash, currVal) => (((prevHash << 5) - prevHash) + currVal.charCodeAt(0))|0, 0);
  140. return val.toString(32)
  141. }
  142.  
  143. // ---------------- TocBar ----------------------
  144. const TOC_BAR_STYLE = `
  145. .toc-bar {
  146. --toc-bar-active-color: #54BC4B;
  147.  
  148. position: fixed;
  149. z-index: 9000;
  150. right: 5px;
  151. top: 80px;
  152. width: 340px;
  153. font-size: 14px;
  154. box-sizing: border-box;
  155. padding: 10px 10px 10px 0;
  156. box-shadow: 0 1px 3px #DDD;
  157. border-radius: 4px;
  158. transition: width 0.2s ease;
  159. color: #333;
  160. background: #FEFEFE;
  161. }
  162.  
  163. .toc-bar.toc-bar--collapsed {
  164. width: 30px;
  165. padding: 0;
  166. }
  167.  
  168. .toc-bar--collapsed .toc {
  169. display: none;
  170. }
  171.  
  172. .toc-bar--collapsed .toc-bar__toggle {
  173. transform: rotate(90deg);
  174. }
  175.  
  176. .toc-bar--collapsed .hidden-when-collapsed {
  177. display: none;
  178. }
  179.  
  180. .toc-bar__header {
  181. font-weight: bold;
  182. padding-bottom: 5px;
  183. display: flex;
  184. justify-content: space-between;
  185. align-items: center;
  186. cursor: move;
  187. }
  188.  
  189. .toc-bar__refresh {
  190. position: relative;
  191. top: -2px;
  192. }
  193.  
  194. .toc-bar__icon-btn {
  195. height: 1em;
  196. width: 1em;
  197. cursor: pointer;
  198. transition: transform 0.2s ease;
  199. }
  200.  
  201. .toc-bar__icon-btn:hover {
  202. opacity: 0.7;
  203. }
  204.  
  205. .toc-bar__icon-btn svg {
  206. max-width: 100%;
  207. max-height: 100%;
  208. }
  209.  
  210. .toc-bar__header-left {
  211. align-items: center;
  212. }
  213.  
  214. .toc-bar__toggle {
  215. cursor: pointer;
  216. padding: 2px 6px;
  217. box-sizing: content-box;
  218. transform: rotate(0);
  219. transition: transform 0.2s ease;
  220. }
  221.  
  222. .toc-bar__title {
  223. margin-left: 5px;
  224. }
  225.  
  226. .toc-bar a.toc-link {
  227. overflow: hidden;
  228. text-overflow: ellipsis;
  229. white-space: nowrap;
  230. display: block;
  231. line-height: 1.6;
  232. }
  233.  
  234. .flex {
  235. display: flex;
  236. }
  237.  
  238. /* tocbot related */
  239. .toc-bar__toc {
  240. max-height: 80vh;
  241. }
  242.  
  243. .toc-list-item > a:hover {
  244. text-decoration: underline;
  245. }
  246.  
  247. .toc-bar__toc > .toc-list {
  248. margin: 0;
  249. overflow: hidden;
  250. position: relative;
  251. padding-left: 5px;
  252. }
  253.  
  254. .toc-bar__toc>.toc-list li {
  255. list-style: none;
  256. padding-left: 8px;
  257. position: static;
  258. }
  259.  
  260. a.toc-link {
  261. color: currentColor;
  262. height: 100%;
  263. }
  264.  
  265. .is-collapsible {
  266. max-height: 1000px;
  267. overflow: hidden;
  268. transition: all 300ms ease-in-out;
  269. }
  270.  
  271. .is-collapsed {
  272. max-height: 0;
  273. }
  274.  
  275. .is-position-fixed {
  276. position: fixed !important;
  277. top: 0;
  278. }
  279.  
  280. .is-active-link {
  281. font-weight: 700;
  282. }
  283.  
  284. .toc-link::before {
  285. background-color: #EEE;
  286. content: ' ';
  287. display: inline-block;
  288. height: inherit;
  289. left: 0;
  290. margin-top: -1px;
  291. position: absolute;
  292. width: 2px;
  293. }
  294.  
  295. .is-active-link::before {
  296. background-color: var(--toc-bar-active-color);
  297. }
  298. /* end tocbot related */
  299. `
  300.  
  301. const TOCBOT_CONTAINTER_CLASS = 'toc-bar__toc'
  302.  
  303. /**
  304. * @class
  305. */
  306. function TocBar() {
  307. // inject style
  308. GM_addStyle(TOC_BAR_STYLE)
  309.  
  310. this.element = document.createElement('div')
  311. this.element.id = 'toc-bar'
  312. this.element.classList.add('toc-bar')
  313. document.body.appendChild(this.element)
  314.  
  315. /** @type {Boolean} */
  316. this.visible = true
  317.  
  318. this.initHeader()
  319.  
  320. // create a container tocbot
  321. const tocElement = document.createElement('div')
  322. this.tocElement = tocElement
  323. tocElement.classList.add(TOCBOT_CONTAINTER_CLASS)
  324. this.element.appendChild(tocElement)
  325. }
  326.  
  327. const TOC_ICON = `<svg t="1593614506959" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6049" width="200" height="200"><path d="M128 384h597.333333v-85.333333H128v85.333333z m0 170.666667h597.333333v-85.333334H128v85.333334z m0 170.666666h597.333333v-85.333333H128v85.333333z m682.666667 0h85.333333v-85.333333h-85.333333v85.333333z m0-426.666666v85.333333h85.333333v-85.333333h-85.333333z m0 256h85.333333v-85.333334h-85.333333v85.333334z" p-id="6050"></path></svg>`
  328. 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>`
  329.  
  330. TocBar.prototype = {
  331. /**
  332. * @method TocBar
  333. */
  334. initHeader() {
  335. const header = document.createElement('div')
  336. header.classList.add('toc-bar__header')
  337. header.innerHTML = `
  338. <div class="flex toc-bar__header-left">
  339. <div class="toc-bar__toggle toc-bar__icon-btn" title="Toggle TOC Bar">
  340. ${TOC_ICON}
  341. </div>
  342. <div class="toc-bar__title hidden-when-collapsed">TOC Bar</div>
  343. </div>
  344. <div class="toc-bar__actions hidden-when-collapsed">
  345. <div class="toc-bar__refresh toc-bar__icon-btn" title="Refresh TOC">
  346. ${REFRESH_ICON}
  347. </div>
  348. </div>
  349. `
  350. const toggleElement = header.querySelector('.toc-bar__toggle')
  351. toggleElement.addEventListener('click', () => {
  352. this.toggle()
  353. })
  354.  
  355. const refreshElement = header.querySelector('.toc-bar__refresh')
  356. refreshElement.addEventListener('click', () => {
  357. tocbot.refresh()
  358. })
  359. // ---------------- header drag ----------------------
  360. const dragState = {
  361. startMouseX: 0,
  362. startMouseY: 0,
  363. startPositionX: 0,
  364. startPositionY: 0,
  365. startElementDisToRight: 0,
  366. isDragging: false,
  367. }
  368.  
  369. const onMouseMove = (e) => {
  370. if (!dragState.isDragging) return
  371. const deltaX = e.pageX - dragState.startMouseX
  372. const deltaY = e.pageY - dragState.startMouseY
  373. // 要换算为 right 数字
  374. const newRight = dragState.startElementDisToRight - deltaX
  375. const newTop = dragState.startPositionY + deltaY
  376. // console.table({ newRight, newTop})
  377. this.element.style.right = `${newRight}px`
  378. this.element.style.top = `${newTop}px`
  379. }
  380.  
  381. const onMouseUp = (e) => {
  382. Object.assign(dragState, {
  383. isDragging: false,
  384. })
  385. document.body.removeEventListener('mousemove', onMouseMove)
  386. document.body.removeEventListener('mouseup', onMouseUp)
  387. }
  388.  
  389. header.addEventListener('mousedown', (e) => {
  390. if (e.target === toggleElement) return
  391. const bbox = this.element.getBoundingClientRect()
  392. Object.assign(dragState, {
  393. isDragging: true,
  394. startMouseX: e.pageX,
  395. startMouseY: e.pageY,
  396. startPositionX: bbox.x,
  397. startPositionY: bbox.y,
  398. startElementDisToRight: document.body.clientWidth - bbox.right,
  399. })
  400. document.body.addEventListener('mousemove', onMouseMove)
  401. document.body.addEventListener('mouseup', onMouseUp)
  402. })
  403. // ----------------end header drag -------------------
  404.  
  405. this.element.appendChild(header)
  406. },
  407. /**
  408. * @method TocBar
  409. */
  410. initTocbot(options) {
  411. const me = this
  412. const tocbotOptions = Object.assign(
  413. {},
  414. {
  415. tocSelector: `.${TOCBOT_CONTAINTER_CLASS}`,
  416. scrollSmoothOffset: options.scrollSmoothOffset || 0,
  417. // hasInnerContainers: true,
  418. headingObjectCallback(obj, ele) {
  419. // if there is no id on the header element, add one that derived from hash of header title
  420. if (!ele.id) {
  421. const newId = me.generateHeaderId(obj, ele)
  422. ele.setAttribute('id', newId)
  423. obj.id = newId
  424. }
  425. return obj
  426. },
  427. headingSelector: 'h1, h2, h3, h4, h5',
  428. collapseDepth: 4,
  429. },
  430. options
  431. )
  432. // console.log('tocbotOptions', tocbotOptions);
  433. tocbot.init(tocbotOptions)
  434. },
  435. generateHeaderId(obj, ele) {
  436. return `tocbar-${doContentHash(obj.textContent)}`
  437. },
  438. /**
  439. * @method TocBar
  440. */
  441. toggle(shouldShow = !this.visible) {
  442. const HIDDEN_CLASS = 'toc-bar--collapsed'
  443. if (shouldShow) {
  444. this.element.classList.remove(HIDDEN_CLASS)
  445. } else {
  446. this.element.classList.add(HIDDEN_CLASS)
  447. }
  448. this.visible = shouldShow
  449. },
  450. refreshStyle() {
  451. const themeColor = guessThemeColor()
  452. if (themeColor) {
  453. this.element.style.setProperty('--toc-bar-active-color', themeColor);
  454. }
  455. },
  456. }
  457. // ----------------end TocBar -------------------
  458.  
  459. function main() {
  460. const options = getPageTocOptions()
  461. if (options) {
  462.  
  463. const tocBar = new TocBar()
  464. tocBar.initTocbot(options)
  465. tocBar.refreshStyle()
  466. }
  467. }
  468.  
  469. main()
  470. })()