hexo-theme-linen 1.0.27 → 1.0.28

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.28",
4
4
  "description": "A clean Hexo theme",
5
5
  "main": "index.js",
6
6
  "homepage": "https://github.com/lynanbreeze/hexo-theme-linen",
@@ -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
 
@@ -270,7 +270,8 @@ function onScroll() {
270
270
  var header = document.querySelector(".nav-header");
271
271
  if (!header) return;
272
272
 
273
- var currentScrollY = window.scrollY;
273
+ var rawScrollY = window.scrollY;
274
+ var currentScrollY = Math.max(0, rawScrollY);
274
275
  var triggerHeight = window.innerHeight * 1.5;
275
276
 
276
277
  if (!window.$gitalkInitiated && currentScrollY > window.innerHeight) {
@@ -283,27 +284,32 @@ function onScroll() {
283
284
  return;
284
285
  }
285
286
 
286
- if (currentScrollY <= 10) {
287
+ // 接近顶部时(<= 60px),强制展开并退出,消除 iOS/Mac 橡皮筋回弹误判与闪烁
288
+ if (currentScrollY <= 60) {
287
289
  header.classList.remove("hide");
290
+ if (backToTop) backToTop.classList.remove("visible");
288
291
  previousScrollY = currentScrollY;
289
292
  return;
290
293
  }
291
294
 
292
295
  var scrollDelta = currentScrollY - previousScrollY;
293
- if (Math.abs(scrollDelta) < 5) {
296
+ // 滤除手指微小抖动
297
+ if (Math.abs(scrollDelta) < 8) {
294
298
  return;
295
299
  }
296
300
 
297
301
  if (scrollDelta < 0) {
302
+ // 向上滑动:即刻平滑展开导航栏
298
303
  if (!document.querySelector(".pswp--open")) {
299
304
  header.classList.remove("hide");
300
305
  }
301
- if (currentScrollY >= triggerHeight) {
306
+ if (backToTop && currentScrollY >= triggerHeight) {
302
307
  backToTop.classList.add("visible");
303
308
  }
304
309
  } else {
305
- backToTop.classList.remove("visible");
306
- if (currentScrollY > window.innerHeight * 0.75) {
310
+ // 向下滑动:离开顶部 60% 视口后平滑收起导航栏
311
+ if (backToTop) backToTop.classList.remove("visible");
312
+ if (currentScrollY > window.innerHeight * 0.6) {
307
313
  header.classList.add("hide");
308
314
  }
309
315
  }
@@ -311,7 +317,20 @@ function onScroll() {
311
317
  previousScrollY = currentScrollY;
312
318
  }
313
319
 
314
- window.addEventListener("scroll", throttle(onScroll, 300));
320
+ var isScrollTicking = false;
321
+ window.addEventListener(
322
+ "scroll",
323
+ function () {
324
+ if (!isScrollTicking) {
325
+ isScrollTicking = true;
326
+ window.requestAnimationFrame(function () {
327
+ onScroll();
328
+ isScrollTicking = false;
329
+ });
330
+ }
331
+ },
332
+ { passive: true }
333
+ );
315
334
 
316
335
  var resizeTimer = null;
317
336
  window.addEventListener("resize", function () {
@@ -339,6 +358,18 @@ function onclickPostItem(element) {
339
358
  }
340
359
  }
341
360
 
361
+ function setBodyScrollLocked(locked) {
362
+ var body = document.body;
363
+ var html = document.documentElement;
364
+ if (locked) {
365
+ body && body.classList.add("no-scroll");
366
+ html && html.classList.add("no-scroll");
367
+ } else {
368
+ body && body.classList.remove("no-scroll");
369
+ html && html.classList.remove("no-scroll");
370
+ }
371
+ }
372
+
342
373
  function handleClick(e) {
343
374
  try {
344
375
  var target = e.target;
@@ -358,7 +389,7 @@ function handleClick(e) {
358
389
  if (/close-series-popup-icon/.test(target.className)) {
359
390
  seriesElement?.classList?.remove("visible");
360
391
  maskElement.classList.remove("visible");
361
- bodyElement.classList.remove("no-scroll");
392
+ setBodyScrollLocked(false);
362
393
  } else if (["H2", "H3"].includes(target.tagName) && target.id) {
363
394
  const newLocation = new URL(location.href);
364
395
  newLocation.hash = `#${target.id}`;
@@ -415,7 +446,7 @@ function handleClick(e) {
415
446
  if (tocElement?.classList?.contains("visible")) {
416
447
  tocElement.classList.remove("visible");
417
448
  maskElement.classList.remove("visible");
418
- bodyElement.classList.remove("no-scroll");
449
+ setBodyScrollLocked(false);
419
450
  }
420
451
  if (targetId === "to-page-top") {
421
452
  window.scrollTo({
@@ -456,11 +487,11 @@ function handleClick(e) {
456
487
  if (tocElement?.classList?.contains("visible")) {
457
488
  tocElement.classList.remove("visible");
458
489
  maskElement.classList.remove("visible");
459
- bodyElement.classList.remove("no-scroll");
490
+ setBodyScrollLocked(false);
460
491
  } else {
461
492
  tocElement.classList.add("visible");
462
493
  maskElement.classList.add("visible");
463
- bodyElement.classList.add("no-scroll");
494
+ setBodyScrollLocked(true);
464
495
  }
465
496
  return;
466
497
  } else if (
@@ -473,13 +504,13 @@ function handleClick(e) {
473
504
  seriesElement?.classList?.add("visible");
474
505
  maskElement?.classList?.add("visible");
475
506
  donateModal?.classList?.remove("visible");
476
- bodyElement.classList.add("no-scroll");
507
+ setBodyScrollLocked(true);
477
508
  return;
478
509
  } else if (target?.className?.includes("series-item-link")) {
479
510
  e.preventDefault();
480
511
  seriesElement?.classList?.remove("visible");
481
512
  maskElement.classList.remove("visible");
482
- bodyElement.classList.remove("no-scroll");
513
+ setBodyScrollLocked(false);
483
514
  const href =
484
515
  target.getAttribute("href") ||
485
516
  target.parentElement.getAttribute("href");
@@ -489,7 +520,7 @@ function handleClick(e) {
489
520
  tocElement?.classList?.remove("visible");
490
521
  seriesElement?.classList?.remove("visible");
491
522
  maskElement.classList.remove("visible");
492
- bodyElement.classList.remove("no-scroll");
523
+ setBodyScrollLocked(false);
493
524
  return;
494
525
  } else if (target?.className?.includes("fold-toggle")) {
495
526
  const parentElement = target.parentElement;
@@ -554,3 +585,23 @@ function initAgeWarning() {
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
+ }
@@ -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
  });