Amplenote: Embeddable Sidebar Table of Contents React Component HTML, Javascript & CSS

Do you embed Amplenote content into a blog or help center? Would you like your content to have a sidebar list of all the sections in your main content, that allows users to remain oriented within their help page, even for long & complex pages like the GitClear Encyclopedia of Developer Analytics?


By consuming events passed in from the Amplenote embed, you can implement your own custom sidebar table of contents. Below, we present the code that GitClear uses to implement a scrolling sidebar table of contents, just like the ones shown when navigating pages from the Amplenote Help Center (like the Amplecap help docs).


linkFunctionality demo

For a live demo, please visit the aforementioned Encyclopedia of Developer Metrics, where this component is utilized to render some 70+ sections. You can also click around pages in the GitClear Help Section or GitClear Blog, where you'll find the sidebar embedded in pages with varying numbers of headers.


linkFunctional React component: AmplenoteSidebarToc

Here is the functional React component that GitClear is using to load a table of contents alongside its Amplenote page embed:


First, amplenote-sidebar-toc.js. Note that lodash is not strictly necessary, but scroll events fire a lot, so your page could experience some amount of slowdown if you don't use some flavor of "debounce" or "throttle" for handling these noisy events:

import { throttle } from "lodash"
import { useCallback, useEffect, useRef, useState } from "react"
 
import "./amplenote-sidebar-toc.scss"
 
const MAX_ITEMS_TO_SEARCH_FOR_TOC = 3;
const MIN_SECTIONS_TO_SHOW = 5;
const THROTTLE_MS = 500;
const TOP_ROW_HEIGHT = 40;
 
// --------------------------------------------------------------------------
function ensureSelectedLinkVisible(highlightedAnchorName, stickyParentRef) {
if (stickyParentRef.current && highlightedAnchorName) {
const listContainerEl = stickyParentRef.current;
// Can't use querySelector directly since name attributes can contain punctuation, which causes "invalid querySelector" exception
const headerListAnchorEl = Array.from(listContainerEl.querySelectorAll("a")).find(a => a.getAttribute("name") === `sidebar-${ highlightedAnchorName }`);
if (headerListAnchorEl && (headerListAnchorEl.offsetTop > (listContainerEl.offsetHeight + listContainerEl.scrollTop) ||
(headerListAnchorEl.offsetTop < listContainerEl.scrollTop))) {
listContainerEl.scrollTo({ top: headerListAnchorEl.offsetTop - listContainerEl.offsetHeight / 2, behavior: "smooth" });
}
}
}
 
// --------------------------------------------------------------------------
function receiveAmplenoteToc(element, setContentSections, toc) {
if (toc && toc.length >= MIN_SECTIONS_TO_SHOW) {
setContentSections(toc);
} else {
console.debug("Not showing Amplenote TOC - only", toc ? toc.length : 0, "sections received");
}
}
 
// --------------------------------------------------------------------------
function renderContentSections(contentSections, highlightedSectionAnchor, onHeaderClick) {
let depthsTraversed = {};
const traverseContentSections = sectionsExcludingToc(contentSections);
 
return (
<div className="header_list_container">
{
traverseContentSections.map(headerDetail => {
let result = (
<a
className={ `header_link ${ headerDetail.tagName }` }
href={ headerDetail.href }
name={ `sidebar-${ headerDetail.anchorName }` }
onClick={ onHeaderClick }
>
{ headerDetail.text }
</a>
);
 
// Sometimes the content author decides to skip a header level (e.g., going from h1 to h3) but we don't
// want to double-indent if there was no heading above this at the intermediary levels, so we subtract any levels that
// weren't visited in the traverse toward this header depth
if (Object.keys(depthsTraversed).length > headerDetail.depth) {
Object.keys(depthsTraversed).forEach(key => key > headerDetail.depth ? (delete depthsTraversed[key]) : null);
}
depthsTraversed[headerDetail.depth] = true;
const depthsSkipped = headerDetail.depth - Object.keys(depthsTraversed).length;
 
for (let i = (headerDetail.depth - depthsSkipped); i > 1; i--) {
result = (<div className={ `header_depth_${ i }` }>{ result }</div>);
}
 
// Wrap the anchor in a div that can be targeted. This div might wrap the 0-many nested divs (creating levels of depth for the link)
return (
<div
className={ `header_row ${ highlightedSectionAnchor === headerDetail.anchorName ? "current_scrolled_section" : "" }` }
key={ `header-${ headerDetail.index }` }
>
{ result }
</div>
);
})
}
</div>
)
}
 
