├── public ├── robots.txt ├── favicon.ico ├── manifest.json └── index.html ├── jsconfig.json ├── src ├── hooks │ ├── useDocumentTitle.js │ ├── useAnalyticsPageView.js │ ├── useDebounce.js │ ├── useClickOutside.js │ ├── useKeyboardQuery.js │ ├── useLocalStorageState.js │ ├── useRouter.js │ ├── useHotKeys.js │ ├── useExpiresArray.js │ └── useColorRouteOnMount.js ├── icons │ ├── moon.svg │ ├── x.svg │ ├── maximize.svg │ ├── user.svg │ ├── info.svg │ ├── menu.svg │ ├── link.svg │ ├── shuffle.svg │ ├── harm-comp.svg │ ├── github.svg │ ├── harm-tri.svg │ ├── harm-anl.svg │ ├── harm-mono.svg │ ├── harm-spt.svg │ ├── sun.svg │ └── harm-tet.svg ├── App.test.js ├── components │ ├── common │ │ ├── ButtonRow.js │ │ ├── ContiguousInputs.js │ │ ├── IconButton.js │ │ ├── Input.js │ │ ├── Button.js │ │ ├── OverlayBox.js │ │ ├── HideAfterDelay.js │ │ ├── HectoInput.js │ │ ├── ByteInput.js │ │ ├── DegreeInput.js │ │ ├── ColorInputRange.js │ │ ├── FullScreenModal.js │ │ └── HexInput.js │ ├── Header.js │ ├── RandomizeControl.js │ ├── ThemeControl.js │ ├── ErrorBoundary.js │ ├── Footer.js │ ├── HexField.js │ ├── ValueInputs.js │ ├── UserNotify.js │ ├── HslFields.js │ ├── RgbFields.js │ ├── Menu.js │ ├── HotKeys.js │ ├── ValuesDisplay.js │ ├── HarmonyToggle.js │ ├── ColorAdjustControls.js │ ├── ColorDisplay.js │ ├── ValueSliders.js │ ├── HarmonyDisplay.js │ ├── About.js │ └── AboutModal.old.js ├── regexDefs.js ├── index.js ├── styles │ ├── breakpoints.js │ ├── global.js │ └── themes.js ├── colorUtils.js ├── config.js ├── contexts │ ├── preferencesContext.js │ └── baseColorContext.js ├── helpers.js ├── App.js ├── colorConvertAlternate.js └── colorConverter.js ├── CHANGELOG.md ├── README.md ├── .gitignore ├── netlify.toml ├── LICENSE └── package.json /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/murbar/web-color-tool/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | }, 5 | "include": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useDocumentTitle.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const useDocumentTitle = title => { 4 | useEffect(() => { 5 | document.title = title; 6 | }, [title]); 7 | }; 8 | 9 | export default useDocumentTitle; 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v2020.2.15 2 | 3 | - parse and display hsl/rgb/hex colors from URL 4 | - update route when active color changes 5 | 6 | # v2019.8.10 7 | 8 | - fix HSL conversion issues when minimizing/maximizing saturation and luminance values 9 | -------------------------------------------------------------------------------- /src/icons/moon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/common/ButtonRow.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Styles = styled.div` 5 | margin-top: 3rem; 6 | pointer-events: auto; 7 | `; 8 | 9 | export default function ButtonRow({ children }) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /src/icons/maximize.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/common/ContiguousInputs.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | display: inline-block; 5 | background: ${p => p.theme.inputColor}; 6 | border-radius: 0.3em; 7 | &:focus { 8 | box-shadow: 0 0 0 0.2rem ${p => p.theme.textColor}; 9 | } 10 | input { 11 | background: transparent; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Color Tool", 3 | "name": "Web Color Tool", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#111111", 14 | "background_color": "#efefef" 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useAnalyticsPageView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactGA from 'react-ga'; 3 | import config from 'config'; 4 | 5 | const useAnalyticsPageView = location => { 6 | React.useEffect(() => { 7 | if (config.env === 'production') { 8 | ReactGA.pageview(location.pathname + location.search); 9 | } 10 | }, [location]); 11 | }; 12 | 13 | export default useAnalyticsPageView; 14 | -------------------------------------------------------------------------------- /src/icons/shuffle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function useDebounce(value, delay) { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => clearTimeout(handler); 12 | }, [value, delay]); 13 | 14 | return debouncedValue; 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A color tool for web developers - explore and convert 2 | 3 | Pick a base color, tweak it until just right, explore harmonies, and copy the CSS values for use in your projects. 4 | 5 | Please share your feedback - let me know if this tool is useful for you, or if you find any bugs. I'd like to add additional functionality in the future. What features would you like to see? 6 | 7 | Built with React. 8 | 9 | https://color.joelb.dev/ 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .netlify 26 | 27 | /assets 28 | -------------------------------------------------------------------------------- /src/icons/harm-comp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from="https://jb-color-tool.netlify.com/*" 3 | to="https://color.joelb.dev/:splat" 4 | status=301 5 | force = true 6 | 7 | [[redirects]] 8 | from="/*" 9 | to="index.html" 10 | status=200 11 | 12 | [build] 13 | # base = "" 14 | publish = "build/" 15 | command = "yarn build" 16 | # functions = "functions/" 17 | 18 | [[headers]] 19 | for = "static/*" 20 | [headers.values] 21 | cache-control = ''' 22 | public, 23 | max-age=31536000''' -------------------------------------------------------------------------------- /src/regexDefs.js: -------------------------------------------------------------------------------- 1 | // 0..100 with leading zeros 2 | export const hectoMatch = /^0*(?:[0-9][0-9]?|100)$/; 3 | 4 | // 0..360 with leading zeros 5 | export const degreeMatch = /^(0?[0-9]?[0-9]|[1-2][0-9][0-9]|3[0-5][0-9]|360)$/; 6 | 7 | // 000..255 8 | export const byteMatch = /^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$/; 9 | 10 | // 000..FFF or 000000..FFFFFF 11 | export const hexColorMatch = /^([0-9a-f]{3}|[0-9a-f]{6})$/i; 12 | 13 | // 0..FFFFFF 14 | export const hexCharsMatch = /^[0-9a-f]{1,6}$/i; 15 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export default function useClickOutside(callback) { 4 | const ref = useRef(); 5 | 6 | useEffect(() => { 7 | const handleClickAway = e => { 8 | if (ref && !ref.current.contains(e.target)) { 9 | callback(e); 10 | } 11 | }; 12 | 13 | window.addEventListener('click', handleClickAway); 14 | return () => window.removeEventListener('click', handleClickAway); 15 | }, [callback]); 16 | 17 | return ref; 18 | } 19 | -------------------------------------------------------------------------------- /src/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/common/IconButton.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Button from 'components/common/Button'; 3 | 4 | const IconButton = styled(Button)` 5 | border: none; 6 | background: transparent; 7 | color: ${p => p.theme.textColor}; 8 | position: relative; 9 | width: 5rem; 10 | height: 5rem; 11 | border-radius: 50%; 12 | svg { 13 | height: 3rem; 14 | position: absolute; 15 | top: 1rem; 16 | left: 1rem; 17 | pointer-events: none; 18 | } 19 | `; 20 | 21 | export default IconButton; 22 | -------------------------------------------------------------------------------- /src/icons/harm-tri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/icons/harm-anl.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/icons/harm-mono.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/icons/harm-spt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/icons/sun.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/useKeyboardQuery.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function useKeyboardQuery(className) { 4 | useEffect(() => { 5 | const keyboardInUse = () => document.body.classList.add(className); 6 | const mouseInUse = () => document.body.classList.remove(className); 7 | 8 | document.body.addEventListener('keydown', keyboardInUse); 9 | document.body.addEventListener('mousedown', mouseInUse); 10 | return () => { 11 | document.body.removeEventListener('keydown', keyboardInUse); 12 | document.body.removeEventListener('mousedown', mouseInUse); 13 | }; 14 | }, [className]); 15 | } 16 | -------------------------------------------------------------------------------- /src/icons/harm-tet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Menu from 'components/Menu'; 4 | 5 | const Styles = styled.header` 6 | margin: 2rem 0; 7 | position: relative; 8 | padding-right: 20%; 9 | h1 { 10 | margin: 0; 11 | } 12 | h1 span { 13 | display: block; 14 | font-size: 0.6em; 15 | font-weight: normal; 16 | } 17 | `; 18 | 19 | const Header = () => { 20 | return ( 21 | 22 |

