/* * collapsible headers * (c) 2020 dragonwocky (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(`
`) 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) } } }); }, }, };