feat(panel): register/render custom views to side panel

+ fix: debounce useState callback triggers to prevent over-handling conflicts
+ minor refactoring
This commit is contained in:
dragonwocky 2023-08-15 23:55:43 +10:00
parent 608b8d42d4
commit 463607c6d2
Signed by: dragonwocky
GPG Key ID: 7998D08F7D7BD7A8
6 changed files with 232 additions and 159 deletions

View File

@ -123,13 +123,18 @@ const insertPanel = async (api, db) => {
const notionFrame = ".notion-frame", const notionFrame = ".notion-frame",
notionTopbarBtn = ".notion-topbar-more-button", notionTopbarBtn = ".notion-topbar-more-button",
togglePanelHotkey = await db.get("togglePanelHotkey"), togglePanelHotkey = await db.get("togglePanelHotkey"),
{ html } = api; { html, setState, addPanelView } = api;
const $panel = html`<${Panel} const $panel = html`<${Panel}
_getWidth=${() => db.get("sidePanelWidth")} ...${Object.assign(
_setWidth=${(width) => db.set("sidePanelWidth", width)} ...["Width", "Open", "View"].map((key) => ({
_getOpen=${() => db.get("sidePanelOpen")} [`_get${key}`]: () => db.get(`sidePanel${key}`),
_setOpen=${(open) => db.set("sidePanelOpen", open)} [`_set${key}`]: async (value) => {
await db.set(`sidePanel${key}`, value);
setState({ rerender: true });
},
}))
)}
/>`, />`,
togglePanel = () => { togglePanel = () => {
if ($panel.hasAttribute("open")) $panel.close(); if ($panel.hasAttribute("open")) $panel.close();

View File

@ -7,9 +7,73 @@
import { Tooltip } from "./Tooltip.mjs"; import { Tooltip } from "./Tooltip.mjs";
import { Select } from "../menu/islands/Select.mjs"; import { Select } from "../menu/islands/Select.mjs";
function View(props) { // note: these islands do not accept extensible
const { html } = globalThis.__enhancerApi; // properties, i.e. they are not reusable.
return html``; // please register your own interfaces via
// globalThis.__enhancerApi.addPanelView and
// not by re-instantiating additional panels
let panelViews = [],
// "$icon" may either be an actual dom element,
// or an icon name from the lucide icons set
addPanelView = ({ title, $icon, $view }) => {
panelViews.push([{ title, $icon }, $view]);
panelViews.sort(([{ title: a }], [{ title: b }]) => a.localeCompare(b));
const { setState } = globalThis.__enhancerApi;
setState?.({ panelViews });
},
removePanelView = ($view) => {
panelViews = panelViews.filter(([, v]) => v !== $view);
const { setState } = globalThis.__enhancerApi;
setState?.({ panelViews });
};
function View({ _get }) {
const { html, setState, useState } = globalThis.__enhancerApi,
$container = html`<div
class="overflow-(y-auto x-hidden)
h-full min-w-[var(--panel--width)]"
></div>`;
useState(["rerender"], async () => {
const openView = await _get?.(),
$view =
panelViews.find(([{ title }]) => {
return title === openView;
})?.[1] || panelViews[0]?.[1];
if (!$container.contains($view)) {
$container.innerHTML = "";
$container.append($view);
}
});
return $container;
}
function Switcher({ _get, _set, minWidth, maxWidth }) {
const { html, extendProps, setState, useState } = globalThis.__enhancerApi,
$switcher = html`<div
class="relative flex items-center
font-medium p-[8.5px] ml-[4px] grow"
></div>`,
setView = (view) => {
_set?.(view);
setState({ activePanelView: view });
};
useState(["panelViews"], ([panelViews]) => {
const values = panelViews.map(([{ title, $icon }]) => {
// panel switcher internally uses the select island,
// which expects an option value rather than a title
return { value: title, $icon };
});
$switcher.innerHTML = "";
$switcher.append(html`<${Select}
popupMode="dropdown"
class="w-full text-left"
maxWidth=${maxWidth - 56}
minWidth=${minWidth - 56}
...${{ _get, _set: setView, values }}
/>`);
});
return $switcher;
} }
function Panel({ function Panel({
@ -17,91 +81,77 @@ function Panel({
_setWidth, _setWidth,
_getOpen, _getOpen,
_setOpen, _setOpen,
_getView,
_setView,
minWidth = 250, minWidth = 250,
maxWidth = 640, maxWidth = 640,
transitionDuration = 300, transitionDuration = 300,
...props
}) { }) {
const { html, extendProps, setState, useState } = globalThis.__enhancerApi, const { html, setState, useState } = globalThis.__enhancerApi,
{ addMutationListener, removeMutationListener } = globalThis.__enhancerApi; { addMutationListener, removeMutationListener } = globalThis.__enhancerApi,
extendProps(props, { $panel = html`<aside
class: `notion-enhancer--panel order-2 shrink-0 class="notion-enhancer--panel relative order-2
transition-[width] open:w-[var(--panel--width)] shrink-0 bg-[color:var(--theme--bg-primary)]
border-l-1 border-[color:var(--theme--fg-border)] border-(l-1 [color:var(--theme--fg-border)])
relative bg-[color:var(--theme--bg-primary)] w-0 transition-[width] w-[var(--panel--width,0)]
duration-[${transitionDuration}ms] group/panel`, duration-[${transitionDuration}ms] group/panel"
}); >
<style>
.notion-frame {
flex-direction: row !important;
}
.notion-frame [role="progressbar"] {
padding-right: var(--panel--width);
}
.notion-frame [role="progressbar"] > div {
overflow-x: clip;
}
</style>
<div
class="flex justify-between items-center
border-(b [color:var(--theme--fg-border)])"
>
<${Switcher}
...${{ _get: _getView, _set: _setView, minWidth, maxWidth }}
/>
<button
aria-label="Close side panel"
class="user-select-none h-[24px] w-[24px] duration-[20ms]
transition inline-flex items-center justify-center mr-[10px]
rounded-[3px] hover:bg-[color:var(--theme--bg-hover)]"
onclick=${() => $panel.close()}
>
<i
class="i-chevrons-right w-[20px] h-[20px]
text-[color:var(--theme--fg-secondary)]"
/>
</button>
</div>
<${View} ...${{ _get: _getView }} />
</aside>`;
const values = [ let preDragWidth,
{ dragStartX = 0;
icon: "type", const $resizeHandle = html`<div
value: "word counter",
},
{
// prettier-ignore
$icon: html`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<circle cx="5" cy="7" r="2.8"/>
<circle cx="5" cy="17" r="2.79"/>
<path d="M21,5.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,5.5,21.55,5.95,21,5.95z"/>
<path d="M17,10.05h-6c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h6c0.55,0,1,0.45,1,1v0C18,9.6,17.55,10.05,17,10.05z"/>
<path d="M21,15.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,15.5,21.55,15.95,21,15.95z" />
<path d="M17,20.05h-6c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h6c0.55,0,1,0.45,1,1v0C18,19.6,17.55,20.05,17,20.05z"/>
</svg>`,
value: "outliner",
},
],
_get = () => useState(["panelView"])[0],
_set = (value) => {
setState({ panelView: value, rerender: true });
};
const $resize = html`<div
class="absolute h-full w-[3px] left-[-3px] class="absolute h-full w-[3px] left-[-3px]
z-10 transition duration-300 hover:(cursor-col-resize z-10 transition duration-300 hover:(cursor-col-resize
shadow-[var(--theme--fg-border)_-2px_0px_0px_0px_inset]) shadow-[var(--theme--fg-border)_-2px_0px_0px_0px_inset])
active:cursor-text group-not-[open]/panel:hidden" active:cursor-text group-not-[open]/panel:hidden"
></div>`, ></div>`,
$close = html`<button $resizeTooltip = html`<${Tooltip}>
aria-label="Close side panel" <span>Drag</span> to resize<br />
class="user-select-none h-[24px] w-[24px] duration-[20ms] <span>Click</span> to closed
transition inline-flex items-center justify-center mr-[10px] <//>`,
rounded-[3px] hover:bg-[color:var(--theme--bg-hover)]" showTooltip = (event) => {
> setTimeout(() => {
<i const panelOpen = $panel.hasAttribute("open"),
class="i-chevrons-right w-[20px] h-[20px] handleHovered = $resizeHandle.matches(":hover");
text-[color:var(--theme--fg-secondary)]" if (!panelOpen || !handleHovered) return;
/> const { x } = $resizeHandle.getBoundingClientRect();
</div>`, $resizeTooltip.show(x, event.clientY);
$switcher = html`<div }, 200);
class="relative flex items-center },
font-medium p-[8.5px] ml-[4px] grow" startDrag = async (event) => {
>
<${Select}
popupMode="dropdown"
class="w-full text-left"
maxWidth=${maxWidth - 56}
minWidth=${minWidth - 56}
...${{ _get, _set, values }}
/>
</div>`,
$view = html`<div
class="overflow-(y-auto x-hidden)
h-full min-w-[var(--panel--width)]"
></div>`,
$panel = html`<aside ...${props}>
${$resize}
<div
class="flex justify-between items-center
border-(b [color:var(--theme--fg-border)])"
>
${$switcher}${$close}
</div>
${$view}
</aside>`;
let preDragWidth,
dragStartX = 0;
const startDrag = async (event) => {
dragStartX = event.clientX; dragStartX = event.clientX;
preDragWidth = await _getWidth?.(); preDragWidth = await _getWidth?.();
if (isNaN(preDragWidth)) preDragWidth = minWidth; if (isNaN(preDragWidth)) preDragWidth = minWidth;
@ -121,32 +171,18 @@ function Panel({
// trigger panel close if not resized // trigger panel close if not resized
if (dragStartX - event.clientX === 0) $panel.close(); if (dragStartX - event.clientX === 0) $panel.close();
}; };
$resize.addEventListener("mousedown", startDrag); $resizeHandle.addEventListener("mouseout", $resizeTooltip.hide);
$resizeHandle.addEventListener("mousedown", startDrag);
const $tooltip = html`<${Tooltip}> $resizeHandle.addEventListener("mouseover", showTooltip);
<span>Drag</span> to resize<br /> $panel.prepend($resizeHandle);
<span>Click</span> to closed
<//>`,
showTooltip = (event) => {
setTimeout(() => {
const panelOpen = $panel.hasAttribute("open"),
handleHovered = $resize.matches(":hover");
if (!panelOpen || !handleHovered) return;
const { x } = $resize.getBoundingClientRect();
$tooltip.show(x, event.clientY);
}, 200);
};
$resize.addEventListener("mouseover", showTooltip);
$resize.addEventListener("mouseout", () => $tooltip.hide());
$close.addEventListener("click", () => $panel.close());
// normally would place outside of an island, but in // normally would place outside of an island, but in
// this case is necessary for syncing up animations // this case is necessary for syncing up animations
const notionHelp = ".notion-help-button", const notionHelp = ".notion-help-button",
repositionHelp = async () => { repositionHelp = async (width) => {
const $notionHelp = document.querySelector(notionHelp); const $notionHelp = document.querySelector(notionHelp);
if (!$notionHelp) return; if (!$notionHelp) return;
let width = await _getWidth?.(); width ??= await _getWidth?.();
if (isNaN(width)) width = minWidth; if (isNaN(width)) width = minWidth;
if (!$panel.hasAttribute("open")) width = 0; if (!$panel.hasAttribute("open")) width = 0;
const position = $notionHelp.style.getPropertyValue("right"), const position = $notionHelp.style.getPropertyValue("right"),
@ -163,15 +199,17 @@ function Panel({
addMutationListener(notionHelp, repositionHelp); addMutationListener(notionHelp, repositionHelp);
$panel.resize = async (width) => { $panel.resize = async (width) => {
$tooltip.hide(); $resizeTooltip.hide();
if (width) { if (width) {
width = Math.max(width, minWidth); width = Math.max(width, minWidth);
width = Math.min(width, maxWidth); width = Math.min(width, maxWidth);
_setWidth?.(width); _setWidth?.(width);
} else width = await _getWidth?.(); } else width = await _getWidth?.();
if (isNaN(width)) width = minWidth; if (isNaN(width)) width = minWidth;
$panel.style.setProperty("--panel--width", `${width}px`); if (!$panel.hasAttribute("open")) width = 0;
repositionHelp(); const $cssVarTarget = $panel.parentElement || $panel;
$cssVarTarget.style.setProperty("--panel--width", `${width}px`);
repositionHelp(width);
}; };
$panel.open = () => { $panel.open = () => {
$panel.setAttribute("open", true); $panel.setAttribute("open", true);
@ -182,13 +220,13 @@ function Panel({
$panel.resize(); $panel.resize();
}; };
$panel.close = () => { $panel.close = () => {
$tooltip.hide(); $resizeTooltip.hide();
$panel.onbeforeclose?.(); $panel.onbeforeclose?.();
$panel.removeAttribute("open"); $panel.removeAttribute("open");
$panel.style.pointerEvents = "auto"; $panel.style.pointerEvents = "auto";
$panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1)); $panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1));
$panel.resize();
setState({ panelOpen: false }); setState({ panelOpen: false });
repositionHelp();
_setOpen(false); _setOpen(false);
setTimeout(() => { setTimeout(() => {
$panel.style.pointerEvents = ""; $panel.style.pointerEvents = "";
@ -198,8 +236,13 @@ function Panel({
_getOpen().then((open) => { _getOpen().then((open) => {
if (open) $panel.open(); if (open) $panel.open();
}); });
return $panel; return $panel;
} }
globalThis.__enhancerApi ??= {};
Object.assign(globalThis.__enhancerApi, {
addPanelView,
removePanelView,
});
export { Panel }; export { Panel };

View File

@ -60,23 +60,25 @@ function Select({
></div>`; ></div>`;
const options = values.map((opt) => { const options = values.map((opt) => {
if (typeof opt === "string") opt = { value: opt }; if (["string", "number"].includes(typeof opt)) opt = { value: opt };
if (!(opt?.$icon instanceof Element)) { if (!(opt?.$icon instanceof Element)) {
if (opt?.icon && typeof opt.icon === "string") { if (typeof opt?.$icon === "string") {
opt.$icon = html`<i class="i-${opt.icon} h-[16px] w-[16px]" />`; opt.$icon = html`<i class="i-${opt.$icon} h-[16px] w-[16px]" />`;
} else delete opt.$icon; } else delete opt.$icon;
} }
opt.$option = html`<${Option} ...${{ ...opt, _get, _set }} />`; return {
opt.$selection = html`<div class="inline-flex items-center gap-[6px]"> ...opt,
<!-- swap icon/value order for correct display when dir="rtl" --> $option: html`<${Option} ...${{ ...opt, _get, _set }} />`,
<span>${opt.value}</span>${opt.$icon?.cloneNode(true) ?? ""} $value: html`<div class="inline-flex items-center gap-[6px]">
</div>`; <!-- swap icon/value order for correct display when dir="rtl" -->
return opt; <span>${opt.value}</span>${opt.$icon?.cloneNode(true) ?? ""}
</div>`,
};
}), }),
getSelected = async () => { getSelected = async () => {
const value = (await _get?.()) ?? $select.innerText, const value = (await _get?.()) ?? $select.innerText,
option = options.find((opt) => opt.value === value); option = options.find((opt) => opt.value === value);
if (!option) _set(options[0].value); if (!option) _set?.(options[0].value);
return option || options[0]; return option || options[0];
}, },
onKeydown = (event) => { onKeydown = (event) => {
@ -102,9 +104,9 @@ function Select({
let _initialValue; let _initialValue;
useState(["rerender"], async () => { useState(["rerender"], async () => {
if (!options.length) return; if (!options.length) return;
const { value, $selection } = await getSelected(); const { value, $value } = await getSelected();
$select.innerHTML = ""; $select.innerHTML = "";
$select.append($selection); $select.append($value);
if (_requireReload) { if (_requireReload) {
_initialValue ??= value; _initialValue ??= value;
if (value !== _initialValue) setState({ databaseUpdated: true }); if (value !== _initialValue) setState({ databaseUpdated: true });

View File

@ -6,6 +6,51 @@
"use strict"; "use strict";
// convenient util: batch event handling
// every ___ ms to avoid over-handling &
// any conflicts / perf.issues that may
// otherwise result. a wait time of ~200ms
// is recommended (the avg. human visual
// reaction time is ~180-200ms)
const debounce = (callback, wait = 200) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => callback(...args), wait);
};
};
// provides basic key/value reactivity:
// this is shared between all active mods,
// i.e. mods can read and update other mods'
// reactive states. this enables interop
// between a mod's component islands and
// supports inter-mod communication if so
// required. caution should be used in
// naming keys to avoid conflicts
const _state = {},
_subscribers = [],
setState = (state) => {
Object.assign(_state, state);
const updates = Object.keys(state);
_subscribers
.filter(([keys]) => updates.some((key) => keys.includes(key)))
.forEach(([keys, callback]) => callback(keys.map((key) => _state[key])));
},
// useState(["keyA", "keyB"]) => returns [valueA, valueB]
// useState(["keyA", "keyB"], callback) => registers callback
// to be triggered after each update to either keyA or keyB,
// with [valueA, valueB] passed to the callback's first arg
useState = (keys, callback) => {
const state = keys.map((key) => _state[key]);
if (callback) {
callback = debounce(callback);
_subscribers.push([keys, callback]);
callback(state);
}
return state;
};
let documentObserver, let documentObserver,
mutationListeners = []; mutationListeners = [];
const mutationQueue = [], const mutationQueue = [],
@ -99,20 +144,21 @@ const modifierAliases = [
} }
}; };
document.addEventListener("keyup", (event) => { document.addEventListener("keyup", (event) => {
const keyupListeners = keyListeners.filter( const keyupListeners = keyListeners //
([, , waitForKeyup]) => waitForKeyup .filter(([, , waitForKeyup]) => waitForKeyup);
);
handleKeypress(event, keyupListeners); handleKeypress(event, keyupListeners);
}); });
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
const keydownListeners = keyListeners.filter( const keydownListeners = keyListeners //
([, , waitForKeyup]) => !waitForKeyup .filter(([, , waitForKeyup]) => !waitForKeyup);
);
handleKeypress(event, keydownListeners); handleKeypress(event, keydownListeners);
}); });
globalThis.__enhancerApi ??= {}; globalThis.__enhancerApi ??= {};
Object.assign(globalThis.__enhancerApi, { Object.assign(globalThis.__enhancerApi, {
debounce,
setState,
useState,
addMutationListener, addMutationListener,
removeMutationListener, removeMutationListener,
addKeyListener, addKeyListener,

View File

@ -560,7 +560,9 @@ const h = (type, props, ...children) => {
} else elem.setAttribute(prop, props[prop]); } else elem.setAttribute(prop, props[prop]);
} else elem[prop] = props[prop]; } else elem[prop] = props[prop];
} }
elem.append(...children); if (type === "style") {
elem.append(children.join("").replace(/\s+/g, " "));
} else elem.append(...children);
return elem; return elem;
}, },
// combines instance-provided element props // combines instance-provided element props
@ -586,38 +588,8 @@ const h = (type, props, ...children) => {
}, },
html = htm.bind(h); html = htm.bind(h);
// provides basic key/value reactivity:
// this is shared between all active mods,
// i.e. mods can read and update other mods'
// reactive states. this enables interop
// between a mod's component islands and
// supports inter-mod communication if so
// required. caution should be used in
// naming keys to avoid conflicts
const _state = {},
_subscribers = [],
setState = (state) => {
Object.assign(_state, state);
const updates = Object.keys(state);
_subscribers
.filter(([keys]) => updates.some((key) => keys.includes(key)))
.forEach(([keys, callback]) => callback(keys.map((key) => _state[key])));
},
// useState(["keyA", "keyB"]) => returns [valueA, valueB]
// useState(["keyA", "keyB"], callback) => registers callback
// to be triggered after each update to either keyA or keyB,
// with [valueA, valueB] passed to the callback's first arg
useState = (keys, callback) => {
const state = keys.map((key) => _state[key]);
if (callback) _subscribers.push([keys, callback]);
callback?.(state);
return state;
};
globalThis.__enhancerApi ??= {}; globalThis.__enhancerApi ??= {};
Object.assign(globalThis.__enhancerApi, { Object.assign(globalThis.__enhancerApi, {
html, html,
extendProps, extendProps,
setState,
useState,
}); });

View File

@ -65,7 +65,12 @@ const modDatabase = async (id) => {
const optionDefaults = const optionDefaults =
(await getMods()) (await getMods())
.find((mod) => mod.id === id) .find((mod) => mod.id === id)
?.options?.map?.((opt) => [opt.key, opt.value ?? opt.values?.[0]]) ?.options?.map?.((opt) => {
let value = opt.value;
value ??= opt.values?.[0]?.value;
value ??= opt.values?.[0];
return [opt.key, value];
})
?.filter?.(([, value]) => typeof value !== "undefined") ?? {}; ?.filter?.(([, value]) => typeof value !== "undefined") ?? {};
return globalThis.__enhancerApi.initDatabase( return globalThis.__enhancerApi.initDatabase(
[await getProfile(), id], [await getProfile(), id],