notion-enhancer/repo/collapsible-headers/client.mjs

287 lines
11 KiB
JavaScript

/**
* notion-enhancer: collapsible headers
* (c) 2020 CloudHill <rl.cloudhill@gmail.com> (https://github.com/CloudHill)
* (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
export default async function ({ web }, db) {
const headerSelector = '.notion-page-content [class*="header-block"]',
pageScroller = '.notion-frame > .notion-scroller.vertical.horizontal',
haloClass = 'notion-selectable-halo',
blockSelector = '.notion-selectable[data-block-id]',
dividerClass = 'notion-divider-block',
toggleClass = 'collapsible_headers--toggle',
inlineToggleClass = 'collapsible_headers--inline';
const togglePosition = await db.get(['position']),
animateToggle = await db.get(['animate']),
breakOnDividers = await db.get(['dividers']),
toggleHotkey = await db.get(['hotkey']);
const animationStyle = {
duration: 250,
easing: 'ease',
},
animationCollapsed = {
height: 0,
opacity: 0,
marginTop: 0,
marginBottom: 0,
overflow: 'hidden',
};
let collapseParentsCache = new Map(),
collapsedBlocksCache = new Map();
const getHeaderLevel = ($block) => {
if (!$block?.className?.includes?.('header-block')) return 9;
return ($block.className.match(/sub_/gi)?.length || 0) + 1;
},
getSelectedHeaders = () => {
return [...document.querySelectorAll(`${headerSelector} ${haloClass}`)]
.map(($halo) => $halo.parentElement)
.filter(($header) => $header.dataset.sectionCollapsed === 'true');
},
getHeaderSection = ($header) => {
const blockList = [];
let $nextBlock = $header?.nextElementSibling;
// is this weird? yes
// labels were the simplest way to do this tho
blockLoop: while (true) {
const isSectionEnd =
!$nextBlock ||
getHeaderLevel($nextBlock) <= getHeaderLevel($header) ||
(breakOnDividers && $nextBlock?.classList?.contains(dividerClass));
if (isSectionEnd) break;
blockList.push($nextBlock);
const $childBlock = $nextBlock.querySelector(blockSelector);
if ($childBlock) {
$nextBlock = $childBlock;
} else if ($nextBlock.nextElementSibling) {
$nextBlock = $nextBlock.nextElementSibling;
} else {
let $parentBlock = $nextBlock.parentElement.closest(blockSelector);
while (!$parentBlock?.nextElementSibling) {
if (!$parentBlock) break blockLoop;
if ($parentBlock === $header.parentElement) break blockLoop;
$parentBlock = $parentBlock.parentElement.closest(blockSelector);
}
$nextBlock = $parentBlock.nextElementSibling;
}
}
return blockList;
};
const expandBlock = async ($header, $block, animate) => {
const collapseParents = collapseParentsCache.get($block.dataset.blockId),
expand = async () => {
delete $block.dataset.collapsed;
if (animate) {
await $block.animate(
[
animationCollapsed,
{
maxHeight: '100%',
opacity: 1,
marginTop: $block.style.marginTop,
marginBottom: $block.style.marginBottom,
overflow: 'hidden',
},
],
animationStyle
).finished;
}
};
if (collapseParents) {
collapseParents.delete($header.dataset.blockId);
if (!collapseParents.size) await expand();
} else await expand();
},
expandHeaderSection = async ($header, animate) => {
const isBusy = $header.dataset.collapseAnimating,
isCollapsibleHeader =
$header.matches(headerSelector) && $header.dataset.sectionCollapsed === 'true';
if (isBusy || !isCollapsibleHeader) return;
$header.dataset.collapseAnimating = 'true';
$header.dataset.sectionCollapsed = false;
await db.set(['collapsed_ids', $header.dataset.blockId], false);
const sectionContent = getHeaderSection($header),
animations = [];
for (const $block of sectionContent) {
animations.push(expandBlock($header, $block, animate));
}
if ($header.dataset.collapsed) {
const collapseParents = collapseParentsCache.get($header.dataset.blockId) || [];
for (const parentId of collapseParents) {
animations.push(
expandHeaderSection(
document.querySelector(`[data-block-id="${parentId}"]`),
animate
)
);
}
}
collapsedBlocksCache.set($header.dataset.blockId, undefined);
await Promise.all(animations);
delete $header.dataset.collapseAnimating;
},
collapseHeaderSection = async ($header, animate) => {
const isBusy = $header.dataset.collapseAnimating,
isCollapsibleHeader =
$header.matches(headerSelector) && $header.dataset.sectionCollapsed === 'false';
if (isBusy || !isCollapsibleHeader) return;
$header.dataset.collapseAnimating = 'true';
$header.dataset.sectionCollapsed = true;
await db.set(['collapsed_ids', $header.dataset.blockId], true);
const sectionContent = getHeaderSection($header),
animations = [];
collapsedBlocksCache.set($header.dataset.blockId, sectionContent);
for (const $block of sectionContent) {
if (!collapseParentsCache.get($block.dataset.blockId)) {
collapseParentsCache.set($block.dataset.blockId, new Set());
}
const collapseParents = collapseParentsCache.get($block.dataset.blockId);
collapseParents.add($header.dataset.blockId);
if (animate) {
animations.push(
$block.animate(
[
{
maxHeight: $block.offsetHeight + 'px',
opacity: 1,
marginTop: $block.style.marginTop,
marginBottom: $block.style.marginBottom,
overflow: 'hidden',
},
animationCollapsed,
],
animationStyle
).finished
);
}
$block.dataset.collapsed = true;
}
await Promise.all(animations);
delete $header.dataset.collapseAnimating;
},
toggleHeaderSection = async ($header, animate) => {
if ($header.dataset.collapseAnimating) return;
if ($header.dataset.sectionCollapsed === 'true') {
const collapseParents = collapseParentsCache.get($header.dataset.blockId) ?? [];
for (const $parent of collapseParents) {
await expandHeaderSection($parent, animateToggle);
}
await expandHeaderSection($header, animate);
} else await collapseHeaderSection($header, animate);
};
const insertToggles = async (event) => {
if ([...event.addedNodes].some(($node) => $node?.matches?.(pageScroller))) {
collapseParentsCache = new Map();
collapsedBlocksCache = new Map();
return;
}
const childNodeEvent =
event.target.matches(blockSelector) && !event.target.matches(headerSelector);
if (childNodeEvent) return;
const removeHeaderEvent = [...event.removedNodes].filter(($node) =>
$node?.className?.includes?.('header-blocks')
);
if (removeHeaderEvent.length) {
return removeHeaderEvent.forEach(($header) => expandHeaderSection($header, false));
}
const toggleEvent =
[...event.addedNodes, ...event.removedNodes].some(($node) =>
$node?.classList?.contains(toggleClass)
) ||
event.target.classList.contains(toggleClass) ||
event.attributeName === 'data-collapsed' ||
(event.target.classList.contains(inlineToggleClass) && event.attributeName === 'class');
if (toggleEvent) return;
const haloRemoveEvent =
event.target.classList.contains(haloClass) ||
[...event.removedNodes].some(($node) => $node?.classList?.contains(haloClass));
if (haloRemoveEvent) return;
for (const $header of document.querySelectorAll(headerSelector)) {
const $nextBlock = $header.nextElementSibling,
sectionContent = getHeaderSection($header),
prevCollapseCache = collapsedBlocksCache.get($header.dataset.blockId) ?? [];
let hasMoved =
prevCollapseCache.length && prevCollapseCache.length !== sectionContent.length;
for (const $collapsedBlock of prevCollapseCache) {
if (hasMoved) break;
if (!sectionContent.includes($collapsedBlock)) hasMoved = true;
}
if (hasMoved) {
for (const $collapsedBlock of prevCollapseCache)
expandBlock($header, $collapsedBlock, animateToggle);
await db.set(['collapsed_ids', $header.dataset.blockId], false);
}
const isEmpty =
!$nextBlock ||
getHeaderLevel($nextBlock) <= getHeaderLevel($header) ||
(breakOnDividers && $nextBlock.classList.contains(dividerClass));
if (isEmpty) {
delete $header.dataset.sectionCollapsed;
$header.querySelector(`.${toggleClass}`)?.remove();
continue;
}
if ($header.querySelector(`.${toggleClass}`)) continue;
const $toggle = web.html`
<div class="${toggleClass}">
<svg viewBox="0 0 100 100"><polygon points="5.9,88.2 50,11.8 94.1,88.2"></polygon></svg>
</div>
`;
if (togglePosition === 'left') {
$header.firstChild.prepend($toggle);
} else web.render($header.firstChild, $toggle);
if (togglePosition === 'inline') $header.firstChild.classList.add(inlineToggleClass);
$toggle.header = $header;
$toggle.addEventListener('click', (ev) => {
ev.stopPropagation();
$header.querySelector('[contenteditable="true"]').click();
toggleHeaderSection($header, animateToggle);
});
$header.dataset.sectionCollapsed = false;
if (await db.get(['collapsed_ids', $header.dataset.blockId], false)) {
await collapseHeaderSection($header, false);
}
}
const haloAddedEvent =
[...event.addedNodes].some(($node) => $node?.classList?.contains(haloClass)) &&
event.target.matches(headerSelector),
$selectedHeaders = new Set(getSelectedHeaders());
if (haloAddedEvent) $selectedHeaders.add(event.target);
for (const $header of $selectedHeaders) {
expandHeaderSection($header, animateToggle);
}
};
web.addDocumentObserver(insertToggles, ['.notion-page-content', headerSelector]);
web.addHotkeyListener(toggleHotkey, (event) => {
const $header = document.activeElement.closest(headerSelector);
if ($header) {
toggleHeaderSection($header, animateToggle);
} else {
getSelectedHeaders().forEach(($header) => toggleHeaderSection($header, animateToggle));
}
});
}