refactor(menu): menu.mjs render functions and components.mjs monolith -> islands/ and components/

This commit is contained in:
dragonwocky 2023-01-23 21:58:17 +11:00
parent c19262a4ce
commit e3f34dfc21
Signed by: dragonwocky
GPG Key ID: 7998D08F7D7BD7A8
27 changed files with 1357 additions and 1719 deletions

View File

@ -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);

View File

@ -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,
});

View File

@ -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" &&

View File

@ -1,937 +0,0 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (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`<i class="i-${icon} ${iconSize}"></i>` : ""}
<span class="text-[${size === "sm" ? "13" : "14"}px] empty:hidden">
${children}
</span>
<//>`;
}
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`<p
class="notion-enhancer--menu-description leading-[16px]
text-([12px] [color:var(--theme--fg-secondary)]) ${cls}"
...${props}
>
${children}
</p>`;
}
function Icon({ icon, ...props }) {
const { html } = globalThis.__enhancerApi;
return html`<button
class="h-[14px] transition duration-[20ms]
text-[color:var(--theme--fg-secondary)]
hover:text-[color:var(--theme--fg-primary)]"
...${props}
>
<i class="i-${icon} w-[14px] h-[14px]"></i>
</button>`;
}
// layout
function Sidebar({}, ...children) {
const { html } = globalThis.__enhancerApi;
return html`<aside
class="notion-enhancer--menu-sidebar z-10 row-span-1
h-full overflow-y-auto bg-[color:var(--theme--bg-secondary)]"
>
${children}
</aside>`;
}
function SidebarSection({}, ...children) {
const { html } = globalThis.__enhancerApi;
return html`<h2
class="text-([11px] [color:var(--theme--fg-secondary)])
py-[5px] px-[15px] mb-px mt-[18px] first:mt-[10px]
uppercase font-medium tracking-[0.03em] leading-none"
>
${children}
</h2>`;
}
function SidebarButton({ id, icon, ...props }, ...children) {
const { html } = globalThis.__enhancerApi,
$icon = icon
? html`<i
class="i-${icon} ${icon.startsWith("notion-enhancer")
? "w-[17px] h-[17px] ml-[1.5px] mr-[9.5px]"
: "w-[18px] h-[18px] ml-px mr-[9px]"}"
></i>`
: "",
$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}
<span class="leading-[20px]">${children}</span>
<//>`;
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`<div class="flex flex-col gap-y-[14px]">
<${Search} type=${id} items=${children} />
<${Description} innerHTML=${description} />
${children}
</div>`;
}
function Footer({}, ...children) {
const { html } = globalThis.__enhancerApi;
return html`<div
class="flex w-full px-[60px] py-[16px]
border-t-(& [color:var(--theme--fg-border)])
bg-[color:var(--theme--bg-primary)]"
>
${children}
</div>`;
}
function View({ id }, ...children) {
const { html } = globalThis.__enhancerApi,
$el = html`<article
id=${id}
class="notion-enhancer--menu-view h-full w-full
absolute overflow-y-auto px-[60px] py-[36px]"
>
${children}
</article>`;
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`<div
class="notion-enhancer--menu-popup
group absolute top-0 left-0 w-full h-full
flex flex-col justify-center items-end
pointer-events-none z-20"
...${props}
>
<div class="relative right-[100%]">
<div
class="bg-[color:var(--theme--bg-secondary)]
w-[250px] max-w-[calc(100vw-24px)] max-h-[70vh]
py-[6px] px-[4px] drop-shadow-xl overflow-y-auto
transition duration-[200ms] opacity-0 scale-95 rounded-[4px]
group-open:(pointer-events-auto opacity-100 scale-100)"
>
${children}
</div>
</div>
</div>`;
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`<input
class="${size === "lg"
? "h-[36px] pl-[12px] pr-[40px]"
: "h-[28px] pl-[8px] pr-[32px]"}
w-full pb-px text-[14px] leading-[1.2]
appearance-none bg-transparent"
...${props}
/>`,
$icon = html`<i
class="i-${icon} absolute w-[16px] h-[16px] pointer-events-none
${size === "lg" ? "right-[12px] top-[10px]" : "right-[8px] top-[6px]"}
text-[color:var(--theme--fg-secondary)]"
></i>`;
useState(["rerender"], () => onrerender?.($input, $icon));
return html`<label
focus=${() => $input.focus()}
class="notion-enhancer--menu-input
relative overflow-hidden rounded-[4px]
focus-within:ring-(& [color:var(--theme--accent-primary)])
${size === "lg" ? "h-[36px] block w-full" : ""}
${size === "md" ? "h-[28px] block w-full" : ""}
${size === "sm" ? "h-[28px] shrink-0 w-[192px]" : ""}
bg-${transparent
? `([image:repeating-linear-gradient(45deg,#aaa_25%,transparent_25%,transparent_75%,#aaa_75%,#aaa),repeating-linear-gradient(45deg,#aaa_25%,#fff_25%,#fff_75%,#aaa_75%,#aaa)]
[position:0_0,4px_4px] [size:8px_8px])`
: "[color:var(--theme--bg-hover)]"} ${cls}"
>${$input}${$icon}
</label>`;
}
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`<span>Upload a file</span>`,
$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`<div
class="notion-enhancer--menu-file-input
shrink-0 flex items-center gap-[8px]"
>
<label
tabindex="0"
class="flex items-center cursor-pointer select-none
h-[28px] px-[8px] bg-[color:var(--theme--bg-secondary)]
text-([14px] [color:var(--theme--fg-secondary)]) rounded-[4px]
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
>
<input
type="file"
class="hidden"
accept=${extensions
?.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
.join(",")}
...${props}
/>
<i class="i-file-up w-[16px] h-[16px] mr-[6px]"></i>
${$filename}
</label>
${$clear}
</div>`;
}
function Select({ values, _get, _set, ...props }) {
const { html } = globalThis.__enhancerApi,
$select = html`<div
dir="rtl"
role="button"
tabindex="0"
class="appearance-none bg-transparent rounded-[4px] cursor-pointer
text-[14px] leading-[28px] h-[28px] max-w-[256px] pl-[8px] pr-[28px]
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
...${props}
></div>`,
$options = values.map((value) => {
return html`<${SelectOption} ...${{ value, _get, _set }} />`;
});
useState(["rerender"], () => {
_get?.().then((value) => ($select.innerText = value));
});
return html`<div class="notion-enhancer--menu-select relative">
${$select}
<${Popup}
for=${$select}
onbeforeclose=${() => {
$select.style.width = `${$select.offsetWidth}px`;
$select.style.background = "transparent";
}}
onclose=${() => {
$select.style.width = "";
$select.style.background = "";
}}
>
${$options}
<//>
<i
class="i-chevron-down pointer-events-none
absolute right-[6px] top-[6px] w-[16px] h-[16px]
text-[color:var(--theme--fg-secondary)]"
></i>
</div>`;
}
function SelectOption({ value, _get, _set, ...props }) {
const { html } = globalThis.__enhancerApi,
$selected = html`<i class="ml-auto i-check w-[16px] h-[16px]"></i>`,
$option = html`<div
tabindex="0"
role="button"
class="select-none cursor-pointer rounded-[3px]
flex items-center w-full h-[28px] px-[12px] leading-[1.2]
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
...${props}
>
<div class="mr-[6px] text-[14px] text-ellipsis overflow-hidden">
${value}
</div>
</div>`;
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`<input
type="checkbox"
class="hidden checked:sibling:children:(
bg-[color:var(--theme--accent-primary)] after:translate-x-[12px])"
...${props}
/>`;
const { onchange } = $input;
$input.onchange = (event) => {
onchange?.(event);
_set?.($input.checked);
};
useState(["rerender"], () => {
_get?.().then((checked) => ($input.checked = checked));
});
return html`<div class="notion-enhancer--menu-toggle shrink-0">
${$input}
<div
tabindex="0"
class="w-[30px] h-[18px] rounded-[44px] cursor-pointer
transition duration-200 bg-[color:var(--theme--bg-hover)]"
>
<div
class="w-full h-full rounded-[44px] text-[12px]
p-[2px] hover:bg-[color:var(--theme--bg-hover)]
transition duration-200 after:(
inline-block w-[14px] h-[14px] rounded-[44px]
bg-[color:var(--theme--accent-primary\\_contrast)]
transition duration-200
)"
></div>
</div>
</div>`;
}
function Checkbox({ _get, _set, ...props }) {
const { html } = globalThis.__enhancerApi,
$input = html`<input
type="checkbox"
class="hidden checked:sibling:(px-[1px]
bg-[color:var(--theme--accent-primary)])
not-checked:sibling:(children:text-transparent
border-(& [color:var(--theme--fg-primary)])
hover:bg-[color:var(--theme--bg-hover)])"
...${props}
/>`;
const { onchange } = $input;
$input.onchange = (event) => {
onchange?.(event);
_set?.($input.checked);
};
useState(["rerender"], () => {
_get?.().then((checked) => ($input.checked = checked));
});
return html`<label tabindex="0" class="cursor-pointer">
${$input}
<div class="flex items-center h-[16px] transition duration-[200ms]">
<i
class="i-check w-[14px] h-[14px]
text-[color:var(--theme--accent-primary\\_contrast)]"
></i>
</div>
</label>`;
}
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`<img
src="${enhancerUrl(`${_src}/${thumbnail}`)}"
class="rounded-[4px] mr-[12px] h-[74px] my-auto"
/>`
: "",
$options = options.length
? html`<button
class="flex items-center p-[4px] rounded-[4px] transition
text-[color:var(--theme--fg-secondary)] my-auto mr-[8px]
duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]
active:text-[color:var(--theme--fg-primary)]"
onclick=${() => setState({ transition: "slide-to-right", view: id })}
>
<i class="i-settings w-[18px] h-[18px]"></i>
</button>`
: "";
return html`<label
for=${toggleId}
class="notion-enhancer--menu-mod flex items-stretch rounded-[4px]
bg-[color:var(--theme--bg-secondary)] w-full py-[18px] px-[16px]
border border-[color:var(--theme--fg-border)] cursor-pointer
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
>
${$thumbnail}
<div class="flex flex-col max-w-[50%]">
<div class="flex items-center text-[14px] mb-[5px]">
<h3 class="my-0">${name}</h3>
${[`v${version}`, ...tags].map((tag) => {
return html`<span
class="text-([12px] [color:var(--theme--fg-secondary)])
ml-[8px] py-[2px] px-[6px] leading-tight tracking-wide
rounded-[3px] bg-[color:var(--theme--bg-hover)]"
>
${tag}
</span>`;
})}
</div>
<${Description} class="mb-[6px]" innerHTML=${description} />
<div class="mt-auto flex gap-x-[8px] text-[12px] leading-[16px]">
${authors.map((author) => {
return html`<a href=${author.homepage} class="flex items-center">
<img src=${author.avatar} alt="" class="h-[12px] rounded-full" />
<span class="ml-[6px]">${author.name}</span>
</a>`;
})}
</div>
</div>
<div class="flex ml-auto">
${$options}
<div class="my-auto scale-[1.15]">
<${Toggle} id=${toggleId} ...${{ _get, _set }} />
</div>
</div>
</label>`;
}
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`<h4
class="notion-enhancer--menu-heading font-semibold
mb-[16px] mt-[48px] first:mt-0 pb-[12px] text-[16px]
border-b-(& [color:var(--theme--fg-border)])"
>
${label}
</h4>`;
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" : ""}"
>
<div class="flex flex-col ${type === "text" ? "w-full" : "mr-[10%]"}">
<h5 class="text-[14px] mb-[2px] mt-0">${label}</h5>
${type === "text" ? $input : ""}
<${Description} innerHTML=${description} />
</div>
${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`<a
class="hidden"
download="notion-enhancer_${await getName()}_${date}.json"
href="data:text/json;charset=utf-8,${encodeURIComponent(
await exportJson()
)}"
/>`;
document.body.append($a);
$a.click();
$a.remove();
};
const $delete = html`<${Icon} icon="x" />`,
$name = html`<mark></mark>`,
$confirmation = html`<${Popup}
for=${$delete}
onopen=${async () => ($name.innerText = await getName())}
>
<p class="text-[14px] pt-[2px] px-[8px]">
Are you sure you want to delete the profile ${$name} permanently?
</p>
<div class="flex flex-col gap-[8px] pt-[8px] pb-[6px] px-[8px]">
<${Button}
tabindex="0"
icon="trash"
class="justify-center"
variant="secondary"
onclick=${() => deleteProfile()}
>
Delete
<//>
<${Button}
tabindex="0"
class="justify-center"
onclick=${() => setState({ rerender: true })}
>
Cancel
<//>
</div>
<//>`;
return html`<li class="flex items-center my-[14px] gap-[8px]" ...${props}>
<${Checkbox}
...${{ _get: isActive, _set: setActive }}
onchange=${(event) => (event.target.checked = true)}
/>
<${Input}
size="md"
type="text"
icon="file-cog"
onchange=${(event) => setName(event.target.value)}
onrerender=${($input) =>
getName().then((value) => ($input.value = value))}
/>
<${Label} size="sm" icon="import">
<input
type="file"
class="hidden"
accept=".json"
onchange=${uploadProfile}
/>
Import
<//>
<${Button} size="sm" icon="upload" onclick=${downloadProfile}>Export<//>
<div class="relative">${$delete}${$confirmation}</div>
</li>`;
}
export {
Button,
Label,
Description,
Icon,
Sidebar,
SidebarSection,
SidebarButton,
List,
Footer,
View,
Input,
TextInput,
NumberInput,
HotkeyInput,
ColorInput,
FileInput,
Select,
Toggle,
Checkbox,
Search,
Mod,
Option,
Profile,
};

