Confluence Floating TOC

在 Confluence 文章页面上浮动展示文章目录,并支持展开和折叠功能

目前為 2024-10-15 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Confluence Floating TOC
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.3
  5. // @description 在 Confluence 文章页面上浮动展示文章目录,并支持展开和折叠功能
  6. // @author mkdir700
  7. // @match https://*.atlassian.net/wiki/*
  8. // @grant none
  9. // @license MIT
  10. // ==/UserScript==
  11.  
  12.  
  13. // 递归处理已有的 TOC,重新生成新的 TOC
  14. function genertateTOCFromExistingToc(toc) {
  15. if (toc.textContent === '') {
  16. return;
  17. }
  18. let currUl = document.createElement('ul');
  19. currUl.id = 'floating-toc-ul';
  20. for (let i = 0; i < toc.children.length; i++) {
  21. // li > span > a
  22. var a = toc.children[i].querySelector('span > a');
  23. var headerTextElement = toc.children[i].querySelector('span > a > span > span');
  24. if (!headerTextElement) {
  25. continue;
  26. }
  27.  
  28. var headerText = headerTextElement.textContent;
  29.  
  30. // 创建目录项
  31. var tocItem = document.createElement('li');
  32.  
  33. // 创建链接
  34. var tocLink = document.createElement('a');
  35. tocLink.textContent = headerText;
  36.  
  37. // 使用标题的 id 作为 URL 片段
  38. // 标题中的空格需要替换为 -,并且转为小写
  39. tocLink.href = a.href;
  40. tocItem.appendChild(tocLink);
  41.  
  42. // 如果有子目录,递归处理
  43. var childUl = toc.children[i].querySelector('ul');
  44. if (childUl) {
  45. var newUl = genertateTOCFromExistingToc(childUl);
  46. if (newUl) {
  47. tocItem.appendChild(newUl);
  48. }
  49. }
  50. currUl.appendChild(tocItem);
  51. }
  52.  
  53. return currUl;
  54. }
  55.  
  56.  
  57. function getExistingToc() {
  58. return document.querySelector('[data-testid="list-style-toc-level-container"]');
  59. }
  60.  
  61. function generateTOCFormPage() {
  62. // 创建目录列表
  63. var tocList = document.createElement('ul');
  64. tocList.id = 'floating-toc-ul';
  65. // 获取所有标题
  66. var headers = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
  67. headers.forEach(function (header) {
  68. // 过滤掉 id 为空的标题
  69. if (header.textContent === '') {
  70. return;
  71. }
  72. // 检查是否有属性 data-item-title
  73. if (header.hasAttribute('data-item-title')) {
  74. return;
  75. }
  76. // 检查属性 data-testid 是否等于 title-text
  77. if (header.getAttribute('data-testid') === 'title-text') {
  78. return;
  79. }
  80. if (header.id === 'floating-toc-title') {
  81. return;
  82. }
  83. // class 为 'cc-te0214' 的标题不需要显示在目录中
  84. if (header.className === 'cc-te0214') {
  85. return;
  86. }
  87. // 排除特定的 h2 标签
  88. if (header.tagName === 'H2' && header.closest('[data-vc="end-of-page-recommendation-component"]')) {
  89. return;
  90. }
  91. // 排除 "快速入门" 标题
  92. if (header.closest('[data-test-id="onboarding-quickstart-experience"]')) {
  93. return;
  94. }
  95.  
  96. if (header.closest('[data-test-id="flag-visibility-wrapper"]')) {
  97. return;
  98. }
  99.  
  100. // 创建目录项
  101. var tocItem = document.createElement('li');
  102. tocItem.style.marginLeft = (parseInt(header.tagName[1]) - 1) * 10 + 'px'; // 根据标题级别缩进
  103.  
  104. // 创建链接
  105. var tocLink = document.createElement('a');
  106. tocLink.textContent = header.textContent;
  107.  
  108. // 使用标题作为 URL 片段
  109. tocLink.href = '#' + header.textContent.replace(/\s/g, '-');
  110. tocItem.appendChild(tocLink);
  111.  
  112. // 将目录项添加到目录列表中
  113. tocList.appendChild(tocItem);
  114. });
  115.  
  116. return tocList;
  117. }
  118.  
  119. function buildToggleButton() {
  120. var toggleButton = document.createElement('div');
  121. toggleButton.id = 'floating-toc-toggle';
  122. toggleButton.innerHTML = '&#9654;'; // 右箭头 Unicode 字符
  123. toggleButton.style.position = 'fixed';
  124. toggleButton.style.top = '200px';
  125. toggleButton.style.right = '0';
  126. toggleButton.style.backgroundColor = '#007bff';
  127. toggleButton.style.color = '#fff';
  128. toggleButton.style.width = '20px';
  129. toggleButton.style.height = '40px';
  130. toggleButton.style.display = 'flex';
  131. toggleButton.style.justifyContent = 'center';
  132. toggleButton.style.alignItems = 'center';
  133. toggleButton.style.cursor = 'pointer';
  134. toggleButton.style.userSelect = 'none';
  135. toggleButton.style.borderRadius = '5px 0 0 5px';
  136. toggleButton.style.zIndex = '1000';
  137. toggleButton.style.transition = 'all 0.3s ease-in-out';
  138. toggleButton.style.fontSize = '14px';
  139.  
  140. var isCollapsed = false;
  141. toggleButton.addEventListener('click', function () {
  142. var tocContainer = document.getElementById('floating-toc-container');
  143. if (isCollapsed) {
  144. tocContainer.style.right = '0';
  145. toggleButton.innerHTML = '&#9654;'; // 右箭头
  146. toggleButton.style.right = '220px'; // 调整按钮位置
  147. } else {
  148. tocContainer.style.right = '-220px'; // 完全隐藏目录
  149. toggleButton.innerHTML = '&#9664;'; // 左箭头
  150. toggleButton.style.right = '-10px';
  151. }
  152. isCollapsed = !isCollapsed;
  153. });
  154.  
  155. toggleButton.addEventListener('mouseenter', function() {
  156. if (isCollapsed) {
  157. toggleButton.style.right = '0';
  158. }
  159. });
  160.  
  161. toggleButton.addEventListener('mouseleave', function() {
  162. if (isCollapsed) {
  163. toggleButton.style.right = '-10px';
  164. }
  165. });
  166.  
  167. return toggleButton;
  168. }
  169.  
  170.  
  171. function buildToc() {
  172. var tocContainer = document.createElement('div');
  173. tocContainer.id = 'floating-toc-container';
  174. tocContainer.style.width = '220px'; // 增加宽度以包含padding
  175. tocContainer.style.backgroundColor = '#fff';
  176. tocContainer.style.border = '1px solid #ccc';
  177. tocContainer.style.padding = '10px';
  178. tocContainer.style.boxSizing = 'border-box'; // 确保padding包含在宽度内
  179. tocContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.1)';
  180. tocContainer.style.position = 'fixed';
  181. tocContainer.style.top = '200px';
  182. tocContainer.style.right = '0';
  183. tocContainer.style.maxHeight = 'calc(100vh - 300px)';
  184. tocContainer.style.overflowY = 'auto';
  185. tocContainer.style.transition = 'right 0.3s ease-in-out';
  186. tocContainer.style.zIndex = '999';
  187. tocContainer.style.scrollbarWidth = 'none';
  188. tocContainer.style.msOverflowStyle = 'none';
  189.  
  190. var style = document.createElement('style');
  191. style.textContent = `
  192. #floating-toc-container::-webkit-scrollbar {
  193. display: none;
  194. }
  195. `;
  196. document.head.appendChild(style);
  197.  
  198. var tocTitle = document.createElement('div');
  199. tocTitle.textContent = '目录';
  200. tocTitle.style.marginTop = '0';
  201. tocTitle.style.marginBottom = '10px';
  202. tocTitle.style.textAlign = 'center';
  203. tocTitle.style.fontSize = '16px';
  204. tocTitle.style.fontWeight = 'bold';
  205. tocContainer.appendChild(tocTitle);
  206.  
  207. return tocContainer;
  208. }
  209.  
  210. function generateTOC() {
  211. var existingTOC = getExistingToc();
  212. var toc;
  213.  
  214. if (existingTOC) {
  215. toc = genertateTOCFromExistingToc(existingTOC);
  216. } else {
  217. toc = generateTOCFormPage();
  218. }
  219.  
  220. // 检查目录是否为空
  221. if (!toc || toc.children.length === 0) {
  222. var emptyMessage = document.createElement('div');
  223. emptyMessage.id = 'floating-toc-empty-message';
  224. emptyMessage.textContent = '当前页面没有可用的目录内容';
  225. emptyMessage.style.color = '#666';
  226. emptyMessage.style.fontStyle = 'italic';
  227. emptyMessage.style.textAlign = 'center';
  228. emptyMessage.style.padding = '20px 0';
  229. return emptyMessage;
  230. }
  231.  
  232. toc.style.listStyle = 'none';
  233. toc.style.padding = '0';
  234. toc.style.margin = '0';
  235.  
  236. // 优化目录列表样式
  237. var listItems = toc.querySelectorAll('li');
  238. listItems.forEach(function(item) {
  239. item.style.marginBottom = '5px';
  240. var link = item.querySelector('a');
  241. if (link) {
  242. link.style.textDecoration = 'none';
  243. link.style.color = '#333';
  244. link.style.display = 'block';
  245. link.style.padding = '3px 5px';
  246. link.style.borderRadius = '3px';
  247. link.style.transition = 'background-color 0.2s';
  248. link.addEventListener('mouseover', function() {
  249. this.style.backgroundColor = '#f0f0f0';
  250. });
  251. link.addEventListener('mouseout', function() {
  252. this.style.backgroundColor = 'transparent';
  253. });
  254. }
  255. });
  256.  
  257. return toc;
  258. }
  259.  
  260. function updateMaxHeight(tocContainer) {
  261. const viewportHeight = window.innerHeight;
  262. const topOffset = parseFloat(tocContainer.style.top);
  263. tocContainer.style.maxHeight = (viewportHeight - topOffset - 20) + 'px'; // 20px 为一些额外的间距
  264. }
  265.  
  266.  
  267. function buildBackToTopButton() {
  268. var backToTopButton = document.createElement('div');
  269. backToTopButton.id = 'back-to-top-button';
  270. backToTopButton.innerHTML = '&#9650;'; // 上箭头 Unicode 字符
  271. backToTopButton.style.position = 'fixed';
  272. backToTopButton.style.bottom = '30px';
  273. backToTopButton.style.right = '220px'; // 调整位置,使其位于目录左侧
  274. backToTopButton.style.backgroundColor = '#007bff';
  275. backToTopButton.style.color = '#fff';
  276. backToTopButton.style.width = '40px';
  277. backToTopButton.style.height = '40px';
  278. backToTopButton.style.borderRadius = '50%';
  279. backToTopButton.style.display = 'flex';
  280. backToTopButton.style.justifyContent = 'center';
  281. backToTopButton.style.alignItems = 'center';
  282. backToTopButton.style.cursor = 'pointer';
  283. backToTopButton.style.fontSize = '20px';
  284. backToTopButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
  285. backToTopButton.style.transition = 'opacity 0.3s';
  286. backToTopButton.style.opacity = '0';
  287. backToTopButton.style.zIndex = '1000';
  288.  
  289. backToTopButton.addEventListener('click', function() {
  290. window.scrollTo({top: 0, behavior: 'smooth'});
  291. });
  292.  
  293. window.addEventListener('scroll', function() {
  294. if (window.pageYOffset > 100) {
  295. backToTopButton.style.opacity = '1';
  296. } else {
  297. backToTopButton.style.opacity = '0';
  298. }
  299. });
  300.  
  301. return backToTopButton;
  302. }
  303.  
  304.  
  305. (function () {
  306. 'use strict';
  307.  
  308. var tocContainer = buildToc();
  309. document.body.appendChild(tocContainer);
  310.  
  311. var toggleButton = buildToggleButton();
  312. document.body.appendChild(toggleButton);
  313.  
  314. // 初始化按钮位置
  315. toggleButton.style.right = '220px';
  316.  
  317. var backToTopButton = buildBackToTopButton();
  318. document.body.appendChild(backToTopButton);
  319.  
  320. function updateTOC() {
  321. var existingContent = document.getElementById('floating-toc-ul') || document.getElementById('floating-toc-empty-message');
  322. if (existingContent) {
  323. existingContent.remove();
  324. }
  325.  
  326. var newContent = generateTOC();
  327. tocContainer.appendChild(newContent);
  328. }
  329.  
  330. // 使用防抖函数来限制更新频率
  331. function debounce(func, wait) {
  332. let timeout;
  333. return function executedFunction(...args) {
  334. const later = () => {
  335. clearTimeout(timeout);
  336. func(...args);
  337. };
  338. clearTimeout(timeout);
  339. timeout = setTimeout(later, wait);
  340. };
  341. }
  342.  
  343. // 防抖处理的更新函数
  344. const debouncedUpdateTOC = debounce(updateTOC, 300);
  345.  
  346. // 初始化目录
  347. updateTOC();
  348.  
  349. // 监听 URL 变化
  350. var lastUrl = location.href;
  351. new MutationObserver(() => {
  352. const url = location.href;
  353. if (url !== lastUrl) {
  354. lastUrl = url;
  355. setTimeout(updateTOC, 1000); // 延迟 1 秒更新目录,确保页面内容已加载
  356. }
  357. }).observe(document, {subtree: true, childList: true});
  358.  
  359. // 监听页面内容变化,包括编辑状态下的变化
  360. var contentObserver = new MutationObserver(function(mutations) {
  361. let shouldUpdate = false;
  362. mutations.forEach(function(mutation) {
  363. if (mutation.type === 'childList') {
  364. // 检查是否有新的标题元素被添加或删除
  365. mutation.addedNodes.forEach(function(node) {
  366. if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
  367. shouldUpdate = true;
  368. }
  369. });
  370. mutation.removedNodes.forEach(function(node) {
  371. if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
  372. shouldUpdate = true;
  373. }
  374. });
  375. } else if (mutation.type === 'characterData') {
  376. // 检查文本内容的变化
  377. let node = mutation.target.parentNode;
  378. while (node && node !== document.body) {
  379. if (/^H[1-6]$/i.test(node.tagName)) {
  380. shouldUpdate = true;
  381. break;
  382. }
  383. node = node.parentNode;
  384. }
  385. }
  386. });
  387.  
  388. if (shouldUpdate) {
  389. debouncedUpdateTOC();
  390. }
  391. });
  392.  
  393. contentObserver.observe(document.body, {
  394. childList: true,
  395. subtree: true,
  396. characterData: true
  397. });
  398.  
  399. })();