mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-04 12:49:03 +00:00
refactor(menu): menu.mjs render functions and components.mjs monolith -> islands/ and components/
This commit is contained in:
parent
c19262a4ce
commit
e3f34dfc21
@ -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);
|
||||
|
124
src/api/mods.js
124
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,
|
||||
});
|
||||
|
@ -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" &&
|
||||
|
@ -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,
|
||||
};
|
41
src/core/menu/components/Button.mjs
Normal file
41
src/core/menu/components/Button.mjs
Normal 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 };
|
39
src/core/menu/components/Checkbox.mjs
Normal file
39
src/core/menu/components/Checkbox.mjs
Normal 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 };
|
18
src/core/menu/components/Description.mjs
Normal file
18
src/core/menu/components/Description.mjs
Normal 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 };
|
19
src/core/menu/components/Heading.mjs
Normal file
19
src/core/menu/components/Heading.mjs
Normal 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 };
|
165
src/core/menu/components/Input.mjs
Normal file
165
src/core/menu/components/Input.mjs
Normal 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 };
|
68
src/core/menu/components/Popup.mjs
Normal file
68
src/core/menu/components/Popup.mjs
Normal 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 };
|
76
src/core/menu/components/Select.mjs
Normal file
76
src/core/menu/components/Select.mjs
Normal 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 };
|
42
src/core/menu/components/Toggle.mjs
Normal file
42
src/core/menu/components/Toggle.mjs
Normal 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 };
|
59
src/core/menu/islands/Footer.mjs
Normal file
59
src/core/menu/islands/Footer.mjs
Normal 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 };
|
57
src/core/menu/islands/List.mjs
Normal file
57
src/core/menu/islands/List.mjs
Normal 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 };
|
85
src/core/menu/islands/Mod.mjs
Normal file
85
src/core/menu/islands/Mod.mjs
Normal 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 };
|
86
src/core/menu/islands/Options.mjs
Normal file
86
src/core/menu/islands/Options.mjs
Normal 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 };
|
204
src/core/menu/islands/Profiles.mjs
Normal file
204
src/core/menu/islands/Profiles.mjs
Normal 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 };
|
88
src/core/menu/islands/Sidebar.mjs
Normal file
88
src/core/menu/islands/Sidebar.mjs
Normal 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 };
|
83
src/core/menu/islands/View.mjs
Normal file
83
src/core/menu/islands/View.mjs
Normal 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 };
|
@ -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);
|
||||
}
|
||||
|
@ -4,370 +4,144 @@
|
||||
* (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 = [
|
||||
{
|
||||
icon: "palette",
|
||||
id: "themes",
|
||||
title: "Themes",
|
||||
description: `Themes override Notion's colour schemes. Dark themes require
|
||||
const categories = [
|
||||
{
|
||||
icon: "palette",
|
||||
id: "themes",
|
||||
title: "Themes",
|
||||
description: `Themes override Notion's colour schemes. Dark themes require
|
||||
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",
|
||||
id: "extensions",
|
||||
title: "Extensions",
|
||||
description: `Extensions add to the functionality and layout of the Notion
|
||||
},
|
||||
{
|
||||
icon: "zap",
|
||||
id: "extensions",
|
||||
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",
|
||||
id: "integrations",
|
||||
title: "Integrations",
|
||||
description: `<span class="text-[color:var(--theme--fg-red)]">
|
||||
},
|
||||
{
|
||||
icon: "plug",
|
||||
id: "integrations",
|
||||
title: "Integrations",
|
||||
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 = [
|
||||
"notion-enhancer",
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Welcome",
|
||||
icon: `notion-enhancer${icon === "Monochrome" ? "?mask" : ""}`,
|
||||
},
|
||||
{
|
||||
icon: "message-circle",
|
||||
title: "Community",
|
||||
href: "https://discord.gg/sFWPXtA",
|
||||
},
|
||||
{
|
||||
icon: "clock",
|
||||
title: "Changelog",
|
||||
href: "https://notion-enhancer.github.io/about/changelog/",
|
||||
},
|
||||
{
|
||||
icon: "book",
|
||||
title: "Documentation",
|
||||
href: "https://notion-enhancer.github.io/",
|
||||
},
|
||||
{
|
||||
icon: "github",
|
||||
title: "Source Code",
|
||||
href: "https://github.com/notion-enhancer",
|
||||
},
|
||||
{
|
||||
icon: "coffee",
|
||||
title: "Sponsor",
|
||||
href: "https://github.com/sponsors/dragonwocky",
|
||||
},
|
||||
"Settings",
|
||||
{
|
||||
id: "core",
|
||||
title: "Core",
|
||||
icon: "sliders-horizontal",
|
||||
},
|
||||
...categories.map((c) => ({ id: c.id, title: c.title, icon: c.icon })),
|
||||
];
|
||||
},
|
||||
],
|
||||
sidebar = [
|
||||
"notion-enhancer",
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Welcome",
|
||||
icon: "notion-enhancer",
|
||||
},
|
||||
{
|
||||
icon: "message-circle",
|
||||
title: "Community",
|
||||
href: "https://discord.gg/sFWPXtA",
|
||||
},
|
||||
{
|
||||
icon: "clock",
|
||||
title: "Changelog",
|
||||
href: "https://notion-enhancer.github.io/about/changelog/",
|
||||
},
|
||||
{
|
||||
icon: "book",
|
||||
title: "Documentation",
|
||||
href: "https://notion-enhancer.github.io/",
|
||||
},
|
||||
{
|
||||
icon: "github",
|
||||
title: "Source Code",
|
||||
href: "https://github.com/notion-enhancer",
|
||||
},
|
||||
{
|
||||
icon: "coffee",
|
||||
title: "Sponsor",
|
||||
href: "https://github.com/sponsors/dragonwocky",
|
||||
},
|
||||
"Settings",
|
||||
{
|
||||
id: "core",
|
||||
title: "Core",
|
||||
icon: "sliders-horizontal",
|
||||
},
|
||||
...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
|
||||
|
@ -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 };
|
||||
|
@ -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);
|
||||
},
|
||||
};
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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",
|
||||
|
13
src/init.js
13
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}`);
|
||||
|
22
src/load.mjs
22
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");
|
||||
})();
|
||||
|
Loading…
Reference in New Issue
Block a user