// --------------------------------------------------------------------------
function sectionsExcludingToc(contentSections) {
let foundToc = false;
return contentSections.filter((section, index) => {
if (index >= MAX_ITEMS_TO_SEARCH_FOR_TOC && !foundToc) return true;
 
if (section.text?.toLowerCase() === "table of contents") {
foundToc = section.depth;
return false;
} else if (foundToc) {
if (section.depth <= foundToc) {
foundToc = false;
return true;
} else {
return false;
}
} else {
return true;
}
})
}
 
// --------------------------------------------------------------------------
function findStickyParent(sidebarRef, stickyParentRef) {
if (sidebarRef.current) {
let element = sidebarRef.current.parentElement;
while (element) {
const computedStyle = window.getComputedStyle(element);
if (computedStyle.position === "sticky") {
stickyParentRef.current = element;
break;
}
element = element.parentElement;
}
}
}
 
// --------------------------------------------------------------------------
function setupAmplenoteTocEventHandlers(handleTocUpdate, highlightHeaderRef, handleScrollHighlight, scrollToAnchorRef, setHighlightedSection) {
window.onAmpleEmbedTOC = handleTocUpdate;
window.onAmpleEmbedHeadingHighlight = function(headerDetail) {
handleScrollHighlight(headerDetail, setHighlightedSection);
};
 
return () => {
window.onAmpleEmbedTOC = null;
window.onAmpleEmbedHeadingHighlight = null;
highlightHeaderRef.current = null;
scrollToAnchorRef.current = null;
}
}
 
// --------------------------------------------------------------------------
function useScrollListener(queryHeaderRef, ) {
useEffect(() => {
const onContentScroll = () => {
if (queryHeaderRef.current) {
queryHeaderRef.current();
}
}
 
const throttled = throttle(onContentScroll, THROTTLE_MS);
window.addEventListener("scroll", throttled, { passive: true });
 
return () => {
window.removeEventListener("scroll", throttled);
};
}, []);
}
 
// -----------------------------------------------------------------------------
export function useWindowHeight(viewerRef, heightOffset = 0, debounceMs = THROTTLE_MS) {
const setViewRefHeight = () => {
if (viewerRef.current) {
viewerRef.current.style.maxHeight = `${ window.innerHeight - heightOffset }px`;
}
}
useEffect(() => {
const resizeListener = throttle(() => {
setViewRefHeight();
}, THROTTLE_MS);
setViewRefHeight();
window.addEventListener("resize", resizeListener);
return () => {
window.removeEventListener("resize", resizeListener);
}
}, [ viewerRef ]);
}
 
// --------------------------------------------------------------------------
// // React component to manage highlighting sidebar links based on scroll position (for Amplenote content)
export default function AmplenoteSidebarToc({ title }) {
const [ collapsed, setCollapsed ] = useState(false);
const [ contentSections, setContentSections ] = useState([]);
const [ highlightedSectionAnchor, setHighlightedSectionAnchor ] = useState(null);
 
const highlightHeaderRef = useRef(null);
const queryHeaderRef = useRef(null);
const sidebarRef = useRef(null);
const scrollToAnchorRef = useRef(null);
const stickyParentRef = useRef(null);
 
const handleTocUpdate = useCallback((element, toc, scrollToAnchorName, queryHighlightedHeader) => {
if (scrollToAnchorRef) scrollToAnchorRef.current = scrollToAnchorName;
if (queryHighlightedHeader) queryHeaderRef.current = queryHighlightedHeader;
receiveAmplenoteToc(element, setContentSections, toc)
}, [ setContentSections ]);
 
const onHeaderClick = useCallback(event => {
event.preventDefault();
 
const anchorName = event.currentTarget.getAttribute("name").replace("sidebar-", "");
history.replaceState(null, "", "#" + anchorName);
if (scrollToAnchorRef.current) scrollToAnchorRef.current(anchorName);
if (highlightHeaderRef.current) highlightHeaderRef.current();
}, [ contentSections ]);
 
const handleScrollHighlight = useCallback((headerDetail, setHighlightedSectionAnchor) => {
if (headerDetail?.anchorName) {
setHighlightedSectionAnchor(headerDetail.anchorName);
}
}, []);
 
useEffect(() => ensureSelectedLinkVisible(highlightedSectionAnchor, stickyParentRef),
[ highlightedSectionAnchor ]);
 
useEffect(() => {
return setupAmplenoteTocEventHandlers(handleTocUpdate, highlightHeaderRef, handleScrollHighlight,
scrollToAnchorRef, setHighlightedSectionAnchor);
}, []);
 
useEffect(() => {
findStickyParent(sidebarRef, stickyParentRef);
}, [ contentSections ]);
 
useScrollListener(queryHeaderRef);
useWindowHeight(stickyParentRef, TOP_ROW_HEIGHT);
 
return (
<div
className={ `amplenote_sidebar_toc_container ${ contentSections.length ? "with_content" : "" }${ collapsed ? " is_collapsed" : "" }` }
ref={ sidebarRef }
>
<div className="sidebar_header">
<h3 className="sidebar_label" title={ title ? `"${ title }" section index` : null }>{ title || "Page Contents" }</h3>
<div className="sidebar_collapser" onClick={ () => setCollapsed(!collapsed) }>
<i className={ `fas fa-caret-${ collapsed ? "down" : "right" }` } />
</div>
</div>
{
renderContentSections(contentSections, highlightedSectionAnchor, onHeaderClick)
}
</div>
)
}


