/* * notion-enhancer: simpler databases * (c) 2020 CloudHill (https://github.com/CloudHill) * (c) 2021 dragonwocky (https://dragonwocky.me/) * (https://notion-enhancer.github.io/) under the MIT license */ export default async function ({ web, components }, db) { const collectionViewSelector = '.notion-collection_view-block[style*="width"][style*="max-width"]', collectionAddNewSelector = '.notion-collection-view-item-add', collectionToolbarSelector = '[style*=" height: 42px"]', linkedCollectionTitleSelector = `${collectionToolbarSelector} > a [placeholder]`, viewContainerSelector = '.notion-scroller [class$="view"]', configButtonClass = 'simpler_databases--config_button', configButtonSvg = web.raw` `, overlayContainerClass = 'simpler_databases--overlay_container', configMenuClass = 'simpler_databases--config_menu', configDividerClass = 'simpler_databases--config_divider', configItemClass = 'simpler_databases--config_item', configTitleClass = 'simpler_databases--config_title', configToggleClass = 'simpler_databases--config_toggle', configInputClassName = 'simpler_databases--config_input notion-focusable', configOpenCollectionSelector = '.notion-collection_view-block[data-simpler-db-tweaks*="[config-open]"]', collectionToggleClass = 'simpler_databases--toggle', notionAppSelector = '.notion-app-inner'; const replaceTitle = ($collection, state) => { const $title = $collection.querySelector(linkedCollectionTitleSelector), blockId = $collection.dataset.blockId; if (!$title) return; if (!$title.dataset.originalTitle && state) { $title.dataset.originalTitle = $title.innerText; } if (!$title.titleObserver) { if (!state) return; $title.titleObserver = new MutationObserver(async () => { const customTitle = await db.get(['collections', blockId, 'replace_title'], false); if (customTitle && $title.innerText !== customTitle) $title.innerText = customTitle; }); } else $title.titleObserver.disconnect(); if (state) { // observe $title.innerText = state; $title.titleObserver.observe($title, { characterData: true, childList: true }); } else { // reset $title.titleObserver.disconnect(); $title.innerText = $title.dataset.originalTitle; delete $title.dataset.originalTitle; } }, insertToggle = async ($collection, state) => { const datasetKey = 'simplerDbToggleHidden', blockId = $collection.dataset.blockId, $toolbar = $collection.querySelector(collectionToolbarSelector); if (!$toolbar) return; const $collectionView = $collection.querySelector('.notion-scroller'), hideCollection = () => { $collectionView.style.height = $collectionView.offsetHeight + 'px'; requestAnimationFrame(() => { $collection.dataset[datasetKey] = true; setTimeout(() => ($collectionView.dataset.simplerDbHideItems = 'true'), 200); // hide drag handles }); }, showCollection = () => { $collection.dataset[datasetKey] = false; $collectionView.style.height = ''; $collectionView.style.height = $collectionView.offsetHeight + 'px'; $collection.dataset[datasetKey] = true; delete $collectionView.dataset.simplerDbHideItems; requestAnimationFrame(() => { $collection.dataset[datasetKey] = false; setTimeout(() => ($collectionView.style.height = ''), 200); }); }; if (!$collection.dataset[datasetKey]) { const storedState = await db.get(['collections', blockId, 'toggle_hidden'], false); if (storedState) { hideCollection(); } } let $toggle = $toolbar.querySelector(`.${collectionToggleClass}`); if ($toggle) { if (!state) $toggle.remove(); return; } else if (state) { $toggle = web.html`
`; $toggle.addEventListener('click', async () => { const hide = !($collection.dataset[datasetKey] === 'true'); await db.set(['collections', blockId, 'toggle_hidden'], hide); if (hide) { hideCollection(); } else showCollection(); }); $toolbar.prepend($toggle); } }; const menuItems = [ { key: 'replace_title', name: 'Replace title...', type: 'input', linkedOnly: true, default: '', action: replaceTitle, }, { key: 'icon', name: 'Icon', type: 'toggle', default: true, }, { key: 'title', name: 'Title', type: 'toggle', default: true, }, { key: 'toggle', name: 'Toggle', type: 'toggle', default: false, action: insertToggle, }, { key: 'views', name: 'Views', type: 'toggle', default: true, }, { key: 'toolbar', name: 'Toolbar', type: 'toggle', default: true, }, { key: 'divider', views: ['table', 'board', 'timeline', 'list', 'gallery'], }, { key: 'header_row', name: 'Header row', type: 'toggle', default: true, views: ['table'], }, { key: 'new_item', name: 'New row', type: 'toggle', default: true, views: ['table', 'timeline'], }, { key: 'new_item', name: 'New item', type: 'toggle', default: true, views: ['board', 'list', 'gallery'], }, { key: 'calc_row', name: 'Calculation row', type: 'toggle', default: true, views: ['table', 'timeline'], }, { key: 'divider', views: ['table', 'board'], }, { key: 'hidden_column', name: 'Hidden columns', type: 'toggle', default: true, views: ['board'], }, { key: 'add_group', name: 'Add group', type: 'toggle', default: true, views: ['board'], }, { key: 'new_column', name: 'New column', type: 'toggle', default: true, views: ['table'], }, { key: 'full_width', name: 'Full width', type: 'toggle', default: true, views: ['table'], }, ]; const isLinked = ($collection) => !!$collection.querySelector(linkedCollectionTitleSelector), getViewType = ($collection) => $collection.querySelector(viewContainerSelector)?.className.split('-')[1], setTweakState = ($collection, key, state) => { const datasetKey = 'simplerDbTweaks'; if (!$collection.dataset[datasetKey]) $collection.dataset[datasetKey] = ''; key = web.escape(key); const isActive = $collection.dataset[datasetKey].includes(`[${key}]`); if (state && !isActive) { $collection.dataset[datasetKey] += `[${key}]`; } else if (!state && isActive) { const prev = $collection.dataset[datasetKey]; $collection.dataset[datasetKey] = prev.replace(`[${key}]`, ''); } }; const clickItem = (event) => { event.stopPropagation(); const focusedItem = event.target.closest(`[class^="${configItemClass}"]`); if (focusedItem) focusedItem.click(); }, focusNextItem = (event) => { event.stopPropagation(); event.preventDefault(); const $focusedItem = event.target.closest(`[class^="${configItemClass}"]`); if (!$focusedItem) return; let $targetItem = $focusedItem.nextElementSibling; if (!$targetItem) $targetItem = $focusedItem.parentElement.firstElementChild; if ($targetItem.classList.contains(configDividerClass)) { $targetItem = $targetItem.nextElementSibling; } const $input = $targetItem.querySelector('input'); if ($input) { $input.focus(); } else $targetItem.focus(); }, focusPrevItem = (event) => { event.stopPropagation(); event.preventDefault(); const $focusedItem = event.target.closest(`[class^="${configItemClass}"]`); if (!$focusedItem) return; let $targetItem = $focusedItem.previousElementSibling; if (!$targetItem) $targetItem = $focusedItem.parentElement.lastElementChild; if ($targetItem.classList.contains(configDividerClass)) { $targetItem = $targetItem.previousElementSibling; } const $input = $targetItem.querySelector('input'); if ($input) { $input.focus(); } else $targetItem.focus(); }, keyListeners = [ { keys: ['Escape'], listener: (event) => { event.stopPropagation(); hideConfig(); }, opts: { listenInInput: true, keydown: true }, }, { keys: [' '], listener: (event) => clickItem(event), opts: { keydown: true }, }, { keys: ['Enter'], listener: (event) => clickItem(event), opts: { keydown: true }, }, { keys: ['ArrowDown'], listener: focusNextItem, opts: { listenInInput: true, keydown: true }, }, { keys: ['ArrowUp'], listener: focusPrevItem, opts: { listenInInput: true, keydown: true }, }, { keys: ['Tab'], listener: focusNextItem, opts: { listenInInput: true, keydown: true }, }, { keys: ['Shift', 'Tab'], listener: focusPrevItem, opts: { listenInInput: true, keydown: true }, }, ]; const renderConfigItem = async ($collection, menuItem) => { if (menuItem.key === 'divider') return web.html`
`; const blockId = $collection.dataset.blockId, storedState = await db.get(['collections', blockId, menuItem.key], menuItem.default), $item = web.html`
`; switch (menuItem.type) { case 'toggle': const $label = web.html`
${menuItem.name}
`, $toggle = web.html`
`; web.render($item, $label, $toggle); $item.setAttribute('tabindex', 0); $item.addEventListener('click', async (e) => { e.stopPropagation(); const newState = !($toggle.dataset.toggled === 'true'); $toggle.dataset.toggled = newState; await db.set(['collections', blockId, menuItem.key], newState); setTweakState($collection, menuItem.key, newState); if (menuItem.action) menuItem.action($collection, newState); }); break; case 'input': const $input = web.html`
`; web.render($item, $input); $item.addEventListener('click', (e) => e.stopPropagation()); if (menuItem.action) { $input.firstElementChild.addEventListener('input', async (e) => { e.stopPropagation(); const newState = e.target.value; await db.set(['collections', blockId, menuItem.key], newState); menuItem.action($collection, newState); }); } break; } return $item; }, renderConfig = async ($collection, $button) => { if (document.querySelector(`.${overlayContainerClass}`)) return; const collectionViewType = getViewType($collection); if (!collectionViewType) return; const $overlay = web.html`
`; $overlay.addEventListener('click', hideConfig); web.render(document.querySelector(notionAppSelector), $overlay); const $config = web.html`
`, viewMenuItems = menuItems.filter( (item) => (!item.views || item.views.includes(collectionViewType)) && (!item.linkedOnly || isLinked($collection)) ), $menuItemElements = await Promise.all( viewMenuItems.map((item) => renderConfigItem($collection, item)) ); web.render($config, ...$menuItemElements); const $firstMenuItem = $config.firstElementChild.getElementsByTagName('input')[0] || $config.firstElementChild; const $position = web.html`
`; $position.firstElementChild.appendChild($config); web.render($overlay, $position); const rect = $button.getBoundingClientRect(); $position.style.left = Math.min(rect.left + rect.width / 2, window.innerWidth - ($config.offsetWidth + 14)) + 'px'; $position.style.top = Math.min( rect.top + rect.height / 2, window.innerHeight - ($config.offsetHeight + 14) ) + 'px'; setTweakState($collection, 'config-open', true); for (const { keys, listener, opts } of keyListeners) { web.addHotkeyListener(keys, listener, opts); } await $config.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 }).finished; $firstMenuItem.focus(); }; async function hideConfig() { const $overlay = document.querySelector(`.${overlayContainerClass}`), $collection = document.querySelector(configOpenCollectionSelector); if (!$overlay) return; $overlay.removeEventListener('click', hideConfig); for (const { listener } of keyListeners) web.removeHotkeyListener(listener); await document .querySelector(`.${configMenuClass}`) .animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200 }).finished; setTweakState($collection, 'config-open', false); $overlay.remove(); } const simplifyCollection = async () => { for (const $collection of document.querySelectorAll(collectionViewSelector)) { const blockId = $collection.dataset.blockId, $addNew = $collection.querySelector(collectionAddNewSelector); if ($collection.querySelector(`.${configButtonClass}`) || !$addNew) continue; const $configButton = $addNew.previousElementSibling.cloneNode(); $configButton.className = configButtonClass; $configButton.innerHTML = configButtonSvg; $configButton.addEventListener('click', () => { renderConfig($collection, $configButton); }); $addNew.parentElement.prepend($configButton); for (const item of menuItems) { if (item.key === 'divider') continue; const state = await db.get(['collections', blockId, item.key], item.default); if ((item.type !== 'input' && !item.linkedOnly) || isLinked($collection)) { setTweakState($collection, item.key, state); } if (state && item.action) item.action($collection, state); } } }; web.addDocumentObserver(simplifyCollection, [collectionViewSelector]); }