View File

@ -0,0 +1,41 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { extendProps } from "../state.mjs";
function Button({ icon, variant, tagName, ...props }, ...children) {
const { html } = globalThis.__enhancerApi;
extendProps(props, {
class: `notion-enhancer--menu-button shrink-0
flex gap-[8px] items-center px-[12px] rounded-[4px]
h-[${variant === "sm" ? "28" : "32"}px] 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)]`
}`,
});
return html`<${tagName ?? "button"} ...${props}>
${icon
? html`<i
class="i-${icon}
text-[${variant === "sm" && children.length ? "14" : "18"}px]"
></i>`
: ""}
<span class="text-[${variant === "sm" ? "13" : "14"}px] empty:hidden">
${children}
</span>
<//>`;
}
export { Button };

View File

@ -0,0 +1,39 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { useState, extendProps } from "../state.mjs";
function Checkbox({ _get, _set, ...props }) {
const { html } = globalThis.__enhancerApi,
$input = html`<input
type="checkbox"
class="hidden checked:sibling:(px-[1px]
bg-[color:var(--theme--accent-primary)])
not-checked:sibling:(children:text-transparent
border-(& [color:var(--theme--fg-primary)])
hover:bg-[color:var(--theme--bg-hover)])"
...${props}
/>`;
extendProps($input, { onchange: () => _set?.($input.checked) });
useState(["rerender"], () => {
_get?.().then((checked) => ($input.checked = checked));
});
return html`<label
tabindex="0"
class="notion-enhancer--menu-checkbox cursor-pointer"
>
${$input}
<div class="flex items-center h-[16px] transition duration-[200ms]">
<i
class="i-check w-[14px] h-[14px]
text-[color:var(--theme--accent-primary\\_contrast)]"
></i>
</div>
</label>`;
}
export { Checkbox };

View File

@ -0,0 +1,18 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { extendProps } from "../state.mjs";
function Description(props, ...children) {
const { html } = globalThis.__enhancerApi;
extendProps(props, {
class: `notion-enhancer--menu-description leading-[16px]
text-([12px] [color:var(--theme--fg-secondary)])`,
});
return html`<p ...${props}>${children}</p>`;
}
export { Description };

View File

@ -0,0 +1,19 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { extendProps } from "../state.mjs";
function Heading(props, children) {
const { html } = globalThis.__enhancerApi;
extendProps(props, {
class: `notion-enhancer--menu-heading font-semibold
mb-[16px] mt-[48px] first:mt-0 pb-[12px] text-[16px]
border-b-(& [color:var(--theme--fg-border)])`,
});
return html`<h4 ...${props}>${children}</h4>`;
}
export { Heading };

View File

@ -0,0 +1,165 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { extendProps, useState } from "../state.mjs";
const 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"));
},
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";
},
readUpload = async (event) => {
const file = event.target.files[0],
reader = new FileReader();
return new Promise((res) => {
reader.onload = async (progress) => {
const content = progress.currentTarget.result,
upload = { filename: file.name, content };
res(upload);
};
reader.readAsText(file);
});
};
function Input({
type,
icon,
variant,
extensions,
class: className,
_get,
_set,
...props
}) {
let $filename, $clear;
const { html } = globalThis.__enhancerApi;
Coloris({ format: "rgb" });
type ??= "text";
if (type === "text") icon ??= "text-cursor";
if (type === "number") icon ??= "hash";
if (type === "hotkey") icon ??= "command";
if (type === "color") icon ??= "pipette";
if (type === "file") {
icon ??= "file-up";
$filename = html`<span class="ml-[6px]">Upload a file</span>`;
$clear = html`<button
style="display: none"
class="h-[14px] transition duration-[20ms]
text-[color:var(--theme--fg-secondary)]
hover:text-[color:var(--theme--fg-primary)]"
onclick=${() => _set?.({ filename: "", content: "" })}
>
<i class="i-x w-[14px] h-[14px]"></i>
</button>`;
props.accept = extensions
?.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
.join(",");
}
const $input = html`<input
type=${["hotkey", "color"].includes(type) ? "text" : type}
class="h-full w-full pb-px text-[14px] leading-[1.2]
${variant === "lg" ? "pl-[12px] pr-[40px]" : "pl-[8px] pr-[32px]"}
appearance-none bg-transparent ${type === "file" ? "hidden" : ""}
${type === "color" ? "font-medium" : ""}"
data-coloris=${type === "color"}
...${props}
/>`,
$icon = html`<span
class="${variant === "lg" ? "pr-[12px]" : "pr-[8px]"}
absolute flex items-center h-full pointer-events-none
text-[color:var(--theme--fg-secondary)] right-0 top-0"
><i class="i-${icon} w-[16px] h-[16px]"></i>
</span>`;
extendProps($input, {
onchange: (event) => {
if (_set && type === "file") {
readUpload(event).then(_set);
} else _set?.($input.value);
},
onrerender: async () => {
_get?.().then((value) => {
value ??= "";
if (type === "file") {
$filename.innerText = value?.filename || "Upload a file";
$clear.style.display = value?.filename ? "" : "none";
} else if ($input.value !== value) $input.value = value;
if (type === "color") updateContrast($input, $icon);
});
},
onkeydown: type === "hotkey" ? updateHotkey : undefined,
oninput: type === "color" ? () => _set?.($input.value) : undefined,
});
useState(["rerender"], () => $input.onrerender?.());
return type === "file"
? html`<div
class="notion-enhancer--menu-file-input shrink-0
flex items-center gap-[8px] ${className ?? ""}"
>
<label
tabindex="0"
class="flex items-center cursor-pointer select-none
h-[28px] px-[8px] bg-[color:var(--theme--bg-secondary)]
text-([14px] [color:var(--theme--fg-secondary)]) rounded-[4px]
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
>${$input}${$icon.children[0]}${$filename}
</label>
${$clear}
</div>`
: html`<label
class="notion-enhancer--menu-input
${variant === "lg" ? "h-[32px]" : "h-[28px]"}
relative overflow-hidden rounded-[4px] w-full inline-block
focus-within:ring-(& [color:var(--theme--accent-primary)])
${className ?? ""} ${type === "color"
? "bg-([image:repeating-linear-gradient(45deg,#aaa_25%,transparent_25%,transparent_75%,#aaa_75%,#aaa),repeating-linear-gradient(45deg,#aaa_25%,#fff_25%,#fff_75%,#aaa_75%,#aaa)] [position:0_0,4px_4px] [size:8px_8px])"
: "bg-[color:var(--theme--bg-hover)]"}"
>${$input}${$icon}
</label>`;
}
export { Input };

