hexo-theme-linen 1.0.27 → 1.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hexo-theme-linen",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "A clean Hexo theme",
5
5
  "main": "index.js",
6
6
  "homepage": "https://github.com/lynanbreeze/hexo-theme-linen",
@@ -19,9 +19,11 @@ module.exports = function (hexo) {
19
19
  ...(hexo?.config?.theme_config?.lazyload || {}),
20
20
  };
21
21
 
22
- const placeholderImage = /^(https?:\/\/|\/|data:image)/i.test(loadingImage) || loadingImage?.startsWith('../img')
23
- ? `url(${loadingImage})`
24
- : loadingImage;
22
+ const placeholderImage =
23
+ /^(https?:\/\/|\/|data:image|\.\.\/)/i.test(loadingImage) ||
24
+ /\.(svg|png|jpg|jpeg|webp|gif)$/i.test(loadingImage)
25
+ ? `url(${loadingImage})`
26
+ : loadingImage;
25
27
 
26
28
  const injectedStyles = `.lazyload-outer-wrap .caption{
27
29
  width: 100%;
@@ -10,9 +10,6 @@
10
10
  }
11
11
  .nav-header.is-post {
12
12
  position: fixed;
13
- .header-inner {
14
- padding: 0 $commonPadding;
15
- }
16
13
  }
17
14
  .nav-header.is-post.hide {
18
15
  transform: translateY(-100%);
@@ -42,6 +39,7 @@
42
39
  border-right: none;
43
40
  -ms-overflow-style: unset;
44
41
  scrollbar-width: unset;
42
+ overscroll-behavior: contain;
45
43
  @include safe-padding(15px);
46
44
  .toc-item-wrap {
47
45
  transition: unset;
@@ -41,6 +41,9 @@ html {
41
41
  min-width: 320px;
42
42
  scroll-behavior: smooth;
43
43
  overscroll-behavior: auto none;
44
+ &.no-scroll {
45
+ overflow: hidden !important;
46
+ }
44
47
  }
45
48
 
46
49
  * {
@@ -217,6 +220,9 @@ body {
217
220
  display: block;
218
221
  }
219
222
  }
223
+ &.no-scroll {
224
+ overflow: hidden !important;
225
+ }
220
226
  }
221
227
  * {
222
228
  box-sizing: border-box;
@@ -233,7 +239,8 @@ body {
233
239
  background-color: #fff;
234
240
  // backdrop-filter: saturate(180%) blur(20px);
235
241
  transform: translateY(0);
236
- transition: transform 0.3s ease;
242
+ transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
243
+ will-change: transform;
237
244
  z-index: 9;
238
245
  position: relative;
239
246
  top: 0;
@@ -362,7 +369,7 @@ body {
362
369
  }
363
370
  }
364
371
  &.hide {
365
- transform: translateY(0);
372
+ transform: translateY(-100%);
366
373
  }
367
374
  }
368
375
 
@@ -1830,6 +1837,7 @@ body {
1830
1837
  transition: transform cubic-bezier(0.4, 0, 0.6, 1) 0.3s;
1831
1838
  display: flex;
1832
1839
  flex-direction: column;
1840
+ overscroll-behavior: contain;
1833
1841
  @include safe-padding(15px);
1834
1842
  &.visible {
1835
1843
  transform: translateY(0);
@@ -2594,6 +2602,7 @@ body {
2594
2602
  &.visible {
2595
2603
  pointer-events: all;
2596
2604
  opacity: 1;
2605
+ touch-action: none;
2597
2606
  }
2598
2607
  }
2599
2608
 
@@ -52,7 +52,7 @@ function switchHDR() {
52
52
  } else {
53
53
  var hrefAttr = link.getAttribute("href");
54
54
  if (hrefAttr && hrefAttr.includes(SDRPath)) {
55
- link.setAttribute("href", HDRPath);
55
+ link.setAttribute("href", hrefAttr.replace(SDRPath, HDRPath));
56
56
  }
57
57
  }
58
58
  } else {
@@ -61,7 +61,7 @@ function switchHDR() {
61
61
  } else {
62
62
  var hrefAttr = link.getAttribute("href");
63
63
  if (hrefAttr && hrefAttr.includes(HDRPath)) {
64
- link.setAttribute("href", SDRPath);
64
+ link.setAttribute("href", hrefAttr.replace(HDRPath, SDRPath));
65
65
  }
66
66
  }
67
67
  }
@@ -147,30 +147,27 @@ function isElementInViewport(el) {
147
147
  }
148
148
 
149
149
  function copyToClipboard(text) {
150
- if (navigator.clipboard) {
151
- copyToClipboard = function () {
152
- navigator.clipboard
153
- .writeText(text)
154
- .then(() => {})
155
- .catch((err) => {
156
- console.error("复制失败:", err);
157
- });
158
- };
159
- } else {
160
- copyToClipboard = function () {
161
- const textArea = document.createElement("textarea");
162
- textArea.value = text;
163
- document.body.appendChild(textArea);
164
- textArea.select();
165
- try {
166
- document.execCommand("copy");
167
- } catch (err) {
150
+ if (navigator.clipboard && window.isSecureContext) {
151
+ navigator.clipboard
152
+ .writeText(text)
153
+ .then(() => {})
154
+ .catch((err) => {
168
155
  console.error("复制失败:", err);
169
- }
170
- document.body.removeChild(textArea);
171
- };
156
+ });
157
+ } else {
158
+ const textArea = document.createElement("textarea");
159
+ textArea.value = text;
160
+ textArea.style.position = "fixed";
161
+ textArea.style.opacity = "0";
162
+ document.body.appendChild(textArea);
163
+ textArea.select();
164
+ try {
165
+ document.execCommand("copy");
166
+ } catch (err) {
167
+ console.error("复制失败:", err);
168
+ }
169
+ document.body.removeChild(textArea);
172
170
  }
173
- copyToClipboard(text);
174
171
  }
175
172
 
176
173
  function highlightAnchor(hash, highlightClass = "anchor-highlight") {
@@ -270,7 +267,8 @@ function onScroll() {
270
267
  var header = document.querySelector(".nav-header");
271
268
  if (!header) return;
272
269
 
273
- var currentScrollY = window.scrollY;
270
+ var rawScrollY = window.scrollY;
271
+ var currentScrollY = Math.max(0, rawScrollY);
274
272
  var triggerHeight = window.innerHeight * 1.5;
275
273
 
276
274
  if (!window.$gitalkInitiated && currentScrollY > window.innerHeight) {
@@ -283,27 +281,32 @@ function onScroll() {
283
281
  return;
284
282
  }
285
283
 
286
- if (currentScrollY <= 10) {
284
+ // 接近顶部时(<= 60px),强制展开并退出,消除 iOS/Mac 橡皮筋回弹误判与闪烁
285
+ if (currentScrollY <= 60) {
287
286
  header.classList.remove("hide");
287
+ if (backToTop) backToTop.classList.remove("visible");
288
288
  previousScrollY = currentScrollY;
289
289
  return;
290
290
  }
291
291
 
292
292
  var scrollDelta = currentScrollY - previousScrollY;
293
- if (Math.abs(scrollDelta) < 5) {
293
+ // 滤除手指微小抖动
294
+ if (Math.abs(scrollDelta) < 8) {
294
295
  return;
295
296
  }
296
297
 
297
298
  if (scrollDelta < 0) {
299
+ // 向上滑动:即刻平滑展开导航栏
298
300
  if (!document.querySelector(".pswp--open")) {
299
301
  header.classList.remove("hide");
300
302
  }
301
- if (currentScrollY >= triggerHeight) {
303
+ if (backToTop && currentScrollY >= triggerHeight) {
302
304
  backToTop.classList.add("visible");
303
305
  }
304
306
  } else {
305
- backToTop.classList.remove("visible");
306
- if (currentScrollY > window.innerHeight * 0.75) {
307
+ // 向下滑动:离开顶部 60% 视口后平滑收起导航栏
308
+ if (backToTop) backToTop.classList.remove("visible");
309
+ if (currentScrollY > window.innerHeight * 0.6) {
307
310
  header.classList.add("hide");
308
311
  }
309
312
  }
@@ -311,7 +314,20 @@ function onScroll() {
311
314
  previousScrollY = currentScrollY;
312
315
  }
313
316
 
314
- window.addEventListener("scroll", throttle(onScroll, 300));
317
+ var isScrollTicking = false;
318
+ window.addEventListener(
319
+ "scroll",
320
+ function () {
321
+ if (!isScrollTicking) {
322
+ isScrollTicking = true;
323
+ window.requestAnimationFrame(function () {
324
+ onScroll();
325
+ isScrollTicking = false;
326
+ });
327
+ }
328
+ },
329
+ { passive: true }
330
+ );
315
331
 
316
332
  var resizeTimer = null;
317
333
  window.addEventListener("resize", function () {
@@ -339,6 +355,18 @@ function onclickPostItem(element) {
339
355
  }
340
356
  }
341
357
 
358
+ function setBodyScrollLocked(locked) {
359
+ var body = document.body;
360
+ var html = document.documentElement;
361
+ if (locked) {
362
+ body && body.classList.add("no-scroll");
363
+ html && html.classList.add("no-scroll");
364
+ } else {
365
+ body && body.classList.remove("no-scroll");
366
+ html && html.classList.remove("no-scroll");
367
+ }
368
+ }
369
+
342
370
  function handleClick(e) {
343
371
  try {
344
372
  var target = e.target;
@@ -358,7 +386,7 @@ function handleClick(e) {
358
386
  if (/close-series-popup-icon/.test(target.className)) {
359
387
  seriesElement?.classList?.remove("visible");
360
388
  maskElement.classList.remove("visible");
361
- bodyElement.classList.remove("no-scroll");
389
+ setBodyScrollLocked(false);
362
390
  } else if (["H2", "H3"].includes(target.tagName) && target.id) {
363
391
  const newLocation = new URL(location.href);
364
392
  newLocation.hash = `#${target.id}`;