23 | Web color tool Convert RGB / HSL / Hex & explore harmonies 24 |

25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Header; 31 | -------------------------------------------------------------------------------- /src/components/RandomizeControl.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconButton from 'components/common/IconButton'; 3 | import { ReactComponent as Random } from 'icons/shuffle.svg'; 4 | import { recordGAEvent } from 'helpers'; 5 | import { useBaseColor } from 'contexts/baseColorContext'; 6 | 7 | const RandomizeControl = () => { 8 | const { randomizeBase } = useBaseColor(); 9 | 10 | return ( 11 | { 13 | randomizeBase(); 14 | recordGAEvent('User', 'Clicked', 'Menu - randomize color'); 15 | }} 16 | title="Randomize color values" 17 | > 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default RandomizeControl; 24 | -------------------------------------------------------------------------------- /src/components/common/Input.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | const errorState = css` 4 | box-shadow: 0 0 0 0.2rem inset rgba(200, 20, 60, 0.8), 5 | 0 0 0 0.2rem ${p => p.theme.textColor}; 6 | `; 7 | 8 | export default styled.input` 9 | background: ${p => p.theme.inputColor}; 10 | color: ${p => p.theme.textColor}; 11 | font-family: ${p => p.theme.fontFixed}; 12 | border: 0; 13 | font-size: 2rem; 14 | padding: 0.25em 0.5em 0.35em; 15 | margin: 0; 16 | text-align: right; 17 | border-radius: 0.3em; 18 | &:focus { 19 | outline: none; 20 | background: ${p => p.theme.textColor}; 21 | color: ${p => p.theme.backgroundColor}; 22 | } 23 | ${p => p.error && errorState} 24 | `; 25 | -------------------------------------------------------------------------------- /src/components/common/Button.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.button` 4 | margin-right: 0.5em; 5 | background: ${p => p.theme.textColor}; 6 | color: ${p => p.theme.backgroundColor}; 7 | font-family: 'Source Code Pro', monospace; 8 | border: 0; 9 | font-size: 1em; 10 | padding: 0.5em 0.85em; 11 | border-radius: 0.3em; 12 | &:focus { 13 | outline: none; 14 | } 15 | body.using-keyboard &:focus { 16 | outline: none; 17 | box-shadow: 0 0 0 0.2rem ${p => p.theme.textColor}; 18 | } 19 | &:hover { 20 | background: ${p => p.theme.buttonHoverColor}; 21 | color: ${p => p.theme.textColor}; 22 | cursor: pointer; 23 | } 24 | &:last-child { 25 | margin-right: 0; 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorageState.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export default function useLocalStorageState(key, initialValue = null) { 4 | const invalidKey = typeof key !== 'string' || key.length < 1; 5 | 6 | if (invalidKey) throw TypeError('Storage key must be a non-empty string.'); 7 | 8 | const [state, setState] = useState(() => { 9 | let value; 10 | try { 11 | value = JSON.parse(window.localStorage.getItem(key) || JSON.stringify(initialValue)); 12 | } catch (err) { 13 | value = initialValue; 14 | } 15 | return value; 16 | }); 17 | 18 | useEffect(() => { 19 | window.localStorage.setItem(key, JSON.stringify(state)); 20 | }, [state, key]); 21 | 22 | return [state, setState]; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/common/OverlayBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import breakpoints from 'styles/breakpoints'; 4 | 5 | const Styles = styled.div` 6 | background: ${p => p.theme.backgroundColor}; 7 | padding: 1rem; 8 | box-shadow: ${p => p.theme.hudShadow}; 9 | border-radius: 1rem; 10 | margin-bottom: 1rem; 11 | position: relative; 12 | pointer-events: auto; 13 | overflow: scroll; 14 | max-width: 100%; 15 | max-height: 100%; 16 | &:last-child { 17 | margin-bottom: 0; 18 | } 19 | h2 { 20 | margin: 0; 21 | } 22 | ${breakpoints.tablet(css` 23 | width: 60rem; 24 | `)} 25 | `; 26 | 27 | export default function OverlayBox({ children }) { 28 | return {children}; 29 | } 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import App from './App'; 5 | import { PreferencesProvider } from 'contexts/preferencesContext'; 6 | import { BaseColorProvider } from 'contexts/baseColorContext'; 7 | import { initializeGA, initializeSentry } from 'helpers'; 8 | 9 | initializeGA(); 10 | initializeSentry(); 11 | 12 | const Root = () => { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | ReactDOM.render(, document.getElementById('root')); 27 | -------------------------------------------------------------------------------- /src/hooks/useRouter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams, useLocation, useHistory, useRouteMatch } from 'react-router-dom'; 3 | import queryString from 'query-string'; 4 | 5 | export default function useRouter() { 6 | const params = useParams(); 7 | const location = useLocation(); 8 | const history = useHistory(); 9 | const match = useRouteMatch(); 10 | 11 | return React.useMemo(() => { 12 | return { 13 | push: history.push, 14 | replace: history.replace, 15 | pathname: location.pathname, 16 | query: { 17 | ...queryString.parse(location.search), // Convert string to object 18 | ...params 19 | }, 20 | match, 21 | matchRoute: useRouteMatch, 22 | location, 23 | history 24 | }; 25 | }, [history, location, match, params]); 26 | } 27 | -------------------------------------------------------------------------------- /src/styles/breakpoints.js: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components'; 2 | 3 | const sizes = { 4 | desktop: 992, 5 | tablet: 768, 6 | phone: 576 7 | }; 8 | 9 | // Iterate through the sizes and create a media template 10 | const breakpoints = Object.keys(sizes).reduce((acc, label) => { 11 | acc[label] = (...args) => css` 12 | @media (min-width: ${sizes[label]}px) { 13 | ${css(...args)} 14 | } 15 | `; 16 | return acc; 17 | }, {}); 18 | 19 | breakpoints.tall = (...args) => css` 20 | @media (min-height: 700px) { 21 | ${css(...args)} 22 | } 23 | `; 24 | 25 | breakpoints.below = Object.keys(sizes).reduce((acc, label) => { 26 | acc[label] = (...args) => css` 27 | @media (max-width: ${sizes[label]}px) { 28 | ${css(...args)} 29 | } 30 | `; 31 | return acc; 32 | }, {}); 33 | 34 | export default breakpoints; 35 | -------------------------------------------------------------------------------- /src/components/ThemeControl.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconButton from 'components/common/IconButton'; 3 | import { ReactComponent as Dark } from 'icons/moon.svg'; 4 | import { ReactComponent as Light } from 'icons/sun.svg'; 5 | import { recordGAEvent } from 'helpers'; 6 | import { usePreferences } from 'contexts/preferencesContext'; 7 | 8 | const ThemeControl = () => { 9 | const { 10 | preferences: { darkTheme }, 11 | toggleTheme 12 | } = usePreferences(); 13 | const title = `Toggle ${darkTheme ? 'light' : 'dark'} mode`; 14 | 15 | return ( 16 | { 18 | toggleTheme(); 19 | recordGAEvent('User', 'Clicked', 'Menu - toggle theme'); 20 | }} 21 | title={title} 22 | > 23 | {darkTheme ? : } 24 | 25 | ); 26 | }; 27 | 28 | export default ThemeControl; 29 | -------------------------------------------------------------------------------- /src/colorUtils.js: -------------------------------------------------------------------------------- 1 | import { hectoMatch, degreeMatch, byteMatch, hexColorMatch } from './regexDefs'; 2 | import { random8Bit } from 'helpers'; 3 | 4 | export const validHsl = (h, s, l) => 5 | degreeMatch.test(h) && hectoMatch.test(s) && hectoMatch.test(l); 6 | 7 | export const validRgb = (r, g, b) => 8 | byteMatch.test(r) && byteMatch.test(g) && byteMatch.test(b); 9 | 10 | export const validHex = hex => hexColorMatch.test(hex); 11 | 12 | export const randomRgbValues = () => [random8Bit(), random8Bit(), random8Bit()]; 13 | 14 | // https://www.w3.org/TR/AERT#color-contrast 15 | export const perceivedBrightness = (r, g, b) => (r * 299 + g * 587 + b * 114) / 1000; 16 | 17 | export const isBrighterThan = (r, g, b, x) => perceivedBrightness(r, g, b) > x; 18 | 19 | // 123 is arbitrary but apparently works well 20 | export const isBright = (r, g, b) => isBrighterThan(r, g, b, 123); 21 | 22 | export const hslTo4x = hslValues => hslValues.map(v => v * 4); 23 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Button from 'components/common/Button'; 4 | 5 | const ErrorDisplay = styled.div` 6 | width: 80%; 7 | margin: 20% auto 0; 8 | p { 9 | font-size: 1.5em; 10 | } 11 | `; 12 | 13 | export default class ErrorBoundary extends React.Component { 14 | state = { error: null, errorInfo: null }; 15 | 16 | componentDidCatch(error, errorInfo) { 17 | this.setState({ 18 | error: error, 19 | errorInfo: errorInfo 20 | }); 21 | } 22 | 23 | render() { 24 | if (this.state.errorInfo) { 25 | return ( 26 | 27 |

Dang! Something went wrong. :(

28 |

The nerds have been notified.

29 |

30 | 31 |

32 |
33 | ); 34 | } 35 | 36 | return this.props.children; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const version = '2020.2.15'; 2 | 3 | const localStorageKeys = { 4 | colors: `color-joeb-dev-colors-${version}`, 5 | baseColor: `color-joeb-dev-base-${version}`, 6 | theme: `color-joeb-dev-theme-${version}`, 7 | preferences: `color-joeb-dev-prefs-${version}` 8 | }; 9 | 10 | const initPreferences = { 11 | darkTheme: true 12 | }; 13 | 14 | const harmonyConstants = { 15 | CO: 'Complementary', 16 | MO: 'Monochromatic', 17 | AN: 'Analogous', 18 | SP: 'Split Complementary', 19 | TR: 'Triadic', 20 | TE: 'Tetradic' 21 | }; 22 | 23 | export default { 24 | version, 25 | publicURL: 'https://color.joelb.dev', 26 | pageTitle: 'Web color tool for developers | Convert RGB/HSL/Hex & explore harmonies', 27 | localStorageKeys, 28 | GAPropertyId: 'UA-140727716-1', 29 | sentryDsn: 'https://4ce61244b73c47a2806e2f9cefeaf925@sentry.io/1527263', 30 | initPreferences, 31 | env: process.env.NODE_ENV, 32 | transitionDurationMs: 400, 33 | hslScaleFactor: 4, 34 | harmonyConstants 35 | }; 36 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | Web Color Tool | Convert RGB/HSL/Hex & explore harmonies 15 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/contexts/preferencesContext.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useContext } from 'react'; 2 | import useLocalStorageState from 'hooks/useLocalStorageState'; 3 | import config from 'config'; 4 | 5 | const PreferencesContext = React.createContext(); 6 | 7 | const PreferencesProvider = ({ children }) => { 8 | const { localStorageKeys } = config; 9 | const [preferences, setPreferences] = useLocalStorageState( 10 | localStorageKeys.preferences, 11 | true 12 | ); 13 | 14 | const contextValue = useMemo(() => { 15 | const toggleTheme = () => 16 | setPreferences(prev => { 17 | return { ...prev, darkTheme: !prev.darkTheme }; 18 | }); 19 | 20 | return { preferences, toggleTheme }; 21 | }, [preferences, setPreferences]); 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | const usePreferences = () => useContext(PreferencesContext); 31 | 32 | export { PreferencesProvider, usePreferences }; 33 | -------------------------------------------------------------------------------- /src/hooks/useHotKeys.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export default function useHotKeys(keyHandlerMap) { 4 | // TODO verify map is string and functions 5 | 6 | const targets = Object.keys(keyHandlerMap); 7 | const keydown = useRef(false); 8 | 9 | useEffect(() => { 10 | const downHandler = e => { 11 | const { key } = e; 12 | // check for long press 13 | if (keydown.current) return; 14 | 15 | if (targets.includes(key)) { 16 | keydown.current = true; 17 | const callback = keyHandlerMap[key]; 18 | callback(e); 19 | } 20 | }; 21 | 22 | const upHandler = ({ key }) => { 23 | if (targets.includes(key)) { 24 | keydown.current = false; 25 | } 26 | }; 27 | 28 | window.addEventListener('keydown', downHandler); 29 | window.addEventListener('keyup', upHandler); 30 | return () => { 31 | window.removeEventListener('keydown', downHandler); 32 | window.removeEventListener('keyup', upHandler); 33 | }; 34 | }, [targets, keyHandlerMap]); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/common/HideAfterDelay.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Styles = styled.div` 5 | opacity: ${p => p.opacity}; 6 | display: none; 7 | `; 8 | 9 | // currently does not work as expected, need to refactor 10 | export default function HideAfterDelay({ children, delay = 1000, ...props }) { 11 | const [visible, setVisible] = useState(true); 12 | const timer = useRef(); 13 | 14 | useEffect(() => { 15 | const setTimer = () => { 16 | if (timer.current !== null) clearTimeout(timer.current); 17 | 18 | timer.current = setTimeout(() => { 19 | setVisible(false); 20 | timer.current = null; 21 | }, delay); 22 | }; 23 | 24 | setTimer(); 25 | setVisible(true); 26 | 27 | return () => { 28 | if (timer.current !== null) clearTimeout(timer.current); 29 | }; 30 | }, [delay, children]); 31 | 32 | return visible ? ( 33 | 34 | {children} 35 | 36 | ) : null; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { ReactComponent as UserIcon } from 'icons/user.svg'; 4 | import { ReactComponent as GitHubIcon } from 'icons/github.svg'; 5 | import config from 'config'; 6 | 7 | const Version = styled.div` 8 | font-family: ${p => p.theme.fontFixed}; 9 | margin-bottom: 0.5rem; 10 | `; 11 | 12 | const StyledContainer = styled.div` 13 | padding: 4rem 0 2rem; 14 | text-align: center; 15 | font-size: 0.8em; 16 | a { 17 | box-shadow: none; 18 | margin: 0 0.25rem 0; 19 | padding: 0.5rem; 20 | } 21 | svg { 22 | height: 1.5em; 23 | } 24 | `; 25 | 26 | const Footer = () => { 27 | return ( 28 | 29 | v{config.version} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default Footer; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Joel Bartlett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "color-tool", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@sentry/browser": "^5.6.1", 7 | "lodash": "^4.17.13", 8 | "lodash.template": "^4.5.0", 9 | "query-string": "^6.11.0", 10 | "react": "^16.9.0", 11 | "react-copy-to-clipboard": "^5.0.1", 12 | "react-dom": "^16.9.0", 13 | "react-ga": "^2.5.7", 14 | "react-input-range": "^1.3.0", 15 | "react-router-dom": "^5.0.0", 16 | "react-scripts": "^3.4.0", 17 | "react-spring": "^8.0.27", 18 | "source-map-explorer": "^1.8.0", 19 | "styled-components": "^4.2.0" 20 | }, 21 | "scripts": { 22 | "analyze": "source-map-explorer 'build/static/js/*.js'", 23 | "start": "BROWSER=chrome react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": "react-app" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/styles/global.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import media from 'styles/breakpoints'; 3 | 4 | export default createGlobalStyle` 5 | * { 6 | box-sizing: border-box; 7 | } 8 | html { 9 | font-size: 62.5%; 10 | } 11 | body { 12 | margin: 0; 13 | padding: 0; 14 | color: ${p => p.theme.textColor}; 15 | background: ${p => p.theme.backgroundColor}; 16 | font-family: ${p => p.theme.font}; 17 | font-size: 1.6rem; 18 | line-height: 1.5; 19 | min-height: 100%; 20 | ${media.tablet` 21 | font-size: 1.8rem; 22 | `} 23 | } 24 | a { 25 | color: inherit; 26 | text-decoration: none; 27 | box-shadow: inset 0 -0.1em 0 0 ${p => p.theme.textColor}; 28 | } 29 | h1 { 30 | font-size: 1.8em; 31 | } 32 | input[type=number]::-webkit-inner-spin-button, 33 | input[type=number]::-webkit-outer-spin-button { 34 | -webkit-appearance: none; 35 | -moz-appearance: none; 36 | appearance: none; 37 | margin: 0; 38 | } 39 | input[type='number'] { 40 | -moz-appearance: textfield; 41 | } 42 | input::selection { 43 | background: ${p => p.theme.backgroundColor}; 44 | color: ${p => p.theme.textColor}; 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /src/components/HexField.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import colorConverter from 'colorConverter'; 3 | import HexInput from 'components/common/HexInput'; 4 | import { recordGAEvent } from 'helpers'; 5 | 6 | const HexField = ({ setColor, baseColor }) => { 7 | const [hexValue, setHexValue] = useState(''); 8 | const [inputError, setInputError] = useState(false); 9 | 10 | useEffect(() => { 11 | setHexValue(baseColor.hex); 12 | }, [baseColor.hex]); 13 | 14 | const handleChange = (e, value) => { 15 | setHexValue(() => { 16 | if (value.length === 6) { 17 | setInputError(false); 18 | setColor(colorConverter.hex.toHsl4x(value)); 19 | } else { 20 | setInputError(true); 21 | } 22 | return value; 23 | }); 24 | }; 25 | 26 | const handleFocus = e => e.target.select(); 27 | 28 | return ( 29 |
recordGAEvent('User', 'Clicked', 'Hex inputs')}> 30 | 31 | 39 |
40 | ); 41 | }; 42 | 43 | export default HexField; 44 | -------------------------------------------------------------------------------- /src/components/common/HectoInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { hectoMatch } from '../../regexDefs'; 5 | import Input from './Input'; 6 | 7 | const Styles = styled(Input)` 8 | width: calc(3ch + 1em); 9 | `; 10 | 11 | const HectoInput = props => { 12 | const { name, value, onChange } = props; 13 | 14 | const handleChange = e => { 15 | const { value, name } = e.target; 16 | if (hectoMatch.test(value) || value === '') { 17 | onChange(e, parseInt(value) || 0, name); 18 | } 19 | }; 20 | 21 | const handlePressEnter = e => { 22 | if (e.key === 'Enter') { 23 | e.target.blur(); 24 | } 25 | }; 26 | 27 | return ( 28 | 39 | ); 40 | }; 41 | 42 | HectoInput.propTypes = { 43 | name: PropTypes.string.isRequired, 44 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, 45 | onChange: PropTypes.func.isRequired 46 | }; 47 | 48 | export default HectoInput; 49 | -------------------------------------------------------------------------------- /src/components/ValueInputs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | import HslFields from 'components/HslFields'; 4 | import RgbFields from 'components/RgbFields'; 5 | import HexField from 'components/HexField'; 6 | import { useBaseColor } from 'contexts/baseColorContext'; 7 | import breakpoints from 'styles/breakpoints'; 8 | 9 | const StyledDiv = styled.div` 10 | margin: 3.5rem 0 1.5rem; 11 | & > div { 12 | margin-bottom: 1.5rem; 13 | } 14 | label { 15 | font-weight: bold; 16 | display: inline-block; 17 | width: 3em; 18 | } 19 | ${breakpoints.tablet(css` 20 | display: flex; 21 | justify-content: space-between; 22 | & > div { 23 | margin-bottom: 0; 24 | } 25 | & > div:last-child input { 26 | margin-right: 0; 27 | } 28 | `)} 29 | `; 30 | 31 | const ValueInputs = () => { 32 | const { baseColor, setBaseHslPrecise } = useBaseColor(); 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default ValueInputs; 44 | -------------------------------------------------------------------------------- /src/components/common/ByteInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { byteMatch } from 'regexDefs'; 5 | import Input from 'components/common/Input'; 6 | 7 | const Styles = styled(Input)` 8 | width: calc(3ch + 1em); 9 | `; 10 | 11 | const ByteInput = props => { 12 | const { name, value, onChange } = props; 13 | 14 | const handleChange = e => { 15 | const { value, name } = e.target; 16 | if (byteMatch.test(value) || value === '') { 17 | onChange(e, parseInt(value) || 0, name); 18 | } 19 | }; 20 | 21 | const handlePressEnter = e => { 22 | if (e.key === 'Enter') { 23 | e.target.blur(); 24 | } 25 | }; 26 | 27 | return ( 28 | 39 | ); 40 | }; 41 | 42 | ByteInput.propTypes = { 43 | name: PropTypes.string.isRequired, 44 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, 45 | onChange: PropTypes.func.isRequired 46 | }; 47 | 48 | export default ByteInput; 49 | -------------------------------------------------------------------------------- /src/components/common/DegreeInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { degreeMatch } from 'regexDefs'; 5 | import Input from 'components/common/Input'; 6 | 7 | const Styles = styled(Input)` 8 | width: calc(3ch + 1em); 9 | `; 10 | 11 | const DegreeInput = props => { 12 | const { name, value, onChange } = props; 13 | 14 | const handleChange = e => { 15 | const { value, name } = e.target; 16 | if (degreeMatch.test(value) || value === '') { 17 | onChange(e, parseInt(value) || 0, name); 18 | } 19 | }; 20 | 21 | const handlePressEnter = e => { 22 | if (e.key === 'Enter') { 23 | e.target.blur(); 24 | } 25 | }; 26 | 27 | return ( 28 | 39 | ); 40 | }; 41 | 42 | DegreeInput.propTypes = { 43 | name: PropTypes.string.isRequired, 44 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, 45 | onChange: PropTypes.func.isRequired 46 | }; 47 | 48 | export default DegreeInput; 49 | -------------------------------------------------------------------------------- /src/components/common/ColorInputRange.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { default as Range } from 'react-input-range'; 3 | import styled from 'styled-components'; 4 | 5 | const RangeStyles = styled.div` 6 | .input-range { 7 | height: 4rem; 8 | position: relative; 9 | width: 100%; 10 | } 11 | 12 | .input-range__label { 13 | display: none; 14 | } 15 | 16 | .input-range__track { 17 | border-radius: 0.5rem; 18 | cursor: pointer; 19 | display: block; 20 | height: 100%; 21 | position: relative; 22 | } 23 | 24 | .input-range__slider { 25 | top: 0; 26 | position: absolute; 27 | width: 2.5rem; 28 | height: 4rem; 29 | background: transparent; 30 | border-radius: 0.5rem; 31 | margin: 0 1rem; 32 | transform: translateX(calc(-50% - 1rem)) translateY(-4rem); 33 | box-shadow: 0 0 0 0.2rem ${p => p.theme.textColor}, 0 0 0 0.4rem ${p => p.theme.backgroundColor}; 34 | &:hover { 35 | cursor: pointer; 36 | } 37 | } 38 | `; 39 | 40 | export default function ColorInputRange({ minValue, maxValue, value, onChange, className }) { 41 | return ( 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/UserNotify.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useTransition, animated } from 'react-spring'; 4 | 5 | const Styles = styled.div` 6 | position: absolute; 7 | top: 50%; 8 | left: 0; 9 | width: 100%; 10 | text-align: center; 11 | pointer-events: none; 12 | font-weight: bold; 13 | font-size: 1.25em; 14 | color: ${p => (p.isBright ? p.theme.colors.offBlack : p.theme.colors.offWhite)}; 15 | opacity: 1; 16 | `; 17 | 18 | export default function CopyNotify({ messages, isBright }) { 19 | // we only want to display the last message in this case 20 | const message = messages.count ? [messages.items[messages.count - 1]] : null; 21 | const fadeTransition = useTransition(message, m => (m ? m.id : 0), { 22 | from: { 23 | opacity: 0, 24 | transform: 'scale(1.15)' 25 | }, 26 | enter: { 27 | opacity: 1, 28 | transform: 'scale(1)' 29 | }, 30 | leave: { 31 | opacity: 0, 32 | transform: 'scale(1.15)' 33 | } 34 | }); 35 | 36 | return fadeTransition.map( 37 | ({ item, key, props }) => 38 | item && ( 39 | 40 | {item.data} 41 | 42 | ) 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/styles/themes.js: -------------------------------------------------------------------------------- 1 | const colors = { 2 | offWhite: '#efefef', 3 | offBlack: '#111111', 4 | lightGrey: '#cccccc', 5 | darkGrey: '#333333' 6 | }; 7 | 8 | const common = { 9 | font: `'Source Sans Pro', sans-serif;`, 10 | fontFixed: `'Source Code Pro', monospace;`, 11 | preview: { 12 | darkOverlayBg: 'hsla(0, 0%, 0%, 0.2)', 13 | brightOverlayBg: 'hsla(0, 0%, 100%, 0.2)' 14 | } 15 | }; 16 | 17 | const dark = { 18 | backgroundColor: colors.offBlack, 19 | textColor: colors.offWhite, 20 | buttonHoverColor: 'rgba(255, 255, 255, 0.15)', 21 | fullScreenModalBgColor: 'rgba(255,255,255, 0.9)', 22 | inputColor: colors.darkGrey, 23 | menu: { 24 | bgColor: colors.offWhite, 25 | textColor: colors.offBlack, 26 | buttonHoverColor: 'rgba(0, 0, 0, 0.1)' 27 | }, 28 | colors: { 29 | ...colors 30 | }, 31 | ...common 32 | }; 33 | 34 | const light = { 35 | backgroundColor: colors.offWhite, 36 | textColor: colors.offBlack, 37 | buttonHoverColor: 'rgba(0,0,0, 0.15)', 38 | fullScreenModalBgColor: 'rgba(0, 0, 0, 0.85)', 39 | inputColor: colors.lightGrey, 40 | menu: { 41 | bgColor: colors.offBlack, 42 | textColor: colors.offWhite, 43 | buttonHoverColor: 'rgba(255, 255, 255, 0.1)' 44 | }, 45 | colors: { 46 | ...colors 47 | }, 48 | ...common 49 | }; 50 | 51 | export { light, dark }; 52 | -------------------------------------------------------------------------------- /src/hooks/useExpiresArray.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useCallback } from 'react'; 2 | 3 | // items remove themselves from the array after a delay 4 | 5 | const useExpiresArray = (initItems = [], defaultDelay = 1000) => { 6 | const [storage, setStorage] = useState(initItems); 7 | const index = useRef(0); 8 | 9 | const add = useCallback( 10 | (item, delay = defaultDelay) => { 11 | const newItem = { 12 | data: item, 13 | id: index.current++ 14 | }; 15 | newItem.timeoutId = setTimeout(() => { 16 | return setStorage(prev => prev.filter(item => item.id !== newItem.id)); 17 | }, delay); 18 | setStorage(prev => [...prev, newItem]); 19 | return newItem; 20 | }, 21 | [defaultDelay] 22 | ); 23 | 24 | const remove = useCallback( 25 | itemId => { 26 | const item = storage.find(i => i.id === itemId); 27 | if (item) { 28 | clearTimeout(item.timeoutId); 29 | setStorage(prev => prev.filter(i => i.id !== itemId)); 30 | } 31 | }, 32 | [storage] 33 | ); 34 | 35 | const clearTimeouts = useCallback(() => storage.forEach(i => clearTimeout(i.timeoutId)), [ 36 | storage 37 | ]); 38 | 39 | const flush = () => { 40 | clearTimeouts(); 41 | setStorage([]); 42 | }; 43 | 44 | return { 45 | items: storage, 46 | add, 47 | remove, 48 | flush, 49 | count: storage.length 50 | }; 51 | }; 52 | 53 | export default useExpiresArray; 54 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga'; 2 | import * as Sentry from '@sentry/browser'; 3 | import config from 'config'; 4 | 5 | export const random8Bit = () => Math.floor(Math.random() * 256); 6 | 7 | export const trueMod = (n, m) => ((n % m) + m) % m; 8 | 9 | export const ensureIsNotInput = event => { 10 | return event.target.tagName.toLowerCase() !== 'input'; 11 | }; 12 | 13 | export const fireHotKey = (e, callback) => { 14 | if (ensureIsNotInput(e)) { 15 | e.preventDefault(); 16 | callback(); 17 | } 18 | }; 19 | 20 | export const initializeSentry = () => { 21 | if (config.env === 'production') { 22 | Sentry.init({ dsn: config.sentryDsn }); 23 | } 24 | }; 25 | 26 | export const initializeGA = () => { 27 | if (config.env === 'production') { 28 | ReactGA.initialize(config.GAPropertyId); 29 | } 30 | }; 31 | 32 | export const recordGAPageView = path => { 33 | if (config.env === 'production') { 34 | ReactGA.pageview(path); 35 | } 36 | }; 37 | 38 | export const recordGAEvent = (category, action, label) => { 39 | if (!category || !action) { 40 | console.warn('GA Event: Category and action are required - aborting'); 41 | } else if (config.env === 'production') { 42 | const payload = { 43 | category, 44 | action 45 | }; 46 | if (label) payload.label = label; 47 | ReactGA.event(payload); 48 | } 49 | }; 50 | 51 | export const getOrNull = (obj, key) => (key in obj ? obj[key] : null); 52 | 53 | export const getOrCreate = (obj, key, defaultValue) => { 54 | if (key in obj) return obj[key]; 55 | 56 | obj[key] = defaultValue; 57 | return defaultValue; 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/common/FullScreenModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import styled from 'styled-components'; 4 | import OverlayBox from 'components/common/OverlayBox'; 5 | import { useTransition, animated } from 'react-spring'; 6 | 7 | const Styles = styled(animated.div)` 8 | position: fixed; 9 | top: 0; 10 | left: 0; 11 | width: 100%; 12 | height: 100%; 13 | z-index: 10000; 14 | display: flex; 15 | align-items: center; 16 | justify-content: center; 17 | padding: 2rem; 18 | background: ${p => p.theme.fullScreenModalBgColor}; 19 | overflow: scroll; 20 | transform: scale(1); 21 | will-change: transform, opacity; 22 | `; 23 | 24 | export default function FullScreenModal({ children, onClickOff, isShowing }) { 25 | const overlayTransition = useTransition(isShowing, null, { 26 | from: { 27 | opacity: 0, 28 | transform: 'scale(1.15)' 29 | }, 30 | enter: { 31 | opacity: 1, 32 | transform: 'scale(1)' 33 | }, 34 | leave: { 35 | opacity: 0, 36 | transform: 'scale(1.15)' 37 | } 38 | }); 39 | 40 | return overlayTransition.map( 41 | ({ item, key, props }) => 42 | item && 43 | ReactDOM.createPortal( 44 | { 48 | if (e.target.parentNode.id === 'modal') onClickOff(e); 49 | }} 50 | > 51 | {children} 52 | , 53 | document.querySelector('#modal') 54 | ) 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/hooks/useColorRouteOnMount.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useBaseColor } from 'contexts/baseColorContext'; 3 | import { validHsl, validRgb, validHex } from 'colorUtils'; 4 | import colorConverter from 'colorConverter'; 5 | import { hslTo4x } from 'colorUtils'; 6 | import useRouter from 'hooks/useRouter'; 7 | 8 | const convertMap = { 9 | hsl: route => { 10 | const { h, s, l } = route.params; 11 | return validHsl(h, s, l) ? hslTo4x([h, s, l]) : null; 12 | }, 13 | rgb: route => { 14 | const { r, g, b } = route.params; 15 | return validRgb(r, g, b) ? colorConverter.rgb.toHsl4x([r, g, b]) : null; 16 | }, 17 | hex: route => { 18 | const { hex } = route.params; 19 | return validHex(hex) ? colorConverter.hex.toHsl4x(hex) : null; 20 | } 21 | }; 22 | 23 | // helper to avoid linter complaining about empty dependency array 24 | const useOnceOnMount = callback => React.useEffect(callback, []); 25 | 26 | export default function useColorRouteOnMount() { 27 | const { setBaseHslPrecise } = useBaseColor(); 28 | const { replace, matchRoute } = useRouter(); 29 | const colorRoute = matchRoute(['/hsl/:h/:s/:l', '/rgb/:r/:g/:b', '/hex/:hex']); 30 | 31 | const clearRoute = () => replace('/'); 32 | 33 | useOnceOnMount(() => { 34 | if (colorRoute?.isExact) { 35 | const type = colorRoute.url.slice(1, 4); 36 | 37 | if (type in convertMap) { 38 | const color = convertMap[type](colorRoute); 39 | if (color) { 40 | setBaseHslPrecise(color); 41 | } else { 42 | // invalid color values 43 | clearRoute(); 44 | } 45 | } 46 | } else { 47 | // invalid color route 48 | clearRoute(); 49 | } 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/common/HexInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import Input from './Input'; 5 | import { hexCharsMatch } from '../../regexDefs'; 6 | import colorConverter from 'colorConverter'; 7 | 8 | const StyledInput = styled(Input)` 9 | width: calc(6ch + 1em); 10 | `; 11 | 12 | const validHex = value => hexCharsMatch.test(value) || value === ''; 13 | 14 | const HexInput = props => { 15 | const { name, value, onChange } = props; 16 | 17 | const handleChange = e => { 18 | const { value, name } = e.target; 19 | const valid = validHex(value); 20 | if (valid) onChange(e, value || '000000', name); 21 | }; 22 | 23 | const handlePaste = e => { 24 | const { name } = e.target; 25 | const pasted = e.clipboardData.getData('Text/plain') || null; 26 | if (pasted.length === 3 && validHex(pasted)) { 27 | const sixDigitHex = colorConverter.hex.normalize(pasted); 28 | onChange(e, sixDigitHex, name); 29 | } 30 | }; 31 | 32 | const handlePressEnter = e => { 33 | if (e.key === 'Enter') { 34 | e.target.blur(); 35 | } 36 | }; 37 | 38 | return ( 39 | 51 | ); 52 | }; 53 | 54 | HexInput.propTypes = { 55 | name: PropTypes.string.isRequired, 56 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, 57 | onChange: PropTypes.func.isRequired 58 | }; 59 | 60 | export default HexInput; 61 | -------------------------------------------------------------------------------- /src/components/HslFields.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import ContiguousInputs from 'components/common/ContiguousInputs'; 3 | import DegreeInput from 'components/common/DegreeInput'; 4 | import HectoInput from 'components/common/HectoInput'; 5 | import { recordGAEvent } from 'helpers'; 6 | import { hslTo4x } from 'colorUtils'; 7 | 8 | const HslFields = ({ setColor, baseColor }) => { 9 | const [hslValues, setHslValues] = useState({ h: 0, s: 0, l: 0 }); 10 | 11 | useEffect(() => { 12 | const [h, s, l] = baseColor.hsl; 13 | setHslValues({ h, s, l }); 14 | }, [baseColor.hsl]); 15 | 16 | const handleChange = (e, value, name) => { 17 | setHslValues(prev => { 18 | const newValues = { ...prev, [name]: value }; 19 | let { h, s, l } = newValues; 20 | setColor(hslTo4x([h, s, l])); 21 | return newValues; 22 | }); 23 | }; 24 | 25 | const handleFocus = e => e.target.select(); 26 | 27 | return ( 28 |
recordGAEvent('User', 'Clicked', 'HSL inputs')}> 29 | 30 | 31 | 38 | 45 | 52 | 53 |
54 | ); 55 | }; 56 | 57 | export default HslFields; 58 | -------------------------------------------------------------------------------- /src/components/RgbFields.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import ByteInput from 'components/common/ByteInput'; 3 | import ContiguousInputs from 'components/common/ContiguousInputs'; 4 | import { recordGAEvent } from 'helpers'; 5 | import colorConverter from 'colorConverter'; 6 | 7 | const RgbFields = ({ setColor, baseColor }) => { 8 | // TODO input valid state for invalid input indicator 9 | const [rgbValues, setRgbValues] = useState({ r: 0, g: 0, b: 0 }); 10 | 11 | useEffect(() => { 12 | const [r, g, b] = baseColor.rgb; 13 | setRgbValues({ r, g, b }); 14 | }, [baseColor.rgb]); 15 | 16 | const handleChange = (e, value, name) => { 17 | setRgbValues(prev => { 18 | const newValues = { ...prev, [name]: value }; 19 | const { r, g, b } = newValues; 20 | setColor(colorConverter.rgb.toHsl4x([r, g, b])); 21 | return newValues; 22 | }); 23 | }; 24 | 25 | const handleFocus = e => e.target.select(); 26 | 27 | return ( 28 |
recordGAEvent('User', 'Clicked', 'RGB inputs')}> 29 | 30 | 31 | 38 | 45 | 52 | 53 |
54 | ); 55 | }; 56 | 57 | export default RgbFields; 58 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { ThemeProvider } from 'styled-components'; 3 | import GlobalStyles from 'styles/global'; 4 | import { dark, light } from 'styles/themes'; 5 | import breakpoints from 'styles/breakpoints'; 6 | import config from 'config'; 7 | import { recordGAPageView } from 'helpers'; 8 | import { usePreferences } from 'contexts/preferencesContext'; 9 | import { useBaseColor } from 'contexts/baseColorContext'; 10 | import ErrorBoundary from 'components/ErrorBoundary'; 11 | import HotKeys from 'components/HotKeys'; 12 | import Header from 'components/Header'; 13 | import ValueInputs from 'components/ValueInputs'; 14 | import ColorDisplay from 'components/ColorDisplay'; 15 | import ColorAdjustControls from 'components/ColorAdjustControls'; 16 | import ValueSliders from 'components/ValueSliders'; 17 | import Footer from 'components/Footer'; 18 | import useDocumentTitle from 'hooks/useDocumentTitle'; 19 | import useKeyboardQuery from 'hooks/useKeyboardQuery'; 20 | import useExpiresArray from 'hooks/useExpiresArray'; 21 | import useColorRouteOnMount from 'hooks/useColorRouteOnMount'; 22 | import About from 'components/About'; 23 | 24 | recordGAPageView('/'); 25 | 26 | const AppStyles = styled.div` 27 | padding: 0 2rem 3rem; 28 | max-width: 100rem; 29 | margin: 0 auto; 30 | ${breakpoints.tablet` 31 | padding: 0 4rem 3rem; 32 | `} 33 | `; 34 | 35 | export default function App() { 36 | const { preferences } = usePreferences(); 37 | const { baseColor } = useBaseColor(); 38 | const { pageTitle } = config; 39 | const userMessages = useExpiresArray([], 2000); 40 | 41 | useColorRouteOnMount(); 42 | useDocumentTitle(`#${baseColor.hex} - ${pageTitle}`); 43 | useKeyboardQuery('using-keyboard'); 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 55 |
56 | 57 | 58 | 59 | 60 | 61 |