Skip to content

Instantly share code, notes, and snippets.

@kjk
Last active November 9, 2024 14:46
Show Gist options
  • Save kjk/d9343c3f45d9f529b2b8156048254840 to your computer and use it in GitHub Desktop.
Save kjk/d9343c3f45d9f529b2b8156048254840 to your computer and use it in GitHub Desktop.
Notion-like table of contents in plain JavaScript / CSS
/* http://localhost:9307/a-ytv9/implementing-notion-like-table-of-contents-in-javascript.html */
.toc-wrapper {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 50;
max-height: calc(100vh - 2rem);
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #d3d3d3;
border-radius: 8px;
}
&::-webkit-scrollbar-track {
background-color: #fafafa;
}
}
.toc-mini {
display: flex;
flex-direction: column;
font-size: 6pt;
cursor: pointer;
}
.toc-list {
display: none;
flex-direction: column;
font-size: 10pt;
/* line-height: 1.4; */
background-color: white;
padding-left: 8px;
padding-right: 8px;
padding: 12px 12px;
/* margin-right: 10px; */
background-color: white;
box-shadow:
rgba(15, 15, 15, 0.04) 0px 0px 0px 1px,
rgba(15, 15, 15, 0.03) 0px 3px 6px,
rgba(15, 15, 15, 0.06) 0px 9px 24px;
border: 1px solid rgba(55, 53, 47, 0.26);
border-radius: 8px;
}
.toc-trunc {
max-width: 32ch;
min-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toc-wrapper:hover > .toc-mini {
display: none;
}
.toc-wrapper:hover > .toc-list {
display: flex;
}
.toc-item {
cursor: pointer;
&:hover {
background-color: #f6f6f6;
}
}
.toc-bold {
font-weight: bold;
}
.toc-light {
color: lightgray;
}
.toc-ind-1 {
padding-left: 4px;
}
.toc-ind-2 {
padding-left: 8px;
}
.toc-ind-3 {
padding-left: 12px;
}
.toc-ind-4 {
padding-left: 16px;
}
.toc-ind-5 {
padding-left: 20px;
}
.toc-ind-6 {
padding-left: 24px;
}
// http://localhost:9307/a-ytv9/implementing-notion-like-table-of-contents-in-javascript.html
function getAllHeaders() {
return Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6"));
}
function removeHash(str) {
return str.replace(/#$/, "");
}
class TocItem {
text = "";
hLevel = 0;
nesting = 0;
element;
}
function buildTocItems() {
let allHdrs = getAllHeaders();
let res = [];
for (let el of allHdrs) {
let text = el.innerText.trim();
text = removeHash(text);
text = text.trim();
let hLevel = parseInt(el.tagName[1]);
let h = new TocItem();
h.text = text;
h.hLevel = hLevel;
h.nesting = 0;
h.element = el;
res.push(h);
}
return res;
}
function fixNesting(hdrs) {
let n = hdrs.length;
for (let i = 0; i < n; i++) {
let h = hdrs[i];
if (i == 0) {
h.nesting = 0;
} else {
h.nesting = h.hLevel - 1;
}
}
}
function genTocMini(items) {
let tmp = "";
let t = `<div class="toc-item-mini toc-light">▃</div>`;
for (let i = 0; i < items.length; i++) {
tmp += t;
}
return `<div class="toc-mini">` + tmp + `</div>`;
}
function genTocList(items) {
let tmp = "";
let t = `<div title="{title}" class="toc-item toc-trunc {ind}" onclick=tocGoTo({n})>{text}</div>`;
let n = 0;
for (let h of items) {
let s = t;
s = s.replace("{n}", n);
let ind = "toc-ind-" + h.nesting;
s = s.replace("{ind}", ind);
s = s.replace("{text}", h.text);
s = s.replace("{title}", h.text);
tmp += s;
n++;
}
return `<div class="toc-list">` + tmp + `</div>`;
}
/**
* @param {HTMLElement} el
*/
function highlightElement(el) {
let tempBgColor = "yellow";
let origCol = el.style.backgroundColor;
if (origCol === tempBgColor) {
return;
}
el.style.backgroundColor = tempBgColor;
setTimeout(() => {
el.style.backgroundColor = origCol;
}, 1000);
}
let tocItems = [];
function tocGoTo(n) {
let el = tocItems[n].element;
let y = el.getBoundingClientRect().top + window.scrollY;
let offY = 12;
y -= offY;
window.scrollTo({
top: y,
});
highlightElement(el);
// the above scrollTo() triggers updateClosestToc() which might
// not be accurate so we set the exact selected after a small delay
setTimeout(() => {
showSelectedTocItem(n);
}, 100);
}
function genToc() {
tocItems = buildTocItems();
fixNesting(tocItems);
const container = document.createElement("div");
container.className = "toc-wrapper";
let s = genTocMini(tocItems);
let s2 = genTocList(tocItems);
container.innerHTML = s + s2;
document.body.appendChild(container);
}
function showSelectedTocItem(elIdx) {
// make toc-mini-item black for closest element
let els = document.querySelectorAll(".toc-item-mini");
let cls = "toc-light";
for (let i = 0; i < els.length; i++) {
let el = els[i];
if (i == elIdx) {
el.classList.remove(cls);
} else {
el.classList.add(cls);
}
}
// make toc-item bold for closest element
els = document.querySelectorAll(".toc-item");
cls = "toc-bold";
for (let i = 0; i < els.length; i++) {
let el = els[i];
if (i == elIdx) {
el.classList.add(cls);
} else {
el.classList.remove(cls);
}
}
}
function updateClosestToc() {
let closestIdx = -1;
let closestDistance = Infinity;
for (let i = 0; i < tocItems.length; i++) {
let tocItem = tocItems[i];
const rect = tocItem.element.getBoundingClientRect();
const distanceFromTop = Math.abs(rect.top);
if (
distanceFromTop < closestDistance &&
rect.bottom > 0 &&
rect.top < window.innerHeight
) {
closestDistance = distanceFromTop;
closestIdx = i;
}
}
if (closestIdx >= 0) {
showSelectedTocItem(closestIdx);
}
}
window.addEventListener("scroll", updateClosestToc);
genToc();
updateClosestToc();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment