diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc36ec..1e9081c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [devtoolsx-v2.10.0] - 2024-07-13 +## [devtoolsx-v2.14.0] - 2024-08-10 ### 🚀 Features @@ -17,7 +17,7 @@ All notable changes to this project will be documented in this file. - Auto open accordion for path - Changelog generator - Css playground -- *(ci)* New bump version script +- _(ci)_ New bump version script - Rewrote jwt - Rest options for monaco - Svg preview @@ -26,17 +26,21 @@ All notable changes to this project will be documented in this file. - Color palette generator - Hmac generator - Image cropper +- Debounce color picker +- Cleanup, copy cursor and copy notification ### 🐛 Bug Fixes - Hardcoded width - Vertical image overflow - Error handling -- *(ci)* Js heap out of mem -- *(ci)* Incorrect matrix platforms +- _(ci)_ Js heap out of mem +- _(ci)_ Incorrect matrix platforms - Autosizing text - No sourcemaps - Upx script +- Remove corepack +- Yarn.lock update ### 📚 Documentation @@ -45,6 +49,9 @@ All notable changes to this project will be documented in this file. - Star history - Reorder features - New feature +- Changelog +- 3 new features +- New authors and feature ### ⚙️ Miscellaneous Tasks @@ -52,14 +59,22 @@ All notable changes to this project will be documented in this file. - Refactoring and error handling - Cleanup - Smaller app drawer -- *(ui)* Bigger icons, flex fix +- _(ui)_ Bigger icons, flex fix - Cleanup - Unused vars rule - Deps upgrade -- *(doc)* Add download instructions -- *(ui)* Shortcut styling +- _(doc)_ Add download instructions +- _(ui)_ Shortcut styling - Rename file -- *(doc)* Changelog +- _(doc)_ Changelog + +### Feature + +- Harmonies & Some cleanup + +### Todo + +- Next pr =) ### Deps @@ -245,7 +260,7 @@ All notable changes to this project will be documented in this file. - Minifier/beautifier - New tauri action - Release v2.1.0 -- *(ci)* Fix broken CI +- _(ci)_ Fix broken CI - Upgrade pnpm version - Move to pnpm - Move to yarn @@ -266,14 +281,14 @@ All notable changes to this project will be documented in this file. - Delete row feature - Open and save markdown files - Lots of cleanup, collapsible navbar -- *(navbar)* Migrate to css-modules +- _(navbar)_ Migrate to css-modules - App style - All version upgrade ### 🐛 Bug Fixes - Pinned cards different layouts -- *(readme)* Borked english +- _(readme)_ Borked english - Minimum width - Styles cleanup - Css cleanup and overflow fix @@ -353,7 +368,7 @@ All notable changes to this project will be documented in this file. ### 🚀 Features -- *(doc)* Reorder features +- _(doc)_ Reorder features - Add loader to show compression progress - Navigate on entire row click - Loader for large file hashes @@ -380,12 +395,12 @@ All notable changes to this project will be documented in this file. ### 🚀 Features - REST API module -- *(rest)* Params support +- _(rest)_ Params support - Headers - Delete and enable params -- *(ui)* Global styles + theme -- *(json)* Move away from jsoneditor -- *(monaco)* New theme +- _(ui)_ Global styles + theme +- _(json)_ Move away from jsoneditor +- _(monaco)_ New theme - Hashing tools rewrite - Moved random text generator, entropy calculation - Exclude chars @@ -412,7 +427,7 @@ All notable changes to this project will be documented in this file. - Theme toggle action - Unstable features tooltip - Enable debug window -- *(doc)* Add coc and contributing +- _(doc)_ Add coc and contributing - Icon assets for all sizes - Add banner - Debug launch configuration @@ -423,9 +438,9 @@ All notable changes to this project will be documented in this file. - Card width - Unused imports - UI changes -- *(nav)* Remove theme for now -- *(wip)* Remove chakra ui -- *(wip)* Remove chakra ui +- _(nav)_ Remove theme for now +- _(wip)_ Remove chakra ui +- _(wip)_ Remove chakra ui - Responsive width - Auto focus on new tab and close tab - Main layout @@ -448,7 +463,7 @@ All notable changes to this project will be documented in this file. - Increase default font size - Reset selection on error - Scroll high blocking last item -- *(ui)* Extra padding on left +- _(ui)_ Extra padding on left ### 🚜 Refactor @@ -456,7 +471,7 @@ All notable changes to this project will be documented in this file. - Reuse outputbox - Lint - Lint -- *(doc)* New badges +- _(doc)_ New badges ### 📚 Documentation @@ -499,7 +514,7 @@ All notable changes to this project will be documented in this file. ### Ui -- *(wip)* Remove chakra UI +- _(wip)_ Remove chakra UI ## [DevToolsv1.5.0] - 2022-06-19 @@ -513,18 +528,18 @@ All notable changes to this project will be documented in this file. - Search navbar items - Pinning items working - Pin feature on nav and home -- *(wip)* Unit conversion -- *(ui)* Animation on route change +- _(wip)_ Unit conversion +- _(ui)_ Animation on route change - Tabs support in json-editor - Generate many random strings at once - Generate multiple strings -- *(ui)* Heading -- *(ui)* Heading on all components +- _(ui)_ Heading +- _(ui)_ Heading on all components - Unit converter - Calculate on swap - Global modal - Focus on escape -- *(wip)* React playground +- _(wip)_ React playground - React live editor ### 🐛 Bug Fixes @@ -535,7 +550,7 @@ All notable changes to this project will be documented in this file. - Disable ctrlP and F3 - Remove prefetch - Lint -- *(ui)* Better animation and height fix +- _(ui)_ Better animation and height fix - Repl scroll increasing - Cards color to match theme - Hide outside links @@ -552,7 +567,7 @@ All notable changes to this project will be documented in this file. - Vite plugins - Color - Layout changed -- Using reusable monaco wrapper +- Using reusable monaco wrapper ### ⚙️ Miscellaneous Tasks @@ -594,13 +609,13 @@ All notable changes to this project will be documented in this file. - Markdown preview and editor - Yaml json convertro - Support monaco offline -- *(wip)* Pastebin +- _(wip)_ Pastebin - Pastebin ### 🐛 Bug Fixes - Regex tester -- *(scope)* Update version +- _(scope)_ Update version - Titles - Cleanup unused deps - Disable right click @@ -646,7 +661,7 @@ All notable changes to this project will be documented in this file. - Darkmode reset on leave - Ignore type - Css overflow on button -- *(ui)* Remove cards +- _(ui)_ Remove cards ### Deps @@ -721,7 +736,7 @@ All notable changes to this project will be documented in this file. ### 🐛 Bug Fixes -- *(utils)* Crash when launching app for the first time +- _(utils)_ Crash when launching app for the first time - Sort order ### Conf @@ -738,13 +753,13 @@ All notable changes to this project will be documented in this file. - Lazy loading components - Profile smaller release -- *(hash)* Calculate hash of a file +- _(hash)_ Calculate hash of a file ### 🐛 Bug Fixes - Eslint config - Quotes -- *(CI)* Tool name in release +- _(CI)_ Tool name in release ### Conf @@ -762,7 +777,7 @@ All notable changes to this project will be documented in this file. ### Lint -- *(format)* Single quotes to double +- _(format)_ Single quotes to double ### Misc @@ -796,7 +811,7 @@ All notable changes to this project will be documented in this file. ### Dep - Using react-hookz -- *(add)* Crypto js for hashes +- _(add)_ Crypto js for hashes - Gen-password util ### Release @@ -813,7 +828,7 @@ All notable changes to this project will be documented in this file. - Dark theme first - Routes -- *(component)* Welcome placeholder +- _(component)_ Welcome placeholder - Card and json-formatter - Navbar using cards - Basic prototype of json @@ -836,7 +851,7 @@ All notable changes to this project will be documented in this file. ### Add -- *(dep)* For type hints +- _(dep)_ For type hints ### Init diff --git a/README.md b/README.md index f2d1f10..b0055fc 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ This project exists solely because I was fed up switching between different tool #### Checkout [features.md](features.md) for a short video demo on every feature. -DevTools-X has about **37 features** as of now, and growing. +DevTools-X has about **41 features** as of now, and growing. The full list in below, One big selling point of DevTools-X is it uses `monaco-editor`, the editor used by vscode, so tons of editor features are available to you right from the start, as if you are using vscode. @@ -101,6 +101,10 @@ available to you right from the start, as if you are using vscode. 35. Generate mock data with Faker 36. CSS live playground 37. QR Code Reader +38. Image cropper +39. HMAC Generator +40. Color palette generator +41. Color Harmonies generator ## Contributing @@ -120,9 +124,12 @@ DevTools-X is **NOT WRITTEN IN ELECTRON**. That should be enough to tell you it's built on top of [Tauri](https://tauri.app/), So we get best of the both worlds: Web + Rust. Web to create beautiful cross-platform UI, Rust to create fast and small applications. Tauri bundle is super small, about 10MB of installer. -## Authors +## Contributors - [@Sparkenstein](https://www.github.com/Sparkenstein) +- [@ThijsZijdel](https://github.com/ThijsZijdel) +- [@yemikudaisi](https://github.com/yemikudaisi) +- [@kuyoonjo](https://github.com/kuyoonjo) - You? ## FAQ diff --git a/package.json b/package.json index 7581bfe..004d472 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dev-tools", - "version": "2.13.0", + "version": "2.14.0", "license": "MIT", "type": "module", "scripts": { @@ -38,7 +38,6 @@ "axios": "^1.6.8", "chroma-js": "^2.4.2", "clsx": "^2.1.0", - "color-convert": "^2.0.1", "convert-units": "^2.3.4", "cron-parser": "^4.9.0", "cronstrue": "^2.48.0", @@ -54,13 +53,14 @@ "lesspass": "^9.2.0", "lorem-ipsum": "^2.0.8", "monaco-themes": "^0.4.4", + "nanoid": "^5.0.7", "polished": "^4.3.1", "preact": "^10.20.1", "qrcode": "^1.5.3", "quicktype": "^23.0.114", "quicktype-core": "^23.0.114", "react": "^18.2.0", - "react-colorful": "^5.6.1", + "react-color": "^2.19.3", "react-compare-slider": "^3.0.1", "react-cropper": "^2.3.3", "react-dom": "^18.2.0", @@ -74,6 +74,7 @@ "simple-base-converter": "^1.0.19", "tauri-plugin-store-api": "https://github.com/tauri-apps/tauri-plugin-store", "terser": "^5.30.0", + "tinycolor2": "^1.6.0", "typescript-eslint": "^7.4.0", "uuid": "^9.0.1" }, @@ -93,7 +94,9 @@ "@types/prismjs": "^1.26.3", "@types/qrcode": "^1.5.5", "@types/react": "^18.2.73", + "@types/react-color": "^3.0.12", "@types/react-dom": "^18.2.23", + "@types/tinycolor2": "^1.4.6", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.4.0", "@vitejs/plugin-react": "^4.2.1", @@ -117,4 +120,4 @@ "typescript": "^5.4.3", "vite": "^5.2.7" } -} +} diff --git a/public/main-logo.png b/public/main-logo.png new file mode 100644 index 0000000..39ca7e4 Binary files /dev/null and b/public/main-logo.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5028f0e..595f412 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -791,7 +791,7 @@ dependencies = [ [[package]] name = "devtools-x" -version = "2.13.0" +version = "2.14.0" dependencies = [ "anyhow", "bardecoder", @@ -2466,9 +2466,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" -version = "0.10.64" +version = "0.10.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" dependencies = [ "bitflags 2.5.0", "cfg-if", @@ -2498,9 +2498,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.102" +version = "0.9.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" dependencies = [ "cc", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d65a8c9..3836805 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devtools-x" -version = "2.13.0" +version = "2.14.0" description = "Developer tools desktop application" authors = ["Sparkenstein"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 493d4e5..96cb591 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "package": { "productName": "dev-tools", - "version": "2.13.0" + "version": "2.14.0" }, "build": { "distDir": "../dist", diff --git a/src/App.tsx b/src/App.tsx index 8b4287a..d0cc1fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,10 +41,15 @@ const JWT = loadable(() => import("./Features/jwt/JWT")); // const Nums = loadable(() => import("./Features/nums/Nums")); const Sql = loadable(() => import("./Features/sql/Sql")); const Colors = loadable(() => import("./Features/colors/Colors")); +const ColorHarmonies = loadable( + () => import("./Features/colors/ColorHarmonies") +); +const ColorTesting = loadable(() => import("./Features/colors/ColorTesting")); + const RegexTester = loadable(() => import("./Features/regex/RegexTester")); const TextDiff = loadable(() => import("./Features/text/TextDiff")); const Markdown = loadable(() => import("./Features/markdown/Markdown")); -const YamlJson = loadable(() => import("./Features/yaml-json/Yaml")); +const YamlJson = loadable(() => import("./Features/json-yaml/Yaml")); const Pastebin = loadable(() => import("./Features/pastebin/Pastebin")); const Repl = loadable(() => import("./Features/repl/Repl")); const Image = loadable(() => import("./Features/image/Image")); @@ -63,6 +68,7 @@ const Quicktpe = loadable(() => import("./Features/quicktype/Quicktype")); const Ping = loadable(() => import("./Features/ping/Ping")); const Minify = loadable(() => import("./Features/minifiers/Minify")); const UrlParser = loadable(() => import("./Features/url/UrlParser")); +const UrlEncoder = loadable(() => import("./Features/url/UrlEncoder")); const HtmlPreview = loadable( () => import("./Features/html-preview/HtmlPreview") ); @@ -223,6 +229,8 @@ function App() { }> }> }> + }> + }> }> }> }> @@ -242,6 +250,7 @@ function App() { }> }> }> + }> }> }> }> diff --git a/src/Components/MonacoWrapper.tsx b/src/Components/MonacoWrapper.tsx index e944767..60df84c 100644 --- a/src/Components/MonacoWrapper.tsx +++ b/src/Components/MonacoWrapper.tsx @@ -4,7 +4,11 @@ import Editor, { OnMount, EditorProps, } from "@monaco-editor/react"; +import * as monaco from "monaco-editor"; import { editor } from "monaco-editor"; +import { useAppContext } from "../Contexts/AppContextProvider"; +import { themes } from "../Layout/themes"; +import { useEffect, useState } from "react"; type MonacoProps = { value?: string; @@ -24,6 +28,8 @@ type MonacoProps = { }; } & EditorProps; +let inMemoryThemeCache: any = {}; + export const Monaco = ({ value, setValue, @@ -37,6 +43,35 @@ export const Monaco = ({ diffProps, ...rest }: MonacoProps) => { + const { config } = useAppContext(); + + const dark = themes.find((t) => t.value === config.editorThemeDark)!; + const light = themes.find((t) => t.value === config.editorThemeLight)!; + + const applyTheme = (monaco: any, theme: { value: string; label: string }) => { + // Importing it from `monaco-themes/themes/${theme.label}.json` gave all sorts of issues + // so fallback to using the CDN is fine for now, but we should look into this later / improve caching + const themeUrl = `https://cdn.jsdelivr.net/npm/monaco-themes@0.4.4/themes/${encodeURIComponent(theme.label)}.json`; + + if ((inMemoryThemeCache as any)[theme.label]) { + monaco.editor.setTheme(theme.value); + return; + } + + fetch(themeUrl) + .then(async (data: any) => { + console.log(`monaco-themes/themes/${theme.label}.json`); + const value = await data.json(); + const name = theme.value ?? "tomorrow-night"; + inMemoryThemeCache[theme.label] = value; + monaco.editor.defineTheme(name, value); + monaco.editor.setTheme(name); + }) + .catch((e: any) => { + console.error(e); + }); + }; + const diffOnMount: DiffOnMount = (editor, monaco) => { // disable TS incorrect diagnostic monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ @@ -44,22 +79,21 @@ export const Monaco = ({ noSyntaxValidation: true, }); - import("monaco-themes/themes/Tomorrow-Night.json").then((data: any) => { - monaco.editor.defineTheme("tmnight", data); - monaco.editor.setTheme("tmnight"); - }); + applyTheme(monaco, dark); if (onDiffEditorMounted) { onDiffEditorMounted(editor, monaco); } }; + const onMount: OnMount = (editor, monaco) => { // disable TS incorrect diagnostic monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: true, }); - // theme command + + // Theme command editor.addAction({ id: "change-theme-dark", label: "Dark Theme", @@ -71,7 +105,7 @@ export const Monaco = ({ ], contextMenuOrder: -1, run: function () { - monaco.editor.setTheme("tmnight"); + applyTheme(monaco, dark); }, }); @@ -86,24 +120,17 @@ export const Monaco = ({ ], contextMenuOrder: -1, run: function () { - monaco.editor.setTheme("dawn"); + applyTheme(monaco, light); }, }); - // - import("monaco-themes/themes/Tomorrow-Night.json").then((data: any) => { - monaco.editor.defineTheme("tmnight", data); - monaco.editor.setTheme("tmnight"); - }); - - import("monaco-themes/themes/Dawn.json").then((data: any) => { - monaco.editor.defineTheme("dawn", data); - }); + applyTheme(monaco, dark); if (onEditorMounted) { onEditorMounted(editor, monaco); } }; + if (mode === "diff") { return ( { const nav = useNavigate(); - const { pinned } = useContext(AppContext); + const { pinned } = useAppContext(); const [showAll, setShowAll] = useState(false); return ( - - DEVTOOLS-X + + + Devtools-X diff --git a/src/Contexts/AppContextProvider.tsx b/src/Contexts/AppContextProvider.tsx index d05706e..18b78a8 100644 --- a/src/Contexts/AppContextProvider.tsx +++ b/src/Contexts/AppContextProvider.tsx @@ -1,27 +1,49 @@ -import { createContext, useState } from "react"; +import { createContext, useContext, useState } from "react"; import { type ElementType } from "react"; +export type AppConfigType = { + editorThemeDark: string; + editorThemeLight: string; +}; + type AppContextType = { pinned: string[]; handleState: (newPin: string[]) => void; + config: AppConfigType; + handleConfig: (newConfig: Partial) => void; }; +export const defaultConfig = { + editorThemeDark: "tomorrow-night", + editorThemeLight: "dawn", +}; const init = { pinned: [], handleState: () => {}, + config: defaultConfig, + handleConfig: () => {}, }; export const AppContext = createContext(init); export const AppContextProvider: ElementType = ({ children }) => { const [pinned, setPinned] = useState([]); + const [config, setConfig] = useState(defaultConfig); const handleState = (newPins: string[]) => { setPinned(newPins); }; + + const handleConfig = (newConfig: Partial) => { + setConfig({ ...config, ...newConfig } as AppConfigType); + }; return ( - + {children} ); }; + +export const useAppContext = () => { + return useContext(AppContext); +}; diff --git a/src/Features/colors/ColorHarmonies.tsx b/src/Features/colors/ColorHarmonies.tsx new file mode 100644 index 0000000..59809b0 --- /dev/null +++ b/src/Features/colors/ColorHarmonies.tsx @@ -0,0 +1,75 @@ +import { Group, Stack, Text } from "@mantine/core"; +import { useEffect, useState } from "react"; +import CustomPicker from "./CustomPicker"; +import { clipboard } from "@tauri-apps/api"; + +import { + analogous, + complementary, + compound, + doubleSplitComplementary, + monochromatic, + splitComplementary, + square, + triadic, +} from "./harmonies"; +import { RenderShades } from "./RenderShades"; +import { notifications } from "@mantine/notifications"; +import { useColorRandomizer } from "./hooks"; + +const ColorHarmonies = () => { + const [color, setColor] = useColorRandomizer(); + + const copy = async (color: string) => { + await clipboard.writeText(color.startsWith("#") ? color : `#${color}`); + notifications.show({ + message: `Copied ${color} to clipboard`, + color: "blue", + autoClose: 1000, + }); + }; + + const [harmonies, setHarmonies] = useState({}); + + useEffect(() => { + setHarmonies({ + analogous: analogous(color), + monochromatic: monochromatic(color), + triadic: triadic(color), + complementary: complementary(color), + splitComplementary: splitComplementary(color), + doubleSplitComplementary: doubleSplitComplementary(color), + square: square(color), + compound: compound(color), + }); + }, [color]); + + return ( + + setColor(newColor.hex)} + /> + + + + Harmonies + + + + {Object.keys(harmonies).map((key) => ( + + ))} + + ); +}; + +export default ColorHarmonies; diff --git a/src/Features/colors/ColorTesting.tsx b/src/Features/colors/ColorTesting.tsx new file mode 100644 index 0000000..d5481ac --- /dev/null +++ b/src/Features/colors/ColorTesting.tsx @@ -0,0 +1,134 @@ +import classes from "./styles.module.css"; + +import { Group, Stack, Text } from "@mantine/core"; +import { Fragment, useMemo } from "react"; +import CustomPicker from "./CustomPicker"; +import { clipboard } from "@tauri-apps/api"; +import { + checkContrast, + formatRatio, + meetsMinimumRequirements, +} from "./contrast"; + +import { RenderShades } from "./RenderShades"; +import { blindnessStats, simulateColorBlindness } from "./blindness"; +import { useColorRandomizer } from "./hooks"; + +const Colors = () => { + const [color, setColor] = useColorRandomizer(); + + const copy = async (color: string) => { + await clipboard.writeText(color.startsWith("#") ? color : `#${color}`); + }; + + const stats = useMemo( + () => + blindnessStats.map((info) => ({ + data: simulateColorBlindness(color, info.name), + info, + })), + [color] + ); + + return ( + + setColor(color.hex)} + /> + + + +
+ {stats.map(({ data, info }) => ( +
+ + {info.name} + + {/* tag */} + = 90 ? "green" : "red", + color: "white", + fontWeight: "bold", + padding: "0 5px", + borderRadius: "5px", + marginLeft: "auto", + fontSize: "0.8rem", + }} + > + {data.percentage.toFixed(2)}% Similar + + + + +
+ ))} +
+
+ + + + + +
+
+ ); +}; + +export const ContrastContent = ({ + color, + background = "#000000", + toggle, +}: { + toggle?: () => void; + background: string; + color: string; +}) => { + const contrast = checkContrast(color, background); + const result = meetsMinimumRequirements(contrast); + + return ( +
+ WCAG contrast +
+ (toggle ? toggle() : null)} + className={`${classes.box} ${background === "#ffffff" ? "dark" : "light"}`} + style={{ color: color, background }} + > + Aa + + {formatRatio(contrast)} +
+
+ {result.map(({ level, label, pass }) => ( + +
+ {label} +
+
{level}
+
+ ))} +
+
+ ); +}; + +export default Colors; diff --git a/src/Features/colors/Colors.tsx b/src/Features/colors/Colors.tsx index 9e7b28c..287fabd 100644 --- a/src/Features/colors/Colors.tsx +++ b/src/Features/colors/Colors.tsx @@ -9,32 +9,33 @@ import { Tooltip, useMantineColorScheme, } from "@mantine/core"; -import cc from "color-convert"; import { useState } from "react"; -import { RgbaColor, RgbaColorPicker } from "react-colorful"; +import CustomPicker from "./CustomPicker"; import { BsMoon, BsSun } from "react-icons/bs"; import { OutputBox } from "../../Components/OutputBox"; import { clipboard } from "@tauri-apps/api"; +import { + Convert, + getInterpolateShades, + hex2cmyk, + interpolateColor, + renderCmyk, + renderHsl, +} from "./utilities"; + +import { RenderShades } from "./RenderShades"; +import { useColorRandomizer } from "./hooks"; const Colors = () => { + const [color, setColor] = useColorRandomizer(); const { colorScheme, toggleColorScheme } = useMantineColorScheme(); - // maintain 20 history Colors - const [history, setHistory] = useState( - Array(20).fill({ - r: 0, - g: 0, - b: 0, - a: 0, - }) as RgbaColor[] - ); - const [color, setColor] = useState({ - r: 34, - g: 135, - b: 199, - a: 0.5, + const [config] = useState({ + steps: 15, + harmoniesOpen: false, }); + const [history, setHistory] = useState(Array(20).fill("#000000")); const onCopy = () => { // fill one color in history @@ -46,86 +47,114 @@ const Colors = () => { }); }; + const copy = async (color: string) => { + await clipboard.writeText(color.startsWith("#") ? color : `#${color}`); + setHistory((prev) => { + let newHistory = [...prev]; + newHistory.pop(); + newHistory.unshift(color); + return newHistory; + }); + }; + + const [l, c, h] = new Convert().hex2lch(color); + + // Generate shades, tints, tones, hues, and temperatures + const shades = interpolateColor([l, c, h], "l", config.steps, 1); // towards black + + const tints = getInterpolateShades(color, "#ffffff", config.steps); // towards white + const tones = getInterpolateShades(color, "#808080", config.steps); // towards grey + + const after = interpolateColor([l, c, h], "h", config.steps / 2, h + 90); // rotating the hue + const before = interpolateColor( + [l, c, h], + "h", + config.steps / 2, + h - 90 + ).reverse(); // rotating the hue + const hues = before.concat(after.slice(1)); + + const temperaturesCool = interpolateColor([l, c, h], "h", config.steps, 240); + const temperaturesWarm = interpolateColor([l, c, h], "h", config.steps, 60); + const temperatures = h > 180 ? temperaturesCool : temperaturesWarm; + + const conv = new Convert(); + const hsv = Object.values(conv.hex2hsv(color)) + .map((v) => v.toFixed()) + .join(", "); + return ( - - { - setColor(e); - }} + + setColor(color.hex)} /> - + + - v.toFixed()) + .join(", ")} onCopy={onCopy} /> + + - - - } - offLabel={} - size={"lg"} - onChange={() => toggleColorScheme()} - /> - - History - - - - {history.map((color, i) => ( - - { - clipboard.writeText( - `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})` - ); - }} - style={{ - backgroundColor: `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`, - }} - > - - ))} - + + + + + + + + + + + } + offLabel={} + size={"lg"} + onChange={() => toggleColorScheme()} + /> + + History + + + + {history.map((color, i) => ( + + { + await clipboard.writeText( + color.startsWith("#") ? color : `#${color}` + ); + }} + style={{ + backgroundColor: color, + }} + > + + ))} + + ); }; diff --git a/src/Features/colors/CustomPicker.tsx b/src/Features/colors/CustomPicker.tsx new file mode 100644 index 0000000..a29aa16 --- /dev/null +++ b/src/Features/colors/CustomPicker.tsx @@ -0,0 +1,252 @@ +import React, { MouseEvent } from "react"; +import { ColorResult, CustomPicker, HSLColor } from "react-color"; +import { + Saturation, + EditableInput, + Hue, +} from "react-color/lib/components/common"; +import { Convert } from "./utilities"; +import tinycolor2 from "tinycolor2"; + +const inputStyles = { + input: { + border: "1px solid black", + padding: "10px", + fontSize: "15px", + // color: "#000", + }, +}; +const inlineStyles = { + container: { + display: "flex", + flexDirection: "column", + height: "auto", + width: "95%", + textAlign: "center", + justifyContent: "center", + // margin: "auto" + }, + pointer: { + transform: "translate(-50%, -50%)", + width: "20px", + height: "20px", + borderRadius: "50%", + backgroundColor: "rgb(248, 248, 248)", + boxShadow: "0 1px 4px 0 rgba(0, 0, 0, 0.37)", + cursor: "pointer", + }, + slider: { + width: "8px", + borderRadius: "1px", + height: "20px", + boxShadow: "0 0 2px rgba(0, 0, 0, .6)", + background: "#fff", + transform: "translateX(-2px)", + }, + saturation: { + width: "100%", + paddingBottom: "15%", + position: "relative", + overflow: "hidden", + cursor: "pointer", + }, + swatchCircle: { + minWidth: 20, + minHeight: 20, + margin: "1px 2px", + cursor: "pointer", + background: "red", + zIndex: 9, + boxShadow: "0 0 2px rgba(0, 0, 0, .6)", + borderRadius: "50%", + }, +}; + +const onPointerMouseDown = (event: MouseEvent) => { + const pointer = document.querySelector(".custom-pointer") as HTMLDivElement; + const pointerContainer = pointer?.parentElement as HTMLDivElement; + if (pointerContainer) { + pointerContainer.style.top = event.clientY + "px"; + pointerContainer.style.left = event.clientX + "px"; + } +}; + +const CustomSlider = () => { + return
; +}; + +const CustomPointer = () => { + return
; +}; + +interface Props { + colors?: string[]; + hexCode: string; + onChange: (color: ColorResult) => void; +} +interface State { + hsl: { h: number; s: number; l: number }; + hsv: { h: number; s: number; v: number }; + hex: string; +} + +// Todo Convert to functional component +// The docs of react-color aren't that good, but the picker is more customizable than the previous one +class CustomColorPicker extends React.Component { + convert = new Convert(); + + constructor(props: Props) { + super(props); + this.state = { + hsl: { + h: 0, + s: 0, + l: 0, + }, + hsv: { + h: 0, + s: 0, + v: 0, + }, + hex: "aaaaaa", + }; + } + + guard = (num: number) => { + if (isNaN(num)) return 0; + return num > 255 ? 255 : num < 0 ? 0 : num; + }; + + componentDidMount() { + const color = tinycolor2(this.props.hexCode); + this.setState({ + hsv: color.toHsv(), + hsl: color.toHsl(), + hex: color.toHex(), + }); + } + + componentWillReceiveProps(nextProps: Props) { + if (nextProps.hexCode !== this.state.hex) { + const color = tinycolor2(nextProps.hexCode); + this.setState({ + hsv: color.toHsv(), + hsl: color.toHsl(), + hex: color.toHex(), + }); + } + } + + handleHueChange = (hue: HSLColor) => { + const color = tinycolor2(hue); + const newState = { + hsl: hue, + hsv: color.toHsv(), + hex: color.toHex(), + rgb: color.toRgb(), + }; + this.setState(newState); + this.props.onChange(newState); + }; + + handleSaturationChange = (hsv: ColorResult) => { + const color = tinycolor2(hsv as any); + + this.props.onChange(hsv); + const input = document.body.getElementsByTagName("input"); + if (input[5]) { + input[5].value = color.toHex(); + } + this.setState({ + hsl: color.toHsl(), + }); + }; + + displayColorSwatches = (colors: string[]) => { + return colors.map((color) => { + return ( +
this.props.onChange(color as any)} + key={color} + style={{ ...inlineStyles.swatchCircle, backgroundColor: color }} + /> + ); + }); + }; + + render() { + return ( +
+
+ +
+
+ +
+
+ + Hex + + + {/* // todo Will add more input methods */} +
+ {this.props.colors?.length && ( +
+ {this.displayColorSwatches(this.props.colors!)} +
+ )} +
+ ); + } +} + +// @ts-ignore +export default CustomPicker(CustomColorPicker); diff --git a/src/Features/colors/RenderShades.tsx b/src/Features/colors/RenderShades.tsx new file mode 100644 index 0000000..0211023 --- /dev/null +++ b/src/Features/colors/RenderShades.tsx @@ -0,0 +1,54 @@ +import { Text } from "@mantine/core"; + +import { canBeWhite } from "./contrast"; + +import classes from "./styles.module.css"; + +export const RenderShades = ({ + colors, + setColor, + label, +}: { + colors: string[]; + setColor: (color: string) => void; + label: string; +}) => ( +
+ + {label} + +
+ {colors.map((color, i) => ( +
{ + setColor(color); + }} + className={classes.shades__box} + style={{ + backgroundColor: color, + color: canBeWhite(color) ? "white" : "black", + }} + > + {color.toUpperCase()} +
+ ))} +
+
+); diff --git a/src/Features/colors/blindness.ts b/src/Features/colors/blindness.ts new file mode 100644 index 0000000..0c5433b --- /dev/null +++ b/src/Features/colors/blindness.ts @@ -0,0 +1,158 @@ +import { Convert } from "./utilities"; + +const hexToRgb = (hex: string) => new Convert().hex2rgb(hex); +const rgbToHex = (r: number, g: number, b: number) => + new Convert().rgb2hex(r, g, b); + +export const simulateColorBlindness = ( + color: string, + blindnessType: string +) => { + const [r, g, b] = hexToRgb(color) ?? [0, 0, 0]; + + let perceivedColor = [r, g, b]; + + if (blindnessType === "Protanopia") { + perceivedColor = [ + 0.567 * r + 0.433 * g + 0.0 * b, + 0.558 * r + 0.442 * g + 0.0 * b, + 0.242 * r - 0.1055 * g + 1.053 * b, + ]; + } + + if (blindnessType === "Deuteranopia") { + perceivedColor = [ + 0.625 * r + 0.375 * g + 0.0 * b, + 0.7 * r + 0.3 * g + 0.0 * b, + 0.15 * r + 0.1 * g + 0.75 * b, + ]; + } + + if (blindnessType === "Tritanopia") { + perceivedColor = [ + 0.95 * r + 0.05 * g + 0.0 * b, + 0.433 * r + 0.567 * g + 0.0 * b, + 0.475 * r + 0.475 * g + 0.05 * b, + ]; + } + + if (blindnessType === "Protanomaly") { + perceivedColor = [ + 0.817 * r + 0.183 * g + 0.0 * b, + 0.333 * r + 0.667 * g + 0.0 * b, + 0.0 * r + 0.125 * g + 0.875 * b, + ]; + } + + if (blindnessType === "Deuteranomaly") { + perceivedColor = [ + 0.8 * r + 0.2 * g + 0.0 * b, + 0.258 * r + 0.742 * g + 0.0 * b, + 0.0 * r + 0.142 * g + 0.858 * b, + ]; + } + + if (blindnessType === "Tritanomaly") { + perceivedColor = [ + 0.967 * r + 0.033 * g + 0.0 * b, + 0.0 * r + 0.733 * g + 0.267 * b, + 0.0 * r + 0.183 * g + 0.817 * b, + ]; + } + + if (blindnessType === "Achromatopsia") { + perceivedColor = [ + 0.299 * r + 0.587 * g + 0.114 * b, + 0.299 * r + 0.587 * g + 0.114 * b, + 0.299 * r + 0.587 * g + 0.114 * b, + ]; + } + + if (blindnessType === "Achromatomaly") { + perceivedColor = [ + 0.618 * r + 0.32 * g + 0.062 * b, + 0.163 * r + 0.775 * g + 0.062 * b, + 0.163 * r + 0.32 * g + 0.516 * b, + ]; + } + + if (blindnessType === "Achromatopsia") { + perceivedColor = [ + 0.299 * r + 0.587 * g + 0.114 * b, + 0.299 * r + 0.587 * g + 0.114 * b, + 0.299 * r + 0.587 * g + 0.114 * b, + ]; + } + + const simulation = rgbToHex( + Math.round(perceivedColor[0]), + Math.round(perceivedColor[1]), + Math.round(perceivedColor[2]) + ); + + return { + simulation, + percentage: colorSimilarityPercentage(color, simulation), + }; +}; + +const colorSimilarityPercentage = (color1: string, color2: string): number => { + const [r1, g1, b1] = hexToRgb(color1) ?? [0, 0, 0]; + const [r2, g2, b2] = hexToRgb(color2) ?? [0, 0, 0]; + + // Compute Euclidean distance + const distance = Math.sqrt( + Math.pow(r2 - r1, 2) + Math.pow(g2 - g1, 2) + Math.pow(b2 - b1, 2) + ); + + // Max possible distance in RGB space is sqrt(3 * 255^2) + const maxDistance = Math.sqrt(3 * Math.pow(255, 2)); + + // Calculate similarity as a percentage + const similarity = 1 - distance / maxDistance; + + return similarity * 100; // Returns similarity percentage +}; + +export const blindnessStats = [ + { + name: "Deuteranomaly", + description: "5.0% of men, 0.35% of women", + info: "Green-weak type of color blindness. Greens are more muted, and reds may be confused with greens.", + }, + { + name: "Protanopia", + description: "1.3% of men, 0.02% of women", + info: "Red-green color blindness, with a leaning towards difficulties distinguishing red hues.", + }, + { + name: "Protanomaly", + description: "1.3% of men, 0.02% of women", + info: "Reduced sensitivity to red light causing reds to be less bright.", + }, + { + name: "Deuteranopia", + description: "1.2% of men, 0.01% of women", + info: "Red-green color blindness, with a leaning towards difficulties distinguishing green hues.", + }, + { + name: "Tritanopia", + description: "0.001% of men, 0.03% of women", + info: "Blue-yellow color blindness. Blues appear greener and it can be hard to tell yellow and red from pink.", + }, + { + name: "Tritanomaly", + description: "0.0001% of men, 0.0001% of women", + info: "Reduced sensitivity to blue light causing blues to be less bright, and difficulties distinguishing between yellow and red from pink.", + }, + { + name: "Achromatomaly", + description: "0.003% of the population", + info: "Reduced sensitivity to light causing colors to appear less bright and less saturated.", + }, + { + name: "Achromatopsia", + description: "0.0001% of the population", + info: "Total color blindness. Colors are seen as completely neutral.", + }, +]; diff --git a/src/Features/colors/contrast.ts b/src/Features/colors/contrast.ts new file mode 100644 index 0000000..78aaa3c --- /dev/null +++ b/src/Features/colors/contrast.ts @@ -0,0 +1,89 @@ +const canBeWhite = (hex: string) => { + const ratio = checkContrast(hex, "#ffffff"); + return ratio >= 4.5; +}; + +function luminance(r: number, g: number, b: number) { + let [lumR, lumG, lumB] = [r, g, b].map((component) => { + let proportion = component / 255; + + return proportion <= 0.03928 + ? proportion / 12.92 + : Math.pow((proportion + 0.055) / 1.055, 2.4); + }); + + return 0.2126 * lumR + 0.7152 * lumG + 0.0722 * lumB; +} + +function contrastRatio(luminance1: number, luminance2: number) { + let lighterLum = Math.max(luminance1, luminance2); + let darkerLum = Math.min(luminance1, luminance2); + + return (lighterLum + 0.05) / (darkerLum + 0.05); +} + +/** + * Because color inputs format their values as hex strings (ex. + * #000000), we have to do a little parsing to extract the red, + * green, and blue components as numbers before calculating the + * luminance values and contrast ratio. + */ +function checkContrast(color1: any, color2: any) { + let [luminance1, luminance2] = [color1, color2].map((color) => { + /* Remove the leading hash sign if it exists */ + color = color.startsWith("#") ? color.slice(1) : color; + + let r = parseInt(color.slice(0, 2), 16); + let g = parseInt(color.slice(2, 4), 16); + let b = parseInt(color.slice(4, 6), 16); + + return luminance(r, g, b); + }); + + return contrastRatio(luminance1, luminance2); +} + +/** + * A utility to format ratios as nice, human-readable strings with + * up to two digits after the decimal point (ex. "4.3:1" or "17:1") + */ +function formatRatio(ratio: number) { + let ratioAsFloat = ratio.toFixed(2); + let isInteger = Number.isInteger(parseFloat(ratioAsFloat)); + return `${isInteger ? Math.floor(ratio) : ratioAsFloat}:1`; +} + +/** + * Determine whether the given contrast ratio meets WCAG + * requirements at any level (AA Large, AA, or AAA). In the return + * value, `isPass` is true if the ratio meets or exceeds the minimum + * of at least one level, and `maxLevel` is the strictest level that + * the ratio passes. + */ +const WCAG_MINIMUM_RATIOS = [ + ["AA Large", 3], + ["AA", 4.5], + ["AAA", 7], +]; + +const NameMapping = { + "AA Large": "Large text", + AA: "Small text", + AAA: "Graphics", +}; + +function meetsMinimumRequirements(ratio: number) { + let didPass = false; + let maxLevel = null; + + const checks = WCAG_MINIMUM_RATIOS.map(([level, minRatio]) => { + return { + level, + pass: ratio >= (minRatio as number), + label: NameMapping[level as keyof typeof NameMapping], + }; + }); + return checks; +} + +export { checkContrast, formatRatio, meetsMinimumRequirements, canBeWhite }; diff --git a/src/Features/colors/generator.ts b/src/Features/colors/generator.ts index 97abc16..2df2a83 100644 --- a/src/Features/colors/generator.ts +++ b/src/Features/colors/generator.ts @@ -31,23 +31,3 @@ export function generateColorsMap(color: string) { return { baseColorIndex, colors }; } - -export type MantineColorsTuple = readonly [ - string, - string, - string, - string, - string, - string, - string, - string, - string, - string, - ...string[], -]; - -export function generateColors(color: string) { - return generateColorsMap(color).colors.map((c) => - c.hex() - ) as unknown as MantineColorsTuple; -} diff --git a/src/Features/colors/harmonies.ts b/src/Features/colors/harmonies.ts new file mode 100644 index 0000000..630282e --- /dev/null +++ b/src/Features/colors/harmonies.ts @@ -0,0 +1,109 @@ +// Harmonies + +import { Convert } from "./utilities"; + +const cc = new Convert(); + +export function analogous(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const a1 = cc.lch2hex(l, c, (h + 30) % 360); + const a2 = cc.lch2hex(l, c, (h + 15) % 360); + const a3 = cc.lch2hex(l, c, (h - 15 + 360) % 360); + const a4 = cc.lch2hex(l, c, (h - 30 + 360) % 360); + + return [main, a1, a2, a3, a4]; +} + +export function monochromatic(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const m1 = cc.lch2hex(l * 0.4, c, h); // Reducing lightness for shades + const m2 = cc.lch2hex(l * 0.6, c * 0.75, h); // Reduced chroma and lightness + const m3 = cc.lch2hex(l * 0.8, c * 0.5, h); // Further reduction in chroma and lightness + const m4 = cc.lch2hex(l * 0.9, c * 0.25, h); // Minimal chroma and near full lightness + + return [main, m1, m2, m3, m4]; +} + +export function triadic(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const t1 = cc.lch2hex(l, c, (h + 120) % 360); + const t2 = cc.lch2hex(l, c * 0.75, (h + 240) % 360); + const t3 = cc.lch2hex(l * 0.6, c * 0.5, (h + 240) % 360); + const t4 = cc.lch2hex(l * 0.4, c * 0.25, (h + 240) % 360); + + return [main, t1, t2, t3, t4]; +} + +// Complementary Colors +export function complementary(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const c1 = cc.lch2hex(l, c, (h + 180) % 360); + const c2 = cc.lch2hex(l, c * 0.75, (h + 60) % 360); + const c3 = cc.lch2hex(l * 0.6, c * 0.5, (h + 60) % 360); + const c4 = cc.lch2hex(l * 0.4, c * 0.25, (h + 60) % 360); + + return [main, c1, c2, c3, c4]; +} + +export function directComplementary(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + return cc.lch2hex(l, c, (h + 180) % 360); +} +// Split Complementary Colors +export function splitComplementary(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const sc1 = cc.lch2hex(l, c, (h + 150) % 360); + const sc2 = cc.lch2hex(l, c, (h + 210) % 360); + const sc3 = cc.lch2hex(l * 0.6, c * 0.75, (h + 60) % 360); + const sc4 = cc.lch2hex(l * 0.4, c * 0.5, (h + 60) % 360); + + return [main, sc1, sc2, sc3, sc4]; +} + +export function doubleSplitComplementary(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const dsc1 = cc.lch2hex(l, c * 0.75, (h + 150) % 360); + const dsc2 = cc.lch2hex(l, c * 0.75, (h + 210) % 360); + const dsc3 = cc.lch2hex(l, c * 0.75, (h + 270) % 360); + const dsc4 = cc.lch2hex(l, c * 0.75, (h + 30) % 360); + + return [main, dsc1, dsc2, dsc3, dsc4]; +} + +// Square Colors +export function square(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const s1 = cc.lch2hex(l, c, (h + 90) % 360); + const s2 = cc.lch2hex(l, c, (h + 180) % 360); + const s3 = cc.lch2hex(l, c, (h + 270) % 360); + const s4 = cc.lch2hex(l, c, (h + 45) % 360); // Typically for a square, the hues should be 90 degrees apart, this seems like a special case. + + return [main, s1, s2, s3, s4]; +} + +// Compound Colors +export function compound(hex: string) { + const [l, c, h] = cc.hex2lch(hex); + + const main = cc.lch2hex(l, c, h); + const cp1 = cc.lch2hex(l, c * 0.75, (h + 30) % 360); + const cp2 = cc.lch2hex(l, c, (h + 150) % 360); + const cp3 = cc.lch2hex(l, c, (h + 210) % 360); + const cp4 = cc.lch2hex(l, c * 0.75, (h + 330) % 360); + + return [main, cp1, cp2, cp3, cp4]; +} diff --git a/src/Features/colors/hooks.ts b/src/Features/colors/hooks.ts new file mode 100644 index 0000000..2de64ff --- /dev/null +++ b/src/Features/colors/hooks.ts @@ -0,0 +1,29 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { getRandomColor } from "./utilities"; + +export const useColorRandomizer = (): [ + string, + React.Dispatch>, +] => { + const [color, setColor] = useState("#000000"); + + const randomize = useCallback(() => { + const randomColor = getRandomColor(); + setColor(randomColor); + }, []); + + useEffect(() => { + // Randomize the color on load + randomize(); + + window.addEventListener("keydown", (e) => { + if (e.code === "Space") randomize(); + }); + + return () => { + window.removeEventListener("keydown", () => {}); + }; + }, []); + + return [color, setColor]; +}; diff --git a/src/Features/colors/styles.module.css b/src/Features/colors/styles.module.css index 129b0c3..8f553e6 100644 --- a/src/Features/colors/styles.module.css +++ b/src/Features/colors/styles.module.css @@ -18,3 +18,97 @@ height: 24px; width: 24px; } +/* Color shades */ +.shades__container { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: row; + min-height: 45px; + width: 100%; +} + +.shades__box { + font-size: 0.7em; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + cursor: copy; +} +.shades__container :first-child { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} +.shades__container :last-child { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} +.shades__box span { + opacity: 0; + transition: opacity 0.3s; +} +.shades__box:hover span { + opacity: 1; +} +/* Wcag contrast box */ + +.wcagBox { + font-size: 14px; + display: flex; + flex-direction: column; + padding: 10px; + width: 40%; + border: 1px solid var(--mantine-color-gray-8); + border-radius: 5px; +} + + +.box { + display: flex; + margin-right: 0.5rem; + align-items: center; + padding: 0.125rem 0.5rem; + border: 2px; + text-align: center; + font-size: 1rem; + border-radius: 0.25rem; + + &.dark { + background-color: var(--mantine-color-gray-7); + border: 1px solid var(--mantine-color-gray-7); + } + &.light { + background-color: var(--mantine-color-gray-3); + border: 1px solid var(--mantine-color-gray-3); + } +} + +.grid { + margin-top: 10px; + display: grid; + gap: 0.5em; + grid-template-columns: repeat(2, 1fr); +} + +.ok { + color: #0ca678; +} + +.fail { + color: #ff5252; +} + +.colorPicker { + cursor: pointer !important; +} + + +/* Simulation */ +.simulation_heading { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/src/Features/colors/utilities.ts b/src/Features/colors/utilities.ts new file mode 100644 index 0000000..bc2bf65 --- /dev/null +++ b/src/Features/colors/utilities.ts @@ -0,0 +1,389 @@ +export const { + abs, + atan2, + cbrt, + cos, + exp, + floor, + max, + min, + PI, + pow, + sin, + sqrt, +} = Math; + +export const epsilon = pow(6, 3) / pow(29, 3); +export const kappa = pow(29, 3) / pow(3, 3); +export const precision = 100000000; +export const [wd50X, wd50Y, wd50Z] = [96.42, 100, 82.49]; + +// Degree and Radian conversion utilities +export const deg2rad = (degrees: number) => (degrees * PI) / 180; +export const rad2deg = (radians: number) => (radians * 180) / PI; +export const atan2d = (y: number, x: number) => rad2deg(atan2(y, x)); +export const cosd = (degrees: number) => cos(deg2rad(degrees)); +export const sind = (degrees: number) => sin(deg2rad(degrees)); + +const matrix = (params: number[], mats: any[]) => { + return mats.map((mat: any[]) => + mat.reduce( + (acc: number, value: number, index: string | number) => + acc + + (params[index as number] * precision * (value * precision)) / + precision / + precision, + 0 + ) + ); +}; + +const hexColorMatch = + /^#?(?:([a-f0-9])([a-f0-9])([a-f0-9])([a-f0-9])?|([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})?)$/i; +const fixFloat = (num: number) => parseFloat((num * 100).toFixed(1)); + +export class Convert { + hex2rgb = (hex: string) => { + // #{3,4,6,8} + const [, r, g, b, a, rr, gg, bb, aa] = hex.match(hexColorMatch) || []; + if (rr !== undefined || r !== undefined) { + const red = rr !== undefined ? parseInt(rr, 16) : parseInt(r + r, 16); + const green = gg !== undefined ? parseInt(gg, 16) : parseInt(g + g, 16); + const blue = bb !== undefined ? parseInt(bb, 16) : parseInt(b + b, 16); + const alpha = + aa !== undefined + ? parseInt(aa, 16) + : a !== undefined + ? parseInt(a + a, 16) + : 255; + return [red, green, blue, alpha].map((c) => (c * 100) / 255); + } + return [0, 0, 0, 1]; + // hex = hex.startsWith("#") ? hex.slice(1) : hex; + // if (hex.length === 3) { + // hex = Array.from(hex).reduce((str, x) => str + x + x, ""); // 123 -> 112233 + // } + // return hex + // .split(/([a-z0-9]{2,2})/) + // .filter(Boolean) + // .map((x) => parseInt(x, 16)); + // return `rgb${values.length == 4 ? "a" : ""}(${values.join(", ")})`; + }; + + hex2hsv = (hex: string) => { + const [h, s, l] = this.hex2hsl(hex) as [number, number, number]; + return this.hsl2hsv(h, s, l); + }; + + hex2hsl = (hex: string, asObj = false) => { + const [r, g, b] = this.hex2rgb(hex); + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h: number, + s: number, + l = (max + min) / 2; + + if (max === min) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + h = + max === r + ? (g - b) / d + (g < b ? 6 : 0) + : max === g + ? (b - r) / d + 2 + : (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + + return [Math.round(h), fixFloat(s), fixFloat(l)]; + }; + + hsl2Object = (hsl: number[]) => { + const [h, s, l] = hsl; + return { h, s, l }; + }; + + hex2lch = (hex: string) => { + const rgb = this.hex2rgb(hex); + if (!rgb) return [0, 0, 0]; + return this.rgb2lch(...(rgb as [number, number, number])); + }; + + rgb2hex = (r: number, g: number, b: number) => { + return `#${((1 << 24) + (Math.round((r * 255) / 100) << 16) + (Math.round((g * 255) / 100) << 8) + Math.round((b * 255) / 100)).toString(16).slice(1)}`; + }; + + rgb2lch = (r: number, g: number, b: number) => { + const [x, y, z] = this.rgb2xyz(r, g, b); + const [_l, _a, _b] = this.xyz2lab(x, y, z); + return this.lab2lch(_l, _a, _b); + }; + + rgb2xyz = (r: number, g: number, b: number) => { + const [lR, lB, lG] = [r, g, b].map((v) => + v > 4.045 ? Math.pow((v + 5.5) / 105.5, 2.4) * 100 : v / 12.92 + ); + return matrix( + [lR, lB, lG], + [ + [0.4124564, 0.3575761, 0.1804375], + [0.2126729, 0.7151522, 0.072175], + [0.0193339, 0.119192, 0.9503041], + ] + ); + }; + + hsv2hsl = (h: number, s: number, v: number) => { + const l = ((200 - s) * v) / 200; + s = l === 0 || l === 100 ? 0 : (s * v) / 100 / (l < 50 ? l : 100 - l); + return [h, s * 100, l / 2]; + }; + + hsl2hsv = (h: number, s: number, l: number) => { + const v = l + (s * Math.min(l, 100 - l)) / 100; + s = v === 0 ? 0 : 2 * (1 - l / v); + return { h, s: s * 100, v }; + }; + + hslToRgb = (h: number, s: number, l: number) => { + let r, g, b; + if (s == 0) { + r = g = b = l; // achromatic + } else { + const hue2rgb = function hue2rgb(p: number, q: number, t: number) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + let q = l < 0.5 ? l * (1 + s) : l + s - l * s; + let p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return [ + Math.min(255, Math.max(0, Math.round(r * 255))), + Math.min(255, Math.max(0, Math.round(g * 255))), + Math.min(255, Math.max(0, Math.round(b * 255))), + ]; + }; + + hsv2rgb = (h: number, s: number, v: number, a: number) => { + const rgbI = floor(h / 60); + // calculate rgb parts + const rgbF = (h / 60 - rgbI) & 1 ? h / 60 - rgbI : 1 - h / 60 - rgbI; + const rgbM = (v * (100 - s)) / 100; + const rgbN = (v * (100 - s * rgbF)) / 100; + const rgbA = a / 100; + + const [rgbR, rgbG, rgbB] = + rgbI === 5 + ? [v, rgbM, rgbN] + : rgbI === 4 + ? [rgbN, rgbM, v] + : rgbI === 3 + ? [rgbM, rgbN, v] + : rgbI === 2 + ? [rgbM, v, rgbN] + : rgbI === 1 + ? [rgbN, v, rgbM] + : [v, rgbN, rgbM]; + return [rgbR, rgbG, rgbB, rgbA]; + }; + + lch2hex = (l: number, c: number, h: number) => { + const [r, g, b] = this.lch2rgb(l, c, h); + return this.rgb2hex(r, g, b); + }; + + lch2rgb = (lchL: number, lchC: number, lchH: number) => { + const [labL, labA, labB] = this.lch2lab(lchL, lchC, lchH); + const [xyzX, xyzY, xyzZ] = this.lab2xyz(labL, labA, labB); + const [rgbR, rgbG, rgbB] = this.xyz2rgb(xyzX, xyzY, xyzZ); + return [rgbR, rgbG, rgbB]; + }; + + lch2lab = (lchL: number, lchC: number, lchH: number) => { + // convert to Lab a and b from the polar form + const [labA, labB] = [lchC * cosd(lchH), lchC * sind(lchH)]; + return [lchL, labA, labB]; + }; + + xyz2rgb = (xyzX: number, xyzY: number, xyzZ: number) => { + const [lrgbR, lrgbB, lrgbG] = matrix( + [xyzX, xyzY, xyzZ], + [ + [3.2404542, -1.5371385, -0.4985314], + [-0.969266, 1.8760108, 0.041556], + [0.0556434, -0.2040259, 1.0572252], + ] + ); + const [rgbR, rgbG, rgbB] = [lrgbR, lrgbB, lrgbG].map((v) => + v > 0.31308 ? 1.055 * pow(v / 100, 1 / 2.4) * 100 - 5.5 : 12.92 * v + ); + return [rgbR, rgbG, rgbB]; + }; + + xyz2lab = (x: number, y: number, z: number) => { + // calculate D50 XYZ from D65 XYZ + const [d50X, d50Y, d50Z] = matrix( + [x, y, z], + [ + [1.0478112, 0.0228866, -0.050127], + [0.0295424, 0.9904844, -0.0170491], + [-0.0092345, 0.0150436, 0.7521316], + ] + ); + // calculate f + const [f1, f2, f3] = [d50X / wd50X, d50Y / wd50Y, d50Z / wd50Z].map( + (value) => (value > epsilon ? cbrt(value) : (kappa * value + 16) / 116) + ); + return [116 * f2 - 16, 500 * (f1 - f2), 200 * (f2 - f3)]; + }; + + lab2xyz = (labL: number, labA: number, labB: number) => { + // compute f, starting with the luminance-related term + const f2 = (labL + 16) / 116; + const f1 = labA / 500 + f2; + const f3 = f2 - labB / 200; + // compute pre-scaled XYZ + const [initX, initY, initZ] = [ + pow(f1, 3) > epsilon ? pow(f1, 3) : (116 * f1 - 16) / kappa, + labL > kappa * epsilon ? pow((labL + 16) / 116, 3) : labL / kappa, + pow(f3, 3) > epsilon ? pow(f3, 3) : (116 * f3 - 16) / kappa, + ]; + const [xyzX, xyzY, xyzZ] = matrix( + // compute XYZ by scaling pre-scaled XYZ by reference white + [initX * wd50X, initY * wd50Y, initZ * wd50Z], + // calculate D65 XYZ from D50 XYZ + [ + [0.9555766, -0.0230393, 0.0631636], + [-0.0282895, 1.0099416, 0.0210077], + [0.0122982, -0.020483, 1.3299098], + ] + ); + return [xyzX, xyzY, xyzZ]; + }; + + lab2lch = (labL: number, labA: number, labB: number) => { + return [ + labL, + sqrt(pow(labA, 2) + pow(labB, 2)), // convert to chroma + rad2deg(atan2(labB, labA)), // convert to hue, in degrees + ]; + }; + + canBeWhite = (hex: string) => { + const [_h, _s, l] = this.hex2hsl(hex); + return l < 55; + }; +} + +const c = new Convert(); + +export const renderHsl = (hsl: number[]) => + `${hsl[0].toFixed()}, ${(hsl[1] * -1).toFixed()}%, ${(hsl[2] / 100).toFixed()}%`; + +export const hex2cmyk = (hex: string) => { + return ChromaJS(hex).cmyk(); +}; +export const renderCmyk = (cmyk: number[]) => + cmyk.map((v) => (v * 100).toFixed()).join(", "); + +import ChromaJS from "chroma-js"; + +const intLch2hex = (l: number, c: number, h: number) => + ChromaJS.lch(l, c, h).hex(); + +const interpolate = ( + start: number, + end: number, + step: number, + maxStep: number +) => { + let diff = end - start; + if (Math.abs(diff) > 180) { + diff = -(Math.sign(diff) * (360 - Math.abs(diff))); + } + return (start + (diff * step) / maxStep) % 360; +}; + +export const interpolateColor = ( + color: [number, number, number], + interpolateBy: "l" | "c" | "h", + steps: number, + endValue: number +): string[] => { + const [l, c, h] = color; + const interpolatedColors: string[] = []; + + for (let i = 0; i < steps; i++) { + let currentL = l, + currentC = c, + currentH = h; + + switch (interpolateBy) { + case "l": + currentL = interpolate(l, endValue, i, steps - 1); + break; + case "c": + currentC = interpolate(c, endValue, i, steps - 1); // Grey color + break; + case "h": + currentH = interpolate(h, endValue, i, steps - 1); + break; + } + + interpolatedColors.push(render_lch2hex(currentL, currentC, currentH)); + } + + return interpolatedColors; +}; + +const render_lch2hex = (l: number, c: number, h: number) => + ChromaJS.lch(l, c, h).hex(); + +export const interpolateTwoColors = ( + c1: [number, number, number], + c2: [number, number, number], + steps: number +) => { + let interpolatedColorArray = []; + + for (let i = 0; i < steps; i++) { + interpolatedColorArray.push( + render_lch2hex( + interpolate(c1[0], c2[0], i, steps - 1), // interpolate lightness + interpolate(c1[1], c2[1], i, steps - 1), // interpolate chroma + interpolate(c1[2], c2[2], i, steps - 1) // interpolate hue + ) + ); + } + + return interpolatedColorArray; +}; + +export const getInterpolateShades = ( + startColor: string, + endColor: string, + shades: number +) => { + const [l1, c1, h1] = c.hex2lch(startColor) ?? [0, 0, 0]; + const [l2, c2, h2] = c.hex2lch(endColor) ?? [0, 0, 0]; + + return interpolateTwoColors([l1, c1, h1], [l2, c2, h2], shades); +}; + +export const getRandomColor = () => { + const [r, g, b] = Array.from({ length: 3 }, () => + Math.floor(Math.random() * 256) + ); + const randomColor = new Convert().rgb2hex(r, g, b); + return randomColor; +}; diff --git a/src/Features/ids/Ids.tsx b/src/Features/ids/Ids.tsx index d61449e..1690d69 100644 --- a/src/Features/ids/Ids.tsx +++ b/src/Features/ids/Ids.tsx @@ -8,33 +8,43 @@ import { Textarea, } from "@mantine/core"; import { useInputState } from "@mantine/hooks"; -import { useState } from "react"; -import { v1, v3, v4, v5 } from "uuid"; +import { useCallback, useEffect, useState } from "react"; +import { v4 } from "uuid"; +import { nanoid, customAlphabet } from "nanoid"; import { Copy } from "../../Components/Copy"; -type Versions = "v1" | "v3" | "v4" | "v5"; +type Generator = "v4" | "nanoid" | "custom"; export default function Ids() { const [ids, setIds] = useState([]); const [count, setCount] = useInputState(5); - const [version, setVersion] = useInputState("v4"); + const [generator, setGenerator] = useInputState("v4"); + const [custom, setCustom] = useInputState<{ + alphabet: string; + length: number; + }>({ + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", + length: 16, + }); - const generateIds = () => { - switch (version) { - case "v1": - // setIds(Array.from({ length: count }, () => v1())); - break; - case "v3": - // setIds(Array.from({ length: count }, () => v3())); - break; - case "v4": - setIds(Array.from({ length: count }, () => v4())); - break; - case "v5": - // setIds(Array.from({ length: count }, () => v5())); - break; - } - }; + const generateIds = useCallback(() => { + const newIds = Array.from({ length: count }, () => { + switch (generator) { + case "v4": + return v4(); + case "nanoid": + return nanoid(); + case "custom": + return customAlphabet(custom.alphabet)(custom.length); + } + }); + setIds(newIds); + }, [count, generator, custom.length, custom.alphabet]); + + // Set initial IDs + useEffect(() => { + generateIds(); + }, [generator]); return ( @@ -46,14 +56,26 @@ export default function Ids() { onChange={(e) => setCount(Number(e))} /> setAlgorithm(e as string)} + /> + + + Payload: Data + { value={payload} options={{ minimap: { enabled: false }, + lineNumbers: "off", + }} + /> + + Verify Signature + +
+ +
+ + Secret + +