mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-05 05:09:03 +00:00
479 lines
17 KiB
JavaScript
479 lines
17 KiB
JavaScript
/*
|
|
* collapsible headers
|
|
* (c) 2020 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
|
|
* (c) 2020 CloudHill
|
|
* under the MIT license
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { createElement } = require('../../pkg/helpers.js');
|
|
|
|
module.exports = {
|
|
id: '548fe2d7-174a-44dd-88d8-35c7f9a093a7',
|
|
tags: ['extension'],
|
|
name: 'collapsible headers',
|
|
desc: 'adds toggles to collapse header sections.',
|
|
version: '1.0.1',
|
|
author: 'CloudHill',
|
|
options: [
|
|
{
|
|
key: 'toggle',
|
|
label: 'toggle position',
|
|
type: 'select',
|
|
value: ['left', 'right', 'inline'],
|
|
},
|
|
{
|
|
key: 'animate',
|
|
label: 'enable animation',
|
|
type: 'toggle',
|
|
value: true,
|
|
},
|
|
{
|
|
key: 'divBreak',
|
|
label: 'use divider blocks to break header sections',
|
|
type: 'toggle',
|
|
value: false,
|
|
},
|
|
],
|
|
hacks: {
|
|
'renderer/preload.js'(store, __exports) {
|
|
document.addEventListener('readystatechange', (event) => {
|
|
if (document.readyState !== 'complete') return false;
|
|
const attempt_interval = setInterval(enhance, 500);
|
|
function enhance() {
|
|
if (!document.querySelector('.notion-frame')) return;
|
|
clearInterval(attempt_interval);
|
|
|
|
if (!store().collapsed_ids) store().collapsed_ids = [];
|
|
|
|
window.addEventListener('hashchange', showSelectedHeader);
|
|
|
|
// add toggles to headers whenever blocks are added/removed
|
|
const contentObserver = new MutationObserver((list, observer) => {
|
|
list.forEach(m => {
|
|
let node = m.addedNodes[0] || m.removedNodes[0];
|
|
if (
|
|
(
|
|
node?.nodeType === Node.ELEMENT_NODE &&
|
|
(
|
|
node.className !== 'notion-selectable-halo' &&
|
|
!node.style.cssText.includes('z-index: 88;')
|
|
)
|
|
) &&
|
|
(
|
|
m.target.className === 'notion-page-content' ||
|
|
m.target.className.includes('notion-selectable')
|
|
)
|
|
) {
|
|
// if a collapsed header is removed
|
|
if (
|
|
node.dataset?.collapsed === 'true' &&
|
|
!node.nextElementSibling
|
|
) showHeaderContent(node);
|
|
|
|
initHeaderToggles();
|
|
}
|
|
})
|
|
});
|
|
|
|
// observe for page changes
|
|
let queue = [];
|
|
const pageObserver = new MutationObserver((list, observer) => {
|
|
if (!queue.length) requestAnimationFrame(() => process(queue));
|
|
queue.push(...list);
|
|
});
|
|
pageObserver.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
function process(list) {
|
|
queue = [];
|
|
for (let { addedNodes } of list) {
|
|
if (
|
|
addedNodes[0] &&
|
|
addedNodes[0].className === 'notion-presence-container'
|
|
) {
|
|
showSelectedHeader();
|
|
initHeaderToggles();
|
|
contentObserver.disconnect();
|
|
contentObserver.observe(
|
|
document.querySelector('.notion-page-content'),
|
|
{
|
|
childList: true,
|
|
subtree: true,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// bind to ctrl + enter
|
|
document.addEventListener('keyup', e => {
|
|
const hotkey = {
|
|
key: 'Enter',
|
|
ctrlKey: true,
|
|
metaKey: false,
|
|
altKey: false,
|
|
shiftKey: false,
|
|
};
|
|
for (let prop in hotkey)
|
|
if (hotkey[prop] !== e[prop]) return;
|
|
// toggle active/selected headers
|
|
const active = document.activeElement;
|
|
let toggle;
|
|
if (
|
|
(toggle = active.nextElementSibling || active.previousElementSibling) &&
|
|
toggle.className === 'collapse-header'
|
|
) {
|
|
toggle.click();
|
|
} else {
|
|
toggleHeaders( getSelectedHeaders() );
|
|
}
|
|
});
|
|
|
|
function initHeaderToggles() {
|
|
const headerBlocks = document
|
|
.querySelectorAll('.notion-page-content [class*="header-block"]');
|
|
|
|
headerBlocks.forEach(header => {
|
|
const nextBlock = header.nextElementSibling;
|
|
|
|
// if header is moved
|
|
if (
|
|
header.dataset.collapsed &&
|
|
header.collapsedBlocks &&
|
|
header.collapsedBlocks[0] !== nextBlock
|
|
) {
|
|
showHeaderContent(header);
|
|
}
|
|
|
|
// if header has no content
|
|
if (
|
|
!nextBlock ||
|
|
getHeaderLevel(nextBlock) <= getHeaderLevel(header) ||
|
|
(
|
|
store().divBreak &&
|
|
nextBlock.classList &&
|
|
nextBlock.classList.contains('notion-divider-block')
|
|
)
|
|
) {
|
|
if (header.dataset.collapsed) {
|
|
delete header.dataset.collapsed;
|
|
const toggle = header.querySelector('.collapse-header');
|
|
if (toggle) toggle.remove();
|
|
}
|
|
return;
|
|
};
|
|
|
|
// if header already has a toggle
|
|
if (header.querySelector('.collapse-header')) return;
|
|
|
|
// add toggle to headers
|
|
const toggle = createElement(`
|
|
<div class="collapse-header">
|
|
<svg viewBox="0 0 100 100" class="triangle">
|
|
<polygon points="5.9,88.2 50,11.8 94.1,88.2" />
|
|
</svg>
|
|
</div>
|
|
`)
|
|
|
|
if (store().toggle === 'left') header.firstChild.prepend(toggle);
|
|
else header.firstChild.appendChild(toggle);
|
|
|
|
if (store().toggle === 'inline')
|
|
header.firstChild.setAttribute('inline-toggle', '');
|
|
|
|
toggle.header = header;
|
|
toggle.addEventListener('click', toggleHeaderContent);
|
|
|
|
// check store for header
|
|
header.dataset.collapsed = false;
|
|
if (store().collapsed_ids.includes(header.dataset.blockId))
|
|
collapseHeaderContent(header, false);
|
|
});
|
|
}
|
|
|
|
function toggleHeaderContent(e) {
|
|
e.stopPropagation();
|
|
const toggle = e.currentTarget;
|
|
const header = toggle.header;
|
|
|
|
const selected = getSelectedHeaders();
|
|
if (selected && selected.includes(header)) return toggleHeaders(selected);
|
|
|
|
if (header.dataset.collapsed === 'true') showHeaderContent(header);
|
|
else collapseHeaderContent(header);
|
|
}
|
|
|
|
function collapseHeaderContent(header, animate = true) {
|
|
if (
|
|
!header.className.includes('header-block') ||
|
|
header.dataset.collapsed === 'true'
|
|
) return;
|
|
header.dataset.collapsed = true;
|
|
|
|
// store collapsed headers
|
|
if (!store().collapsed_ids.includes(header.dataset.blockId)) {
|
|
store().collapsed_ids.push(header.dataset.blockId);
|
|
}
|
|
|
|
const headerLevel = getHeaderLevel(header);
|
|
const toggle = header.querySelector('.collapse-header');
|
|
|
|
header.collapsedBlocks = getHeaderContent(header);
|
|
header.collapsedBlocks.forEach(block => {
|
|
// don't collapse already collapsed blocks
|
|
if (block.hasAttribute('collapsed')) {
|
|
if (+(block.getAttribute('collapsed')) < headerLevel) {
|
|
block.setAttribute('collapsed', headerLevel);
|
|
if (block.storeAttributes) block.storeAttributes.header = header;
|
|
}
|
|
return;
|
|
};
|
|
|
|
block.storeAttributes = {
|
|
marginTop: block.style.marginTop,
|
|
marginBottom: block.style.marginBottom,
|
|
header: header,
|
|
}
|
|
block.style.marginTop = 0;
|
|
block.style.marginBottom = 0;
|
|
|
|
if (!store().animate) {
|
|
block.setAttribute('collapsed', headerLevel);
|
|
toggleInnerBlocks(block, true);
|
|
} else {
|
|
const height = block.offsetHeight;
|
|
block.storeAttributes.height = height + 'px';
|
|
block.setAttribute('collapsed', headerLevel);
|
|
|
|
if (!animate) toggleInnerBlocks(block, true);
|
|
else {
|
|
if (toggle) toggle.removeEventListener('click', toggleHeaderContent);
|
|
block.animate(
|
|
[
|
|
{
|
|
maxHeight: height + 'px',
|
|
opacity: 1,
|
|
marginTop: block.storeAttributes.marginTop,
|
|
marginBottom: block.storeAttributes.marginBottom,
|
|
},
|
|
{
|
|
maxHeight: (height - 100 > 0 ? height - 100 : 0) + 'px',
|
|
opacity: 0, marginTop: 0, marginBottom: 0,
|
|
},
|
|
{
|
|
maxHeight: 0, opacity: 0, marginTop: 0, marginBottom: 0,
|
|
}
|
|
],
|
|
{
|
|
duration: 300,
|
|
easing: 'ease-out'
|
|
}
|
|
).onfinish = () => {
|
|
if (toggle) toggle.addEventListener('click', toggleHeaderContent);
|
|
toggleInnerBlocks(block, true);
|
|
};
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function showHeaderContent(header, animate = true) {
|
|
if (
|
|
!header.className.includes('header-block') ||
|
|
header.dataset.collapsed === 'false'
|
|
) return;
|
|
header.dataset.collapsed = false;
|
|
|
|
// remove header from store
|
|
const collapsed_ids = store().collapsed_ids;
|
|
if (collapsed_ids.includes(header.dataset.blockId)) {
|
|
store().collapsed_ids = collapsed_ids.filter(id => id !== header.dataset.blockId);
|
|
}
|
|
|
|
if (!header.collapsedBlocks) return;
|
|
const toggle = header.querySelector('.collapse-header');
|
|
|
|
showBlockHeader(header);
|
|
|
|
header.collapsedBlocks.forEach(block => {
|
|
// don't toggle blocks collapsed under other headers
|
|
if (
|
|
+(block.getAttribute('collapsed')) > getHeaderLevel(header) ||
|
|
!block.storeAttributes
|
|
) return;
|
|
|
|
block.style.marginTop = block.storeAttributes.marginTop;
|
|
block.style.marginBottom = block.storeAttributes.marginBottom;
|
|
|
|
if (!store().animate) {
|
|
block.removeAttribute('collapsed');
|
|
toggleInnerBlocks(block, false);
|
|
|
|
} else if (block.storeAttributes) {
|
|
toggleInnerBlocks(block, false);
|
|
|
|
if (!animate) block.removeAttribute('collapsed');
|
|
else {
|
|
const height = parseInt(block.storeAttributes.height);
|
|
if (toggle) toggle.removeEventListener('click', toggleHeaderContent);
|
|
block.animate(
|
|
[
|
|
{
|
|
maxHeight: 0, opacity: 0, marginTop: 0, marginBottom: 0,
|
|
},
|
|
{
|
|
maxHeight: (height - 100 > 0 ? height - 100 : 0) + 'px',
|
|
opacity: 1,
|
|
marginTop: block.storeAttributes.marginTop,
|
|
marginBottom: block.storeAttributes.marginBottom,
|
|
},
|
|
{
|
|
maxHeight: height + 'px',
|
|
opacity: 1,
|
|
marginTop: block.storeAttributes.marginTop,
|
|
marginBottom: block.storeAttributes.marginBottom,
|
|
}
|
|
],
|
|
{
|
|
duration: 300,
|
|
easing: 'ease-out'
|
|
}
|
|
).onfinish = () => {
|
|
if (toggle) toggle.addEventListener('click', toggleHeaderContent);
|
|
block.removeAttribute('collapsed');
|
|
};
|
|
}
|
|
}
|
|
delete block.storeAttributes;
|
|
});
|
|
delete header.collapsedBlocks;
|
|
}
|
|
|
|
// query for headers marked with the selection halo
|
|
function getSelectedHeaders() {
|
|
const selectedHeaders = Array.from(
|
|
document.querySelectorAll('[class*="header-block"] .notion-selectable-halo')
|
|
).map(halo => halo.parentElement);
|
|
|
|
if (selectedHeaders.length > 0) return selectedHeaders;
|
|
return null;
|
|
}
|
|
|
|
// toggle an array of headers
|
|
function toggleHeaders(headers) {
|
|
if (!headers) return;
|
|
headers = headers
|
|
.filter(h =>
|
|
!( h.hasAttribute('collapsed') && h.dataset.collapsed === 'false' )
|
|
);
|
|
|
|
if (headers && headers.length > 0) {
|
|
const collapsed = headers
|
|
.filter(h => h.dataset.collapsed === 'true').length;
|
|
headers.forEach(h => {
|
|
if (collapsed >= headers.length) showHeaderContent(h);
|
|
else collapseHeaderContent(h);
|
|
});
|
|
}
|
|
}
|
|
|
|
// get subsequent blocks
|
|
function getHeaderContent(header) {
|
|
let blockList = [];
|
|
let nextBlock = header.nextElementSibling;
|
|
while (nextBlock) {
|
|
if (
|
|
getHeaderLevel(nextBlock) <= getHeaderLevel(header) ||
|
|
(
|
|
store().divBreak &&
|
|
nextBlock.classList &&
|
|
nextBlock.classList.contains('notion-divider-block')
|
|
)
|
|
) break;
|
|
blockList.push(nextBlock);
|
|
nextBlock = nextBlock.nextElementSibling;
|
|
}
|
|
return blockList;
|
|
}
|
|
|
|
// toggles a header from one of its collapsed blocks
|
|
function showBlockHeader(block) {
|
|
if (
|
|
block?.hasAttribute('collapsed') &&
|
|
block.storeAttributes?.header
|
|
) {
|
|
showHeaderContent(block.storeAttributes.header);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getHeaderLevel(header) {
|
|
if (!header.className || !header.className.includes('header-block')) return 9;
|
|
const subCount = header.classList[1].match(/sub/gi) || '';
|
|
let headerLevel = 1 + subCount.length;
|
|
return headerLevel;
|
|
}
|
|
|
|
// ensures that any columns and indented blocks are also hidden
|
|
// true => collapse, false => show
|
|
function toggleInnerBlocks(block, collapse) {
|
|
const header = block.storeAttributes?.header;
|
|
Array.from(
|
|
block.querySelectorAll('.notion-selectable')
|
|
).forEach(b => {
|
|
if (!b.getAttribute('collapsed')) {
|
|
if (collapse) {
|
|
if (!b.storeAttributes) {
|
|
b.storeAttributes = {
|
|
height: b.offsetHeight,
|
|
marginTop: b.style.marginTop,
|
|
marginBottom: b.style.marginBottom,
|
|
header: header,
|
|
};
|
|
}
|
|
b.setAttribute('collapsed', '')
|
|
}
|
|
else {
|
|
b.removeAttribute('collapsed');
|
|
delete b.storeAttributes;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function showSelectedHeader() {
|
|
setTimeout(() => {
|
|
const halo = document.querySelector('.notion-selectable-halo');
|
|
const header = halo?.parentElement;
|
|
|
|
if (!header?.className?.includes('header-block')) return;
|
|
|
|
// clear hash so that the same header can be toggled again
|
|
location.hash = '';
|
|
|
|
if (showBlockHeader(header)) {
|
|
setTimeout(
|
|
() => {
|
|
// is header in view?
|
|
var rect = header.getBoundingClientRect();
|
|
if (
|
|
(rect.top >= 0) &&
|
|
(rect.bottom <= window.innerHeight)
|
|
) return;
|
|
// if not, scroll to header
|
|
header.scrollIntoView({ behavior: 'smooth' });
|
|
}, 400
|
|
)
|
|
}
|
|
}, 0)
|
|
}
|
|
}
|
|
});
|
|
},
|
|
},
|
|
};
|