diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
index 465925b59..b1023c890 100644
--- a/apps/web/src/app.tsx
+++ b/apps/web/src/app.tsx
@@ -38,6 +38,7 @@ import { FlexScrollContainer } from "./components/scroll-container";
import CachedRouter from "./components/cached-router";
import { WebExtensionRelay } from "./utils/web-extension-relay";
import { usePersistentState } from "./hooks/use-persistent-state";
+import ThemeBuilder from "./components/theme-builder";
new WebExtensionRelay();
@@ -213,6 +214,13 @@ function DesktopAppContents({
+
+
+
diff --git a/apps/web/src/components/accordion/index.tsx b/apps/web/src/components/accordion/index.tsx
index 38cf83c15..0d44b782d 100644
--- a/apps/web/src/components/accordion/index.tsx
+++ b/apps/web/src/components/accordion/index.tsx
@@ -27,12 +27,24 @@ export type AccordionProps = {
isClosed: boolean;
color?: SchemeColors;
testId?: string;
+ titleSx?: FlexProps["sx"];
+ buttonSx?: FlexProps["sx"];
};
export default function Accordion(
props: PropsWithChildren & FlexProps
) {
- const { isClosed, title, color, children, testId, sx, ...restProps } = props;
+ const {
+ isClosed,
+ title,
+ color,
+ children,
+ testId,
+ sx,
+ titleSx,
+ buttonSx,
+ ...restProps
+ } = props;
const [isContentHidden, setIsContentHidden] = useState(false);
useEffect(() => {
@@ -48,14 +60,15 @@ export default function Accordion(
cursor: "pointer",
bg: "var(--background-secondary)",
p: 1,
- borderRadius: "default"
+ borderRadius: "default",
+ ...buttonSx
}}
onClick={() => {
setIsContentHidden((state) => !state);
}}
data-test-id={testId}
>
-
+
{title}
{isContentHidden ? (
@@ -64,7 +77,15 @@ export default function Accordion(
)}
- {!isContentHidden && children}
+
+
+ {children}
+
);
}
diff --git a/apps/web/src/components/theme-builder/index.tsx b/apps/web/src/components/theme-builder/index.tsx
new file mode 100644
index 000000000..703c7671c
--- /dev/null
+++ b/apps/web/src/components/theme-builder/index.tsx
@@ -0,0 +1,672 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+import {
+ ThemeAuthor,
+ ThemeDefinition,
+ ThemeScopes,
+ useThemeProvider
+} from "@notesnook/theme";
+import { Button, Flex, IconButton, Input, Text } from "@theme-ui/components";
+import { useRef, useState } from "react";
+import Field from "../field";
+import Accordion from "../accordion";
+import { Close, Download, Reupload } from "../icons";
+import { showToast } from "../../utils/toast";
+import { debounce } from "@notesnook/common";
+import { useStore } from "../../stores/theme-store";
+import FileSaver from "file-saver";
+import { showFilePicker } from "../editor/picker";
+
+const ThemeInfoTemplate: Omit<
+ ThemeDefinition,
+ "authors" | "compatibilityVersion" | "colorScheme" | "codeBlockCSS" | "scopes"
+> = {
+ name: "",
+ id: "",
+ version: 0,
+ license: "",
+ homepage: "",
+ description: ""
+};
+
+function toTitleCase(value: string) {
+ return (
+ value.slice(0, 1).toUpperCase() +
+ value.slice(1).replace(/([A-Z]+)*([A-Z][a-z])/g, "$1 $2")
+ );
+}
+
+const ColorNames = [
+ "accent",
+ "paragraph",
+ "background",
+ "border",
+ "heading",
+ "icon",
+ "separator",
+ "placeholder",
+ "hover",
+ "shade",
+ "backdrop",
+ "textSelection"
+];
+
+const Variants = [
+ "primary",
+ "secondary",
+ "disabled",
+ "selected",
+ "error",
+ "success"
+];
+const Scopes = [
+ "base",
+ "statusBar",
+ "list",
+ "editor",
+ "editorToolbar",
+ "dialog",
+ "navigationMenu",
+ "contextMenu",
+ "sheet"
+];
+
+const RequiredKeys = [
+ "version",
+ "id",
+ "name",
+ "license",
+ "authors.0.name",
+ "authors.0.email",
+ "authors.0.url",
+ "description",
+ "colorScheme",
+ "compatibilityVersion",
+ "homepage",
+ ...Variants.map((variant) =>
+ ColorNames.map((colorName) => `scopes.base.${variant}.${colorName}`)
+ ).flat()
+];
+
+const flatten = (object: { [name: string]: any }) => {
+ const flattenedObject: { [name: string]: any } = {};
+
+ for (const innerObj in object) {
+ if (typeof object[innerObj] === "object") {
+ if (typeof object[innerObj] === "function") continue;
+
+ const newObject = flatten(object[innerObj]);
+ for (const key in newObject) {
+ flattenedObject[innerObj + "." + key] = newObject[key];
+ }
+ } else {
+ if (typeof object[innerObj] === "function") continue;
+ flattenedObject[innerObj] = object[innerObj];
+ }
+ }
+ return flattenedObject;
+};
+
+function unflatten(data: any) {
+ const result = {};
+ for (const i in data) {
+ const keys = i.split(".");
+ keys.reduce(function (r: any, e, j) {
+ return (
+ r[e] ||
+ (r[e] = isNaN(Number(keys[j + 1]))
+ ? keys.length - 1 == j
+ ? data[i]
+ : {}
+ : [])
+ );
+ }, result);
+ }
+ return result;
+}
+
+export default function ThemeBuilder() {
+ const { theme: currentTheme } = useThemeProvider();
+ const setTheme = useStore((state) => state.setTheme);
+ const [loading, setLoading] = useState(false);
+ const currentThemeFlattened = flatten(currentTheme);
+ const [authors, setAuthors] = useState(
+ currentTheme.authors || [
+ {
+ name: ""
+ }
+ ]
+ );
+
+ const formRef = useRef(null);
+
+ const onChange: React.FormEventHandler = debounce(() => {
+ if (!formRef.current) return;
+ const body = new FormData(formRef.current);
+ const flattenedThemeRaw = {
+ ...Object.fromEntries(body.entries()),
+ ...flatten({ authors: [...authors] })
+ };
+
+ const flattenedTheme: { [name: string]: any } = {};
+
+ for (const key in flattenedThemeRaw) {
+ if (flattenedThemeRaw[key] === "" || !flattenedThemeRaw[key]) continue;
+ flattenedTheme[key] = flattenedThemeRaw[key];
+ }
+
+ const missingKeys = [];
+ for (const key of RequiredKeys) {
+ if (!Object.keys(flattenedTheme).includes(key)) {
+ missingKeys.push(key);
+ }
+ }
+ if (missingKeys.length > 0) {
+ showToast(
+ "error",
+ `Failed to apply theme, ${missingKeys.join(
+ ","
+ )} are missing from the theme.`
+ );
+ return;
+ }
+
+ const invalidColors = [];
+
+ for (const key in flattenedTheme) {
+ if (!key.startsWith("scopes")) continue;
+ const value = flattenedTheme[key];
+
+ const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/g;
+ const HEX_COLOR_REGEX_ALPHA =
+ /^#(?:(?:[\da-fA-F]{3}){1,2}|(?:[\da-fA-F]{4}){1,2})$/g;
+
+ if (
+ !/hover|shade|backdrop|textSelection/g.test(key) &&
+ !HEX_COLOR_REGEX.test(value)
+ ) {
+ invalidColors.push(key);
+ } else if (
+ /hover|shade|backdrop|textSelection/g.test(key) &&
+ !HEX_COLOR_REGEX_ALPHA.test(value)
+ ) {
+ invalidColors.push(key);
+ }
+ }
+
+ if (invalidColors.length > 0) {
+ showToast(
+ "error",
+ `Failed to apply theme, ${invalidColors.join(",")} are invalid.`
+ );
+ return;
+ }
+
+ const theme = unflatten(flattenedTheme);
+ setTheme({ ...theme } as ThemeDefinition);
+ }, 500);
+
+ const loadThemeFile = async () => {
+ const file = await showFilePicker({
+ acceptedFileTypes: ".nnbackup,application/json,.json"
+ });
+ if (!file) return;
+ const reader = new FileReader();
+ const theme = (await new Promise((resolve) => {
+ reader.addEventListener("load", (event) => {
+ const text = event.target?.result;
+ try {
+ resolve(JSON.parse(text as string));
+ } catch (e) {
+ alert(
+ "Error: Could not read the backup file provided. Either it's corrupted or invalid."
+ );
+ resolve(undefined);
+ }
+ });
+ reader.readAsText(file);
+ })) as ThemeDefinition | undefined;
+ if (
+ !theme ||
+ !theme.scopes ||
+ !theme.compatibilityVersion ||
+ !theme.id ||
+ !theme.version
+ )
+ return;
+ setLoading(true);
+ setTheme(theme);
+ setLoading(false);
+ };
+
+ const exportTheme = () => {
+ const json = JSON.stringify(currentTheme);
+ FileSaver.saveAs(
+ new Blob([json], {
+ type: "text/plain"
+ }),
+ `${currentTheme.id}.json`
+ );
+ };
+
+ const onChangeColor = (
+ target: HTMLInputElement,
+ sibling: HTMLInputElement
+ ) => {
+ const value = target.value;
+ if ((sibling as HTMLInputElement).value !== value) {
+ (sibling as HTMLInputElement).value = target.value;
+ }
+ };
+
+ return loading ? null : (
+
+
+
+ Theme Builder 1.0
+
+
+
+
+
+
+ {
+ event?.preventDefault();
+ }}
+ sx={{
+ flexDirection: "column",
+ rowGap: "0.5rem"
+ }}
+ >
+ {Object.keys(ThemeInfoTemplate).map((key) => {
+ return (
+
+ );
+ })}
+
+
+
+
+ {authors.map((author, index) => (
+
+
+
+ Author {index + 1}
+
+
+ {authors.length > 1 ? (
+
+ ) : null}
+
+
+ {["name", "email", "url"].map((key) => (
+ <>
+
+ >
+ ))}
+
+ ))}
+
+
+
+ {Scopes.map((scopeName) => (
+ <>
+
+ {Variants.map((variantName) => (
+ <>
+
+ {ColorNames.map((colorName) => (
+
+
+ {colorName}
+
+ {
+ onChangeColor(
+ event.target,
+ event.target
+ .nextElementSibling as HTMLInputElement
+ );
+ }}
+ />
+ {
+ onChangeColor(
+ event.target,
+ event.target
+ .previousElementSibling as HTMLInputElement
+ );
+ }}
+ title={
+ /hover|shade|backdrop|textSelection/g.test(
+ colorName
+ )
+ ? `Only Hex RGB values are supported. No Alpha. (e.g. #f33ff3)`
+ : `Hex RGB & ARGB values both are supported. (e.g. #dbdbdb99)`
+ }
+ defaultValue={
+ currentThemeFlattened[
+ `scopes.${scopeName}.${variantName}.${colorName}`
+ ]
+ }
+ sx={{
+ borderRadius: 0,
+ borderBottom: "1px solid var(--border)",
+ outline: "none",
+ width: "20px",
+ height: "20px",
+ padding: "0px"
+ }}
+ />
+
+ ))}
+
+ >
+ ))}
+
+ >
+ ))}
+
+
+ );
+}
+
+function SelectItem(props: {
+ options: { title: string; value: any }[];
+ defaultValue: any;
+ onChange?: (value: string) => void;
+ label: string;
+ name: string;
+}) {
+ return (
+
+
+ {props.label}
+
+
+
+ );
+}