Add sticky ToC sidebar to document read view

Builds a table of contents client-side from rendered heading IDs.
Highlights the current section on scroll. Collapses to inline on
narrow screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 23:59:01 -07:00
parent 7ab00fc518
commit ed9291a7ad
2 changed files with 138 additions and 28 deletions

View File

@@ -353,8 +353,75 @@ button:hover, .btn:hover {
} }
/* =========================== /* ===========================
Read view Read view — two-column layout
=========================== */ =========================== */
.read-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 2rem 2rem 0;
}
.read-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 2rem;
align-items: start;
}
.read-main {
max-width: 900px;
min-width: 0;
}
.read-toc {
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
padding: 0.5rem 0;
font-size: 0.75rem;
line-height: 1.5;
border-right: 1px solid var(--border-lt);
padding-right: 1rem;
}
.read-toc ul {
list-style: none;
padding: 0;
margin: 0;
}
.read-toc li {
margin-bottom: 0.25rem;
}
.read-toc a {
color: var(--text-lt);
text-decoration: none;
display: block;
padding: 0.125rem 0;
transition: color 0.1s;
}
.read-toc a:hover {
color: var(--text);
text-decoration: none;
}
.read-toc a.toc-active {
color: var(--accent);
}
@media (max-width: 900px) {
.read-container {
padding: 1.5rem 1rem;
}
.read-layout {
grid-template-columns: 1fr;
}
.read-toc {
position: static;
max-height: none;
border-right: none;
border-bottom: 1px solid var(--border-lt);
padding-right: 0;
padding-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
}
.read-header { .read-header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }

View File

@@ -1,5 +1,9 @@
{{define "title"}} — {{.Doc.Title}}{{end}} {{define "title"}} — {{.Doc.Title}}{{end}}
{{define "container-class"}}read-container{{end}}
{{define "content"}} {{define "content"}}
<div class="read-layout">
<nav class="read-toc" id="toc" aria-label="Table of contents"></nav>
<div class="read-main">
<div class="read-header"> <div class="read-header">
<h2>{{.Doc.Title}}</h2> <h2>{{.Doc.Title}}</h2>
<div class="read-meta"> <div class="read-meta">
@@ -26,7 +30,46 @@
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a> <a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
</div> </div>
</div> </div>
<div class="card markdown-body"> <div class="card markdown-body" id="article">
{{.HTML}} {{.HTML}}
</div> </div>
</div>
</div>
<script>
(function(){
var toc=document.getElementById("toc");
var headings=document.querySelectorAll("#article h1, #article h2, #article h3");
if(headings.length<2){toc.style.display="none";return;}
var ul=document.createElement("ul");
var minLevel=6;
headings.forEach(function(h){var l=parseInt(h.tagName[1]);if(l<minLevel)minLevel=l;});
headings.forEach(function(h){
if(!h.id)return;
var li=document.createElement("li");
var a=document.createElement("a");
a.href="#"+h.id;
a.textContent=h.textContent;
var depth=parseInt(h.tagName[1])-minLevel;
li.style.paddingLeft=(depth*0.75)+"rem";
li.appendChild(a);
ul.appendChild(li);
});
toc.appendChild(ul);
/* highlight current section on scroll */
var links=toc.querySelectorAll("a");
var ids=[];links.forEach(function(a){ids.push(a.getAttribute("href").slice(1));});
function onScroll(){
var current="";
for(var i=0;i<ids.length;i++){
var el=document.getElementById(ids[i]);
if(el&&el.getBoundingClientRect().top<=80)current=ids[i];
}
links.forEach(function(a){
a.classList.toggle("toc-active",a.getAttribute("href")==="#"+current);
});
}
window.addEventListener("scroll",onScroll,{passive:true});
onScroll();
})();
</script>
{{end}} {{end}}