View File

@ -0,0 +1,68 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { setState, useState, extendProps } from "../state.mjs";
function Popup({ trigger, onopen, onclose, onbeforeclose }, ...children) {
const { html } = globalThis.__enhancerApi,
$popup = html`<div
class="notion-enhancer--menu-popup
group absolute top-0 left-0 w-full h-full
flex flex-col justify-center items-end
pointer-events-none z-20"
>
<div class="relative right-[100%]">
<div
class="bg-[color:var(--theme--bg-secondary)]
w-[250px] max-w-[calc(100vw-24px)] max-h-[70vh]
py-[6px] px-[4px] drop-shadow-xl overflow-y-auto
transition duration-[200ms] opacity-0 scale-95 rounded-[4px]
group-open:(pointer-events-auto opacity-100 scale-100)"
>
${children}
</div>
</div>
</div>`;
$popup.show = () => {
$popup.setAttribute("open", true);
$popup.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = 0));
setState({ popupOpen: true });
onopen?.();
};
$popup.hide = () => {
$popup.removeAttribute("open");
$popup.style.pointerEvents = "auto";
$popup.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1));
onbeforeclose?.();
setTimeout(() => {
$popup.style.pointerEvents = "";
setState({ popupOpen: false });
onclose?.();
}, 200);
};
$popup.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1));
extendProps(trigger, {
onclick: $popup.show,
onkeydown(event) {
if (event.key === "Enter") $popup.show();
},
});
useState(["rerender"], () => {
if ($popup.hasAttribute("open")) $popup.hide();
});
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;
$popup.hide();
});
return $popup;
}
export { Popup };

View File

