mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-06 05:29:02 +00:00
453 lines
16 KiB
JavaScript
453 lines
16 KiB
JavaScript
/**
|
|
* notion-enhancer: simpler databases
|
|
* (c) 2020 CloudHill <rl.cloudhill@gmail.com> (https://github.com/CloudHill)
|
|
* (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (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`<svg viewBox="0 0 14 14">
|
|
<path d="M14,7.77 L14,6.17 L12.06,5.53 L11.61,4.44 L12.49,2.6 L11.36,1.47
|
|
L9.55,2.38 L8.46,1.93 L7.77,0.01 L6.17,0.01 L5.54,1.95 L4.43,2.4 L2.59,1.52
|
|
L1.46,2.65 L2.37,4.46 L1.92,5.55 L0,6.23 L0,7.82 L1.94,8.46 L2.39,9.55
|
|
L1.51,11.39 L2.64,12.52 L4.45,11.61 L5.54,12.06 L6.23,13.98 L7.82,13.98
|
|
L8.45,12.04 L9.56,11.59 L11.4,12.47 L12.53,11.34 L11.61,9.53 L12.08,8.44
|
|
L14,7.75 L14,7.77 Z M7,10 C5.34,10 4,8.66 4,7 C4,5.34 5.34,4 7,4 C8.66,4
|
|
10,5.34 10,7 C10,8.66 8.66,10 7,10 Z" />
|
|
</svg>`,
|
|
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`
|
|
<div class="${collectionToggleClass}">
|
|
<svg viewBox="0 0 100 100"><polygon points="5.9,88.2 50,11.8 94.1,88.2" /></svg>
|
|
</div>
|
|
`;
|
|
$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`<div class="${configDividerClass}"></div>`;
|
|
|
|
const blockId = $collection.dataset.blockId,
|
|
storedState = await db.get(['collections', blockId, menuItem.key], menuItem.default),
|
|
$item = web.html`<div class="${configItemClass}-${menuItem.type}"></div>`;
|
|
|
|
switch (menuItem.type) {
|
|
case 'toggle':
|
|
const $label = web.html`<div class="${configTitleClass}">${menuItem.name}</div>`,
|
|
$toggle = web.html`<div class="${configToggleClass}"
|
|
data-toggled="${storedState || false}"></div>`;
|
|
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`<div class="${configInputClassName}">
|
|
<input placeholder="${menuItem.name}" type="text"
|
|
value="${web.escape(storedState) || ''}">
|
|
</div>`;
|
|
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`<div class="${overlayContainerClass}"></div>`;
|
|
$overlay.addEventListener('click', hideConfig);
|
|
web.render(document.querySelector(notionAppSelector), $overlay);
|
|
|
|
const $config = web.html`<div class="${configMenuClass}"></div>`,
|
|
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`
|
|
<div style="position: fixed;">
|
|
<div style="position: relative; pointer-events: auto;"></div>
|
|
</div>
|
|
`;
|
|
$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]);
|
|
}
|