Toc Bar

A floating table of content widget

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

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