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 051abae390
commit 62df7ed6cd
2 changed files with 138 additions and 28 deletions

View File

@@ -1,32 +1,75 @@
{{define "title"}} — {{.Doc.Title}}{{end}}
{{define "container-class"}}read-container{{end}}
{{define "content"}}
<div class="read-header">
<h2>{{.Doc.Title}}</h2>
<div class="read-meta">
<span>Pushed by {{.Doc.PushedBy}}</span>
<span>{{.Doc.PushedAt}}</span>
</div>
<div class="read-actions">
{{if .Doc.Read}}
<form method="POST" action="/d/{{.Doc.Slug}}/unread" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark unread</button>
</form>
{{else}}
<form method="POST" action="/d/{{.Doc.Slug}}/read" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
</form>
{{end}}
<form method="POST" action="/d/{{.Doc.Slug}}/delete" style="display:inline"
onsubmit="return confirm('Delete this document?')">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm btn-danger">Unqueue</button>
</form>
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
<div class="read-layout">
<nav class="read-toc" id="toc" aria-label="Table of contents"></nav>
<div class="read-main">
<div class="read-header">
<h2>{{.Doc.Title}}</h2>
<div class="read-meta">
<span>Pushed by {{.Doc.PushedBy}}</span>
<span>{{.Doc.PushedAt}}</span>
</div>
<div class="read-actions">
{{if .Doc.Read}}
<form method="POST" action="/d/{{.Doc.Slug}}/unread" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark unread</button>
</form>
{{else}}
<form method="POST" action="/d/{{.Doc.Slug}}/read" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
</form>
{{end}}
<form method="POST" action="/d/{{.Doc.Slug}}/delete" style="display:inline"
onsubmit="return confirm('Delete this document?')">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm btn-danger">Unqueue</button>
</form>
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
</div>
</div>
<div class="card markdown-body" id="article">
{{.HTML}}
</div>
</div>
</div>
<div class="card markdown-body">
{{.HTML}}
</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}}