Last active
November 9, 2024 14:46
-
-
Save kjk/d9343c3f45d9f529b2b8156048254840 to your computer and use it in GitHub Desktop.
Notion-like table of contents in plain JavaScript / CSS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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