Confluence Floating TOC

在 Confluence 文章页面上浮动展示文章目录,并支持展开和折叠功能,自动适应暗色/亮色模式

  1. // ==UserScript==
  2. // @name Confluence Floating TOC
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.6
  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. // 检查当前颜色模式
  125. const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  126. (document.documentElement.getAttribute('data-color-mode') === 'light' &&
  127. document.documentElement.getAttribute('data-theme') &&
  128. document.documentElement.getAttribute('data-theme').includes('light:dark'));
  129. var toggleButton = document.createElement('div');
  130. toggleButton.id = 'floating-toc-toggle';
  131. toggleButton.innerHTML = '&#9654;'; // 右箭头 Unicode 字符
  132. toggleButton.style.position = 'fixed';
  133. toggleButton.style.top = '200px';
  134. toggleButton.style.right = '0';
  135. toggleButton.style.backgroundColor = isDarkMode ? '#4688ec' : '#007bff';
  136. toggleButton.style.color = '#fff';
  137. toggleButton.style.width = '20px';
  138. toggleButton.style.height = '40px';
  139. toggleButton.style.display = 'flex';
  140. toggleButton.style.justifyContent = 'center';
  141. toggleButton.style.alignItems = 'center';
  142. toggleButton.style.cursor = 'pointer';
  143. toggleButton.style.userSelect = 'none';
  144. toggleButton.style.borderRadius = '5px 0 0 5px';
  145. toggleButton.style.zIndex = '1000';
  146. toggleButton.style.transition = 'all 0.3s ease-in-out';
  147. toggleButton.style.fontSize = '14px';
  148.  
  149. var isCollapsed = false;
  150. toggleButton.addEventListener('click', function () {
  151. var tocContainer = document.getElementById('floating-toc-container');
  152. if (isCollapsed) {
  153. tocContainer.style.right = '0';
  154. toggleButton.innerHTML = '&#9654;'; // 右箭头
  155. toggleButton.style.right = '220px'; // 调整按钮位置
  156. } else {
  157. tocContainer.style.right = '-220px'; // 完全隐藏目录
  158. toggleButton.innerHTML = '&#9664;'; // 左箭头
  159. toggleButton.style.right = '-10px';
  160. }
  161. isCollapsed = !isCollapsed;
  162. });
  163.  
  164. toggleButton.addEventListener('mouseenter', function() {
  165. if (isCollapsed) {
  166. toggleButton.style.right = '0';
  167. }
  168. });
  169.  
  170. toggleButton.addEventListener('mouseleave', function() {
  171. if (isCollapsed) {
  172. toggleButton.style.right = '-10px';
  173. }
  174. });
  175.  
  176. return toggleButton;
  177. }
  178.  
  179.  
  180. function buildToc() {
  181. // 检查当前颜色模式
  182. const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  183. (document.documentElement.getAttribute('data-color-mode') === 'light' &&
  184. document.documentElement.getAttribute('data-theme') &&
  185. document.documentElement.getAttribute('data-theme').includes('light:dark'));
  186. var tocContainer = document.createElement('div');
  187. tocContainer.id = 'floating-toc-container';
  188. tocContainer.style.width = '220px'; // 增加宽度以包含padding
  189. tocContainer.style.backgroundColor = isDarkMode ? '#2c2c2e' : '#fff';
  190. tocContainer.style.border = isDarkMode ? '1px solid #444' : '1px solid #ccc';
  191. tocContainer.style.padding = '10px';
  192. tocContainer.style.boxSizing = 'border-box'; // 确保padding包含在宽度内
  193. tocContainer.style.boxShadow = isDarkMode ? '0 0 10px rgba(0,0,0,0.3)' : '0 0 10px rgba(0,0,0,0.1)';
  194. tocContainer.style.position = 'fixed';
  195. tocContainer.style.top = '200px';
  196. tocContainer.style.right = '0';
  197. tocContainer.style.maxHeight = 'calc(100vh - 300px)';
  198. tocContainer.style.overflowY = 'auto';
  199. tocContainer.style.transition = 'right 0.3s ease-in-out';
  200. tocContainer.style.zIndex = '999';
  201. tocContainer.style.scrollbarWidth = 'none';
  202. tocContainer.style.msOverflowStyle = 'none';
  203.  
  204. var style = document.createElement('style');
  205. style.textContent = `
  206. #floating-toc-container::-webkit-scrollbar {
  207. display: none;
  208. }
  209. `;
  210. document.head.appendChild(style);
  211.  
  212. var tocTitle = document.createElement('div');
  213. tocTitle.textContent = '目录';
  214. tocTitle.style.marginTop = '0';
  215. tocTitle.style.marginBottom = '10px';
  216. tocTitle.style.textAlign = 'center';
  217. tocTitle.style.fontSize = '16px';
  218. tocTitle.style.fontWeight = 'bold';
  219. tocTitle.style.color = isDarkMode ? '#bfc1c4' : '#000';
  220. tocContainer.appendChild(tocTitle);
  221.  
  222. return tocContainer;
  223. }
  224.  
  225. function generateTOC() {
  226. // var existingTOC = getExistingToc();
  227. // var toc;
  228.  
  229. // if (existingTOC) {
  230. // toc = genertateTOCFromExistingToc(existingTOC);
  231. // } else {
  232. // toc = generateTOCFormPage();
  233. // }
  234. var toc = generateTOCFormPage();
  235.  
  236. if (!toc || toc.children.length === 0) {
  237. var emptyMessage = document.createElement('div');
  238. emptyMessage.id = 'floating-toc-empty-message';
  239. emptyMessage.textContent = '当前页面没有可用的目录内容';
  240. emptyMessage.style.color = '#666';
  241. emptyMessage.style.fontStyle = 'italic';
  242. emptyMessage.style.textAlign = 'center';
  243. emptyMessage.style.padding = '20px 0';
  244. return emptyMessage;
  245. }
  246.  
  247. toc.style.listStyle = 'none';
  248. toc.style.padding = '0';
  249. toc.style.margin = '0';
  250.  
  251. // 检查当前颜色模式
  252. const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  253. (document.documentElement.getAttribute('data-color-mode') === 'light' &&
  254. document.documentElement.getAttribute('data-theme') &&
  255. document.documentElement.getAttribute('data-theme').includes('light:dark'));
  256.  
  257. // 优化目录列表样式
  258. var listItems = toc.querySelectorAll('li');
  259. listItems.forEach(function(item, index) {
  260. item.style.marginBottom = '5px';
  261. var link = item.querySelector('a');
  262. if (link) {
  263. link.style.textDecoration = 'none';
  264. link.style.color = isDarkMode ? '#a9abaf' : '#333';
  265. link.style.display = 'block';
  266. link.style.padding = '3px 5px';
  267. link.style.borderRadius = '3px';
  268. link.style.transition = 'background-color 0.2s';
  269. link.style.whiteSpace = 'nowrap';
  270. link.style.overflow = 'hidden';
  271. link.style.textOverflow = 'ellipsis';
  272. link.style.maxWidth = '180px'; // 减小最大宽度
  273.  
  274. // 设置标题完整内容为title属性
  275. link.title = link.textContent;
  276.  
  277. // 截断长标题
  278. if (link.textContent.length > 25) {
  279. link.textContent = link.textContent.substring(0, 22) + '...';
  280. }
  281.  
  282. // 根据颜色模式设置悬停效果
  283. const hoverBgColor = isDarkMode ? '#3a3a3c' : '#f0f0f0';
  284. link.addEventListener('mouseover', function() {
  285. this.style.backgroundColor = hoverBgColor;
  286. });
  287. link.addEventListener('mouseout', function() {
  288. this.style.backgroundColor = 'transparent';
  289. });
  290.  
  291. // 优化缩进
  292. var level = parseInt(item.style.marginLeft) / 10;
  293. item.style.paddingLeft = (level * 15) + 'px'; // 使用 padding 代替 margin
  294. item.style.marginLeft = '0'; // 移除左边距
  295.  
  296. // 为第三级及以下的标题添加折叠功能
  297. if (level > 2) {
  298. item.style.display = 'none';
  299. var parentLi = item.parentElement.closest('li');
  300. if (parentLi && !parentLi.classList.contains('has-submenu')) {
  301. parentLi.classList.add('has-submenu');
  302. var toggleBtn = document.createElement('span');
  303. toggleBtn.textContent = '▶';
  304. toggleBtn.style.cursor = 'pointer';
  305. toggleBtn.style.marginRight = '5px';
  306. toggleBtn.style.fontSize = '10px'; // 减小箭头大小
  307. toggleBtn.style.color = isDarkMode ? '#a9abaf' : '#333'; // 根据颜色模式设置颜色
  308. parentLi.insertBefore(toggleBtn, parentLi.firstChild);
  309.  
  310. toggleBtn.addEventListener('click', function(e) {
  311. e.stopPropagation(); // 防止点击事件冒泡
  312. var subItems = this.parentElement.querySelectorAll('li');
  313. subItems.forEach(function(subItem) {
  314. subItem.style.display = subItem.style.display === 'none' ? 'block' : 'none';
  315. });
  316. this.textContent = this.textContent === '▶' ? '▼' : '▶';
  317. });
  318. }
  319. }
  320. }
  321. });
  322.  
  323. // 添加平滑滚动
  324. toc.style.scrollBehavior = 'smooth';
  325.  
  326. return toc;
  327. }
  328.  
  329. function updateMaxHeight(tocContainer) {
  330. const viewportHeight = window.innerHeight;
  331. const topOffset = parseFloat(tocContainer.style.top);
  332. tocContainer.style.maxHeight = (viewportHeight - topOffset - 20) + 'px'; // 20px 为一些额外的间距
  333. }
  334.  
  335. // 检测 Confluence 页面的颜色模式并相应地调整插件样式
  336. function detectColorModeAndApplyStyles() {
  337. // 检查 HTML 元素的 data-color-mode 属性
  338. const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  339. (document.documentElement.getAttribute('data-color-mode') === 'light' &&
  340. document.documentElement.getAttribute('data-theme') &&
  341. document.documentElement.getAttribute('data-theme').includes('light:dark'));
  342. // 获取插件元素
  343. const tocContainer = document.getElementById('floating-toc-container');
  344. const toggleButton = document.getElementById('floating-toc-toggle');
  345. const backToTopButton = document.getElementById('back-to-top-button');
  346. if (isDarkMode) {
  347. // 暗色模式样式
  348. if (tocContainer) {
  349. tocContainer.style.backgroundColor = '#2c2c2e'; // 深色背景
  350. tocContainer.style.border = '1px solid #444';
  351. tocContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.3)';
  352. // 修改目录标题和链接颜色
  353. const tocTitle = tocContainer.querySelector('div');
  354. if (tocTitle) {
  355. tocTitle.style.color = '#bfc1c4'; // 使用 --ds-text 变量值
  356. }
  357. // 修改所有链接颜色
  358. const links = tocContainer.querySelectorAll('a');
  359. links.forEach(link => {
  360. link.style.color = '#a9abaf'; // 使用 --ds-text-subtle 变量值
  361. // 修改鼠标悬停效果
  362. link.addEventListener('mouseover', function() {
  363. this.style.backgroundColor = '#3a3a3c';
  364. });
  365. link.addEventListener('mouseout', function() {
  366. this.style.backgroundColor = 'transparent';
  367. });
  368. });
  369. }
  370. if (toggleButton) {
  371. toggleButton.style.backgroundColor = '#4688ec'; // 使用 --ds-icon-accent-blue 变量值
  372. }
  373. if (backToTopButton) {
  374. backToTopButton.style.backgroundColor = '#4688ec'; // 使用 --ds-icon-accent-blue 变量值
  375. }
  376. } else {
  377. // 亮色模式样式(恢复默认)
  378. if (tocContainer) {
  379. tocContainer.style.backgroundColor = '#fff';
  380. tocContainer.style.border = '1px solid #ccc';
  381. tocContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.1)';
  382. // 恢复目录标题颜色
  383. const tocTitle = tocContainer.querySelector('div');
  384. if (tocTitle) {
  385. tocTitle.style.color = '#000';
  386. }
  387. // 恢复所有链接颜色
  388. const links = tocContainer.querySelectorAll('a');
  389. links.forEach(link => {
  390. link.style.color = '#333';
  391. // 恢复鼠标悬停效果
  392. link.addEventListener('mouseover', function() {
  393. this.style.backgroundColor = '#f0f0f0';
  394. });
  395. link.addEventListener('mouseout', function() {
  396. this.style.backgroundColor = 'transparent';
  397. });
  398. });
  399. }
  400. if (toggleButton) {
  401. toggleButton.style.backgroundColor = '#007bff';
  402. }
  403. if (backToTopButton) {
  404. backToTopButton.style.backgroundColor = '#007bff';
  405. }
  406. }
  407. }
  408.  
  409. function buildBackToTopButton() {
  410. // 检查当前颜色模式
  411. const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  412. (document.documentElement.getAttribute('data-color-mode') === 'light' &&
  413. document.documentElement.getAttribute('data-theme') &&
  414. document.documentElement.getAttribute('data-theme').includes('light:dark'));
  415. var backToTopButton = document.createElement('div');
  416. backToTopButton.id = 'back-to-top-button';
  417. backToTopButton.innerHTML = '&#9650;'; // 上箭头 Unicode 字符
  418. backToTopButton.style.position = 'fixed';
  419. backToTopButton.style.bottom = '30px';
  420. backToTopButton.style.right = '220px'; // 调整位置,使其位于目录左侧
  421. backToTopButton.style.backgroundColor = isDarkMode ? '#4688ec' : '#007bff';
  422. backToTopButton.style.color = '#fff';
  423. backToTopButton.style.width = '40px';
  424. backToTopButton.style.height = '40px';
  425. backToTopButton.style.borderRadius = '50%';
  426. backToTopButton.style.display = 'flex';
  427. backToTopButton.style.justifyContent = 'center';
  428. backToTopButton.style.alignItems = 'center';
  429. backToTopButton.style.cursor = 'pointer';
  430. backToTopButton.style.fontSize = '20px';
  431. backToTopButton.style.boxShadow = isDarkMode ? '0 2px 5px rgba(0,0,0,0.3)' : '0 2px 5px rgba(0,0,0,0.2)';
  432. backToTopButton.style.transition = 'opacity 0.3s';
  433. backToTopButton.style.opacity = '0';
  434. backToTopButton.style.zIndex = '1000';
  435.  
  436. backToTopButton.addEventListener('click', function() {
  437. window.scrollTo({top: 0, behavior: 'smooth'});
  438. });
  439.  
  440. window.addEventListener('scroll', function() {
  441. if (window.pageYOffset > 100) {
  442. backToTopButton.style.opacity = '1';
  443. } else {
  444. backToTopButton.style.opacity = '0';
  445. }
  446. });
  447.  
  448. return backToTopButton;
  449. }
  450.  
  451.  
  452. (function () {
  453. 'use strict';
  454.  
  455. var tocContainer = buildToc();
  456. document.body.appendChild(tocContainer);
  457.  
  458. var toggleButton = buildToggleButton();
  459. document.body.appendChild(toggleButton);
  460.  
  461. // 初始化按钮位置
  462. toggleButton.style.right = '220px';
  463.  
  464. var backToTopButton = buildBackToTopButton();
  465. document.body.appendChild(backToTopButton);
  466.  
  467. function updateTOC() {
  468. var existingContent = document.getElementById('floating-toc-ul') || document.getElementById('floating-toc-empty-message');
  469. if (existingContent) {
  470. existingContent.remove();
  471. }
  472.  
  473. var newContent = generateTOC();
  474. tocContainer.appendChild(newContent);
  475. }
  476.  
  477. // 使用防抖函数来限制更新频率
  478. function debounce(func, wait) {
  479. let timeout;
  480. return function executedFunction(...args) {
  481. const later = () => {
  482. clearTimeout(timeout);
  483. func(...args);
  484. };
  485. clearTimeout(timeout);
  486. timeout = setTimeout(later, wait);
  487. };
  488. }
  489.  
  490. // 防抖处理的更新函数
  491. const debouncedUpdateTOC = debounce(updateTOC, 300);
  492.  
  493. // 初始化目录
  494. updateTOC();
  495.  
  496. // 监听 URL 变化
  497. var lastUrl = location.href;
  498. new MutationObserver(() => {
  499. const url = location.href;
  500. if (url !== lastUrl) {
  501. lastUrl = url;
  502. setTimeout(updateTOC, 1000); // 延迟 1 秒更新目录,确保页面内容已加载
  503. }
  504. }).observe(document, {subtree: true, childList: true});
  505.  
  506. // 监听页面内容变化,包括编辑状态下的变化
  507. var contentObserver = new MutationObserver(function(mutations) {
  508. let shouldUpdate = false;
  509. mutations.forEach(function(mutation) {
  510. if (mutation.type === 'childList') {
  511. // 检查是否有新的标题元素被添加或删除
  512. mutation.addedNodes.forEach(function(node) {
  513. if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
  514. shouldUpdate = true;
  515. }
  516. });
  517. mutation.removedNodes.forEach(function(node) {
  518. if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
  519. shouldUpdate = true;
  520. }
  521. });
  522. } else if (mutation.type === 'characterData') {
  523. // 检查文本内容的变化
  524. let node = mutation.target.parentNode;
  525. while (node && node !== document.body) {
  526. if (/^H[1-6]$/i.test(node.tagName)) {
  527. shouldUpdate = true;
  528. break;
  529. }
  530. node = node.parentNode;
  531. }
  532. }
  533. });
  534.  
  535. if (shouldUpdate) {
  536. debouncedUpdateTOC();
  537. }
  538. });
  539.  
  540. contentObserver.observe(document.body, {
  541. childList: true,
  542. subtree: true,
  543. characterData: true
  544. });
  545.  
  546. // 检测颜色模式并应用样式
  547. detectColorModeAndApplyStyles();
  548.  
  549. // 监听颜色模式变化
  550. const colorModeObserver = new MutationObserver(function(mutations) {
  551. mutations.forEach(function(mutation) {
  552. if (mutation.type === 'attributes' &&
  553. (mutation.attributeName === 'data-color-mode' || mutation.attributeName === 'data-theme')) {
  554. detectColorModeAndApplyStyles();
  555. }
  556. });
  557. });
  558.  
  559. colorModeObserver.observe(document.documentElement, {
  560. attributes: true,
  561. attributeFilter: ['data-color-mode', 'data-theme']
  562. });
  563.  
  564. // ... 其他现有代码 ...
  565. })();