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;
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>
);
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>);
}
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 ]);
}
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>
)
}