From 1c3b8e5fa4e5f6629e7d1847019c5be34e69b0fb Mon Sep 17 00:00:00 2001 From: Ryo Hilmawan <54142180+CloudHill@users.noreply.github.com> Date: Sun, 6 Dec 2020 01:13:01 +0700 Subject: [PATCH] new extension: collapsible headers (#320) --- mods/collapsible-headers/app.css | 86 ++++++ mods/collapsible-headers/mod.js | 475 +++++++++++++++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 mods/collapsible-headers/app.css create mode 100644 mods/collapsible-headers/mod.js diff --git a/mods/collapsible-headers/app.css b/mods/collapsible-headers/app.css new file mode 100644 index 0000000..d96a25f --- /dev/null +++ b/mods/collapsible-headers/app.css @@ -0,0 +1,86 @@ +/* + * collapsible headers + * (c) 2020 dragonwocky (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; +} diff --git a/mods/collapsible-headers/mod.js b/mods/collapsible-headers/mod.js new file mode 100644 index 0000000..c9c0d47 --- /dev/null +++ b/mods/collapsible-headers/mod.js @@ -0,0 +1,475 @@ +/* + * 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.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(` +
+ + + +
+ `) + + 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) + } + } + }); + }, + }, +};