文章目录高亮 + 自动折叠展开
2023年12月6日 2024年2月13日
思路
- 目前标题id唯一. 由于需要数组存放标题高亮状态, 仍旧为标题进行编号, 使用编号读取数组值
- 文章目录标题项和正文标题项均使用li元素定位
- 出现在页面的所有标题高亮
框架
获取文章目录标题数和正文标题数, 判等
1const toc = document.querySelector('.toc-panel'); 2if (!toc) 3{ 4 console.log("toc is null", toc); 5 return; 6} 7 8const headings = Array.apply(null, document.querySelectorAll('h2[id], h3[id], h4[id]')) 9 .filter(function(value, index, arr) { return arr[index].querySelector('.anchor'); }); 10const tocHeadings = toc.querySelectorAll('li'); 11 12if (tocHeadings.length !== headings.length) 13{ 14 console.log(headings); 15 console.log(tocHeadings); 16 return; 17}
为文章目录标题和正文标题添加关联编号, 创建标题高亮数组
1function addHeadingIdx(list) 2{ 3 let i = 0; 4 list.forEach((item) => { item.setAttribute('headingIdx', i++); }); 5} 6 7let HeadingFlag; 8 9addHeadingIdx(tocHeadings); 10addHeadingIdx(headings); 11 12HeadingFlag = new Array(headings.length).fill(false);
创建观察器, 当标题进入/离开窗口时触发处理
1const intersectionOptions = {threshold : 1.0}; 2const headingObserver = new IntersectionObserver(headings => { unfold_headings(headings); }, intersectionOptions);
观察正文标题
1headings.forEach((heading) => { headingObserver.observe(heading); });
完整代码
1let HeadingFlag; 2 3function addHeadingIdx(list) 4{ 5 let i = 0; 6 list.forEach((item) => { item.setAttribute('headingIdx', i++); }); 7} 8 9document.addEventListener('DOMContentLoaded', () => { 10 const toc = document.querySelector('.toc-panel'); 11 if (!toc) 12 { 13 console.log("toc is null", toc); 14 return; 15 } 16 17 const headings = Array.apply(null, document.querySelectorAll('h2[id], h3[id], h4[id]')) 18 .filter(function(value, index, arr) { return arr[index].querySelector('.anchor'); }); 19 const tocHeadings = toc.querySelectorAll('li'); 20 21 if (tocHeadings.length !== headings.length) 22 { 23 console.log(headings); 24 console.log(tocHeadings); 25 return; 26 } 27 28 addHeadingIdx(tocHeadings); 29 addHeadingIdx(headings); 30 31 HeadingFlag = new Array(headings.length).fill(false); 32 33 const intersectionOptions = {threshold : 1.0}; 34 const headingObserver = new IntersectionObserver(headings => { unfold_headings(headings); }, intersectionOptions); 35 36 headings.forEach((heading) => { headingObserver.observe(heading); }); 37});
文章目录结构
1<div> 2 <ul> 3 <li><a href="#"><span></span>heading 1</a></li> 4 <li> 5 <a href="#"><span></span>heading 2</a> 6 <button></button> 7 <div> 8 <ul> 9 <li><a href="#"><span></span>heading 2 - 1</a></li> 10 <li><a href="#"><span></span>heading 2 - 2</a> 11 </li> 12 </ul> 13 </div> 14 </li> 15 <li><a href="#><span></span>heading 3</a></li> 16 </ul> 17</div>
高亮和折叠标题逻辑
更新标题高亮状态, 记录最后一个移除高亮标题编号
1let last; 2// console.log(headings.length); 3headings.forEach(heading => { 4 // console.log('ratio', heading.target.getAttribute('id'), heading.intersectionRatio, heading.isIntersecting, HeadingCnt); 5 const idx = heading.target.getAttribute('headingIdx'); 6 HeadingFlag[idx] = heading.isIntersecting; 7 if (!heading.isIntersecting) 8 { 9 last = parseInt(idx); 10 } 11});
计算当前高亮标题个数: 如果为0, 保留最后一个移除高亮标题的高亮状态
1function get_highlight_num() 2{ 3 let cnt = 0; 4 for (let i = 0; i < HeadingFlag.length; ++i) 5 { 6 if (HeadingFlag[i]) 7 { 8 ++cnt; 9 } 10 } 11 return cnt; 12} 13 14let cnt = get_highlight_num(); 15if (cnt) 16{ 17 last = -1; 18}
设置标题高亮
- 如果最后一个移除高亮标题编号不为-1, 保留其高亮状态
- 根据标题高亮状态设置标题
- 之前的高亮状态存在暂时不移除高亮的情况: 这一次遍历的例外情况已改变
1function refresh_highlight(last) 2{ 3 for (let i = 0; i < HeadingFlag.length; ++i) 4 { 5 if (i === last) 6 { 7 continue; 8 } 9 10 if (HeadingFlag[i]) 11 { 12 document.querySelector(`.toc-panel li[headingIdx="${i}"]`).childNodes[0].classList.add('active'); 13 } 14 else 15 { 16 document.querySelector(`.toc-panel li[headingIdx="${i}"]`).childNodes[0].classList.remove('active'); 17 } 18 } 19}
设置标题折叠/展开逻辑
- 遍历标题
1function refresh_fold(last) 2{ 3 // console.log(last); 4 for (let i = 0; i < HeadingFlag.length; ++i) 5 { 6 const cur_toc_item = document.querySelector(`.toc-panel li[headingIdx="${i}"]`); 7 8 // 处理 9 } 10}
- 如果最后一个移除高亮标题编号不为-1, 保留其展开, 或双亲标题展开
- 若标题高亮, 展开; 否则折叠
可以展开/折叠的标题其子元素个数不为0
1// 展开/关闭子节点 2if (cur_toc_item.childElementCount !== 1) 3{ 4 const toc_button = cur_toc_item.childNodes[1]; 5 const toc_div = cur_toc_item.childNodes[2]; 6 7 if (HeadingFlag[i] || i === last) 8 { 9 toc_button.setAttribute('aria-expanded', 'true'); 10 toc_div.classList.add('show'); 11 } 12 else 13 { 14 toc_button.setAttribute('aria-expanded', 'false'); 15 toc_div.classList.remove('show'); 16 } 17}
- 若标题高亮, 展开双亲标题
拥有子标题的节点也可以有双亲节点
1if (HeadingFlag[i] || i === last) // 展开双亲节点 2{ 3 // if (i === last) console.log(1, i); 4 // console.log(cur_toc_item); 5 // console.log(cur_toc_item.parentElement.parentElement.previousElementSibling.tagName); 6 if (cur_toc_item && cur_toc_item.parentElement && cur_toc_item.parentElement.parentElement && 7 cur_toc_item.parentElement.parentElement.previousElementSibling && 8 cur_toc_item.parentElement.parentElement.previousElementSibling.tagName == "BUTTON") 9 { 10 11 // console.log(cur_toc_item.parentElement.parentElement); 12 // console.log(cur_toc_item.parentElement.parentElement.previousElementSibling); 13 const toc_div = cur_toc_item.parentElement.parentElement; 14 const toc_button = toc_div.previousElementSibling; 15 16 toc_div.classList.add('show'); 17 toc_button.setAttribute('aria-expanded', 'true'); 18 19 let sub_item = toc_div.parentElement; 20 21 while (sub_item && sub_item.parentElement && sub_item.parentElement.parentElement && 22 sub_item.parentElement.parentElement.previousElementSibling && 23 sub_item.parentElement.parentElement.previousElementSibling.tagName == "BUTTON") 24 { 25 // console.log(sub_item.parentElement.parentElement); 26 // console.log(sub_item.parentElement.parentElement.previousElementSibling.childNodes[2]); 27 28 const toc_div_sub = sub_item.parentElement.parentElement; 29 const toc_button_sub = toc_div_sub.previousElementSibling; 30 31 toc_div_sub.classList.add('show'); 32 toc_button_sub.setAttribute('aria-expanded', 'true'); 33 34 sub_item = toc_div_sub.parentElement; 35 } 36 } 37}
完整代码
1function refresh_highlight(last) 2{ 3 for (let i = 0; i < HeadingFlag.length; ++i) 4 { 5 if (i === last) 6 { 7 continue; 8 } 9 10 if (HeadingFlag[i]) 11 { 12 document.querySelector(`.toc-panel li[headingIdx="${i}"]`).childNodes[0].classList.add('active'); 13 } 14 else 15 { 16 document.querySelector(`.toc-panel li[headingIdx="${i}"]`).childNodes[0].classList.remove('active'); 17 } 18 } 19} 20 21function refresh_fold(last) 22{ 23 // console.log(last); 24 for (let i = 0; i < HeadingFlag.length; ++i) 25 { 26 const cur_toc_item = document.querySelector(`.toc-panel li[headingIdx="${i}"]`); 27 28 // console.log(cur_toc_item); 29 // console.log(cur_toc_item.childElementCount); 30 31 // 展开/关闭子节点 32 if (cur_toc_item.childElementCount !== 1) 33 { 34 // console.log(cur_toc_item.childNodes[1]); 35 36 const toc_button = cur_toc_item.childNodes[1]; 37 const toc_div = cur_toc_item.childNodes[2]; 38 39 if (HeadingFlag[i] || i === last) 40 { 41 // if (i === last) console.log(2, i); 42 toc_button.setAttribute('aria-expanded', 'true'); 43 toc_div.classList.add('show'); 44 } 45 else 46 { 47 toc_button.setAttribute('aria-expanded', 'false'); 48 toc_div.classList.remove('show'); 49 } 50 } 51 52 if (HeadingFlag[i] || i === last) // 展开双亲节点 53 { 54 // if (i === last) console.log(1, i); 55 // console.log(cur_toc_item); 56 // console.log(cur_toc_item.parentElement.parentElement.previousElementSibling.tagName); 57 if (cur_toc_item && cur_toc_item.parentElement && cur_toc_item.parentElement.parentElement && 58 cur_toc_item.parentElement.parentElement.previousElementSibling && 59 cur_toc_item.parentElement.parentElement.previousElementSibling.tagName == "BUTTON") 60 { 61 62 // console.log(cur_toc_item.parentElement.parentElement); 63 // console.log(cur_toc_item.parentElement.parentElement.previousElementSibling); 64 const toc_div = cur_toc_item.parentElement.parentElement; 65 const toc_button = toc_div.previousElementSibling; 66 67 toc_div.classList.add('show'); 68 toc_button.setAttribute('aria-expanded', 'true'); 69 70 let sub_item = toc_div.parentElement; 71 72 while (sub_item && sub_item.parentElement && sub_item.parentElement.parentElement && 73 sub_item.parentElement.parentElement.previousElementSibling && 74 sub_item.parentElement.parentElement.previousElementSibling.tagName == "BUTTON") 75 { 76 // console.log(sub_item.parentElement.parentElement); 77 // console.log(sub_item.parentElement.parentElement.previousElementSibling.childNodes[2]); 78 79 const toc_div_sub = sub_item.parentElement.parentElement; 80 const toc_button_sub = toc_div_sub.previousElementSibling; 81 82 toc_div_sub.classList.add('show'); 83 toc_button_sub.setAttribute('aria-expanded', 'true'); 84 85 sub_item = toc_div_sub.parentElement; 86 } 87 } 88 } 89 } 90} 91 92function get_highlight_num() 93{ 94 let cnt = 0; 95 for (let i = 0; i < HeadingFlag.length; ++i) 96 { 97 if (HeadingFlag[i]) 98 { 99 ++cnt; 100 } 101 } 102 return cnt; 103} 104 105function unfold_headings(headings) 106{ 107 let last; 108 // console.log(headings.length); 109 headings.forEach(heading => { 110 // console.log('ratio', heading.target.getAttribute('id'), heading.intersectionRatio, heading.isIntersecting, HeadingCnt); 111 const idx = heading.target.getAttribute('headingIdx'); 112 HeadingFlag[idx] = heading.isIntersecting; 113 if (!heading.isIntersecting) 114 { 115 last = parseInt(idx); 116 } 117 }); 118 119 let cnt = get_highlight_num(); 120 if (cnt) 121 { 122 last = -1; 123 } 124 refresh_highlight(last); 125 refresh_fold(last); 126}