From e3f34dfc21363c7d25c1a0414c859bfb3a9d6c02 Mon Sep 17 00:00:00 2001 From: dragonwocky Date: Mon, 23 Jan 2023 21:58:17 +1100 Subject: [PATCH] refactor(menu): menu.mjs render functions and components.mjs monolith -> islands/ and components/ --- src/api/interface.js | 6 +- src/api/mods.js | 124 ++- src/core/client.mjs | 6 +- src/core/menu/components.mjs | 937 ----------------------- src/core/menu/components/Button.mjs | 41 + src/core/menu/components/Checkbox.mjs | 39 + src/core/menu/components/Description.mjs | 18 + src/core/menu/components/Heading.mjs | 19 + src/core/menu/components/Input.mjs | 165 ++++ src/core/menu/components/Popup.mjs | 68 ++ src/core/menu/components/Select.mjs | 76 ++ src/core/menu/components/Toggle.mjs | 42 + src/core/menu/islands/Footer.mjs | 59 ++ src/core/menu/islands/List.mjs | 57 ++ src/core/menu/islands/Mod.mjs | 85 ++ src/core/menu/islands/Options.mjs | 86 +++ src/core/menu/islands/Profiles.mjs | 204 +++++ src/core/menu/islands/Sidebar.mjs | 88 +++ src/core/menu/islands/View.mjs | 83 ++ src/core/menu/menu.css | 6 +- src/core/menu/menu.mjs | 474 +++--------- src/core/menu/state.mjs | 33 +- src/core/menuu/components.mjs | 292 ------- src/core/menuu/mod.json | 31 - src/core/mod.json | 2 +- src/init.js | 13 +- src/load.mjs | 22 +- 27 files changed, 1357 insertions(+), 1719 deletions(-) delete mode 100644 src/core/menu/components.mjs create mode 100644 src/core/menu/components/Button.mjs create mode 100644 src/core/menu/components/Checkbox.mjs create mode 100644 src/core/menu/components/Description.mjs create mode 100644 src/core/menu/components/Heading.mjs create mode 100644 src/core/menu/components/Input.mjs create mode 100644 src/core/menu/components/Popup.mjs create mode 100644 src/core/menu/components/Select.mjs create mode 100644 src/core/menu/components/Toggle.mjs create mode 100644 src/core/menu/islands/Footer.mjs create mode 100644 src/core/menu/islands/List.mjs create mode 100644 src/core/menu/islands/Mod.mjs create mode 100644 src/core/menu/islands/Options.mjs create mode 100644 src/core/menu/islands/Profiles.mjs create mode 100644 src/core/menu/islands/Sidebar.mjs create mode 100644 src/core/menu/islands/View.mjs delete mode 100644 src/core/menuu/components.mjs delete mode 100644 src/core/menuu/mod.json diff --git a/src/api/interface.js b/src/api/interface.js index 7bffb00..b661c9e 100644 --- a/src/api/interface.js +++ b/src/api/interface.js @@ -538,8 +538,10 @@ const h = (type, props, ...children) => { : document.createElement(type); for (const prop in props ?? {}) { if (htmlAttributes.includes(prop) || prop.startsWith("data-")) { - if (typeof props[prop] === "boolean" && !props[prop]) continue; - elem.setAttribute(prop, props[prop]); + if (typeof props[prop] === "boolean") { + if (!props[prop]) continue; + elem.setAttribute(prop, ""); + } else elem.setAttribute(prop, props[prop]); } else elem[prop] = props[prop]; } elem.append(...children); diff --git a/src/api/mods.js b/src/api/mods.js index 8d7683f..8bd64a9 100644 --- a/src/api/mods.js +++ b/src/api/mods.js @@ -6,89 +6,75 @@ "use strict"; -let _mods; -const getMods = async () => { - const { readJson } = globalThis.__enhancerApi; - _mods ??= await Promise.all( - // prettier-ignore - (await readJson("registry.json")).map(async (_src) => { - const modManifest = await readJson(`${_src}/mod.json`); - return { ...modManifest, _src }; - }) - ); - return _mods; - }, - getCore = async () => { - const mods = await getMods(); - return mods.find(({ _src }) => _src === "core"); - }, - getThemes = async () => { - const mods = await getMods(); - return mods.filter(({ _src }) => _src.startsWith("themes/")); - }, - getExtensions = async () => { - const mods = await getMods(); - return mods.filter(({ _src }) => _src.startsWith("extensions/")); - }, - getIntegrations = async () => { - const mods = await getMods(); - return mods.filter(({ _src }) => _src.startsWith("integrations/")); - }; +const _isManifestValid = (modManifest) => { + const hasRequiredFields = + modManifest.id && + modManifest.name && + modManifest.version && + modManifest.description && + modManifest.authors, + meetsThemeRequirements = + !modManifest._src.startsWith("themes/") || + ((modManifest.tags?.includes("dark") || + modManifest.tags?.includes("light")) && + modManifest.thumbnail), + targetsCurrentPlatform = + !modManifest.platforms || // + modManifest.platforms.includes(platform); + return hasRequiredFields && meetsThemeRequirements && targetsCurrentPlatform; +}; -const getProfile = async () => { - const { initDatabase } = globalThis.__enhancerApi, - db = initDatabase(); +let _mods; +const getMods = async (category) => { + const { readJson } = globalThis.__enhancerApi; + // prettier-ignore + _mods ??= (await Promise.all((await readJson("registry.json")).map(async (_src) => { + const modManifest = { ...(await readJson(`${_src}/mod.json`)), _src }; + return _isManifestValid(modManifest) ? modManifest : undefined; + }))).filter((mod) => mod); + return category + ? _mods.filter(({ _src }) => { + return _src === category || _src.startsWith(`${category}/`); + }) + : _mods; + }, + getProfile = async () => { + const db = globalThis.__enhancerApi.initDatabase(); let activeProfile = await db.get("activeProfile"); activeProfile ??= (await db.get("profileIds"))?.[0]; return activeProfile ?? "default"; - }, - isEnabled = async (id) => { - const { platform } = globalThis.__enhancerApi, - mod = (await getMods()).find((mod) => mod.id === id); - if (mod._src === "core") return true; - if (mod.platforms && !mod.platforms.includes(platform)) return false; - const { initDatabase } = globalThis.__enhancerApi, - enabledMods = initDatabase([await getProfile(), "enabledMods"]); - return Boolean(await enabledMods.get(id)); - }, - setEnabled = async (id, enabled) => { - const { initDatabase } = globalThis.__enhancerApi; - // prettier-ignore - return await initDatabase([ - await getProfile(), - "enabledMods" - ]).set(id, enabled); }; -const optionDefaults = async (id) => { - const mod = (await getMods()).find((mod) => mod.id === id), - optionEntries = mod.options - .map((opt) => { - if ( - ["toggle", "text", "number", "hotkey", "color"].includes(opt.type) - ) - return [opt.key, opt.value]; - if (opt.type === "select") return [opt.key, opt.values[0]]; - return undefined; - }) - .filter((opt) => opt); - return Object.fromEntries(optionEntries); +const isEnabled = async (id) => { + const mod = (await getMods()).find((mod) => mod.id === id); + // prettier-ignore + return mod._src === "core" || await globalThis.__enhancerApi + .initDatabase([await getProfile(), "enabledMods"]) + .get(id); }, - modDatabase = async (id) => { - const { initDatabase } = globalThis.__enhancerApi; - return initDatabase([await getProfile(), id], await optionDefaults(id)); + setEnabled = async (id, enabled) => { + return await globalThis.__enhancerApi + .initDatabase([await getProfile(), "enabledMods"]) + .set(id, enabled); }; +const modDatabase = async (id) => { + // prettier-ignore + const optionDefaults = (await getMods()) + .find((mod) => mod.id === id)?.options + .map((opt) => [opt.key, opt.value ?? opt.values?.[0]]) + .filter(([, value]) => typeof value !== "undefined"); + return globalThis.__enhancerApi.initDatabase( + [await getProfile(), id], + Object.fromEntries(optionDefaults) + ); +}; + globalThis.__enhancerApi ??= {}; Object.assign(globalThis.__enhancerApi, { getMods, - getCore, - getThemes, - getExtensions, - getIntegrations, getProfile, isEnabled, setEnabled, - optionDefaults, modDatabase, }); diff --git a/src/core/client.mjs b/src/core/client.mjs index 88461c9..76f0c82 100644 --- a/src/core/client.mjs +++ b/src/core/client.mjs @@ -10,7 +10,7 @@ export default async (api, db) => { const { html, platform, - getThemes, + getMods, isEnabled, enhancerUrl, onMessage, @@ -25,7 +25,9 @@ export default async (api, db) => { // appearance - const enabledThemes = (await getThemes()).map((theme) => isEnabled(theme.id)), + const enabledThemes = (await getMods("themes")).map((theme) => + isEnabled(theme.id) + ), forceLoadOverrides = loadThemeOverrides === "Enabled", autoLoadOverrides = loadThemeOverrides === "Auto" && diff --git a/src/core/menu/components.mjs b/src/core/menu/components.mjs deleted file mode 100644 index a62fb61..0000000 --- a/src/core/menu/components.mjs +++ /dev/null @@ -1,937 +0,0 @@ -/** - * notion-enhancer - * (c) 2023 dragonwocky (https://dragonwocky.me/) - * (https://notion-enhancer.github.io/) under the MIT license - */ - -import { setState, useState, getState } from "./state.mjs"; - -// generic - -function _Button( - { type, size, variant, icon, class: cls = "", ...props }, - ...children -) { - const { html } = globalThis.__enhancerApi, - iconSize = - size === "sm" && children.length - ? "w-[14px] h-[14px]" - : "w-[18px] h-[18px]"; - return html`<${type} - class="flex gap-[8px] items-center px-[12px] shrink-0 - rounded-[4px] ${size === "sm" ? "h-[28px]" : "h-[32px]"} - transition duration-[20ms] ${variant === "primary" - ? `text-[color:var(--theme--accent-primary\\_contrast)] - font-medium bg-[color:var(--theme--accent-primary)] - hover:bg-[color:var(--theme--accent-primary\\_hover)]` - : variant === "secondary" - ? `text-[color:var(--theme--accent-secondary)] - border-(& [color:var(--theme--accent-secondary)]) - hover:bg-[color:var(--theme--accent-secondary\\_hover)]` - : `border-(& [color:var(--theme--fg-border)]) - hover:bg-[color:var(--theme--bg-hover)]`} ${cls}" - ...${props} - > - ${icon ? html`` : ""} - - ${children} - - `; -} - -function Button(props, ...children) { - const { html } = globalThis.__enhancerApi; - return html`<${_Button} type="button" ...${props}>${children}`; -} - -function Label(props, ...children) { - const { html } = globalThis.__enhancerApi; - return html`<${_Button} type="label" ...${props}>${children}`; -} - -function Description({ class: cls = "", ...props }, ...children) { - const { html } = globalThis.__enhancerApi; - return html`

- ${children} -

`; -} - -function Icon({ icon, ...props }) { - const { html } = globalThis.__enhancerApi; - return html``; -} - -// layout - -function Sidebar({}, ...children) { - const { html } = globalThis.__enhancerApi; - return html``; -} - -function SidebarSection({}, ...children) { - const { html } = globalThis.__enhancerApi; - return html`

- ${children} -

`; -} - -function SidebarButton({ id, icon, ...props }, ...children) { - const { html } = globalThis.__enhancerApi, - $icon = icon - ? html`` - : "", - $el = html`<${props.href ? "a" : "button"} - class="flex select-none cursor-pointer w-full - items-center py-[5px] px-[15px] text-[14px] last:mb-[12px] - transition hover:bg-[color:var(--theme--bg-hover)]" - ...${props} - >${$icon} - ${children} - `; - if (!props.href) { - $el.onclick ??= () => setState({ transition: "fade", view: id }); - useState(["view"], ([view = "welcome"]) => { - const active = view.toLowerCase() === id.toLowerCase(); - $el.style.background = active ? "var(--theme--bg-hover)" : ""; - $el.style.fontWeight = active ? "600" : ""; - }); - } - return $el; -} - -function List({ id, description }, ...children) { - const { html } = globalThis.__enhancerApi; - return html`
- <${Search} type=${id} items=${children} /> - <${Description} innerHTML=${description} /> - ${children} -
`; -} - -function Footer({}, ...children) { - const { html } = globalThis.__enhancerApi; - return html`
- ${children} -
`; -} - -function View({ id }, ...children) { - const { html } = globalThis.__enhancerApi, - $el = html`
- ${children} -
`; - useState(["view"], ([view = "welcome"]) => { - const [transition] = getState(["transition"]), - isVisible = $el.style.display !== "none", - nowActive = view.toLowerCase() === id.toLowerCase(); - switch (transition) { - case "fade": { - const duration = 100, - cssTransition = `opacity ${duration}ms`; - if (isVisible && !nowActive) { - $el.style.transition = cssTransition; - $el.style.opacity = "0"; - setTimeout(() => ($el.style.display = "none"), duration); - } else if (!isVisible && nowActive) { - setTimeout(() => { - $el.style.opacity = "0"; - $el.style.display = ""; - requestIdleCallback(() => { - $el.style.transition = cssTransition; - $el.style.opacity = "1"; - }); - }, duration); - } - break; - } - case "slide-to-left": - case "slide-to-right": { - const duration = 200, - cssTransition = `opacity ${duration}ms, transform ${duration}ms`, - transformOut = `translateX(${ - transition === "slide-to-right" ? "-100%" : "100%" - })`, - transformIn = `translateX(${ - transition === "slide-to-right" ? "100%" : "-100%" - })`; - if (isVisible && !nowActive) { - $el.style.transition = cssTransition; - $el.style.transform = transformOut; - $el.style.opacity = "0"; - setTimeout(() => { - $el.style.display = "none"; - $el.style.transform = ""; - }, duration); - } else if (!isVisible && nowActive) { - $el.style.transform = transformIn; - $el.style.opacity = "0"; - $el.style.display = ""; - requestIdleCallback(() => { - $el.style.transition = cssTransition; - $el.style.transform = ""; - $el.style.opacity = "1"; - }); - } - break; - } - default: - $el.style.transition = ""; - $el.style.opacity = nowActive ? "1" : "0"; - $el.style.display = nowActive ? "" : "none"; - } - }); - return $el; -} - -function Popup( - { for: $trigger, onopen, onclose, onbeforeclose, ...props }, - ...children -) { - const { html } = globalThis.__enhancerApi, - $popup = html`
-
-
- ${children} -
-
-
`; - - const { onclick, onkeydown } = $trigger, - enableTabbing = () => { - $popup - .querySelectorAll("[tabindex]") - .forEach(($el) => ($el.tabIndex = 0)); - }, - disableTabbing = () => { - $popup - .querySelectorAll("[tabindex]") - .forEach(($el) => ($el.tabIndex = -1)); - }, - openPopup = () => { - $popup.setAttribute("open", true); - enableTabbing(); - onopen?.(); - setState({ popupOpen: true }); - }, - closePopup = () => { - $popup.removeAttribute("open"); - disableTabbing(); - onbeforeclose?.(); - setTimeout(() => { - onclose?.(); - setState({ popupOpen: false }); - }, 200); - }; - disableTabbing(); - $trigger.onclick = (event) => { - onclick?.(event); - openPopup(); - }; - $trigger.onkeydown = (event) => { - onkeydown?.(event); - if (event.key === "Enter") openPopup(); - }; - useState(["rerender"], () => { - if ($popup.hasAttribute("open")) closePopup(); - }); - document.addEventListener("click", (event) => { - if (!$popup.hasAttribute("open")) return; - if ($popup.contains(event.target) || $popup === event.target) return; - if ($trigger.contains(event.target) || $trigger === event.target) return; - closePopup(); - }); - - return $popup; -} - -// input - -function Input({ - size, - icon, - transparent, - onrerender, - class: cls = "", - ...props -}) { - const { html } = globalThis.__enhancerApi, - $input = html``, - $icon = html``; - useState(["rerender"], () => onrerender?.($input, $icon)); - return html``; -} - -function TextInput({ _get, _set, onchange, ...props }) { - const { html } = globalThis.__enhancerApi; - return html`<${Input} - size="md" - type="text" - icon="text-cursor" - class="mt-[4px] mb-[8px]" - onchange=${(event) => { - onchange?.(event); - _set?.(event.target.value); - }} - onrerender=${($input) => { - _get?.().then((value) => ($input.value = value)); - }} - ...${props} - />`; -} - -function NumberInput({ _get, _set, onchange, ...props }) { - const { html } = globalThis.__enhancerApi; - return html`<${Input} - size="sm" - type="number" - icon="hash" - onchange=${(event) => { - onchange?.(event); - _set?.(event.target.value); - }} - onrerender=${($input) => { - _get?.().then((value) => ($input.value = value)); - }} - ...${props} - />`; -} - -function HotkeyInput({ _get, _set, onkeydown, ...props }) { - const { html } = globalThis.__enhancerApi, - updateHotkey = (event) => { - event.preventDefault(); - const keys = []; - for (const modifier of ["metaKey", "ctrlKey", "altKey", "shiftKey"]) { - if (!event[modifier]) continue; - const alias = modifier[0].toUpperCase() + modifier.slice(1, -3); - keys.push(alias); - } - if (!keys.length && ["Backspace", "Delete"].includes(event.key)) { - event.target.value = ""; - } else if (event.key) { - let key = event.key; - if (key === " ") key = "Space"; - if (["+", "="].includes(key)) key = "Plus"; - if (key === "-") key = "Minus"; - if (event.code === "Comma") key = ","; - if (event.code === "Period") key = "."; - if (key === "Control") key = "Ctrl"; - // avoid e.g. Shift+Shift, force inclusion of non-modifier - if (keys.includes(key)) return; - keys.push(key.length === 1 ? key.toUpperCase() : key); - event.target.value = keys.join("+"); - } - event.target.dispatchEvent(new Event("input")); - event.target.dispatchEvent(new Event("change")); - }; - return html`<${Input} - size="sm" - type="text" - icon="command" - onkeydown=${(event) => { - updateHotkey(event); - onkeydown?.(event); - _set?.(event.target.value); - }} - onrerender=${($input) => { - _get?.().then((value) => ($input.value = value)); - }} - ...${props} - />`; -} - -function ColorInput({ _get, _set, oninput, ...props }) { - Coloris({ format: "rgb" }); - const { html } = globalThis.__enhancerApi, - updateContrast = ($input, $icon) => { - $input.style.background = $input.value; - const [r, g, b, a = 1] = $input.value - .replace(/^rgba?\(/, "") - .replace(/\)$/, "") - .split(",") - .map((n) => parseFloat(n)); - if (a > 0.5) { - // pick a contrasting foreground for an rgb background - // using the percieved brightness constants from http://alienryderflex.com/hsp.html - const brightness = 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b); - $input.style.color = Math.sqrt(brightness) > 165.75 ? "#000" : "#fff"; - } else $input.style.color = "#000"; - $icon.style.color = $input.style.color; - $icon.style.opacity = "0.7"; - }; - return html`<${Input} - transparent - size="sm" - type="text" - icon="pipette" - data-coloris - oninput=${(event) => { - oninput?.(event); - _set?.(event.target.value); - }} - onrerender=${($input, $icon) => { - _get?.().then((value) => { - $input.value = value; - updateContrast($input, $icon); - }); - }} - ...${props} - />`; -} - -function FileInput({ extensions, _get, _set, ...props }) { - const { html } = globalThis.__enhancerApi, - $filename = html`Upload a file`, - $clear = html`<${Icon} - icon="x" - style="display: none" - onclick=${() => { - $filename.innerText = "Upload a file"; - $clear.style.display = "none"; - _set?.({ filename: "", content: "" }); - }} - />`; - - const { onchange } = props; - props.onchange = (event) => { - const file = event.target.files[0], - reader = new FileReader(); - reader.onload = async (progress) => { - const content = progress.currentTarget.result, - upload = { filename: file.name, content }; - $filename.innerText = file.name; - $clear.style.display = ""; - _set?.(upload); - }; - reader.readAsText(file); - onchange?.(event); - }; - useState(["rerender"], () => { - _get?.().then((file) => { - $filename.innerText = file?.filename || "Upload a file"; - $clear.style.display = file?.filename ? "" : "none"; - }); - }); - - return html`
- - ${$clear} -
`; -} - -function Select({ values, _get, _set, ...props }) { - const { html } = globalThis.__enhancerApi, - $select = html`
`, - $options = values.map((value) => { - return html`<${SelectOption} ...${{ value, _get, _set }} />`; - }); - useState(["rerender"], () => { - _get?.().then((value) => ($select.innerText = value)); - }); - - return html`
- ${$select} - <${Popup} - for=${$select} - onbeforeclose=${() => { - $select.style.width = `${$select.offsetWidth}px`; - $select.style.background = "transparent"; - }} - onclose=${() => { - $select.style.width = ""; - $select.style.background = ""; - }} - > - ${$options} - - -
`; -} - -function SelectOption({ value, _get, _set, ...props }) { - const { html } = globalThis.__enhancerApi, - $selected = html``, - $option = html`
-
- ${value} -
-
`; - - const { onclick, onkeydown } = $option; - $option.onclick = (event) => { - onclick?.(event); - _set?.(value); - }; - $option.onkeydown = (event) => { - onkeydown?.(event); - if (event.key === "Enter") _set?.(value); - }; - useState(["rerender"], () => { - _get?.().then((actualValue) => { - if (actualValue === value) { - $option.append($selected); - } else $selected.remove(); - }); - }); - - return $option; -} - -function Toggle({ _get, _set, ...props }) { - const { html } = globalThis.__enhancerApi, - $input = html``; - - const { onchange } = $input; - $input.onchange = (event) => { - onchange?.(event); - _set?.($input.checked); - }; - useState(["rerender"], () => { - _get?.().then((checked) => ($input.checked = checked)); - }); - - return html`
- ${$input} -
-
-
-
`; -} - -function Checkbox({ _get, _set, ...props }) { - const { html } = globalThis.__enhancerApi, - $input = html``; - - const { onchange } = $input; - $input.onchange = (event) => { - onchange?.(event); - _set?.($input.checked); - }; - useState(["rerender"], () => { - _get?.().then((checked) => ($input.checked = checked)); - }); - - return html``; -} - -function Search({ type, items, oninput, ...props }) { - const { html, addKeyListener } = globalThis.__enhancerApi, - $search = html`<${Input} - size="lg" - type="text" - placeholder="Search ${items.length} ${items.length === 1 - ? type.replace(/s$/, "") - : type} (Press '/' to focus)" - icon="search" - oninput=${(event) => { - oninput?.(event); - const query = event.target.value.toLowerCase(); - for (const $item of items) { - const matches = $item.innerText.toLowerCase().includes(query); - $item.style.display = matches ? "" : "none"; - } - }} - ...${props} - />`; - addKeyListener("/", (event) => { - if (document.activeElement?.nodeName === "INPUT") return; - // offsetParent == null if parent has "display: none;" - if ($search.offsetParent) { - event.preventDefault(); - $search.focus(); - } - }); - return $search; -} - -// representative - -function Mod({ - id, - name, - version, - description, - thumbnail, - tags = [], - authors, - options = [], - _get, - _set, - _src, -}) { - const { html, enhancerUrl } = globalThis.__enhancerApi, - toggleId = Math.random().toString(36).slice(2, 5), - $thumbnail = thumbnail - ? html`` - : "", - $options = options.length - ? html`` - : ""; - return html``; -} - -function Option({ type, value, description, _get, _set, ...props }) { - const { html } = globalThis.__enhancerApi, - camelToSentenceCase = (string) => - string[0].toUpperCase() + - string.replace(/[A-Z]/g, (match) => ` ${match.toLowerCase()}`).slice(1); - - let $input; - const label = props.label ?? camelToSentenceCase(props.key); - switch (type) { - case "heading": - return html`

- ${label} -

`; - case "text": - $input = html`<${TextInput} ...${{ _get, _set }} />`; - break; - case "number": - $input = html`<${NumberInput} ...${{ _get, _set }} />`; - break; - case "hotkey": - $input = html`<${HotkeyInput} ...${{ _get, _set }} />`; - break; - case "color": - $input = html`<${ColorInput} ...${{ _get, _set }} />`; - break; - case "file": - $input = html`<${FileInput} - extensions="${props.extensions}" - ...${{ _get, _set }} - />`; - break; - case "select": - $input = html`<${Select} values=${props.values} ...${{ _get, _set }} />`; - break; - case "toggle": - $input = html`<${Toggle} ...${{ _get, _set }} />`; - } - return html`<${type === "toggle" ? "label" : "div"} - class="notion-enhancer--menu-option flex items-center justify-between - mb-[18px] ${type === "toggle" ? "cursor-pointer" : ""}" - > -
-
${label}
- ${type === "text" ? $input : ""} - <${Description} innerHTML=${description} /> -
- ${type === "text" ? "" : $input} - `; -} - -function Profile({ - getName, - setName, - isActive, - setActive, - exportJson, - importJson, - deleteProfile, - ...props -}) { - const { html } = globalThis.__enhancerApi, - uploadProfile = (event) => { - const file = event.target.files[0], - reader = new FileReader(); - reader.onload = async (progress) => { - const res = progress.currentTarget.result; - importJson(res); - }; - reader.readAsText(file); - }, - downloadProfile = async () => { - const now = new Date(), - year = now.getFullYear().toString(), - month = (now.getMonth() + 1).toString().padStart(2, "0"), - day = now.getDate().toString().padStart(2, "0"), - hour = now.getHours().toString().padStart(2, "0"), - min = now.getMinutes().toString().padStart(2, "0"), - sec = now.getSeconds().toString().padStart(2, "0"), - date = year + month + day + hour + min + sec; - - const $a = html` + +
+ ${options.length + ? html`` + : ""} +
+ <${Toggle} id=${toggleId} ...${{ _get, _set }} /> +
+
+ `; +} + +export { Mod }; diff --git a/src/core/menu/islands/Options.mjs b/src/core/menu/islands/Options.mjs new file mode 100644 index 0000000..d71b36d --- /dev/null +++ b/src/core/menu/islands/Options.mjs @@ -0,0 +1,86 @@ +/** + * notion-enhancer + * (c) 2023 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +import { setState } from "../state.mjs"; +import { Heading } from "../components/Heading.mjs"; +import { Description } from "../components/Description.mjs"; +import { Input } from "../components/Input.mjs"; +import { Select } from "../components/Select.mjs"; +import { Toggle } from "../components/Toggle.mjs"; + +const camelToSentenceCase = (string) => + string[0].toUpperCase() + + string.replace(/[A-Z]/g, (match) => ` ${match.toLowerCase()}`).slice(1), + filterOptionsForRender = (options) => { + const { platform } = globalThis.__enhancerApi; + options = options.reduce((options, opt) => { + // option must have key, headings may use label + if (!opt.key && (opt.type !== "heading" || !opt.label)) return options; + // ignore platform-specific options + if (opt.platforms && !opt.platforms.includes(platform)) return options; + // replace consective headings + const prev = options[options.length - 1]; + if (opt.type === "heading" && prev?.type === opt.type) { + options[options.length - 1] = opt; + } else options.push(opt); + return options; + }, []); + // remove trailing heading + return options.at(-1)?.type === "heading" ? options.slice(0, -1) : options; + }; + +function Option({ _get, _set, ...opt }) { + const { html } = globalThis.__enhancerApi; + return html`<${opt.type === "toggle" ? "label" : "div"} + class="notion-enhancer--menu-option flex items-center justify-between + mb-[18px] ${opt.type === "toggle" ? "cursor-pointer" : ""}" + > +
+
${opt.label}
+ ${opt.type === "text" + ? html`<${Input} + type="text" + class="mt-[4px] mb-[8px]" + ...${{ _get, _set }} + />` + : ""} + <${Description} innerHTML=${opt.description} /> +
+ ${["number", "hotkey", "color"].includes(opt.type) + ? html`<${Input} + type=${opt.type} + class="shrink-0 !w-[192px]" + ...${{ _get, _set }} + />` + : opt.type === "file" + ? html`<${Input} + type="file" + extensions=${opt.extensions} + ...${{ _get, _set }} + />` + : opt.type === "select" + ? html`<${Select} values=${opt.values} ...${{ _get, _set }} />` + : opt.type === "toggle" + ? html`<${Toggle} ...${{ _get, _set }} />` + : ""} + `; +} + +function Options({ mod }) { + const { html, modDatabase } = globalThis.__enhancerApi; + return filterOptionsForRender(mod.options).map((opt) => { + opt.label ??= camelToSentenceCase(opt.key); + if (opt.type === "heading") return html`<${Heading}>${opt.label}`; + const _get = async () => (await modDatabase(mod.id)).get(opt.key), + _set = async (value) => { + await (await modDatabase(mod.id)).set(opt.key, value); + setState({ rerender: true, databaseUpdated: true }); + }; + return html`<${Option} ...${{ _get, _set, ...opt }} />`; + }); +} + +export { Options }; diff --git a/src/core/menu/islands/Profiles.mjs b/src/core/menu/islands/Profiles.mjs new file mode 100644 index 0000000..cfa5fb0 --- /dev/null +++ b/src/core/menu/islands/Profiles.mjs @@ -0,0 +1,204 @@ +/** + * notion-enhancer + * (c) 2023 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +import { setState, useState } from "../state.mjs"; +import { Heading } from "../components/Heading.mjs"; +import { Description } from "../components/Description.mjs"; +import { Checkbox } from "../components/Checkbox.mjs"; +import { Button } from "../components/Button.mjs"; +import { Input } from "../components/Input.mjs"; +import { Popup } from "../components/Popup.mjs"; + +function Profile({ id }) { + const { html, getProfile, initDatabase } = globalThis.__enhancerApi, + profile = initDatabase([id]), + db = initDatabase(); + + const getName = async () => { + let profileName = await profile.get("profileName"); + if (id === "default") profileName ??= "default"; + return profileName ?? ""; + }, + setName = async (name) => { + // name only has effect in menu + // doesn't need reload triggered + await profile.set("profileName", name); + }; + + const isActive = async () => id === (await getProfile()), + setActive = async () => { + await db.set("activeProfile", id); + setState({ rerender: true, databaseUpdated: true }); + }; + + const uploadProfile = (event) => { + const file = event.target.files[0], + reader = new FileReader(); + reader.onload = async (progress) => { + const res = progress.currentTarget.result; + try { + await profile.import({ + ...JSON.parse(res), + profileName: await getName(), + }); + setState({ rerender: true, databaseUpdated: true }); + } catch (err) { + console.error(err); + } + }; + reader.readAsText(file); + }, + downloadProfile = async () => { + const now = new Date(), + year = now.getFullYear().toString(), + month = (now.getMonth() + 1).toString().padStart(2, "0"), + day = now.getDate().toString().padStart(2, "0"), + hour = now.getHours().toString().padStart(2, "0"), + min = now.getMinutes().toString().padStart(2, "0"), + sec = now.getSeconds().toString().padStart(2, "0"), + date = year + month + day + hour + min + sec; + const $a = html` - ${web.escape(author.name)}'s avatar ${web.escape(author.name)} - `; - return web.render(web.html`

`, ...authors.map(author)); - }, - toggle: (label, checked) => { - const $label = web.html``, - $input = web.html``, - $feature = web.html``; - $label.addEventListener('keyup', (event) => { - if (['Enter', ' '].includes(event.key)) $input.checked = !$input.checked; - }); - return web.render($label, $input, $feature); - }, -}; - -export const options = { - toggle: async (mod, opt) => { - const profileDB = await registry.profileDB(), - checked = await profileDB.get([mod.id, opt.key], opt.value), - $toggle = modComponents.toggle(opt.label, checked), - $tooltipIcon = web.html`${await components.feather('info', { class: 'input-tooltip' })}`, - $label = $toggle.children[0], - $input = $toggle.children[1]; - if (opt.tooltip) { - $label.prepend($tooltipIcon); - components.addTooltip($tooltipIcon, opt.tooltip, { - offsetDirection: 'left', - maxLines: 3, - }); - } - $input.addEventListener('change', async (_event) => { - await profileDB.set([mod.id, opt.key], $input.checked); - notifications.onChange(); - }); - return $toggle; - }, - - select: async (mod, opt) => { - const profileDB = await registry.profileDB(), - value = await profileDB.get([mod.id, opt.key], opt.values[0]), - $tooltipIcon = web.html`${await components.feather('info', { class: 'input-tooltip' })}`, - $label = web.render( - web.html``, - web.render(web.html`

`, opt.tooltip ? $tooltipIcon : '', opt.label) - ), - $options = opt.values.map( - (option) => web.raw`` - ), - $select = web.html``, - $icon = web.html`${await components.feather('chevron-down', { class: 'input-icon' })}`; - if (opt.tooltip) - components.addTooltip($tooltipIcon, opt.tooltip, { - offsetDirection: 'left', - maxLines: 3, - }); - $select.addEventListener('change', async (_event) => { - await profileDB.set([mod.id, opt.key], $select.value); - notifications.onChange(); - }); - return web.render($label, $select, $icon); - }, - - text: async (mod, opt) => { - const profileDB = await registry.profileDB(), - value = await profileDB.get([mod.id, opt.key], opt.value), - $tooltipIcon = web.html`${await components.feather('info', { class: 'input-tooltip' })}`, - $label = web.render( - web.html``, - web.render(web.html`

`, opt.tooltip ? $tooltipIcon : '', opt.label) - ), - $input = web.html``, - $icon = web.html`${await components.feather('type', { class: 'input-icon' })}`; - if (opt.tooltip) - components.addTooltip($tooltipIcon, opt.tooltip, { - offsetDirection: 'left', - maxLines: 3, - }); - $input.addEventListener('change', async (_event) => { - await profileDB.set([mod.id, opt.key], $input.value); - notifications.onChange(); - }); - return web.render($label, $input, $icon); - }, - - number: async (mod, opt) => { - const profileDB = await registry.profileDB(), - value = await profileDB.get([mod.id, opt.key], opt.value), - $tooltipIcon = web.html`${await components.feather('info', { class: 'input-tooltip' })}`, - $label = web.render( - web.html``, - web.render(web.html`

`, opt.tooltip ? $tooltipIcon : '', opt.label) - ), - $input = web.html``, - $icon = web.html`${await components.feather('hash', { class: 'input-icon' })}`; - if (opt.tooltip) - components.addTooltip($tooltipIcon, opt.tooltip, { - offsetDirection: 'left', - maxLines: 3, - }); - $input.addEventListener('change', async (_event) => { - await profileDB.set([mod.id, opt.key], $input.value); - notifications.onChange(); - }); - return web.render($label, $input, $icon); - }, - - color: async (mod, opt) => { - const profileDB = await registry.profileDB(), - value = await profileDB.get([mod.id, opt.key], opt.value), - $tooltipIcon = web.html`${await components.feather('info', { class: 'input-tooltip' })}`, - $label = web.render( - web.html``, - web.render(web.html`

`, opt.tooltip ? $tooltipIcon : '', opt.label) - ), - $input = web.html``, - $icon = web.html`${await components.feather('droplet', { class: 'input-icon' })}`, - paint = () => { - $input.style.background = $picker.toBackground(); - const [r, g, b] = $picker - .toRGBAString() - .slice(5, -1) - .split(',') - .map((i) => parseInt(i)); - $input.style.color = fmt.rgbContrast(r, g, b); - $input.style.padding = ''; - }, - $picker = new JSColor($input, { - value, - format: 'rgba', - previewSize: 0, - borderRadius: 3, - borderColor: 'var(--theme--ui_divider)', - controlBorderColor: 'var(--theme--ui_divider)', - backgroundColor: 'var(--theme--bg)', - onInput: paint, - onChange: paint, - }); - if (opt.tooltip) - components.addTooltip($tooltipIcon, opt.tooltip, { - offsetDirection: 'left', - maxLines: 3, - }); - $input.addEventListener('change', async (_event) => { - await profileDB.set([mod.id, opt.key], $input.value); - notifications.onChange(); - }); - paint(); - return web.render($label, $input, $icon); - }, - - file: async (mod, opt) => { - const profileDB = await registry.profileDB(), - { filename } = (await profileDB.get([mod.id, opt.key], {})) || {}, - $tooltipIcon = web.html`${await components.feather('info', { class: 'input-tooltip' })}`, - $label = web.render( - web.html``, - web.render(web.html`

`, opt.tooltip ? $tooltipIcon : '', opt.label) - ), - $pseudo = web.html`Upload file...`, - $input = web.html``, - $icon = web.html`${await components.feather('file', { class: 'input-icon' })}`, - $filename = web.html`${web.escape(filename || 'none')}`, - $latest = web.render(web.html``, $filename); - if (opt.tooltip) - components.addTooltip($tooltipIcon, opt.tooltip, { - offsetDirection: 'left', - maxLines: 3, - }); - $input.addEventListener('change', (event) => { - const file = event.target.files[0], - reader = new FileReader(); - reader.onload = async (progress) => { - $filename.innerText = file.name; - await profileDB.set([mod.id, opt.key], { - filename: file.name, - content: progress.currentTarget.result, - }); - notifications.onChange(); - }; - reader.readAsText(file); - }); - $latest.addEventListener('click', (_event) => { - $filename.innerText = 'none'; - profileDB.set([mod.id, opt.key], {}); - }); - return web.render( - web.html`
`, - web.render($label, $input, $pseudo, $icon), - $latest - ); - }, - - hotkey: async (mod, opt) => { - const profileDB = await registry.profileDB(), - value = await profileDB.get([mod.id, opt.key], opt.value), - $tooltipIcon = web.html`${await components.feather('info', { class: 'input-tooltip' })}`, - $label = web.render( - web.html``, - web.render(web.html`

`, opt.tooltip ? $tooltipIcon : '', opt.label) - ), - $input = web.html``, - $icon = web.html`${await components.feather('command', { class: 'input-icon' })}`; - if (opt.tooltip) - components.addTooltip($tooltipIcon, opt.tooltip, { - offsetDirection: 'left', - maxLines: 3, - }); - $input.addEventListener('keydown', async (event) => { - event.preventDefault(); - const pressed = [], - modifiers = { - metaKey: 'Meta', - ctrlKey: 'Control', - altKey: 'Alt', - shiftKey: 'Shift', - }; - for (const modifier in modifiers) { - if (event[modifier]) pressed.push(modifiers[modifier]); - } - const empty = ['Backspace', 'Delete'].includes(event.key) && !pressed.length; - if (!empty && !pressed.includes(event.key)) { - let key = event.key; - if (key === ' ') key = 'Space'; - if (key === '+') key = 'Plus'; - if (key.length === 1) key = event.key.toUpperCase(); - pressed.push(key); - } - $input.value = pressed.join('+'); - await profileDB.set([mod.id, opt.key], $input.value); - notifications.onChange(); - }); - return web.render($label, $input, $icon); - }, -}; diff --git a/src/core/menuu/mod.json b/src/core/menuu/mod.json deleted file mode 100644 index 7689da9..0000000 --- a/src/core/menuu/mod.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "menu", - "id": "a6621988-551d-495a-97d8-3c568bca2e9e", - "version": "0.11.0", - "description": "the enhancer's graphical menu, related buttons and shortcuts.", - "tags": ["core"], - "authors": [ - { - "name": "dragonwocky", - "email": "thedragonring.bod@gmail.com", - "homepage": "https://dragonwocky.me/", - "avatar": "https://dragonwocky.me/avatar.jpg" - } - ], - "css": { - "client": ["client.css"], - "menu": ["menu.css", "markdown.css"] - }, - "js": { - "client": ["client.mjs"] - }, - "options": [ - { - "type": "hotkey", - "key": "hotkey", - "label": "toggle focus hotkey", - "tooltip": "**switches between notion & the enhancer menu**", - "value": "Ctrl+Alt+E" - } - ] -} diff --git a/src/core/mod.json b/src/core/mod.json index 59fa468..fda9f5c 100644 --- a/src/core/mod.json +++ b/src/core/mod.json @@ -65,7 +65,7 @@ "type": "color", "key": "color", "description": "Activates built-in debugging tools accessible through the application menu.", - "value": "" + "value": "rgb(232, 69, 93)" }, { "type": "text", diff --git a/src/init.js b/src/init.js index 4562f44..ab659e0 100644 --- a/src/init.js +++ b/src/init.js @@ -16,14 +16,8 @@ const isElectron = () => { if (isElectron()) { require("./api/electron.cjs"); require("./api/mods.js"); - const { - getMods, - getProfile, - isEnabled, - optionDefaults, - enhancerUrl, - initDatabase, - } = globalThis.__enhancerApi; + const { enhancerUrl } = globalThis.__enhancerApi, + { getMods, isEnabled, modDatabase } = globalThis.__enhancerApi; module.exports = async (target, __exports, __eval) => { if (target === "main/main") require("./worker.js"); @@ -43,8 +37,7 @@ if (isElectron()) { // electronScripts for (const mod of await getMods()) { if (!mod.electronScripts || !(await isEnabled(mod.id))) continue; - const options = await optionDefaults(mod.id), - db = initDatabase([await getProfile(), mod.id], options); + const db = await modDatabase(mod.id); for (const { source, target: targetScript } of mod.electronScripts) { if (`${target}.js` !== targetScript) continue; const script = require(`notion-enhancer/${mod._src}/${source}`); diff --git a/src/load.mjs b/src/load.mjs index e131fb5..3a48c4f 100644 --- a/src/load.mjs +++ b/src/load.mjs @@ -9,12 +9,10 @@ export default (async () => { // prettier-ignore const { enhancerUrl } = globalThis.__enhancerApi, - isMenu = location.href.startsWith(enhancerUrl("/core/menu/index.html")), - pageLoaded = /(^\/$)|((-|\/)[0-9a-f]{32}((\?.+)|$))/.test(location.pathname), - signedIn = localStorage["LRU:KeyValueStore2:current-user-id"]; - if (!isMenu && !(signedIn && pageLoaded)) return; - - // avoid repeat logging + isMenu = location.href.startsWith(enhancerUrl("/core/menu/index.html")), + pageLoaded = /(^\/$)|((-|\/)[0-9a-f]{32}((\?.+)|$))/.test(location.pathname), + signedIn = localStorage["LRU:KeyValueStore2:current-user-id"]; + if (!isMenu && (!signedIn || !pageLoaded)) return; if (!isMenu) console.log("notion-enhancer: loading..."); await Promise.all([ @@ -26,14 +24,12 @@ export default (async () => { import("./api/mods.js"), ]); await import("./api/interface.js"); - const { getMods, getProfile } = globalThis.__enhancerApi, - { isEnabled, optionDefaults, initDatabase } = globalThis.__enhancerApi; + const { getMods, isEnabled, modDatabase } = globalThis.__enhancerApi; for (const mod of await getMods()) { if (!(await isEnabled(mod.id))) continue; - const isTheme = mod._src.startsWith("themes/"), - isCore = mod._src === "core"; - if (isMenu && !(isTheme || isCore)) continue; + const isTheme = mod._src.startsWith("themes/"); + if (isMenu && !(mod._src === "core" || isTheme)) continue; // clientStyles for (let stylesheet of mod.clientStyles ?? []) { @@ -45,14 +41,12 @@ export default (async () => { // clientScripts if (isMenu) continue; - const options = await optionDefaults(mod.id), - db = initDatabase([await getProfile(), mod.id], options); + const db = await modDatabase(mod.id); for (let script of mod.clientScripts ?? []) { script = await import(enhancerUrl(`${mod._src}/${script}`)); script.default(globalThis.__enhancerApi, db); } } - // consider "ready" after menu has loaded if (isMenu) console.log("notion-enhancer: ready"); })();