Confluence Floating TOC

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

当前为 2024-10-16 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Confluence Floating TOC
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.5
  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. if (header.tagName === 'H2' && header.closest('[class="atlaskit-portal-container"]')) {
  101. return;
  102. }
  103.  
  104. // 创建目录项
  105. var tocItem = document.createElement('li');
  106. tocItem.style.marginLeft = (parseInt(header.tagName[1]) - 1) * 10 + 'px'; // 根据标题级别缩进
  107.  
  108. // 创建链接
  109. var tocLink = document.createElement('a');
  110. tocLink.textContent = header.textContent;
  111.  
  112. // 使用标题作为 URL 片段
  113. tocLink.href = '#' + header.textContent.replace(/\s/g, '-');
  114. tocItem.appendChild(tocLink);
  115.  
  116. // 将目录项添加到目录列表中
  117. tocList.appendChild(tocItem);
  118. });
  119.  
  120. return tocList;
  121. }
  122.  
  123. function buildToggleButton() {
  124. var toggleButton = document.createElement('div');
  125. toggleButton.id = 'floating-toc-toggle';
  126. toggleButton.innerHTML = '&#9654;'; // 右箭头 Unicode 字符
  127. toggleButton.style.position = 'fixed';
  128. toggleButton.style.top = '200px';
  129. toggleButton.style.right = '0';
  130. toggleButton.style.backgroundColor = '#007bff';
  131. toggleButton.style.color = '#fff';
  132. toggleButton.style.width = '20px';
  133. toggleButton.style.height = '40px';
  134. toggleButton.style.display = 'flex';
  135. toggleButton.style.justifyContent = 'center';
  136. toggleButton.style.alignItems = 'center';
  137. toggleButton.style.cursor = 'pointer';
  138. toggleButton.style.userSelect = 'none';
  139. toggleButton.style.borderRadius = '5px 0 0 5px';
  140. toggleButton.style.zIndex = '1000';
  141. toggleButton.style.transition = 'all 0.3s ease-in-out';
  142. toggleButton.style.fontSize = '14px';
  143.  
  144. var isCollapsed = false;
  145. toggleButton.addEventListener('click', function () {
  146. var tocContainer = document.getElementById('floating-toc-container');
  147. if (isCollapsed) {
  148. tocContainer.style.right = '0';
  149. toggleButton.innerHTML = '&#9654;'; // 右箭头
  150. toggleButton.style.right = '220px'; // 调整按钮位置
  151. } else {
  152. tocContainer.style.right = '-220px'; // 完全隐藏目录
  153. toggleButton.innerHTML = '&#9664;'; // 左箭头
  154. toggleButton.style.right = '-10px';
  155. }
  156. isCollapsed = !isCollapsed;
  157. });
  158.  
  159. toggleButton.addEventListener('mouseenter', function() {
  160. if (isCollapsed) {
  161. toggleButton.style.right = '0';
  162. }
  163. });
  164.  
  165. toggleButton.addEventListener('mouseleave', function() {
  166. if (isCollapsed) {
  167. toggleButton.style.right = '-10px';
  168. }
  169. });
  170.  
  171. return toggleButton;
  172. }
  173.  
  174.  
  175. function buildToc() {
  176. var tocContainer = document.createElement('div');
  177. tocContainer.id = 'floating-toc-container';
  178. tocContainer.style.width = '220px'; // 增加宽度以包含padding
  179. tocContainer.style.backgroundColor = '#fff';
  180. tocContainer.style.border = '1px solid #ccc';
  181. tocContainer.style.padding = '10px';
  182. tocContainer.style.boxSizing = 'border-box'; // 确保padding包含在宽度内
  183. tocContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.1)';
  184. tocContainer.style.position = 'fixed';
  185. tocContainer.style.top = '200px';
  186. tocContainer.style.right = '0';
  187. tocContainer.style.maxHeight = 'calc(100vh - 300px)';
  188. tocContainer.style.overflowY = 'auto';
  189. tocContainer.style.transition = 'right 0.3s ease-in-out';
  190. tocContainer.style.zIndex = '999';
  191. tocContainer.style.scrollbarWidth = 'none';
  192. tocContainer.style.msOverflowStyle = 'none';
  193.  
  194. var style = document.createElement('style');
  195. style.textContent = `
  196. #floating-toc-container::-webkit-scrollbar {
  197. display: none;
  198. }
  199. `;
  200. document.head.appendChild(style);
  201.  
  202. var tocTitle = document.createElement('div');
  203. tocTitle.textContent = '目录';
  204. tocTitle.style.marginTop = '0';
  205. tocTitle.style.marginBottom = '10px';
  206. tocTitle.style.textAlign = 'center';
  207. tocTitle.style.fontSize = '16px';
  208. tocTitle.style.fontWeight = 'bold';
  209. tocContainer.appendChild(tocTitle);
  210.  
  211. return tocContainer;
  212. }
  213.  
  214. function generateTOC() {
  215. // var existingTOC = getExistingToc();
  216. // var toc;
  217.  
  218. // if (existingTOC) {
  219. // toc = genertateTOCFromExistingToc(existingTOC);
  220. // } else {
  221. // toc = generateTOCFormPage();
  222. // }
  223. var toc = generateTOCFormPage();
  224.  
  225. if (!toc || toc.children.length === 0) {
  226. var emptyMessage = document.createElement('div');
  227. emptyMessage.id = 'floating-toc-empty-message';
  228. emptyMessage.textContent = '当前页面没有可用的目录内容';
  229. emptyMessage.style.color = '#666';
  230. emptyMessage.style.fontStyle = 'italic';
  231. emptyMessage.style.textAlign = 'center';
  232. emptyMessage.style.padding = '20px 0';
  233. return emptyMessage;
  234. }
  235.  
  236. toc.style.listStyle = 'none';
  237. toc.style.padding = '0';
  238. toc.style.margin = '0';
  239.  
  240. // 优化目录列表样式
  241. var listItems = toc.querySelectorAll('li');
  242. listItems.forEach(function(item, index) {
  243. item.style.marginBottom = '5px';
  244. var link = item.querySelector('a');
  245. if (link) {
  246. link.style.textDecoration = 'none';
  247. link.style.color = '#333';
  248. link.style.display = 'block';
  249. link.style.padding = '3px 5px';
  250. link.style.borderRadius = '3px';
  251. link.style.transition = 'background-color 0.2s';
  252. link.style.whiteSpace = 'nowrap';
  253. link.style.overflow = 'hidden';
  254. link.style.textOverflow = 'ellipsis';
  255. link.style.maxWidth = '180px'; // 减小最大宽度
  256.  
  257. // 设置标题完整内容为title属性
  258. link.title = link.textContent;
  259.  
  260. // 截断长标题
  261. if (link.textContent.length > 25) {
  262. link.textContent = link.textContent.substring(0, 22) + '...';
  263. }
  264.  
  265. link.addEventListener('mouseover', function() {
  266. this.style.backgroundColor = '#f0f0f0';
  267. });
  268. link.addEventListener('mouseout', function() {
  269. this.style.backgroundColor = 'transparent';
  270. });
  271.  
  272. // 优化缩进
  273. var level = parseInt(item.style.marginLeft) / 10;
  274. item.style.paddingLeft = (level * 15) + 'px'; // 使用 padding 代替 margin
  275. item.style.marginLeft = '0'; // 移除左边距
  276.  
  277. // 为第三级及以下的标题添加折叠功能
  278. if (level > 2) {
  279. item.style.display = 'none';
  280. var parentLi = item.parentElement.closest('li');
  281. if (parentLi && !parentLi.classList.contains('has-submenu')) {
  282. parentLi.classList.add('has-submenu');
  283. var toggleBtn = document.createElement('span');
  284. toggleBtn.textContent = '▶';
  285. toggleBtn.style.cursor = 'pointer';
  286. toggleBtn.style.marginRight = '5px';
  287. toggleBtn.style.fontSize = '10px'; // 减小箭头大小
  288. parentLi.insertBefore(toggleBtn, parentLi.firstChild);
  289.  
  290. toggleBtn.addEventListener('click', function(e) {
  291. e.stopPropagation(); // 防止点击事件冒泡
  292. var subItems = this.parentElement.querySelectorAll('li');
  293. subItems.forEach(function(subItem) {
  294. subItem.style.display = subItem.style.display === 'none' ? 'block' : 'none';
  295. });
  296. this.textContent = this.textContent === '▶' ? '▼' : '▶';
  297. });
  298. }
  299. }
  300. }
  301. });
  302.  
  303. // 添加平滑滚动
  304. toc.style.scrollBehavior = 'smooth';
  305.  
  306. return toc;
  307. }
  308.  
  309. function updateMaxHeight(tocContainer) {
  310. const viewportHeight = window.innerHeight;
  311. const topOffset = parseFloat(tocContainer.style.top);
  312. tocContainer.style.maxHeight = (viewportHeight - topOffset - 20) + 'px'; // 20px 为一些额外的间距
  313. }
  314.  
  315.  
  316. function buildBackToTopButton() {
  317. var backToTopButton = document.createElement('div');
  318. backToTopButton.id = 'back-to-top-button';
  319. backToTopButton.innerHTML = '&#9650;'; // 上箭头 Unicode 字符
  320. backToTopButton.style.position = 'fixed';
  321. backToTopButton.style.bottom = '30px';
  322. backToTopButton.style.right = '220px'; // 调整位置,使其位于目录左侧
  323. backToTopButton.style.backgroundColor = '#007bff';
  324. backToTopButton.style.color = '#fff';
  325. backToTopButton.style.width = '40px';
  326. backToTopButton.style.height = '40px';
  327. backToTopButton.style.borderRadius = '50%';
  328. backToTopButton.style.display = 'flex';
  329. backToTopButton.style.justifyContent = 'center';
  330. backToTopButton.style.alignItems = 'center';
  331. backToTopButton.style.cursor = 'pointer';
  332. backToTopButton.style.fontSize = '20px';
  333. backToTopButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
  334. backToTopButton.style.transition = 'opacity 0.3s';
  335. backToTopButton.style.opacity = '0';
  336. backToTopButton.style.zIndex = '1000';
  337.  
  338. backToTopButton.addEventListener('click', function() {
  339. window.scrollTo({top: 0, behavior: 'smooth'});
  340. });
  341.  
  342. window.addEventListener('scroll', function() {
  343. if (window.pageYOffset > 100) {
  344. backToTopButton.style.opacity = '1';
  345. } else {
  346. backToTopButton.style.opacity = '0';
  347. }
  348. });
  349.  
  350. return backToTopButton;
  351. }
  352.  
  353.  
  354. (function () {
  355. 'use strict';
  356.  
  357. var tocContainer = buildToc();
  358. document.body.appendChild(tocContainer);
  359.  
  360. var toggleButton = buildToggleButton();
  361. document.body.appendChild(toggleButton);
  362.  
  363. // 初始化按钮位置
  364. toggleButton.style.right = '220px';
  365.  
  366. var backToTopButton = buildBackToTopButton();
  367. document.body.appendChild(backToTopButton);
  368.  
  369. function updateTOC() {
  370. var existingContent = document.getElementById('floating-toc-ul') || document.getElementById('floating-toc-empty-message');
  371. if (existingContent) {
  372. existingContent.remove();
  373. }
  374.  
  375. var newContent = generateTOC();
  376. tocContainer.appendChild(newContent);
  377. }
  378.  
  379. // 使用防抖函数来限制更新频率
  380. function debounce(func, wait) {
  381. let timeout;
  382. return function executedFunction(...args) {
  383. const later = () => {
  384. clearTimeout(timeout);
  385. func(...args);
  386. };
  387. clearTimeout(timeout);
  388. timeout = setTimeout(later, wait);
  389. };
  390. }
  391.  
  392. // 防抖处理的更新函数
  393. const debouncedUpdateTOC = debounce(updateTOC, 300);
  394.  
  395. // 初始化目录
  396. updateTOC();
  397.  
  398. // 监听 URL 变化
  399. var lastUrl = location.href;
  400. new MutationObserver(() => {
  401. const url = location.href;
  402. if (url !== lastUrl) {
  403. lastUrl = url;
  404. setTimeout(updateTOC, 1000); // 延迟 1 秒更新目录,确保页面内容已加载
  405. }
  406. }).observe(document, {subtree: true, childList: true});
  407.  
  408. // 监听页面内容变化,包括编辑状态下的变化
  409. var contentObserver = new MutationObserver(function(mutations) {
  410. let shouldUpdate = false;
  411. mutations.forEach(function(mutation) {
  412. if (mutation.type === 'childList') {
  413. // 检查是否有新的标题元素被添加或删除
  414. mutation.addedNodes.forEach(function(node) {
  415. if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
  416. shouldUpdate = true;
  417. }
  418. });
  419. mutation.removedNodes.forEach(function(node) {
  420. if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
  421. shouldUpdate = true;
  422. }
  423. });
  424. } else if (mutation.type === 'characterData') {
  425. // 检查文本内容的变化
  426. let node = mutation.target.parentNode;
  427. while (node && node !== document.body) {
  428. if (/^H[1-6]$/i.test(node.tagName)) {
  429. shouldUpdate = true;
  430. break;
  431. }
  432. node = node.parentNode;
  433. }
  434. }
  435. });
  436.  
  437. if (shouldUpdate) {
  438. debouncedUpdateTOC();
  439. }
  440. });
  441.  
  442. contentObserver.observe(document.body, {
  443. childList: true,
  444. subtree: true,
  445. characterData: true
  446. });
  447.  
  448. // ... 其他现有代码 ...
  449. })();