mirror of
				https://github.com/notion-enhancer/notion-enhancer.git
				synced 2025-10-31 22:28:08 +11: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