From 29611ffdef0ce3441d48eedf2ca2495ea0f29de0 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Wed, 26 Jul 2023 13:56:05 +0500 Subject: [PATCH] theme: add theme builder to web app --- apps/web/src/app.tsx | 8 + apps/web/src/components/accordion/index.tsx | 29 +- .../src/components/theme-builder/index.tsx | 672 ++++++++++++++++++ 3 files changed, 705 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/theme-builder/index.tsx 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} + + + + ); +}