mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Add toc
This commit is contained in:
Binary file not shown.
@@ -323,6 +323,130 @@
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Table of Contents Layout ===== */
|
||||
#post-toc-layout {
|
||||
max-width: 80rem; /* max-w-6xl equivalent */
|
||||
margin: 0.5rem auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
#toc-sidebar {
|
||||
display: none; /* hidden by default (mobile) */
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
#post-toc-layout {
|
||||
display: flex;
|
||||
align-items: stretch; /* sidebar stretches to match content height — required for sticky */
|
||||
gap: 1.5rem;
|
||||
}
|
||||
#toc-sidebar {
|
||||
display: block;
|
||||
width: 16rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#toc-sidebar-inner {
|
||||
position: sticky;
|
||||
top: 5rem; /* sits just below fixed navbar (~64px) */
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
#post-main-content {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
#toc-mobile-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
#toc-sidebar-inner > div {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#toc-sidebar .toc-link {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s, background-color 0.15s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
#toc-sidebar .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; }
|
||||
#toc-sidebar .toc-link.toc-active {
|
||||
color: #1d4ed8;
|
||||
border-left-color: #1d4ed8;
|
||||
font-weight: 500;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
#toc-sidebar .toc-h2 { font-weight: 500; color: #374151; }
|
||||
#toc-sidebar .toc-h3,
|
||||
#toc-sidebar .toc-h4 { font-size: 0.775rem; }
|
||||
|
||||
/* Mobile TOC button */
|
||||
#toc-mobile-btn {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 40;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
#toc-mobile-btn:hover { background: #1d4ed8; }
|
||||
|
||||
/* Mobile drawer */
|
||||
#toc-mobile-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 40;
|
||||
}
|
||||
#toc-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 18rem;
|
||||
max-width: 80vw;
|
||||
background: #fff;
|
||||
z-index: 50;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,.25);
|
||||
overflow-y: auto;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
#toc-nav-mobile .toc-link {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
#toc-nav-mobile .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; }
|
||||
#toc-nav-mobile .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -357,8 +481,21 @@
|
||||
|
||||
|
||||
|
||||
<!-- Main Content - Optimized Layout -->
|
||||
<div class="main-content max-w-5xl mx-auto px-2 py-2">
|
||||
<!-- Two-column layout: sticky TOC sidebar + main content -->
|
||||
<div id="post-toc-layout">
|
||||
|
||||
<!-- TOC Sidebar (desktop only, sticky, populated by JS) -->
|
||||
<aside id="toc-sidebar">
|
||||
<div id="toc-sidebar-inner">
|
||||
<div>
|
||||
<p style="font-size:0.7rem;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.75rem;user-select:none;">Contents</p>
|
||||
<nav id="toc-nav"></nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content" id="post-main-content">
|
||||
<!-- Title and Actions - Responsive Layout -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 sm:flex-1">{{ post.title }}</h1>
|
||||
@@ -555,6 +692,31 @@
|
||||
{% trans "Append" %}
|
||||
</button>
|
||||
</div>
|
||||
</div><!-- end post-main-content -->
|
||||
</div><!-- end post-toc-layout -->
|
||||
|
||||
<!-- Mobile TOC floating button -->
|
||||
<button id="toc-mobile-btn" title="Table of Contents" aria-label="Table of Contents">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile TOC backdrop -->
|
||||
<div id="toc-mobile-backdrop" aria-hidden="true"></div>
|
||||
|
||||
<!-- Mobile TOC drawer -->
|
||||
<div id="toc-mobile-drawer">
|
||||
<div style="padding:1rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.75rem;border-bottom:1px solid #f3f4f6;">
|
||||
<p style="font-weight:600;color:#374151;margin:0;">{% trans "Contents" %}</p>
|
||||
<button id="toc-mobile-close" style="background:none;border:none;cursor:pointer;padding:0.25rem;color:#9ca3af;border-radius:0.25rem;" aria-label="Close">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="toc-nav-mobile"></nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -952,5 +1114,145 @@ function regenerateWithVoice() {
|
||||
regenerateBtn.textContent = originalText;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Table of Contents
|
||||
// ============================================================
|
||||
(function() {
|
||||
function buildTOC() {
|
||||
const article = document.querySelector('article.prose');
|
||||
if (!article) return;
|
||||
|
||||
const headings = article.querySelectorAll('h2, h3, h4');
|
||||
if (headings.length < 2) {
|
||||
// Not enough headings - hide TOC sidebar and mobile button
|
||||
const sidebar = document.getElementById('toc-sidebar');
|
||||
const mobileBtn = document.getElementById('toc-mobile-btn');
|
||||
if (sidebar) sidebar.style.display = 'none';
|
||||
if (mobileBtn) mobileBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
function createLinks(navEl) {
|
||||
navEl.innerHTML = '';
|
||||
headings.forEach(function(heading) {
|
||||
// Ensure heading has an id (toc extension adds these, but fallback just in case)
|
||||
if (!heading.id) {
|
||||
heading.id = heading.textContent.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
const level = parseInt(heading.tagName[1]); // 2, 3, or 4
|
||||
const link = document.createElement('a');
|
||||
link.href = '#' + heading.id;
|
||||
link.textContent = heading.textContent;
|
||||
link.classList.add('toc-link', 'toc-h' + level);
|
||||
link.setAttribute('data-heading-id', heading.id);
|
||||
|
||||
// Visual indentation by heading level
|
||||
link.style.paddingLeft = ((level - 2) * 12 + 8) + 'px';
|
||||
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
closeMobileDrawer();
|
||||
const target = document.getElementById(heading.id);
|
||||
if (target) {
|
||||
const nav = document.getElementById('main-nav');
|
||||
const navH = nav ? nav.offsetHeight : 70;
|
||||
const y = target.getBoundingClientRect().top + window.scrollY - navH - 16;
|
||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
navEl.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
const tocNav = document.getElementById('toc-nav');
|
||||
const tocNavMobile = document.getElementById('toc-nav-mobile');
|
||||
if (tocNav) createLinks(tocNav);
|
||||
if (tocNavMobile) createLinks(tocNavMobile);
|
||||
|
||||
// Scroll-spy: find the heading whose top is closest above the viewport threshold
|
||||
var _rafPending = false;
|
||||
function onScroll() {
|
||||
if (_rafPending) return;
|
||||
_rafPending = true;
|
||||
requestAnimationFrame(function() {
|
||||
_rafPending = false;
|
||||
var nav = document.getElementById('main-nav');
|
||||
var navH = nav ? nav.offsetHeight : 64;
|
||||
var threshold = navH + 24; // a bit below the navbar
|
||||
var activeId = null;
|
||||
// Walk headings in reverse: pick the last one whose top <= threshold
|
||||
for (var i = headings.length - 1; i >= 0; i--) {
|
||||
var rect = headings[i].getBoundingClientRect();
|
||||
if (rect.top <= threshold) {
|
||||
activeId = headings[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If none is above threshold, highlight the first heading
|
||||
if (!activeId && headings.length > 0) activeId = headings[0].id;
|
||||
highlightTOC(activeId);
|
||||
// Auto-scroll the active TOC link into view within the sidebar
|
||||
var activeLinkInSidebar = document.querySelector('#toc-nav .toc-link.toc-active');
|
||||
if (activeLinkInSidebar) {
|
||||
var sidebarInner = document.getElementById('toc-sidebar-inner');
|
||||
if (sidebarInner) {
|
||||
var linkTop = activeLinkInSidebar.offsetTop;
|
||||
var linkH = activeLinkInSidebar.offsetHeight;
|
||||
var scrollTop = sidebarInner.scrollTop;
|
||||
var sidebarH = sidebarInner.clientHeight;
|
||||
if (linkTop < scrollTop) {
|
||||
sidebarInner.scrollTop = linkTop - 8;
|
||||
} else if (linkTop + linkH > scrollTop + sidebarH) {
|
||||
sidebarInner.scrollTop = linkTop + linkH - sidebarH + 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll(); // run once on load
|
||||
|
||||
function highlightTOC(id) {
|
||||
document.querySelectorAll('.toc-link').forEach(function(a) {
|
||||
if (id && a.getAttribute('data-heading-id') === id) {
|
||||
a.classList.add('toc-active');
|
||||
} else {
|
||||
a.classList.remove('toc-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openMobileDrawer() {
|
||||
const drawer = document.getElementById('toc-mobile-drawer');
|
||||
const backdrop = document.getElementById('toc-mobile-backdrop');
|
||||
if (drawer) drawer.style.transform = 'translateX(0)';
|
||||
if (backdrop) backdrop.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeMobileDrawer() {
|
||||
const drawer = document.getElementById('toc-mobile-drawer');
|
||||
const backdrop = document.getElementById('toc-mobile-backdrop');
|
||||
if (drawer) drawer.style.transform = 'translateX(-100%)';
|
||||
if (backdrop) backdrop.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
buildTOC();
|
||||
const mobileBtn = document.getElementById('toc-mobile-btn');
|
||||
const mobileClose = document.getElementById('toc-mobile-close');
|
||||
const backdrop = document.getElementById('toc-mobile-backdrop');
|
||||
if (mobileBtn) mobileBtn.addEventListener('click', openMobileDrawer);
|
||||
if (mobileClose) mobileClose.addEventListener('click', closeMobileDrawer);
|
||||
if (backdrop) backdrop.addEventListener('click', closeMobileDrawer);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -75,34 +75,143 @@
|
||||
.think-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ===== Table of Contents Layout ===== */
|
||||
#post-toc-layout {
|
||||
max-width: 80rem;
|
||||
margin: 2.5rem auto 0;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
#toc-sidebar { display: none; }
|
||||
@media (min-width: 1024px) {
|
||||
#post-toc-layout { display: flex; align-items: stretch; gap: 1.5rem; }
|
||||
#toc-sidebar { display: block; width: 16rem; flex-shrink: 0; }
|
||||
#toc-sidebar-inner { position: sticky; top: 1.5rem; max-height: calc(100vh - 4rem); overflow-y: auto; }
|
||||
#post-main-content { flex: 1 1 0%; min-width: 0; }
|
||||
#toc-mobile-btn { display: none !important; }
|
||||
}
|
||||
#toc-sidebar-inner > div {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,.07);
|
||||
}
|
||||
#toc-sidebar .toc-link {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s, background-color 0.15s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
#toc-sidebar .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; }
|
||||
#toc-sidebar .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; background-color: #eff6ff; }
|
||||
#toc-sidebar .toc-h2 { font-weight: 500; color: #374151; }
|
||||
#toc-sidebar .toc-h3, #toc-sidebar .toc-h4 { font-size: 0.775rem; }
|
||||
|
||||
/* Mobile button */
|
||||
#toc-mobile-btn {
|
||||
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 40;
|
||||
background: #2563eb; color: #fff; border: none; border-radius: 9999px;
|
||||
width: 3rem; height: 3rem; cursor: pointer;
|
||||
box-shadow: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
#toc-mobile-btn:hover { background: #1d4ed8; }
|
||||
#toc-mobile-backdrop {
|
||||
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 40;
|
||||
}
|
||||
#toc-mobile-drawer {
|
||||
position: fixed; top: 0; left: 0; height: 100%;
|
||||
width: 18rem; max-width: 80vw; background: #fff; z-index: 50;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,.25); overflow-y: auto;
|
||||
transform: translateX(-100%); transition: transform 0.3s ease;
|
||||
}
|
||||
#toc-nav-mobile .toc-link {
|
||||
display: block; font-size: 0.875rem; line-height: 1.7;
|
||||
color: #374151; text-decoration: none; padding: 0.3rem 0.5rem;
|
||||
border-left: 2px solid transparent; transition: color 0.15s, border-color 0.15s;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
#toc-nav-mobile .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; }
|
||||
#toc-nav-mobile .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="max-w-4xl mx-auto mt-10 p-8 bg-white shadow-md rounded-lg">
|
||||
<!-- Post Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ post.title }}</h1>
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500">
|
||||
<span>{{ post.created_at|date:"Y-m-d H:i" }}</span>
|
||||
</div>
|
||||
<!-- Two-column layout: sticky TOC sidebar + main content -->
|
||||
<div id="post-toc-layout">
|
||||
|
||||
<!-- TOC Sidebar (desktop only, sticky, populated by JS) -->
|
||||
<aside id="toc-sidebar">
|
||||
<div id="toc-sidebar-inner">
|
||||
<div>
|
||||
<p style="font-size:0.7rem;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.75rem;user-select:none;">Contents</p>
|
||||
<nav id="toc-nav"></nav>
|
||||
</div>
|
||||
</div>
|
||||
{% if post.tags.exists %}
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
{% for tag in post.tags.all %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="prose">
|
||||
{% markdown_with_tasks post.content post.task_states %}
|
||||
<!-- Main Content -->
|
||||
<div id="post-main-content" style="padding:2rem;background:#fff;box-shadow:0 1px 3px 0 rgba(0,0,0,.1);border-radius:0.5rem;">
|
||||
<!-- Post Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ post.title }}</h1>
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500">
|
||||
<span>{{ post.created_at|date:"Y-m-d H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if post.tags.exists %}
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
{% for tag in post.tags.all %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="prose">
|
||||
{% markdown_with_tasks post.content post.task_states %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile TOC floating button -->
|
||||
<button id="toc-mobile-btn" title="Table of Contents" aria-label="Table of Contents">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile TOC backdrop -->
|
||||
<div id="toc-mobile-backdrop" aria-hidden="true"></div>
|
||||
|
||||
<!-- Mobile TOC drawer -->
|
||||
<div id="toc-mobile-drawer">
|
||||
<div style="padding:1rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.75rem;border-bottom:1px solid #f3f4f6;">
|
||||
<p style="font-weight:600;color:#374151;margin:0;">Contents</p>
|
||||
<button id="toc-mobile-close" style="background:none;border:none;cursor:pointer;padding:0.25rem;color:#9ca3af;border-radius:0.25rem;" aria-label="Close">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="toc-nav-mobile"></nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,6 +255,130 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Table of Contents
|
||||
// ============================================================
|
||||
(function() {
|
||||
function buildTOC() {
|
||||
const proseEl = document.querySelector('.prose');
|
||||
if (!proseEl) return;
|
||||
|
||||
const headings = proseEl.querySelectorAll('h2, h3, h4');
|
||||
if (headings.length < 2) {
|
||||
const sidebar = document.getElementById('toc-sidebar');
|
||||
const mobileBtn = document.getElementById('toc-mobile-btn');
|
||||
if (sidebar) sidebar.style.display = 'none';
|
||||
if (mobileBtn) mobileBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
function createLinks(navEl) {
|
||||
navEl.innerHTML = '';
|
||||
headings.forEach(function(heading) {
|
||||
if (!heading.id) {
|
||||
heading.id = heading.textContent.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
const level = parseInt(heading.tagName[1]);
|
||||
const link = document.createElement('a');
|
||||
link.href = '#' + heading.id;
|
||||
link.textContent = heading.textContent;
|
||||
link.classList.add('toc-link', 'toc-h' + level);
|
||||
link.setAttribute('data-heading-id', heading.id);
|
||||
link.style.paddingLeft = ((level - 2) * 12 + 8) + 'px';
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
closeMobileDrawer();
|
||||
const target = document.getElementById(heading.id);
|
||||
if (target) {
|
||||
const y = target.getBoundingClientRect().top + window.scrollY - 24;
|
||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
navEl.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
const tocNav = document.getElementById('toc-nav');
|
||||
const tocNavMobile = document.getElementById('toc-nav-mobile');
|
||||
if (tocNav) createLinks(tocNav);
|
||||
if (tocNavMobile) createLinks(tocNavMobile);
|
||||
|
||||
// Scroll-spy: find heading closest above the viewport threshold
|
||||
var _rafPending = false;
|
||||
function onScroll() {
|
||||
if (_rafPending) return;
|
||||
_rafPending = true;
|
||||
requestAnimationFrame(function() {
|
||||
_rafPending = false;
|
||||
var threshold = 40;
|
||||
var activeId = null;
|
||||
for (var i = headings.length - 1; i >= 0; i--) {
|
||||
if (headings[i].getBoundingClientRect().top <= threshold) {
|
||||
activeId = headings[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!activeId && headings.length > 0) activeId = headings[0].id;
|
||||
highlightTOC(activeId);
|
||||
var activeLinkInSidebar = document.querySelector('#toc-nav .toc-link.toc-active');
|
||||
if (activeLinkInSidebar) {
|
||||
var sidebarInner = document.getElementById('toc-sidebar-inner');
|
||||
if (sidebarInner) {
|
||||
var linkTop = activeLinkInSidebar.offsetTop;
|
||||
var linkH = activeLinkInSidebar.offsetHeight;
|
||||
var scrollTop = sidebarInner.scrollTop;
|
||||
var sidebarH = sidebarInner.clientHeight;
|
||||
if (linkTop < scrollTop) sidebarInner.scrollTop = linkTop - 8;
|
||||
else if (linkTop + linkH > scrollTop + sidebarH) sidebarInner.scrollTop = linkTop + linkH - sidebarH + 8;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
|
||||
function highlightTOC(id) {
|
||||
document.querySelectorAll('.toc-link').forEach(function(a) {
|
||||
if (id && a.getAttribute('data-heading-id') === id) {
|
||||
a.classList.add('toc-active');
|
||||
} else {
|
||||
a.classList.remove('toc-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openMobileDrawer() {
|
||||
const drawer = document.getElementById('toc-mobile-drawer');
|
||||
const backdrop = document.getElementById('toc-mobile-backdrop');
|
||||
if (drawer) drawer.style.transform = 'translateX(0)';
|
||||
if (backdrop) backdrop.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeMobileDrawer() {
|
||||
const drawer = document.getElementById('toc-mobile-drawer');
|
||||
const backdrop = document.getElementById('toc-mobile-backdrop');
|
||||
if (drawer) drawer.style.transform = 'translateX(-100%)';
|
||||
if (backdrop) backdrop.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
buildTOC();
|
||||
const mobileBtn = document.getElementById('toc-mobile-btn');
|
||||
const mobileClose = document.getElementById('toc-mobile-close');
|
||||
const backdrop = document.getElementById('toc-mobile-backdrop');
|
||||
if (mobileBtn) mobileBtn.addEventListener('click', openMobileDrawer);
|
||||
if (mobileClose) mobileClose.addEventListener('click', closeMobileDrawer);
|
||||
if (backdrop) backdrop.addEventListener('click', closeMobileDrawer);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,6 +17,7 @@ def markdown(value):
|
||||
return mark_safe(md.markdown(value, extensions=['markdown.extensions.fenced_code',
|
||||
'markdown.extensions.tables',
|
||||
'markdown.extensions.nl2br',
|
||||
'markdown.extensions.toc',
|
||||
think_markdown.ThinkExtension()]))
|
||||
|
||||
|
||||
@@ -37,6 +38,7 @@ def markdown_with_tasks(content, task_states=None):
|
||||
'markdown.extensions.fenced_code',
|
||||
'markdown.extensions.tables',
|
||||
'markdown.extensions.nl2br',
|
||||
'markdown.extensions.toc',
|
||||
think_markdown.ThinkExtension(),
|
||||
tasklist_markdown.TaskListExtension(task_states=task_states)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user