|
import { addMeltEventListener, makeElement, createElHelpers, executeCallbacks, } from '../../internal/helpers/index.js'; |
|
import { dequal } from 'dequal'; |
|
import { derived, writable } from 'svelte/store'; |
|
import { safeOnMount } from '../../internal/helpers/lifecycle.js'; |
|
import { withGet } from '../../internal/helpers/withGet.js'; |
|
const defaults = { |
|
exclude: ['h1'], |
|
scrollOffset: 0, |
|
scrollBehaviour: 'smooth', |
|
activeType: 'lowest', |
|
}; |
|
export function createTableOfContents(args) { |
|
const argsWithDefaults = { ...defaults, ...args }; |
|
const { selector, exclude, activeType, scrollBehaviour, scrollOffset, headingFilterFn, scrollFn, } = argsWithDefaults; |
|
const { name } = createElHelpers('table-of-contents'); |
|
|
|
const possibleHeadings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; |
|
let headingsList = []; |
|
let elementsList = []; |
|
|
|
let elementHeadingLU = {}; |
|
|
|
let headingParentsLU = {}; |
|
|
|
const activeParentIdxs = withGet.writable([]); |
|
|
|
const visibleElementIdxs = withGet.writable([]); |
|
let elementTarget = null; |
|
let mutationObserver = null; |
|
let observer = null; |
|
const observer_threshold = 0.01; |
|
|
|
const activeHeadingIdxs = withGet(writable([])); |
|
const headingsTree = withGet(writable([])); |
|
|
|
function generateInitialLists(elementTarget) { |
|
let headingsList = []; |
|
let elementsList = []; |
|
const includedHeadings = possibleHeadings.filter((h) => !exclude.includes(h)); |
|
const targetHeaders = elementTarget?.querySelectorAll(includedHeadings.join(', ')); |
|
|
|
targetHeaders?.forEach((el) => { |
|
if (!el.id) { |
|
const uniqueID = el.innerText |
|
.replaceAll(/[^a-zA-Z0-9 ]/g, '') |
|
.replaceAll(' ', '-') |
|
.toLowerCase(); |
|
el.id = `${uniqueID}`; |
|
} |
|
headingsList.push(el); |
|
}); |
|
headingsList = [...headingsList]; |
|
if (headingFilterFn) { |
|
headingsList = headingsList.filter((heading) => headingFilterFn(heading)); |
|
} |
|
|
|
elementsList = [].slice.call(elementTarget?.getElementsByTagName('*')); |
|
|
|
elementsList = elementsList.filter((el) => includedHeadings.includes(el.nodeName.toLowerCase()) || el.children.length === 0); |
|
|
|
elementsList.splice(0, elementsList.indexOf(headingsList[0])); |
|
return { |
|
headingsList, |
|
elementsList, |
|
}; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
function createTree(arr, startIndex = 0) { |
|
const tree = []; |
|
let i = 0; |
|
while (i < arr.length) { |
|
const node = { |
|
title: arr[i].innerText, |
|
index: startIndex + i, |
|
id: arr[i].id, |
|
node: arr[i], |
|
children: [], |
|
}; |
|
let j = i + 1; |
|
while (j < arr.length && |
|
parseInt(arr[j].tagName.charAt(1)) > parseInt(arr[i].tagName.charAt(1))) { |
|
j++; |
|
} |
|
|
|
node.children = createTree(arr.slice(i + 1, j), startIndex + i + 1); |
|
tree.push(node); |
|
i = j; |
|
} |
|
return tree; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function scrollToTargetAdjusted(selector) { |
|
const element = document.getElementById(selector); |
|
if (element) { |
|
const elementPosition = element.getBoundingClientRect().top; |
|
const offsetPosition = elementPosition + window.scrollY - scrollOffset; |
|
window.scrollTo({ |
|
top: offsetPosition, |
|
behavior: scrollBehaviour, |
|
}); |
|
} |
|
} |
|
const shouldHighlightParents = activeType === 'highest-parents' || |
|
activeType === 'lowest-parents' || |
|
activeType === 'all-parents'; |
|
function handleElementObservation(entries) { |
|
|
|
for (let i = 0; i < entries.length; i++) { |
|
|
|
const el_idx = elementsList.indexOf(entries[i].target); |
|
const toc_idx = elementHeadingLU[el_idx]; |
|
let tempVisibleElementIdxs = visibleElementIdxs.get(); |
|
if (entries[i].intersectionRatio >= observer_threshold) { |
|
|
|
if (tempVisibleElementIdxs.indexOf(el_idx) === -1) { |
|
tempVisibleElementIdxs = [...tempVisibleElementIdxs, el_idx]; |
|
visibleElementIdxs.set(tempVisibleElementIdxs); |
|
|
|
if (shouldHighlightParents && headingParentsLU[toc_idx]) { |
|
activeParentIdxs.update((prev) => { |
|
return [...prev, ...headingParentsLU[toc_idx]]; |
|
}); |
|
} |
|
} |
|
} |
|
else { |
|
|
|
tempVisibleElementIdxs = tempVisibleElementIdxs.filter((item) => item !== el_idx); |
|
visibleElementIdxs.set(tempVisibleElementIdxs); |
|
|
|
if (shouldHighlightParents && headingParentsLU[toc_idx]) { |
|
activeParentIdxs.update((prev) => { |
|
const newArr = [...prev]; |
|
headingParentsLU[toc_idx]?.forEach((parent) => { |
|
const index = newArr.indexOf(parent); |
|
newArr.splice(index, 1); |
|
}); |
|
return newArr; |
|
}); |
|
} |
|
} |
|
} |
|
const allActiveHeaderIdxs = Array.from(new Set(visibleElementIdxs.get().map((idx) => elementHeadingLU[idx]))); |
|
let activeHeaderIdxs; |
|
if (allActiveHeaderIdxs.length === 0) { |
|
activeHeaderIdxs = []; |
|
} |
|
else { |
|
switch (activeType) { |
|
case 'highest': |
|
activeHeaderIdxs = [Math.min(...allActiveHeaderIdxs)]; |
|
break; |
|
case 'lowest': |
|
activeHeaderIdxs = [Math.max(...allActiveHeaderIdxs)]; |
|
break; |
|
case 'all': |
|
activeHeaderIdxs = allActiveHeaderIdxs; |
|
break; |
|
case 'all-parents': { |
|
const parentIdxs = allActiveHeaderIdxs.flatMap((idx) => headingParentsLU[idx] ?? []); |
|
activeHeaderIdxs = [...allActiveHeaderIdxs, ...parentIdxs]; |
|
break; |
|
} |
|
default: { |
|
const activeHeaderIdx = activeType === 'highest-parents' |
|
? Math.min(...allActiveHeaderIdxs) |
|
: Math.max(...allActiveHeaderIdxs); |
|
if (headingParentsLU[activeHeaderIdx]) { |
|
activeHeaderIdxs = [...headingParentsLU[activeHeaderIdx], activeHeaderIdx]; |
|
} |
|
else { |
|
activeHeaderIdxs = [activeHeaderIdx]; |
|
} |
|
} |
|
} |
|
} |
|
|
|
activeHeadingIdxs.set(activeHeaderIdxs); |
|
} |
|
function initialization() { |
|
observer?.disconnect(); |
|
|
|
|
|
|
|
|
|
headingsList.forEach((h, i) => { |
|
headingParentsLU[i] = null; |
|
let current_heading = h.tagName; |
|
let parents = []; |
|
for (let j = i - 1; j >= 0; j--) { |
|
if (headingsList[j].tagName < current_heading) { |
|
current_heading = headingsList[j].tagName; |
|
parents = [...parents, j]; |
|
} |
|
} |
|
headingParentsLU[i] = parents.length > 0 ? parents : null; |
|
|
|
const startIndex = elementsList.indexOf(headingsList[i]); |
|
const endIndex = i !== headingsList.length - 1 |
|
? elementsList.indexOf(headingsList[i + 1]) |
|
: elementsList.length; |
|
for (let j = startIndex; j < endIndex; j++) { |
|
elementHeadingLU[j] = i; |
|
} |
|
}); |
|
headingsTree.set(createTree(headingsList)); |
|
if (activeType !== 'none') { |
|
|
|
observer = new IntersectionObserver(handleElementObservation, { |
|
root: null, |
|
threshold: observer_threshold, |
|
}); |
|
elementsList.forEach((el) => observer?.observe(el)); |
|
} |
|
} |
|
function mutationHandler() { |
|
const newElementTarget = document.querySelector(selector); |
|
if (!newElementTarget) |
|
return; |
|
const { headingsList: newHeadingsList, elementsList: newElementsList } = generateInitialLists(newElementTarget); |
|
if (dequal(headingsList, newHeadingsList)) |
|
return; |
|
|
|
headingsList = newHeadingsList; |
|
elementsList = newElementsList; |
|
headingParentsLU = {}; |
|
elementHeadingLU = {}; |
|
initialization(); |
|
} |
|
safeOnMount(() => { |
|
elementTarget = document.querySelector(selector); |
|
if (!elementTarget) |
|
return; |
|
({ headingsList, elementsList } = generateInitialLists(elementTarget)); |
|
initialization(); |
|
mutationObserver = new MutationObserver(mutationHandler); |
|
mutationObserver.observe(elementTarget, { childList: true, subtree: true }); |
|
return () => { |
|
observer?.disconnect(); |
|
mutationObserver?.disconnect(); |
|
}; |
|
}); |
|
|
|
const item = makeElement(name('item'), { |
|
stores: activeHeadingIdxs, |
|
returned: ($activeHeadingIdxs) => { |
|
return (id) => { |
|
const idx = headingsList.findIndex((heading) => heading.id === id); |
|
const active = $activeHeadingIdxs.includes(idx); |
|
return { |
|
'data-id': id, |
|
'data-active': active ? '' : undefined, |
|
}; |
|
}; |
|
}, |
|
action: (node) => { |
|
const id = node.getAttribute('data-id'); |
|
const unsub = executeCallbacks(addMeltEventListener(node, 'click', (e) => { |
|
e.preventDefault(); |
|
if (scrollFn) { |
|
scrollFn(`${id}`); |
|
} |
|
else { |
|
scrollToTargetAdjusted(`${id}`); |
|
} |
|
|
|
if (id) { |
|
history.pushState({}, '', `#${id}`); |
|
} |
|
})); |
|
return { |
|
destroy: unsub, |
|
}; |
|
}, |
|
}); |
|
|
|
const isActive = derived(activeHeadingIdxs, ($activeHeadingIdxs) => { |
|
return (headingId) => { |
|
const idx = headingsList.findIndex((heading) => heading.id === headingId); |
|
return $activeHeadingIdxs.includes(idx); |
|
}; |
|
}); |
|
return { |
|
elements: { |
|
item, |
|
}, |
|
states: { |
|
activeHeadingIdxs, |
|
headingsTree, |
|
}, |
|
helpers: { |
|
isActive, |
|
}, |
|
}; |
|
} |
|
|