Toc Bar

A floating table of content widget

目前為 2020-07-02 提交的版本,檢視 最新版本

  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. // @grant none
  9. // @version 1.0.1
  10. // @match *://www.jianshu.com/p/*
  11. // @match *://cdn2.jianshu.io/p/*
  12. // @match *://juejin.im/post/*
  13. // @match *://juejin.im/entry/*
  14. // @match *://sspai.com/*
  15. // @match *://zhuanlan.zhihu.com/p/*
  16. // @match *://mp.weixin.qq.com/s*
  17. // @match *://cnodejs.org/topic/*
  18. // @match *://div.io/topic/*
  19. // @match *://www.zcfy.cc/article/*
  20. // @match *://dev.to/*
  21. // @match *://medium.com/*
  22. // @run-at document-idle
  23. // @grant GM_getResourceText
  24. // @grant GM_addStyle
  25. // @require https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.11.1/tocbot.min.js
  26. // @resource TOCBOT_STYLE https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.11.1/tocbot.css
  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. sspai: {
  46. contentSelector: '.notion-page-content',
  47. },
  48. juejin: {
  49. contentSelector: '.article-content',
  50. },
  51. zcfy: {
  52. contentSelector: '.markdown-body',
  53. },
  54. qq: {
  55. contentSelector: '.rich_media_content',
  56. },
  57. 'dev.to': {
  58. contentSelector: 'article',
  59. scrollSmoothOffset: -56,
  60. shouldShow() {
  61. return ['/search', '/top/'].every(s => !location.pathname.startsWith(s))
  62. },
  63. },
  64. 'medium.com': {
  65. contentSelector: 'article'
  66. },
  67. }
  68.  
  69. function getSiteInfo() {
  70. let siteName
  71. if (SITE_SETTINGS[location.hostname]) {
  72. siteName = location.hostname
  73. } else {
  74. const match = location.href.match(
  75. /([\d\w]+)\.(com|cn|net|org|im|io|cc|site|tv)/i
  76. )
  77. siteName = match ? match[1] : null
  78. }
  79. if (siteName && SITE_SETTINGS[siteName]) {
  80. return {
  81. siteName,
  82. siteSetting: SITE_SETTINGS[siteName],
  83. }
  84. }
  85. }
  86.  
  87. function getPageTocOptions() {
  88. let siteInfo = getSiteInfo()
  89. if (siteInfo) {
  90. const siteSetting = siteInfo.siteSetting
  91. if (siteSetting.shouldShow && !siteSetting.shouldShow()) {
  92. return
  93. }
  94. console.log('[toc-bar] found site info for', siteInfo.siteName)
  95. return siteSetting
  96. }
  97. }
  98.  
  99. function loadStyles() {
  100. const tocbotCss = GM_getResourceText('TOCBOT_STYLE')
  101. if (tocbotCss) {
  102. GM_addStyle(tocbotCss)
  103. }
  104. }
  105.  
  106. /**
  107. * @param {String} content
  108. * @return {String}
  109. */
  110. function doContentHash(content) {
  111. const val = content.split('').reduce((prevHash, currVal) => (((prevHash << 5) - prevHash) + currVal.charCodeAt(0))|0, 0);
  112. return val.toString(32)
  113. }
  114.  
  115. // ---------------- TocBar ----------------------
  116. const TOC_BAR_STYLE = `
  117. .toc-bar {
  118. position: fixed;
  119. z-index: 9000;
  120. right: 5px;
  121. top: 80px;
  122. width: 340px;
  123. font-size: 14px;
  124. box-sizing: border-box;
  125. padding: 10px 10px 10px 0;
  126. background: #FEFEFE;
  127. box-shadow: 0 1px 3px #DDD;
  128. border-radius: 4px;
  129. transition: width 0.2s ease;
  130. }
  131.  
  132. .toc-bar.toc-bar--collapsed {
  133. width: 30px;
  134. padding: 0;
  135. }
  136.  
  137. .toc-bar--collapsed .toc {
  138. display: none;
  139. }
  140.  
  141. .toc-bar--collapsed .toc-bar__toggle {
  142. transform: rotate(90deg);
  143. }
  144.  
  145. .toc-bar--collapsed .hidden-when-collapsed {
  146. display: none;
  147. }
  148.  
  149. .toc-bar__header {
  150. font-weight: bold;
  151. padding-bottom: 5px;
  152. display: flex;
  153. justify-content: space-between;
  154. align-items: center;
  155. cursor: move;
  156. }
  157.  
  158. .toc-bar__refresh {
  159. position: relative;
  160. top: -2px;
  161. }
  162.  
  163. .toc-bar__icon-btn {
  164. height: 1em;
  165. width: 1em;
  166. cursor: pointer;
  167. transition: transform 0.2s ease;
  168. }
  169.  
  170. .toc-bar__icon-btn:hover {
  171. opacity: 0.7;
  172. }
  173.  
  174. .toc-bar__icon-btn svg {
  175. max-width: 100%;
  176. max-height: 100%;
  177. }
  178.  
  179. .toc-bar__header-left {
  180. align-items: center;
  181. }
  182.  
  183. .toc-bar__toggle {
  184. cursor: pointer;
  185. padding: 2px 6px;
  186. box-sizing: content-box;
  187. transform: rotate(0);
  188. transition: transform 0.2s ease;
  189. }
  190.  
  191. .toc-bar__title {
  192. margin-left: 5px;
  193. }
  194.  
  195. .toc-bar a.toc-link {
  196. overflow: hidden;
  197. text-overflow: ellipsis;
  198. white-space: nowrap;
  199. display: inline-block;
  200. line-height: 1.4;
  201. }
  202.  
  203. .flex {
  204. display: flex;
  205. }
  206.  
  207. /* override tocbot */
  208. .toc-bar .toc {
  209. max-height: 80vh;
  210. }
  211.  
  212. .toc>.toc-list li {
  213. padding-left: 8px;
  214. }
  215.  
  216. .toc-list-item > a:hover {
  217. text-decoration: underline;
  218. }
  219. /* end override tocbot */
  220. `
  221.  
  222. /**
  223. * @class
  224. */
  225. function TocBar() {
  226. // inject style
  227. GM_addStyle(TOC_BAR_STYLE)
  228.  
  229. this.element = document.createElement('div')
  230. this.element.id = 'toc-bar'
  231. this.element.classList.add('toc-bar')
  232. document.body.appendChild(this.element)
  233.  
  234. /** @type {Boolean} */
  235. this.visible = true
  236.  
  237. this.initHeader()
  238.  
  239. // create a container tocbot
  240. const tocElement = document.createElement('div')
  241. this.tocElement = tocElement
  242. tocElement.classList.add('toc')
  243. this.element.appendChild(tocElement)
  244. }
  245.  
  246. 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>`
  247. 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>`
  248.  
  249. TocBar.prototype = {
  250. /**
  251. * @method TocBar
  252. */
  253. initHeader() {
  254. const header = document.createElement('div')
  255. header.classList.add('toc-bar__header')
  256. header.innerHTML = `
  257. <div class="flex toc-bar__header-left">
  258. <div class="toc-bar__toggle toc-bar__icon-btn" title="Toggle TOC Bar">
  259. ${TOC_ICON}
  260. </div>
  261. <div class="toc-bar__title hidden-when-collapsed">TOC Bar</div>
  262. </div>
  263. <div class="toc-bar__actions hidden-when-collapsed">
  264. <div class="toc-bar__refresh toc-bar__icon-btn" title="Refresh TOC">
  265. ${REFRESH_ICON}
  266. </div>
  267. </div>
  268. `
  269. const toggleElement = header.querySelector('.toc-bar__toggle')
  270. toggleElement.addEventListener('click', () => {
  271. this.toggle()
  272. })
  273.  
  274. const refreshElement = header.querySelector('.toc-bar__refresh')
  275. refreshElement.addEventListener('click', () => {
  276. tocbot.refresh()
  277. })
  278. // ---------------- header drag ----------------------
  279. const dragState = {
  280. startMouseX: 0,
  281. startMouseY: 0,
  282. startPositionX: 0,
  283. startPositionY: 0,
  284. startElementDisToRight: 0,
  285. isDragging: false,
  286. }
  287.  
  288. const onMouseMove = (e) => {
  289. if (!dragState.isDragging) return
  290. const deltaX = e.pageX - dragState.startMouseX
  291. const deltaY = e.pageY - dragState.startMouseY
  292. // 要换算为 right 数字
  293. const newRight = dragState.startElementDisToRight - deltaX
  294. const newTop = dragState.startPositionY + deltaY
  295. // console.table({ newRight, newTop})
  296. this.element.style.right = `${newRight}px`
  297. this.element.style.top = `${newTop}px`
  298. }
  299.  
  300. const onMouseUp = (e) => {
  301. Object.assign(dragState, {
  302. isDragging: false,
  303. })
  304. document.body.removeEventListener('mousemove', onMouseMove)
  305. document.body.removeEventListener('mouseup', onMouseUp)
  306. }
  307.  
  308. header.addEventListener('mousedown', (e) => {
  309. if (e.target === toggleElement) return
  310. const bbox = this.element.getBoundingClientRect()
  311. Object.assign(dragState, {
  312. isDragging: true,
  313. startMouseX: e.pageX,
  314. startMouseY: e.pageY,
  315. startPositionX: bbox.x,
  316. startPositionY: bbox.y,
  317. startElementDisToRight: document.body.clientWidth - bbox.right,
  318. })
  319. document.body.addEventListener('mousemove', onMouseMove)
  320. document.body.addEventListener('mouseup', onMouseUp)
  321. })
  322. // ----------------end header drag -------------------
  323.  
  324. this.element.appendChild(header)
  325. },
  326. /**
  327. * @method TocBar
  328. */
  329. initTocbot(options) {
  330. const me = this
  331. const tocbotOptions = Object.assign(
  332. {},
  333. {
  334. tocSelector: '.toc',
  335. scrollSmoothOffset: options.scrollSmoothOffset || 0,
  336. // hasInnerContainers: true,
  337. headingObjectCallback(obj, ele) {
  338. // if there is no id on the header element, add one that derived from hash of header title
  339. if (!ele.id) {
  340. const newId = me.generateHeaderId(obj, ele)
  341. ele.setAttribute('id', newId)
  342. obj.id = newId
  343. }
  344. return obj
  345. },
  346. headingSelector: 'h1, h2, h3, h4, h5',
  347. collapseDepth: 3,
  348. },
  349. options
  350. )
  351. // console.log('tocbotOptions', tocbotOptions);
  352. tocbot.init(tocbotOptions)
  353. },
  354. generateHeaderId(obj, ele) {
  355. return `tocbar-${doContentHash(obj.textContent)}`
  356. },
  357. /**
  358. * @method TocBar
  359. */
  360. toggle(shouldShow = !this.visible) {
  361. const HIDDEN_CLASS = 'toc-bar--collapsed'
  362. if (shouldShow) {
  363. this.element.classList.remove(HIDDEN_CLASS)
  364. } else {
  365. this.element.classList.add(HIDDEN_CLASS)
  366. }
  367. this.visible = shouldShow
  368. },
  369. }
  370. // ----------------end TocBar -------------------
  371.  
  372. function main() {
  373. const options = getPageTocOptions()
  374. if (options) {
  375. loadStyles()
  376.  
  377. const tocBar = new TocBar()
  378. tocBar.initTocbot(options)
  379. }
  380. }
  381.  
  382. main()
  383. })()