@@ -415,7 +443,7 @@ function handleClick(e) {
415
443
  if (tocElement?.classList?.contains("visible")) {
416
444
  tocElement.classList.remove("visible");
417
445
  maskElement.classList.remove("visible");
418
- bodyElement.classList.remove("no-scroll");
446
+ setBodyScrollLocked(false);
419
447
  }
420
448
  if (targetId === "to-page-top") {
421
449
  window.scrollTo({
@@ -456,11 +484,11 @@ function handleClick(e) {
456
484
  if (tocElement?.classList?.contains("visible")) {
457
485
  tocElement.classList.remove("visible");
458
486
  maskElement.classList.remove("visible");
459
- bodyElement.classList.remove("no-scroll");
487
+ setBodyScrollLocked(false);
460
488
  } else {
461
489
  tocElement.classList.add("visible");
462
490
  maskElement.classList.add("visible");
463
- bodyElement.classList.add("no-scroll");
491
+ setBodyScrollLocked(true);
464
492
  }
465
493
  return;
466
494
  } else if (
@@ -473,13 +501,13 @@ function handleClick(e) {
473
501
  seriesElement?.classList?.add("visible");
474
502
  maskElement?.classList?.add("visible");
475
503
  donateModal?.classList?.remove("visible");
476
- bodyElement.classList.add("no-scroll");
504
+ setBodyScrollLocked(true);
477
505
  return;
478
506
  } else if (target?.className?.includes("series-item-link")) {
479
507
  e.preventDefault();
480
508
  seriesElement?.classList?.remove("visible");
481
509
  maskElement.classList.remove("visible");
482
- bodyElement.classList.remove("no-scroll");
510
+ setBodyScrollLocked(false);
483
511
  const href =
484
512
  target.getAttribute("href") ||
485
513
  target.parentElement.getAttribute("href");
@@ -489,7 +517,7 @@ function handleClick(e) {
489
517
  tocElement?.classList?.remove("visible");
490
518
  seriesElement?.classList?.remove("visible");
491
519
  maskElement.classList.remove("visible");
492
- bodyElement.classList.remove("no-scroll");
520
+ setBodyScrollLocked(false);
493
521
  return;
494
522
  } else if (target?.className?.includes("fold-toggle")) {
495
523
  const parentElement = target.parentElement;
@@ -517,24 +545,27 @@ function handleClick(e) {
517
545
  }
518
546
  }
519
547
 
548
+ var isClickHandlerBound = false;
520
549
  function bindClickHandler() {
550
+ if (isClickHandlerBound) return;
521
551
  document.addEventListener("click", handleClick, true);
552
+ isClickHandlerBound = true;
522
553
  }
523
554
 
524
555
  window.addEventListener("load", bindClickHandler);
525
- window.addEventListener("pageshow", (e) => {
526
- requestAnimationFrame(() => {
527
- bindClickHandler();
528
- });
556
+ window.addEventListener("pageshow", () => {
557
+ bindClickHandler();
529
558
  });
530
559
 
531
560
  function initAgeWarning() {
532
561
  const ageWarningElement = document.getElementById("post-age-warning");
533
562
  if (!ageWarningElement) return;
534
- const dateStr = document
535
- .querySelector("#post .post-meta .date")
536
- .getAttribute("datetime");
563
+ const dateElement = document.querySelector("#post .post-meta .date");
564
+ const dateStr = dateElement ? dateElement.getAttribute("datetime") : null;
565
+ if (!dateStr) return;
566
+
537
567
  const postDate = new Date(dateStr);
568
+ if (isNaN(postDate.getTime())) return;
538
569
  const now = new Date();
539
570
 
540
571
  const diffTime = now - postDate;
@@ -547,10 +578,30 @@ function initAgeWarning() {
547
578
  }
548
579
  } else {
549
580
  if (ageWarningElement) {
550
- ageWarningElement.style = "display: none";
581
+ ageWarningElement.style.display = "none";
551
582
  }
552
583
  }
553
584
  return diffDays;
554
585
  }
555
586
 
556
587
  initAgeWarning();
588
+
589
+ function initMaskScrollLock() {
590
+ var mask = document.getElementById("mask");
591
+ if (!mask) return;
592
+ mask.addEventListener(
593
+ "touchmove",
594
+ function (e) {
595
+ if (mask.classList.contains("visible")) {
596
+ e.preventDefault();
597
+ }
598
+ },
599
+ { passive: false }
600
+ );
601
+ }
602
+
603
+ if (document.readyState === "loading") {
604
+ document.addEventListener("DOMContentLoaded", initMaskScrollLock);
605
+ } else {
606
+ initMaskScrollLock();
607
+ }
@@ -1,4 +1,5 @@
1
1
  function replaceEmojis() {
2
+ if (!document.body) return;
2
3
  const replacements = [{ emoji: "✅", className: "custom-checked-checkbox" }];
3
4
 
4
5
  replacements.forEach(({ emoji, className }) => {
@@ -7,9 +8,16 @@ function replaceEmojis() {
7
8
  NodeFilter.SHOW_TEXT,
8
9
  {
9
10
  acceptNode(node) {
10
- return node.nodeValue.includes(emoji)
11
- ? NodeFilter.FILTER_ACCEPT
12
- : NodeFilter.FILTER_REJECT;
11
+ if (!node.nodeValue || !node.nodeValue.includes(emoji)) {
12
+ return NodeFilter.FILTER_REJECT;
13
+ }
14
+ if (
15
+ node.parentElement &&
16
+ node.parentElement.closest("pre, code, textarea, script, style")
17
+ ) {
18
+ return NodeFilter.FILTER_REJECT;
19
+ }
20
+ return NodeFilter.FILTER_ACCEPT;
13
21
  },
14
22
  },
15
23
  );
@@ -2,182 +2,212 @@ document.addEventListener("DOMContentLoaded", () => {
2
2
  const tocContainer = document.getElementById("toc");
3
3
  if (!tocContainer) return;
4
4
 
5
- const headers = [...document.querySelectorAll("h2, h3")];
6
- if (headers.length === 0) return;
5
+ const contentContainer =
6
+ document.getElementById("page-content") ||
7
+ document.getElementById("post") ||
8
+ document.body;
9
+
10
+ const headerElements = Array.from(
11
+ contentContainer.querySelectorAll("h2, h3"),
12
+ );
13
+ if (headerElements.length === 0) return;
14
+
15
+ // 1. 保证所有标题均具备有效 ID(补全缺失的 ID 并写回 DOM)
16
+ headerElements.forEach((header, index) => {
17
+ if (!header.id) {
18
+ const text = (header.textContent || "").trim();
19
+ header.id =
20
+ text.replace(/\s+/g, "-").toLowerCase() || `heading-${index}`;
21
+ }
22
+ });
23
+
24
+ // 2. 解析 TOC 目录的 DOM 结构建立映射字典
25
+ const tocItemWraps = Array.from(
26
+ tocContainer.querySelectorAll(".toc-item-wrap"),
27
+ );
28
+ const tocItemsMap = new Map(); // h2Id -> { wrap, link, subMap: Map(h3Id -> link) }
29
+ const h3ToH2Map = new Map(); // h3Id -> h2Id
30
+
31
+ tocItemWraps.forEach((wrap) => {
32
+ const h2Link = wrap.querySelector(".toc-item-link");
33
+ if (!h2Link) return;
34
+ const h2Id = h2Link.dataset.id;
35
+ if (!h2Id) return;
36
+
37
+ const subMap = new Map();
38
+ const subLinks = Array.from(wrap.querySelectorAll(".toc-sub-item-link"));
39
+ subLinks.forEach((subLink) => {
40
+ const h3Id = subLink.dataset.id;
41
+ if (h3Id) {
42
+ subMap.set(h3Id, subLink);
43
+ h3ToH2Map.set(h3Id, h2Id);
44
+ }
45
+ });
46
+
47
+ tocItemsMap.set(h2Id, {
48
+ wrap,
49
+ link: h2Link,
50
+ subMap,
51
+ });
52
+ });
53
+
54
+ // 3. 构建正文标题列表
55
+ let currentH2Id = null;
56
+ const headers = headerElements.map((header) => {
57
+ const isH2 = header.tagName === "H2";
58
+ if (isH2) {
59
+ currentH2Id = header.id;
60
+ }
61
+ return {
62
+ id: header.id,
63
+ isH2,
64
+ h2Id: isH2 ? header.id : h3ToH2Map.get(header.id) || currentH2Id,
65
+ element: header,
66
+ };
67
+ });
68
+
69
+ // 顶部导航栏避让阈值
70
+ const TOP_OFFSET = 80;
7
71
 
8
- const toc = [];
9
- let lastH2 = null;
10
- let previousH2Id = null;
11
- let previousH3Id = null;
12
72
  let lastActiveH2Id = null;
13
73
  let lastActiveH3Id = null;
14
74
 
15
- headers.forEach((header) => {
16
- const title = header.textContent.trim();
17
- const id = header.id || title.replace(/\s+/g, "-").toLowerCase();
18
-
19
- if (header.tagName === "H2") {
20
- lastH2 = {
21
- title,
22
- id,
23
- active: false,
24
- children: [],
25
- visible: false,
26
- passiveVisible: false,
75
+ // 4. 目录容器内部滚动居中/可见,避免触发外部 window 的异常抖动
76
+ function scrollIntoTocView(element) {
77
+ if (!element || !tocContainer) return;
78
+ const containerRect = tocContainer.getBoundingClientRect();
79
+ const elementRect = element.getBoundingClientRect();
80
+
81
+ if (elementRect.top < containerRect.top) {
82
+ tocContainer.scrollTop -= containerRect.top - elementRect.top + 20;
83
+ } else if (elementRect.bottom > containerRect.bottom) {
84
+ tocContainer.scrollTop += elementRect.bottom - containerRect.bottom + 20;
85
+ }
86
+ }
87
+
88
+ // 5. 计算当前应当激活的 H2 / H3
89
+ function determineActive() {
90
+ // 优先兼容外部锚定参数 window.anchoringId
91
+ const currentAnchoringId =
92
+ typeof window !== "undefined" && "anchoringId" in window
93
+ ? window.anchoringId
94
+ : null;
95
+
96
+ if (currentAnchoringId) {
97
+ if (tocItemsMap.has(currentAnchoringId)) {
98
+ return {
99
+ h2Id: currentAnchoringId,
100
+ h3Id: null,
101
+ };
102
+ }
103
+ if (h3ToH2Map.has(currentAnchoringId)) {
104
+ return {
105
+ h2Id: h3ToH2Map.get(currentAnchoringId),
106
+ h3Id: currentAnchoringId,
107
+ };
108
+ }
109
+ }
110
+
111
+ // 根据滚动位置,查找位于视口顶部触发线(top <= TOP_OFFSET)之上的最后一个标题
112
+ let activeHeading = null;
113
+ for (let i = 0; i < headers.length; i++) {
114
+ const header = headers[i];
115
+ const rect = header.element.getBoundingClientRect();
116
+ if (rect.top <= TOP_OFFSET) {
117
+ activeHeading = header;
118
+ } else {
119
+ break;
120
+ }
121
+ }
122
+
123
+ // 若未滚动到任何标题(如文章开头前言),激活第一个 H2
124
+ if (!activeHeading) {
125
+ const firstH2 = headers.find((h) => h.isH2);
126
+ return {
127
+ h2Id: firstH2 ? firstH2.id : null,
128
+ h3Id: null,
27
129
  };
28
- toc.push(lastH2);
29
- } else if (header.tagName === "H3" && lastH2) {
30
- lastH2.children.push({
31
- title,
32
- id,
33
- active: false,
34
- visible: false,
35
- });
36
130
  }
37
- });
38
131
 
39
- const tocItems = (tocContainer.children[1] && tocContainer.children[1].children) || [];
40
-
41
- const observer = new IntersectionObserver(
42
- (entries) => {
43
- entries.forEach((entry) => {
44
- const id = entry.target.id;
45
- const isVisible = !!entry.intersectionRatio;
46
- toc.forEach((h2) => {
47
- if (h2.id === id) {
48
- h2.visible = isVisible;
49
- if (isVisible) {
50
- previousH2Id = h2.id;
51
- }
52
- }
53
- h2.children.forEach((h3) => {
54
- if (h3.id === id) {
55
- h3.visible = isVisible;
56
- if (isVisible) {
57
- previousH3Id = h3.id;
58
- }
59
- }
60
- });
61
- });
62
- toc.forEach((item) => {
63
- item.passiveVisible =
64
- (item.children || []).findIndex((h3) => h3.visible) !== -1;
65
- if (item.passiveVisible) {
66
- previousH2Id = item.id;
67
- }
68
- });
69
- });
132
+ if (activeHeading.isH2) {
133
+ return {
134
+ h2Id: activeHeading.id,
135
+ h3Id: null,
136
+ };
137
+ }
70
138
 
71
- const activeH2Index = toc.findIndex(
72
- (tocItem) => tocItem.passiveVisible || tocItem.visible,
73
- );
74
- toc.forEach((h2, h2Index) => {
75
- h2.active = h2Index === activeH2Index;
76
- if (h2Index === activeH2Index) {
77
- const activeH3Index = (h2.children || []).findIndex(
78
- (h3) => h3.visible,
79
- );
80
- h2.children.forEach((h3, h3Index) => {
81
- h3.active = h3Index === activeH3Index;
82
- });
83
- } else {
84
- h2.children.forEach((h3) => {
85
- h3.active = false;
86
- });
87
- }
88
- });
139
+ return {
140
+ h2Id: activeHeading.h2Id,
141
+ h3Id: activeHeading.id,
142
+ };
143
+ }
144
+
145
+ // 6. 执行高亮更新与 DOM 渲染
146
+ function updateActive() {
147
+ const { h2Id, h3Id } = determineActive();
148
+
149
+ if (lastActiveH2Id === h2Id && lastActiveH3Id === h3Id) {
150
+ return;
151
+ }
89
152
 
90
- const activeH2 = toc.find((item) =>
91
- typeof anchoringId !== "undefined" && anchoringId
92
- ? item.id === anchoringId
93
- : item.active,
94
- );
153
+ lastActiveH2Id = h2Id;
154
+ lastActiveH3Id = h3Id;
95
155
 
96
- let targetH2Id = null;
97
- let targetH3Id = null;
156
+ let targetElementToScroll = null;
98
157
 
99
- if (activeH2) {
100
- targetH2Id = activeH2.id;
101
- const activeH3 = (activeH2.children || []).find((item) => item.active);
102
- targetH3Id = activeH3 ? activeH3.id : null;
158
+ tocItemsMap.forEach((item, currentH2Id) => {
159
+ const isH2Active = currentH2Id === h2Id;
160
+ if (isH2Active) {
161
+ item.wrap.classList.add("active");
162
+ targetElementToScroll = item.wrap;
103
163
  } else {
104
- const isScrollingUp = window.scrollY < previousScrollY;
105
- if (isScrollingUp) {
106
- const previousH2Index = toc.findIndex((h2) => h2.id === previousH2Id);
107
- const previousH3ParentIndex = toc.findIndex((h2) =>
108
- h2 && h2.children && h2.children.find((h3) => h3.id === previousH3Id),
109
- );
110
- let targetH2Index = 0;
111
- if (!previousH3Id || previousH3ParentIndex > previousH2Index) {
112
- targetH2Index = previousH2Index - 1;
113
- } else {
114
- targetH2Index = previousH3ParentIndex;
115
- }
116
- const newActiveH2 = toc[targetH2Index];
117
- if (newActiveH2) {
118
- targetH2Id = newActiveH2.id;
119
- let activeH3Index = (newActiveH2.children ? newActiveH2.children.length : 0) - 1;
120
- if (newActiveH2.children && newActiveH2.children.find((h3) => h3.id === previousH3Id)) {
121
- const previousH3Index = newActiveH2.children.findIndex(
122
- (h3) => h3.id === previousH3Id,
123
- );
124
- activeH3Index =
125
- previousH3Index - 1 > -1 ? previousH3Index - 1 : 0;
126
- }
127
- targetH3Id = (newActiveH2.children && newActiveH2.children[activeH3Index] && newActiveH2.children[activeH3Index].id) || null;
128
- }
129
- } else {
130
- targetH2Id = previousH2Id;
131
- targetH3Id = previousH3Id;
132
- }
164
+ item.wrap.classList.remove("active");
133
165
  }
134
166
 
135
- if (targetH2Id) {
136
- if (lastActiveH2Id === targetH2Id && lastActiveH3Id === targetH3Id && !anchoringId) {
137
- return;
167
+ // 仅在当前 H2 为 active 时匹配子项 H3,彻底杜绝跨章节残留
168
+ item.subMap.forEach((subLink, currentH3Id) => {
169
+ if (isH2Active && currentH3Id === h3Id) {
170
+ subLink.classList.add("active");
171
+ targetElementToScroll = subLink;
172
+ } else {
173
+ subLink.classList.remove("active");
138
174
  }
139
- lastActiveH2Id = targetH2Id;
140
- lastActiveH3Id = targetH3Id;
175
+ });
176
+ });
141
177
 
142
- if (activeH2 && activeH2.children && activeH2.children.every((h3) => !h3.active)) {
143
- previousH3Id = null;
144
- }
178
+ if (targetElementToScroll) {
179
+ scrollIntoTocView(targetElementToScroll);
180
+ }
181
+ }
145
182
 
146
- Array.from(tocItems).forEach((tocItem) => {
147
- if (tocItem.children[0].dataset.id === targetH2Id) {
148
- tocItem.className = "toc-item-wrap active";
149
- tocItem.scrollIntoView({
150
- block: "nearest",
151
- inline: "nearest",
152
- });
153
- } else {
154
- tocItem.className = "toc-item-wrap";
155
- }
156
- const subItems = tocItem.children[1];
157
- if (subItems) {
158
- Array.from(subItems.children || []).forEach((subItem) => {
159
- if (subItem.children[0].dataset.id === targetH3Id) {
160
- subItem.children[0].className = "toc-sub-item-link active";
161
- subItem.children[0].scrollIntoView({
162
- block: "nearest",
163
- inline: "nearest",
164
- });
165
- } else {
166
- subItem.children[0].className = "toc-sub-item-link";
167
- }
168
- });
169
- }
170
- });
171
-
172
- if (anchoringId) {
173
- anchoringId = null;
174
- }
175
- }
176
- },
177
- {
178
- rootMargin: "0px",
179
- threshold: [0, 1],
180
- },
181
- );
182
- headers.forEach((header) => observer.observe(header));
183
+ // 7. 使用 requestAnimationFrame 优化滚动触发频率
184
+ let isTicking = false;
185
+ function requestUpdate() {
186
+ if (isTicking) return;
187
+ isTicking = true;
188
+ window.requestAnimationFrame(() => {
189
+ updateActive();
190
+ isTicking = false;
191
+ });
192
+ }
193
+
194
+ // 8. 监听视口变化与滚动
195
+ window.addEventListener("scroll", requestUpdate, { passive: true });
196
+ window.addEventListener("resize", requestUpdate, { passive: true });
197
+
198
+ if ("IntersectionObserver" in window) {
199
+ const observer = new IntersectionObserver(
200
+ () => {
201
+ requestUpdate();
202
+ },
203
+ {
204
+ rootMargin: `-${TOP_OFFSET}px 0px 0px 0px`,
205
+ threshold: [0, 1],
206
+ },
207
+ );
208
+ headerElements.forEach((header) => observer.observe(header));
209
+ }
210
+
211
+ // 初始执行一次
212
+ updateActive();
183
213
  });