Then, amplenote-sidebar-toc.scss. Note that this code presumes that your initial React component is mounted in a div with data-react-class="AmplenoteSidebarToc", which must be a direct child of the parent div that contains your Amplenote embedded content. More on that in the next section.

// scss
div[data-react-class="AmplenoteSidebarToc"] {
&:has(.amplenote_sidebar_toc_container.with_content) {
background-color: $color-background-primary;
border: 1px solid $color-border-primary;
border-radius: 5px;
margin-left: 20px;
margin-bottom: 10px;
max-height: calc(100vh - 20px);
overflow-y: auto;
position: sticky;
top: 10px;
}
 
&:has(.amplenote_sidebar_toc_container.with_content.is_collapsed) {
background-color: transparent;
border: none;
overflow: hidden;
}
 
.amplenote_sidebar_toc_container {
display: none;
overflow: hidden;
transition: width 0.3s ease;
width: 0;
 
&.with_content {
@include breakpoint(tablet) {
left: 0;
display: block;
margin-left: 5px;
width: 300px;
padding: 10px 0;
top: 10px;
}
 
@include breakpoint(laptop) {
width: 350px;
}
 
@include breakpoint(desktop) {
width: 400px;
}
 
&.is_collapsed {
width: 60px;
 
.header_list_container,
.sidebar_label {
display: none;
}
}
}
 
.sidebar_header {
display: flex;
justify-content: space-between;
padding-left: 10px;
padding-right: 10px;
 
.sidebar_label {
font-weight: 400;
margin: 0;
overflow: hidden;
padding: 4px 0;
text-transform: uppercase;
white-space: nowrap;
text-overflow: ellipsis;
}
 
.sidebar_collapser {
align-items: center;
border: 1px solid $color-border-primary;
border-radius: 5px;
cursor: pointer;
display: flex;
justify-content: center;
margin-right: 6px;
padding: 5px 10px;
 
&:hover {
background-color: $color-background-light-glow;
}
}
}
 
.header_link {
border-radius: 5px;
display: block;
padding: 5px 15px 5px 10px;
transition: 400ms background-color ease;
 
&:hover {
background-color: $color-background-blueish;
}
 
&:active {
background-color: $color-background-blueish-secondary;
}
 
&.h1 {
margin: 4px 0 2px 0;
}
 
&.h2, &.h3, &.h4 {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
}
 
.header_depth_2,
.header_depth_3,
.header_depth_4 {
border-left: 1px solid $color-border-secondary;
margin-left: 12px;
}
}
 
.current_scrolled_section {
font-weight: bold;
}
}


linkAdding the sidebar component to your HTML

To implement the sidebar component, it must be a child of a div that allows it to enact its position: sticky CSS property. On GitClear we structure the sidebar as follows:


<!-- HTML -->
<div class="content_page_container amplenote_content_plus_sidebar">
<div class="amplenote_side">
<div class="amplenote-embed" data-note-token="#{ @content_page.amplenote_public_token }" data-styles="/assets/amplenote/help_style.css">
- frame_src = "https://public.amplenote.com/embed/#{ @content_page.amplenote_public_token }?hostname=www.gitclear.com&styles=%2Fassets%2Famplenote%2Fhelp_style.css"
<iframe frameborder=0 src="#{ frame_src.html_safe }"></iframe>
<script defer src="https://public.amplenote.com/embed.js"></script>
<div data-react-class="AmplenoteSidebarToc" data-react-props="#{ JSON.stringify({ title: @content_page.title }) }"></div>
</div>
</div>


Note how the React component (the div with data-react-class="AmplenoteSidebarToc" that is mounted as a React component on page load) is a sibling of the div that holds the Amplenote content embed.


Finally, a tablespoon of CSS to ensure that the sticky-positioned React component stays visible & anchored atop page:

// SCSS
.amplenote_content_plus_sidebar {
align-items: flex-start;
display: flex;
 
.amplenote_side {
flex: 1;
padding-left: 5px;
}
}


Upon mounting the React component, you should get all the bells & whistles of an auto-scrolling sidebar table of contents.