mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-06 21:49:03 +00:00
new extension: collapsible headers (#320)
This commit is contained in:
parent
caa2360a3d
commit
38dded687e
86
repo/collapsible-headers/app.css
Normal file
86
repo/collapsible-headers/app.css
Normal file
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* collapsible headers
|
||||
* (c) 2020 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
|
||||
* (c) 2020 CloudHill
|
||||
* under the MIT license
|
||||
*/
|
||||
|
||||
.notion-page-content .notion-selectable[collapsed] {
|
||||
max-height: 0px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.notion-page-content .notion-selectable[collapsed] .notion-selectable {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.collapse-header {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 6px;
|
||||
margin: 0 6px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
transition: 200ms ease-in;
|
||||
}
|
||||
.collapse-header:hover {
|
||||
background: var(--theme--interactive_hover);
|
||||
}
|
||||
/* position: left */
|
||||
.collapse-header:first-child {
|
||||
margin-left: 2px;
|
||||
}
|
||||
/* position: right / inline */
|
||||
.collapse-header:last-child {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* show toggle on: collapsed, hover, focus */
|
||||
[data-collapsed="true"] .collapse-header:last-child,
|
||||
[data-collapsed]:hover .collapse-header:last-child,
|
||||
[data-collapsed] :focus + .collapse-header:last-child {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.collapse-header svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 200ms ease-out 0s;
|
||||
}
|
||||
/* position: left */
|
||||
.collapse-header:first-child svg {
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
/* position: right / inline */
|
||||
.collapse-header:last-child svg {
|
||||
transform: rotateZ(270deg);
|
||||
}
|
||||
|
||||
[data-collapsed="false"] .collapse-header svg {
|
||||
transform: rotateZ(180deg);
|
||||
}
|
||||
|
||||
/* position: inline */
|
||||
[inline-toggle] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
[inline-toggle] [placeholder] {
|
||||
width: auto !important;
|
||||
}
|
||||
[inline-toggle] [placeholder]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: text;
|
||||
}
|
475
repo/collapsible-headers/mod.js
Normal file
475
repo/collapsible-headers/mod.js
Normal file
@ -0,0 +1,475 @@
|
||||
/*
|
||||
* 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.0',
|
||||
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-page-content'
|
||||
) {
|
||||
showSelectedHeader();
|
||||
initHeaderToggles();
|
||||
contentObserver.disconnect();
|
||||
contentObserver.observe(addedNodes[0], {
|
||||
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)
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue
Block a user