@ -0,0 +1,76 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { useState, extendProps } from "../state.mjs";
import { Popup } from "./Popup.mjs";
function Option({ value, _get, _set }) {
const { html } = globalThis.__enhancerApi,
$selected = html`<i class="ml-auto i-check w-[16px] h-[16px]"></i>`,
$option = html`<div
tabindex="0"
role="button"
class="select-none cursor-pointer rounded-[3px]
flex items-center w-full h-[28px] px-[12px] leading-[1.2]
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
onclick=${() => _set?.(value)}
onkeydown=${(event) => {
if (event.key === "Enter") _set?.(value);
}}
>
<div class="mr-[6px] text-[14px] text-ellipsis overflow-hidden">
${value}
</div>
</div>`;
useState(["rerender"], () => {
_get?.().then((actualValue) => {
if (actualValue === value) {
$option.append($selected);
} else $selected.remove();
});
});
return $option;
}
function Select({ values, _get, _set, ...props }) {
const { html } = globalThis.__enhancerApi,
// dir="rtl" overflows to the left during transition
$select = html`<div
dir="rtl"
role="button"
tabindex="0"
class="appearance-none bg-transparent rounded-[4px] cursor-pointer
text-[14px] leading-[28px] h-[28px] max-w-[256px] pl-[8px] pr-[28px]
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
...${props}
></div>`;
useState(["rerender"], () => {
_get?.().then((value) => ($select.innerText = value));
});
return html`<div class="notion-enhancer--menu-select relative">
${$select}
<${Popup}
trigger=${$select}
onbeforeclose=${() => {
$select.style.width = `${$select.offsetWidth}px`;
$select.style.background = "transparent";
}}
onclose=${() => {
$select.style.width = "";
$select.style.background = "";
}}
>${values.map((value) => html`<${Option} ...${{ value, _get, _set }} />`)}
<//>
<i
class="i-chevron-down pointer-events-none
absolute right-[6px] top-[6px] w-[16px] h-[16px]
text-[color:var(--theme--fg-secondary)]"
></i>
</div>`;
}
export { Select };

View File

@ -0,0 +1,42 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { useState, extendProps } from "../state.mjs";
function Toggle({ _get, _set, ...props }) {
const { html } = globalThis.__enhancerApi,
$input = html`<input
type="checkbox"
class="hidden checked:sibling:children:(
bg-[color:var(--theme--accent-primary)] after:translate-x-[12px])"
...${props}
/>`;
extendProps($input, { onchange: () => _set?.($input.checked) });
useState(["rerender"], () => {
_get?.().then((checked) => ($input.checked = checked));
});
return html`<div class="notion-enhancer--menu-toggle shrink-0">
${$input}
<div
tabindex="0"
class="w-[30px] h-[18px] rounded-[44px] cursor-pointer
transition duration-200 bg-[color:var(--theme--bg-hover)]"
>
<div
class="w-full h-full rounded-[44px] text-[12px]
p-[2px] hover:bg-[color:var(--theme--bg-hover)]
transition duration-200 after:(
inline-block w-[14px] h-[14px] rounded-[44px]
bg-[color:var(--theme--accent-primary\\_contrast)]
transition duration-200
)"
></div>
</div>
</div>`;
}
export { Toggle };

View File

@ -0,0 +1,59 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { setState, useState } from "../state.mjs";
import { Button } from "../components/Button.mjs";
function Footer({ categories }) {
const { html, reloadApp } = globalThis.__enhancerApi,
$reload = html`<${Button}
class="ml-auto"
variant="primary"
icon="refresh-cw"
onclick=${reloadApp}
style="display: none"
>
Reload & Apply Changes
<//>`,
$categories = categories.map(({ id, title, mods }) => {
return [
mods.map((mod) => mod.id),
html`<${Button}
icon="chevron-left"
onclick=${() => setState({ transition: "slide-to-left", view: id })}
>
${title}
<//>`,
];
});
const buttons = [...$categories.map(([, $btn]) => $btn), $reload],
updateFooter = () => {
const buttonsVisible = buttons.some(($el) => $el.style.display === "");
setState({ footerOpen: buttonsVisible });
};
useState(["view"], ([view]) => {
for (const [ids, $btn] of $categories) {
const modActive = ids.some((id) => id === view);
$btn.style.display = modActive ? "" : "none";
}
updateFooter();
});
useState(["databaseUpdated"], ([databaseUpdated]) => {
$reload.style.display = databaseUpdated ? "" : "none";
updateFooter();
});
return html`<div
class="flex w-full px-[60px] py-[16px]
border-t-(& [color:var(--theme--fg-border)])
bg-[color:var(--theme--bg-primary)]"
>
${buttons}
</div>`;
}
export { Footer };

View File

@ -0,0 +1,57 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { setState } from "../state.mjs";
import { Description } from "../components/Description.mjs";
import { Input } from "../components/Input.mjs";
import { Mod } from "./Mod.mjs";
function Search({ items, itemType }) {
const { html, addKeyListener } = globalThis.__enhancerApi,
$search = html`<${Input}
type="text"
icon="search"
variant="lg"
placeholder="Search ${items.length} ${items.length === 1
? itemType.replace(/s$/, "")
: itemType} (Press '/' to focus)"
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";
}
}}
/>`;
addKeyListener("/", (event) => {
if (document.activeElement?.nodeName === "INPUT") return;
// offsetParent == null if parent has "display: none;"
if ($search.offsetParent) {
event.preventDefault();
$search.focus();
}
});
return $search;
}
function List({ id, mods, description }) {
const { html, isEnabled, setEnabled } = globalThis.__enhancerApi,
$mods = mods.map((mod) => {
const _get = () => isEnabled(mod.id),
_set = async (enabled) => {
await setEnabled(mod.id, enabled);
setState({ rerender: true, databaseUpdated: true });
};
return html`<${Mod} ...${{ ...mod, _get, _set }} />`;
});
return html`<div class="flex flex-col gap-y-[14px]">
<${Search} items=${$mods} itemType=${id} />
<${Description} innerHTML=${description} />
${$mods}
</div>`;
}
export { List };

View File

@ -0,0 +1,85 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { setState } from "../state.mjs";
import { Description } from "../components/Description.mjs";
import { Toggle } from "../components/Toggle.mjs";
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);
return html`<label
for=${toggleId}
class="notion-enhancer--menu-mod flex items-stretch rounded-[4px]
bg-[color:var(--theme--bg-secondary)] w-full py-[18px] px-[16px]
border border-[color:var(--theme--fg-border)] cursor-pointer
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
>
${thumbnail
? html`<img
src="${enhancerUrl(`${_src}/${thumbnail}`)}"
class="rounded-[4px] mr-[12px] h-[74px] my-auto"
/>`
: ""}
<div class="flex flex-col max-w-[50%]">
<div class="flex items-center text-[14px] mb-[5px]">
<h3 class="my-0">${name}</h3>
${[`v${version}`, ...tags].map((tag) => {
return html`<span
class="text-([12px] [color:var(--theme--fg-secondary)])
ml-[8px] py-[2px] px-[6px] leading-tight tracking-wide
rounded-[3px] bg-[color:var(--theme--bg-hover)]"
>
${tag}
</span>`;
})}
</div>
<${Description} class="mb-[6px]" innerHTML=${description} />
<div class="mt-auto flex gap-x-[8px] text-[12px] leading-[16px]">
${authors.map((author) => {
return html`<a href=${author.homepage} class="flex items-center">
<img src=${author.avatar} alt="" class="h-[12px] rounded-full" />
<span class="ml-[6px]">${author.name}</span>
</a>`;
})}
</div>
</div>
<div class="flex ml-auto">
${options.length
? html`<button
class="flex items-center p-[4px] rounded-[4px] transition
text-[color:var(--theme--fg-secondary)] my-auto mr-[8px]
duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]
active:text-[color:var(--theme--fg-primary)]"
onclick=${() => {
setState({ transition: "slide-to-right", view: id });
}}
>
<i class="i-settings w-[18px] h-[18px]"></i>
</button>`
: ""}
<div class="my-auto scale-[1.15]">
<${Toggle} id=${toggleId} ...${{ _get, _set }} />
</div>
</div>
</label>`;
}
export { Mod };

