Skip to content

Commit 93e1286

Browse files
authored
Merge pull request #35 from devlive-community/dev
优化计算内容距离菜单位置
2 parents 424336e + e999f87 commit 93e1286

File tree

5 files changed

+283
-53
lines changed

5 files changed

+283
-53
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pageforge",
3-
"version": "2025.1.3",
3+
"version": "2025.1.4",
44
"description": "PageForge 是一款现代化的静态页面生成与部署平台,旨在帮助用户快速创建精美的静态网站,并一键部署到 GitHub Pages。 无论是个人博客、项目文档还是企业官网,PageForge 都能让你轻松实现高效构建、智能部署和即时上线。",
55
"homepage": "https://pageforge.devlive.org",
66
"repository": {

templates/assets/js/pageforge.js

Lines changed: 242 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,91 @@ const CodeCopy = {
160160
}
161161
};
162162

163+
// Banner 和 Header 高度管理模块
164+
const Banner = {
165+
updateHeaderHeight() {
166+
requestAnimationFrame(() => {
167+
const header = DOMUtils.find('header nav');
168+
if (header) {
169+
const height = header.offsetHeight;
170+
document.documentElement.style.setProperty('--header-height', height + 'px');
171+
172+
// 更新 sidebar 和 toc 的 top 位置
173+
const sidebar = DOMUtils.find('#sidebar-menu nav');
174+
const toc = DOMUtils.find('#toc-container nav');
175+
176+
if (sidebar) {
177+
// 更新 sidebar 的 top 位置
178+
sidebar.style.top = `calc(var(--header-height) + 1rem)`;
179+
}
180+
181+
if (toc) {
182+
// 更新 toc 的 top 位置
183+
toc.style.top = `calc(var(--header-height) + 1rem)`;
184+
}
185+
}
186+
});
187+
},
188+
189+
close(button) {
190+
const banner = button.closest('[data-banner]');
191+
if (!banner) {
192+
return;
193+
}
194+
195+
// 移除之前的监听器
196+
if (window._bannerObserver) {
197+
window._bannerObserver.disconnect();
198+
}
199+
200+
// 添加过渡效果
201+
banner.style.transition = 'height 0.2s ease-out, opacity 0.2s ease-out';
202+
banner.style.height = banner.offsetHeight + 'px';
203+
banner.style.opacity = '1';
204+
205+
// 强制重绘
206+
banner.offsetHeight;
207+
208+
// 开始动画
209+
banner.style.height = '0';
210+
banner.style.opacity = '0';
211+
banner.style.overflow = 'hidden';
212+
213+
// 动画结束后移除元素并更新高度
214+
banner.addEventListener('transitionend', function handler() {
215+
banner.remove();
216+
Banner.updateHeaderHeight();
217+
banner.removeEventListener('transitionend', handler);
218+
});
219+
},
220+
221+
init() {
222+
// 初始化 header 高度
223+
this.updateHeaderHeight();
224+
225+
// 监听窗口大小变化
226+
window.addEventListener('resize', () => this.updateHeaderHeight());
227+
228+
// 监听 banner 的变化
229+
const banner = DOMUtils.find('[data-banner]');
230+
if (banner) {
231+
window._bannerObserver = new MutationObserver(() => this.updateHeaderHeight());
232+
window._bannerObserver.observe(banner, {
233+
attributes: true,
234+
childList: true,
235+
subtree: true,
236+
characterData: true
237+
});
238+
239+
// 绑定关闭按钮事件
240+
const closeButton = DOMUtils.find('[data-banner-close]', banner);
241+
if (closeButton) {
242+
closeButton.addEventListener('click', () => this.close(closeButton));
243+
}
244+
}
245+
}
246+
};
247+
163248
// Header 组件
164249
const Header = {
165250
DarkMode: {
@@ -280,6 +365,7 @@ const Header = {
280365
init() {
281366
this.DarkMode.init();
282367
this.initEventListeners();
368+
Banner.init();
283369

284370
const observer = new MutationObserver(() => {
285371
this.initEventListeners();
@@ -331,19 +417,174 @@ const FontSizeControl = {
331417
}
332418
};
333419

420+
const TOC = {
421+
init() {
422+
// 添加样式
423+
if (!document.getElementById('toc-styles')) {
424+
const style = document.createElement('style');
425+
style.id = 'toc-styles';
426+
style.textContent = `
427+
.toc-link.active-toc-item {
428+
background-color: rgb(239 246 255);
429+
color: rgb(37 99 235);
430+
position: relative;
431+
}
432+
.dark .toc-link.active-toc-item {
433+
background-color: rgba(30 58 138 / 0.2);
434+
color: rgb(96 165 250);
435+
}
436+
.toc-link.active-toc-item::before {
437+
content: '';
438+
position: absolute;
439+
left: 0;
440+
top: 50%;
441+
transform: translateY(-50%);
442+
width: 3px;
443+
height: 16px;
444+
background-color: rgb(37 99 235);
445+
border-top-right-radius: 2px;
446+
border-bottom-right-radius: 2px;
447+
}
448+
.dark .toc-link.active-toc-item::before {
449+
background-color: rgb(96 165 250);
450+
}
451+
`;
452+
document.head.appendChild(style);
453+
}
454+
455+
// 获取所有 TOC 链接
456+
const tocLinks = document.querySelectorAll('.toc-link');
457+
458+
// 处理点击事件
459+
tocLinks.forEach(link => {
460+
link.addEventListener('click', (e) => {
461+
e.preventDefault();
462+
463+
// 移除所有活跃状态
464+
tocLinks.forEach(l => l.classList.remove('active-toc-item'));
465+
466+
// 添加当前项的活跃状态
467+
link.classList.add('active-toc-item');
468+
469+
// 获取目标元素和滚动位置
470+
const targetId = link.getAttribute('data-slug');
471+
const target = document.getElementById(targetId);
472+
const header = document.querySelector('header nav');
473+
const offset = header ? header.offsetHeight + 10 : 80;
474+
const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - offset;
475+
476+
// 滚动到目标位置
477+
window.scrollTo({
478+
top: targetPosition,
479+
behavior: 'smooth'
480+
});
481+
482+
// 如果是移动端,关闭 TOC 面板
483+
const tocMobile = document.getElementById('toc-mobile');
484+
if (tocMobile) {
485+
tocMobile.classList.add('translate-y-full');
486+
}
487+
});
488+
});
489+
490+
// 监听滚动,更新当前活跃项
491+
const updateActiveItem = () => {
492+
const header = document.querySelector('header nav');
493+
const headerHeight = header ? header.offsetHeight : 0;
494+
495+
// 查找所有带 slug 的容器
496+
const headings = Array.from(document.querySelectorAll('[id].inline-flex'));
497+
498+
// 找到当前视窗中最靠上的标题
499+
let current = null;
500+
let minDistance = Infinity;
501+
502+
headings.forEach(heading => {
503+
const rect = heading.getBoundingClientRect();
504+
const top = rect.top - headerHeight - 20;
505+
506+
// 计算到视窗顶部的距离
507+
const distance = Math.abs(top);
508+
509+
// 如果元素在视窗上方或接近顶部,且距离比当前最小距离更小
510+
if (top <= 10 && distance < minDistance) {
511+
minDistance = distance;
512+
current = heading;
513+
}
514+
});
515+
516+
// 更新 TOC 活跃状态
517+
if (current) {
518+
const slug = current.id;
519+
const tocLinks = document.querySelectorAll('.toc-link');
520+
521+
tocLinks.forEach(link => {
522+
if (link.getAttribute('data-slug') === slug) {
523+
link.classList.add('active-toc-item');
524+
525+
// 确保活跃项在滚动区域内可见
526+
const nav = link.closest('.overflow-y-auto');
527+
if (nav) {
528+
const navRect = nav.getBoundingClientRect();
529+
const linkRect = link.getBoundingClientRect();
530+
531+
if (linkRect.top < navRect.top || linkRect.bottom > navRect.bottom) {
532+
link.scrollIntoView({behavior: 'smooth', block: 'center'});
533+
}
534+
}
535+
}
536+
else {
537+
link.classList.remove('active-toc-item');
538+
}
539+
});
540+
}
541+
};
542+
543+
// 使用 IntersectionObserver 来优化滚动监听
544+
const observer = new IntersectionObserver((entries) => {
545+
entries.forEach(() => {
546+
requestAnimationFrame(updateActiveItem);
547+
});
548+
}, {
549+
rootMargin: '-20% 0px -80% 0px',
550+
threshold: [0, 1]
551+
});
552+
553+
// 观察所有标题元素
554+
document.querySelectorAll('[id].inline-flex').forEach(heading => {
555+
observer.observe(heading);
556+
});
557+
558+
// 仍然保留滚动监听作为备份
559+
let scrollTimeout;
560+
window.addEventListener('scroll', () => {
561+
if (scrollTimeout) {
562+
window.cancelAnimationFrame(scrollTimeout);
563+
}
564+
scrollTimeout = window.requestAnimationFrame(updateActiveItem);
565+
}, {passive: true});
566+
567+
// 初始调用一次
568+
updateActiveItem();
569+
}
570+
};
571+
334572
// 暴露到全局
335573
window.PageForge = {
336574
GitHubStats,
337575
CodeCopy,
338576
Header,
339-
FontSizeControl
577+
FontSizeControl,
578+
Banner,
579+
TOC
340580
};
341581

342582
// DOM 加载完成后初始化
343583
if (document.readyState === 'loading') {
344584
document.addEventListener('DOMContentLoaded', () => {
345585
Header.init();
346586
FontSizeControl.init();
587+
TOC.init();
347588
});
348589
}
349590
else {

templates/includes/header-banner.ejs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<%- locals.siteData.banner.content %>
66
</div>
77
<button class="text-blue-500 hover:text-blue-700 <%= darkClasses('dark:text-blue-400 dark:hover:text-blue-200') %>"
8-
onclick="this.closest('[data-banner]').remove()">
8+
data-banner-close>
99
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
1010
<path d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
1111
</svg>

templates/includes/toc.ejs

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<div class="hidden lg:block flex-none w-64 pl-8 mr-8">
2-
<div class="sticky top-20">
2+
<div class="sticky" style="top: calc(var(--header-height) + 1rem)">
33
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">目录</div>
4-
<nav class="overflow-y-auto max-h-[calc(100vh-8rem)] pr-4 -mr-4 space-y-1 pageforge-scrollbar"
4+
<nav class="overflow-y-auto pr-4 -mr-4 space-y-1 pageforge-scrollbar"
5+
style="max-height: calc(100vh - var(--header-height) - 8rem);"
56
id="table-of-contents">
67
<% function renderTocItem(items) { %>
78
<% items?.forEach(item => { %>
89
<div class="pl-<%= (item.level - 1) * 4 %>">
910
<a href="#<%= item.slug %>"
10-
onclick="event.preventDefault(); const target = document.getElementById('<%= item.slug %>'); const offset = 80; const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - offset; window.scrollTo({top: targetPosition, behavior: 'smooth'});"
1111
class="block px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md transition-colors duration-150 toc-link"
1212
data-slug="<%= item.slug %>">
1313
<%- item.text %>
@@ -23,15 +23,7 @@
2323
</div>
2424
</div>
2525
26-
<!-- 移动端目录按钮 -->
27-
<button class="lg:hidden fixed right-4 bottom-4 z-20 bg-white dark:bg-gray-800 p-2 rounded-full shadow-lg"
28-
onclick="document.getElementById('toc-mobile').classList.toggle('translate-y-full')">
29-
<svg class="w-6 h-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
30-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"></path>
31-
</svg>
32-
</button>
33-
34-
<!-- 移动端底部目录面板 -->
26+
<!-- 移动端的模板也需要同样的修改 -->
3527
<div id="toc-mobile"
3628
class="lg:hidden fixed bottom-0 inset-x-0 z-30 bg-white dark:bg-gray-800
3729
transform translate-y-full transition duration-200 ease-in-out
@@ -45,17 +37,12 @@
4537
</svg>
4638
</button>
4739
</div>
48-
<nav class="overflow-y-auto max-h-[60vh] space-y-1">
40+
<nav class="overflow-y-auto space-y-1"
41+
style="max-height: calc(60vh - var(--header-height));">
4942
<% function renderTocItemMobile(items) { %>
5043
<% items?.forEach(item => { %>
5144
<div class="pl-<%= (item.level - 1) * 4 %>">
5245
<a href="#<%= item.slug %>"
53-
onclick="event.preventDefault();
54-
const target = document.getElementById('<%= item.slug %>');
55-
const offset = 80;
56-
const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - offset;
57-
window.scrollTo({top: targetPosition, behavior: 'smooth'});
58-
document.getElementById('toc-mobile').classList.add('translate-y-full');"
5946
class="block px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md transition-colors duration-150 toc-link"
6047
data-slug="<%= item.slug %>">
6148
<%- item.text %>

0 commit comments

Comments
 (0)