View File

@ -0,0 +1,86 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (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" : ""}"
>
<div class="flex flex-col ${opt.type === "text" ? "w-full" : "mr-[10%]"}">
<h5 class="text-[14px] mb-[2px] mt-0">${opt.label}</h5>
${opt.type === "text"
? html`<${Input}
type="text"
class="mt-[4px] mb-[8px]"
...${{ _get, _set }}
/>`
: ""}
<${Description} innerHTML=${opt.description} />
</div>
${["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 };

View File

@ -0,0 +1,204 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (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`<a
class="hidden"
download="notion-enhancer_${await getName()}_${date}.json"
href="data:text/json;charset=utf-8,${encodeURIComponent(
JSON.stringify(await profile.export())
)}"
/>`;
document.body.append($a);
$a.click();
$a.remove();
},
deleteProfile = async () => {
let profileIds = await db.get("profileIds");
if (!profileIds?.length) profileIds = ["default"];
// clear profile data
const keys = Object.keys(await profile.export());
await profile.remove(keys);
// remove profile from list
const index = profileIds.indexOf(id);
if (index > -1) profileIds.splice(index, 1);
await db.set("profileIds", profileIds);
if (await isActive()) {
await db.remove("activeProfile");
setState({ rerender: true, databaseUpdated: true });
} else setState({ rerender: true });
};
const $name = html`<span
class="py-[2px] px-[4px] rounded-[3px]
bg-[color:var(--theme--bg-hover)]"
></span>`,
$delete = html`<button
class="h-[14px] transition duration-[20ms]
text-[color:var(--theme--fg-secondary)]
hover:text-[color:var(--theme--fg-primary)]"
>
<i class="i-x w-[14px] h-[14px]"></i>
</button>`,
$confirm = html`<${Popup}
trigger=${$delete}
onopen=${async () => ($name.innerText = await getName())}
>
<p class="text-[14px] pt-[2px] px-[8px]">
Are you sure you want to delete the profile ${$name} permanently?
</p>
<div class="flex flex-col gap-[8px] p-[8px]">
<${Button}
tabindex="0"
icon="trash"
class="justify-center"
variant="secondary"
onclick=${deleteProfile}
>
Delete
<//>
<${Button}
tabindex="0"
class="justify-center"
onclick=${() => $confirm.close()}
>
Cancel
<//>
</div>
<//>`;
return html`<li class="flex items-center my-[14px] gap-[8px]" id=${id}>
<${Checkbox}
...${{ _get: isActive, _set: setActive }}
onchange=${(event) => (event.target.checked = true)}
/>
<${Input} icon="file-cog" ...${{ _get: getName, _set: setName }} />
<${Button} size="sm" icon="import" tagName="label">
<input
type="file"
class="hidden"
accept=".json"
onchange=${uploadProfile}
/>
Import
<//>
<${Button} size="sm" icon="upload" onclick=${downloadProfile}>Export<//>
<div class="relative flex">${$delete}${$confirm}</div>
</li>`;
}
function Profiles() {
const { html, initDatabase } = globalThis.__enhancerApi,
$input = html`<${Input} icon="file-cog" />`,
$list = html`<ul></ul>`;
const db = initDatabase(),
refreshProfiles = async () => {
let profileIds = await db.get("profileIds");
if (!profileIds?.length) profileIds = ["default"];
const $profiles = profileIds.map((id) => {
return document.getElementById(id) || html`<${Profile} id=${id} />`;
});
// replace rows one-by-one to avoid layout shift
for (let i = 0; i < $profiles.length || i < $list.children.length; i++) {
if ($profiles[i] === $list.children[i]) continue;
if ($list.children[i]) {
if ($profiles[i]) {
$list.children[i].replaceWith($profiles[i]);
} else $list.children[i].remove();
} else $list.append($profiles[i]);
}
},
addProfile = async () => {
if (!$input.children[0].value) return;
const name = $input.children[0].value,
id = crypto.randomUUID();
let profileIds = await db.get("profileIds");
if (!profileIds?.length) profileIds = ["default"];
await db.set("profileIds", [...profileIds, id]);
await initDatabase([id]).set("profileName", name);
$input.children[0].value = "";
setState({ rerender: true });
};
useState(["rerender"], () => refreshProfiles());
$input.onkeydown = (event) => {
if (event.key === "Enter") addProfile();
};
return html`
<${Heading}>Profiles<//>
<${Description}>
Profiles can be used to preserve and switch between notion-enhancer
configurations.
<//>
<div>
${$list}
<div class="flex items-center my-[14px] gap-[8px]">
${$input}
<${Button} variant="sm" icon="plus" onclick=${addProfile}>
Add Profile
<//>
</div>
</div>
`;
}
export { Profiles };

View File

@ -0,0 +1,88 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { extendProps, setState, useState } from "../state.mjs";
function SidebarHeading({}, ...children) {
const { html } = globalThis.__enhancerApi;
return html`<h2
class="text-([11px] [color:var(--theme--fg-secondary)])
py-[5px] px-[15px] mb-px mt-[18px] first:mt-[10px]
uppercase font-medium tracking-[0.03em] leading-none"
>
${children}
</h2>`;
}
function SidebarButton({ id, icon, ...props }, ...children) {
const { html } = globalThis.__enhancerApi,
$btn = 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
? html`<i
class="i-${icon} ${icon.startsWith("notion-enhancer")
? "w-[17px] h-[17px] ml-[1.5px] mr-[9.5px]"
: "w-[18px] h-[18px] ml-px mr-[9px]"}"
></i>`
: ""}
<span class="leading-[20px]">${children}</span>
<//>`;
if (!props.href) {
extendProps($btn, {
onclick: () => setState({ transition: "fade", view: id }),
});
useState(["view"], ([view = "welcome"]) => {
const active = view.toLowerCase() === id.toLowerCase();
$btn.style.background = active ? "var(--theme--bg-hover)" : "";
$btn.style.fontWeight = active ? "600" : "";
});
}
return $btn;
}
function Sidebar({ items, categories }) {
const { html, isEnabled } = globalThis.__enhancerApi,
$sidebar = html`<aside
class="notion-enhancer--menu-sidebar z-10 row-span-1
h-full overflow-y-auto bg-[color:var(--theme--bg-secondary)]"
>
${items.map((item) => {
if (typeof item === "object") {
const { title, ...props } = item;
return html`<${SidebarButton} ...${props}>${title}<//>`;
} else return html`<${SidebarHeading}>${item}<//>`;
})}
</aside>`;
for (const { title, mods } of categories) {
const $title = html`<${SidebarHeading}>${title}<//>`,
$mods = mods.map((mod) => [
mod.id,
html`<${SidebarButton} id=${mod.id}>${mod.name}<//>`,
]);
$sidebar.append($title, ...$mods.map(([, $btn]) => $btn));
useState(["rerender"], async () => {
let sectionVisible = false;
for (const [id, $btn] of $mods) {
if (await isEnabled(id)) {
$btn.style.display = "";
sectionVisible = true;
} else $btn.style.display = "none";
}
$title.style.display = sectionVisible ? "" : "none";
});
}
return $sidebar;
}
export { Sidebar };

View File

@ -0,0 +1,83 @@
/**
* notion-enhancer
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { useState } from "../state.mjs";
function View({ id }, ...children) {
const { html } = globalThis.__enhancerApi,
$view = html`<article
id=${id}
class="notion-enhancer--menu-view h-full w-full
absolute overflow-y-auto px-[60px] py-[36px]"
>
${children}
</article>`;
useState(["view"], ([view = "welcome"]) => {
const [transition] = useState(["transition"]),
isVisible = $view.style.display !== "none",
nowActive = view.toLowerCase() === id.toLowerCase();
switch (transition) {
case "fade": {
const duration = 100,
cssTransition = `opacity ${duration}ms`;
if (isVisible && !nowActive) {
$view.style.transition = cssTransition;
$view.style.opacity = "0";
setTimeout(() => ($view.style.display = "none"), duration);
} else if (!isVisible && nowActive) {
setTimeout(() => {
$view.style.opacity = "0";
$view.style.display = "";
requestIdleCallback(() => {
$view.style.transition = cssTransition;
$view.style.opacity = "1";
});
}, duration);
}
break;
}
case "slide-to-left":
case "slide-to-right": {
const duration = 200,
cssTransition = `opacity ${duration}ms, transform ${duration}ms`;
if (isVisible && !nowActive) {
$view.style.transition = cssTransition;
$view.style.transform = `translateX(${
transition === "slide-to-right" ? "-100%" : "100%"
})`;
$view.style.opacity = "0";
setTimeout(() => {
$view.style.display = "none";
$view.style.transform = "";
}, duration);
} else if (!isVisible && nowActive) {
$view.style.transform = `translateX(${
transition === "slide-to-right" ? "100%" : "-100%"
})`;
$view.style.opacity = "0";
$view.style.display = "";
requestIdleCallback(() => {
$view.style.transition = cssTransition;
$view.style.transform = "";
$view.style.opacity = "1";
});
}
break;
}
default:
$view.style.transition = "";
$view.style.opacity = nowActive ? "1" : "0";
$view.style.display = nowActive ? "" : "none";
}
});
return $view;
}
export { View };

View File

@ -1,6 +1,6 @@
/**
* notion-enhancer
* (c) 2022 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
@ -94,9 +94,9 @@ body > #skeleton .row-group .shimmer {
height: 11px;
}
mark {
.notion-enhancer--menu-description mark {
color: inherit;
padding: 2px 4px;
padding: 0 4px;
border-radius: 3px;
background-color: var(--theme--bg-hover);
}

View File

@ -4,232 +4,16 @@
* (https://notion-enhancer.github.io/) under the MIT license
*/
import { getState, setState, useState } from "./state.mjs";
import {
Button,
Description,
Sidebar,
SidebarSection,
SidebarButton,
List,
Footer,
View,
Input,
Mod,
Option,
Profile,
} from "./components.mjs";
import { setState, useState } from "./state.mjs";
import { Sidebar } from "./islands/Sidebar.mjs";
import { Footer } from "./islands/Footer.mjs";
import { View } from "./islands/View.mjs";
import { List } from "./islands/List.mjs";
import { Mod } from "./islands/Mod.mjs";
import { Options } from "./islands/Options.mjs";
import { Profiles } from "./islands/Profiles.mjs";
const compatibleMods = (mods) => {
const { platform } = globalThis.__enhancerApi;
return mods.filter((mod) => {
const required =
mod.id &&
mod.name &&
mod.version &&
mod.description &&
mod.thumbnail &&
mod.authors,
compatible = !mod.platforms || mod.platforms.includes(platform);
return required && compatible;
});
};
const renderSidebar = (items, categories) => {
const { html, isEnabled } = globalThis.__enhancerApi,
$sidebar = html`<${Sidebar}>
${items.map((item) => {
if (typeof item === "object") {
const { title, ...props } = item;
return html`<${SidebarButton} ...${props}>${title}<//>`;
} else return html`<${SidebarSection}>${item}<//>`;
})}
<//>`;
for (const { title, mods } of categories) {
const $title = html`<${SidebarSection}>${title}<//>`,
$mods = mods.map((mod) => [
mod.id,
html`<${SidebarButton} id=${mod.id}>${mod.name}<//>`,
]);
$sidebar.append($title, ...$mods.map(([, $btn]) => $btn));
useState(["rerender"], async () => {
let sectionVisible = false;
for (const [id, $btn] of $mods) {
if (await isEnabled(id)) {
$btn.style.display = "";
sectionVisible = true;
} else $btn.style.display = "none";
}
$title.style.display = sectionVisible ? "" : "none";
});
}
return $sidebar;
},
renderList = async (id, mods, description) => {
const { html, isEnabled, setEnabled } = globalThis.__enhancerApi;
mods = mods.map(async (mod) => {
const _get = () => isEnabled(mod.id),
_set = async (enabled) => {
await setEnabled(mod.id, enabled);
setState({ rerender: true, databaseUpdated: true });
};
return html`<${Mod} ...${{ ...mod, _get, _set }} />`;
});
return html`<${List} ...${{ id, description }}>
${await Promise.all(mods)}
<//>`;
},
renderOptions = async (mod) => {
const { html, platform, modDatabase } = globalThis.__enhancerApi;
let options = mod.options.reduce((options, opt) => {
if (!opt.key && (opt.type !== "heading" || !opt.label)) return options;
if (opt.platforms && !opt.platforms.includes(platform)) return options;
const prevOpt = options[options.length - 1];
// no consective headings
if (opt.type === "heading" && prevOpt?.type === opt.type) {
options[options.length - 1] = opt;
} else options.push(opt);
return options;
}, []);
// no empty/end headings e.g. if section is platform-specific
if (options[options.length - 1]?.type === "heading") options.pop();
options = options.map(async (opt) => {
if (opt.type === "heading") return html`<${Option} ...${opt} />`;
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} ...${{ ...opt, _get, _set }} />`;
});
return Promise.all(options);
},
renderProfiles = async () => {
let profileIds;
const { html, initDatabase, getProfile } = globalThis.__enhancerApi,
db = initDatabase(),
$list = html`<ul></ul>`,
renderProfile = (id) => {
const profile = initDatabase([id]),
isActive = async () => id === (await getProfile()),
deleteProfile = async () => {
const keys = Object.keys(await profile.export());
profileIds.splice(profileIds.indexOf(id), 1);
await db.set("profileIds", profileIds);
await profile.remove(keys);
if (isActive()) {
await db.remove("activeProfile");
setState({ databaseUpdated: true });
}
setState({ rerender: true });
};
return html`<${Profile}
id=${id}
getName=${async () =>
(await profile.get("profileName")) ??
(id === "default" ? "default" : "")}
setName=${(name) => profile.set("profileName", name)}
isActive=${isActive}
setActive=${async () => {
await db.set("activeProfile", id);
setState({ rerender: true, databaseUpdated: true });
}}
exportJson=${async () => JSON.stringify(await profile.export())}
importJson=${async (json) => {
try {
await profile.import(JSON.parse(json));
setState({ rerender: true, databaseUpdated: true });
// success
} catch {
// error
}
}}
deleteProfile=${deleteProfile}
/>`;
},
refreshProfiles = async () => {
profileIds = await db.get("profileIds");
if (!profileIds?.length) profileIds = ["default"];
for (const $profile of $list.children) {
const exists = profileIds.includes($profile.id);
if (!exists) $profile.remove();
}
for (let i = 0; i < profileIds.length; i++) {
const id = profileIds[i];
if (document.getElementById(id)) continue;
const $profile = await renderProfile(id),
$next = document.getElementById(profileIds[i + 1]);
if ($next) $list.insertBefore($profile, $next);
else $list.append($profile);
}
},
addProfile = async (name) => {
const id = crypto.randomUUID();
await db.set("profileIds", [...profileIds, id]);
const profile = initDatabase([id]);
await profile.set("profileName", name);
refreshProfiles();
};
useState(["rerender"], () => refreshProfiles());
const $input = html`<${Input}
size="md"
type="text"
icon="file-cog"
onkeydown=${(event) => {
if (event.key === "Enter") {
if (!$input.children[0].value) return;
addProfile($input.children[0].value);
$input.children[0].value = "";
}
}}
/>`;
return html`<div>
${$list}
<div class="flex items-center my-[14px] gap-[8px]">
${$input}
<${Button}
size="sm"
icon="plus"
onclick=${() => {
if (!$input.children[0].value) return;
addProfile($input.children[0].value);
$input.children[0].value = "";
}}
>
Add Profile
<//>
</div>
</div>`;
},
renderMods = async (mods) => {
const { html, isEnabled, setEnabled } = globalThis.__enhancerApi;
mods = mods
.filter((mod) => {
return mod.options?.filter((opt) => opt.type !== "heading").length;
})
.map(async (mod) => {
const _get = () => isEnabled(mod.id),
_set = async (enabled) => {
await setEnabled(mod.id, enabled);
setState({ rerender: true, databaseUpdated: true });
};
return html`<${View} id=${mod.id}>
<${Mod} ...${{ ...mod, options: [], _get, _set }} />
${await renderOptions(mod)}
<//>`;
});
return Promise.all(mods);
};
const render = async () => {
const { html, reloadApp, getCore } = globalThis.__enhancerApi,
{ getThemes, getExtensions, getIntegrations } = globalThis.__enhancerApi,
[icon, renderStarted] = getState(["icon", "renderStarted"]);
if (!html || !getCore || !icon || renderStarted) return;
setState({ renderStarted: true });
const categories = [
const categories = [
{
icon: "palette",
id: "themes",
@ -238,7 +22,6 @@ const render = async () => {
Notion to be in dark mode and light themes require Notion to be in light
mode. To switch between dark mode and light mode, go to <mark>Settings &
members My notifications & settings My settings Appearance</mark>.`,
mods: compatibleMods(await getThemes()),
},
{
icon: "zap",
@ -246,7 +29,6 @@ const render = async () => {
title: "Extensions",
description: `Extensions add to the functionality and layout of the Notion
client, interacting with and modifying existing interfaces.`,
mods: compatibleMods(await getExtensions()),
},
{
icon: "plug",
@ -255,7 +37,6 @@ const render = async () => {
description: `<span class="text-[color:var(--theme--fg-red)]">
Integrations access and modify Notion content. They interact directly with
<mark>https://www.notion.so/api/v3</mark>. Use at your own risk.</span>`,
mods: compatibleMods(await getIntegrations()),
},
],
sidebar = [
@ -263,7 +44,7 @@ const render = async () => {
{
id: "welcome",
title: "Welcome",
icon: `notion-enhancer${icon === "Monochrome" ? "?mask" : ""}`,
icon: "notion-enhancer",
},
{
icon: "message-circle",
@ -299,75 +80,68 @@ const render = async () => {
...categories.map((c) => ({ id: c.id, title: c.title, icon: c.icon })),
];
// view wrapper necessary for transitions
const $views = html`<div class="grow relative overflow-hidden">
<${View} id="welcome">welcome<//>
<${View} id="core">
${await renderOptions(await getCore())}
<${Option} type="heading" label="Profiles" />
<${Description}>
Profiles can be used to preserve and switch between notion-enhancer
configurations.
<//>
${await renderProfiles()}
<//>
</div>`;
for (const { id, description, mods } of categories) {
const $list = await renderList(id, mods, description),
$mods = await renderMods(mods);
$views.append(html`<${View} id=${id}>${$list}<//>`, ...$mods);
const render = async () => {
const { html, getMods, isEnabled, setEnabled } = globalThis.__enhancerApi,
[icon, renderStarted] = useState(["icon", "renderStarted"]);
if (!html || !getMods || !icon || renderStarted) return;
if (icon === "Monochrome") sidebar[1].icon += "?mask";
setState({ renderStarted: true });
const mods = await getMods();
for (let i = 0; i < categories.length; i++) {
const { id } = categories[i];
categories[i].mods = mods.filter(({ _src }) => _src.startsWith(`${id}/`));
categories[i].view = html`<${View} id=${id}>
<${List} ...${categories[i]} />
<//>`;
}
for (let i = 0; i < mods.length; i++) {
const options = mods[i].options?.filter((opt) => opt.type !== "heading");
if (mods[i]._src === "core" || !options.length) continue;
const _get = () => isEnabled(mods[i].id),
_set = async (enabled) => {
await setEnabled(mods[i].id, enabled);
setState({ rerender: true, databaseUpdated: true });
};
mods[i].view = html`<${View} id=${mods[i].id}>
<!-- passing an empty options array hides the settings button -->
<${Mod} ...${{ ...mods[i], options: [], _get, _set }} />
<${Options} mod=${mods[i]} />
<//>`;
}
// footer appears only if buttons are visible
// - the matching category button appears on a mod's options page
// - the reload button appears if any options are changed
categories.forEach((c) => {
c.button = html`<${Button}
icon="chevron-left"
onclick=${() => setState({ transition: "slide-to-left", view: c.id })}
>
${c.title}
<//>`;
});
const $reload = html`<${Button}
class="ml-auto"
variant="primary"
icon="refresh-cw"
onclick=${() => reloadApp()}
style="display: none"
>
Reload & Apply Changes
<//>`,
$footer = html`<${Footer}>${categories.map((c) => c.button)}${$reload}<//>`,
$main = html`<div class="flex flex-col overflow-hidden transition-[height]">
${$views} ${$footer}
</div>`,
updateFooter = () => {
const buttons = [...$footer.children],
renderFooter = buttons.some(($el) => $el.style.display === "");
$main.style.height = renderFooter ? "100%" : "calc(100% + 33px)";
};
useState(["view"], ([view]) => {
for (const { mods, button } of categories) {
const renderButton = mods.some((mod) => mod.id === view);
button.style.display = renderButton ? "" : "none";
updateFooter();
}
});
useState(["databaseUpdated"], ([databaseUpdated]) => {
if (!databaseUpdated) return;
$reload.style.display = "";
updateFooter();
const $sidebar = html`<${Sidebar}
items=${sidebar}
categories=${categories}
/>`,
$main = html`
<main class="flex flex-col overflow-hidden transition-[height]">
<!-- wrapper necessary for transitions -->
<div class="grow relative overflow-hidden">
<${View} id="welcome">welcome<//>
<${View} id="core">
<${Options} mod=${mods.find(({ _src }) => _src === "core")} />
<${Profiles} />
<//>
${[...categories, ...mods]
.filter(({ view }) => view)
.map(({ view }) => view)}
</div>
<${Footer} categories=${categories} />
</main>
`;
useState(["footerOpen"], ([footerOpen]) => {
$main.style.height = footerOpen ? "100%" : "calc(100% + 33px)";
});
const $skeleton = document.querySelector("#skeleton");
$skeleton.replaceWith(renderSidebar(sidebar, categories), $main);
$skeleton.replaceWith($sidebar, $main);
};
window.addEventListener("focus", () => setState({ rerender: true }));
window.addEventListener("message", (event) => {
if (event.data?.namespace !== "notion-enhancer") return;
const [hotkey, theme, icon] = getState(["hotkey", "theme", "icon"]);
const [hotkey, theme, icon] = useState(["hotkey", "theme", "icon"]);
setState({
rerender: true,
hotkey: event.data?.hotkey ?? hotkey,
@ -377,7 +151,7 @@ window.addEventListener("message", (event) => {
});
useState(["hotkey"], ([hotkey]) => {
const { addKeyListener } = globalThis.__enhancerApi ?? {},
[hotkeyRegistered] = getState(["hotkeyRegistered"]);
[hotkeyRegistered] = useState(["hotkeyRegistered"]);
if (!hotkey || !addKeyListener || hotkeyRegistered) return;
setState({ hotkeyRegistered: true });
addKeyListener(hotkey, (event) => {
@ -386,20 +160,20 @@ useState(["hotkey"], ([hotkey]) => {
parent?.postMessage(msg, "*");
});
addKeyListener("Escape", () => {
const [popupOpen] = getState(["popupOpen"]);
const [popupOpen] = useState(["popupOpen"]);
if (!popupOpen) {
const msg = { namespace: "notion-enhancer", action: "close-menu" };
parent?.postMessage(msg, "*");
} else setState({ rerender: true });
});
});
useState(["theme"], ([theme]) => {
if (theme === "dark") document.body.classList.add("dark");
if (theme === "light") document.body.classList.remove("dark");
});
useState(["rerender"], async () => {
const [theme, icon] = getState(["theme", "icon"]);
const [theme, icon] = useState(["theme", "icon"]);
if (!theme || !icon) return;
// chrome extensions run in an isolated execution context
// but extension:// pages can access chrome apis

View File

@ -5,11 +5,9 @@
*/
const _state = {},
_subscribers = [],
getState = (keys) => {
return keys.map((key) => _state[key]);
},
setState = (state) => {
_subscribers = [];
const setState = (state) => {
Object.assign(_state, state);
const updates = Object.keys(state);
_subscribers
@ -17,8 +15,27 @@ const _state = {},
.forEach(([keys, callback]) => callback(keys.map((key) => _state[key])));
},
useState = (keys, callback) => {
_subscribers.push([keys, callback]);
callback(getState(keys));
const state = keys.map((key) => _state[key]);
if (callback) _subscribers.push([keys, callback]);
callback?.(state);
return state;
};
export { setState, useState, getState };
const extendProps = (props, extend) => {
for (const key in extend) {
const { [key]: userProvided } = props;
if (typeof extend[key] === "function") {
props[key] = (...args) => {
extend[key](...args);
userProvided?.(...args);
};
} else if (key === "class") {
if (userProvided) props[key] += " ";
if (!userProvided) props[key] = "";
props[key] += extend[key];
} else props[key] = extend[key] ?? userProvided;
}
return props;
};
export { setState, useState, extendProps };

View File

@ -1,292 +0,0 @@
/**
* notion-enhancer: menu
* (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
'use strict';
import { fmt, web, registry, components } from '../../api/index.mjs';
import { notifications } from './notifications.mjs';
import '../../dep/jscolor.min.js';
import '../../dep/markdown-it.min.js';
const md = markdownit({ linkify: true });
export const modComponents = {
preview: (url) => web.html`<img
class="mod-preview"
src="${web.escape(url)}"
alt=""
>`,
title: (title) => web.html`<h4 class="mod-title"><span>${web.escape(title)}</span></h4>`,
version: (version) => web.html`<span class="mod-version">v${web.escape(version)}</span>`,
tags: (tags) => {
if (!tags.length) return '';
return web.render(
web.html`<p class="mod-tags"></p>`,
tags.map((tag) => `#${web.escape(tag)}`).join(' ')
);
},
description: (description) => {
const $description = web.html`<p class="mod-description markdown-inline">
${md.renderInline(description)}
</p>`;
$description.querySelectorAll('a').forEach((a) => {
a.target = '_blank';
});
return $description;
},
authors: (authors) => {
const author = (author) => web.html`<a
class="mod-author"
href="${web.escape(author.homepage)}"
target="_blank"
>
<img class="mod-author-avatar"
src="${web.escape(author.avatar)}" alt="${web.escape(author.name)}'s avatar"
> <span>${web.escape(author.name)}</span>
</a>`;
return web.render(web.html`<p class="mod-authors-container"></p>`, ...authors.map(author));
},
toggle: (label, checked) => {
const $label = web.html`<label tabindex="0" class="toggle-label">
<span>${web.escape(label)}</span>
</label>`,
$input = web.html`<input tabindex="-1" type="checkbox" class="toggle-check"
${checked ? 'checked' : ''}>`,
$feature = web.html`<span class="toggle-box toggle-feature"></span>`;
$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`<label class="input-label"></label>`,
web.render(web.html`<p></p>`, opt.tooltip ? $tooltipIcon : '', opt.label)
),
$options = opt.values.map(
(option) => web.raw`<option
class="select-option"
value="${web.escape(option)}"
${option === value ? 'selected' : ''}
>${web.escape(option)}</option>`
),
$select = web.html`<select class="input">
${$options.join('')}
</select>`,
$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`<label class="input-label"></label>`,
web.render(web.html`<p></p>`, opt.tooltip ? $tooltipIcon : '', opt.label)
),
$input = web.html`<input type="text" class="input" value="${web.escape(value)}">`,
$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`<label class="input-label"></label>`,
web.render(web.html`<p></p>`, opt.tooltip ? $tooltipIcon : '', opt.label)
),
$input = web.html`<input type="number" class="input" value="${value}">`,
$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`<label class="input-label"></label>`,
web.render(web.html`<p></p>`, opt.tooltip ? $tooltipIcon : '', opt.label)
),
$input = web.html`<input type="text" class="input">`,
$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`<label class="input-label"></label>`,
web.render(web.html`<p></p>`, opt.tooltip ? $tooltipIcon : '', opt.label)
),
$pseudo = web.html`<span class="input"><span class="input-placeholder">Upload file...</span></span>`,
$input = web.html`<input type="file" class="hidden" accept=${web.escape(
opt.extensions.join(',')
)}>`,
$icon = web.html`${await components.feather('file', { class: 'input-icon' })}`,
$filename = web.html`<span>${web.escape(filename || 'none')}</span>`,
$latest = web.render(web.html`<button class="file-latest">Latest: </button>`, $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`<div></div>`,
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`<label class="input-label"></label>`,
web.render(web.html`<p></p>`, opt.tooltip ? $tooltipIcon : '', opt.label)
),
$input = web.html`<input type="text" class="input" value="${web.escape(value)}">`,
$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);
},
};

View File

@ -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"
}
]
}

View File

@ -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",

View File

@ -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}`);

View File

@ -12,9 +12,7 @@ export default (async () => {
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
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");
})();