├── 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 | window.location.reload()}>Reload
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 | You need to enable JavaScript to run this app.
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 | HEX
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 | HSL
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 | RGB
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 |
62 |
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/Menu.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import ThemeControl from 'components/ThemeControl';
4 | import RandomizeControl from 'components/RandomizeControl';
5 | import IconButton from 'components/common/IconButton';
6 | import useClickOutside from 'hooks/useClickOutside';
7 | import { ReactComponent as MenuIcon } from 'icons/menu.svg';
8 | import { ReactComponent as CloseIcon } from 'icons/x.svg';
9 | import breakpoints from 'styles/breakpoints';
10 |
11 | const expandedCss = css`
12 | ${breakpoints.below.tablet`
13 | padding: 1rem;
14 | z-index: 100;
15 | background: ${p => p.theme.menu.bgColor};
16 | ${IconButton} {
17 | color: ${p => p.theme.menu.textColor};
18 | &:hover {
19 | background: ${p => p.theme.menu.buttonHoverColor};
20 | }
21 | }
22 | `}
23 | `;
24 |
25 | const Styles = styled.div`
26 | position: absolute;
27 | top: 0;
28 | right: 0;
29 | border-radius: 4rem;
30 | transition: all 100ms;
31 | ${IconButton} {
32 | margin: 0 0 0.5rem 0;
33 | &:last-of-type {
34 | margin: 0;
35 | }
36 | }
37 | ${p => p.showing && expandedCss}
38 | ${breakpoints.tablet`
39 | ${IconButton} {
40 | margin: 0 1rem 0 0;
41 | &:last-of-type {
42 | margin: 0;
43 | }
44 | }
45 | `}
46 | `;
47 |
48 | const Toggle = styled.div`
49 | ${breakpoints.tablet(css`
50 | display: none;
51 | `)}
52 | `;
53 |
54 | const Expanded = styled.div`
55 | display: ${p => (p.showing ? 'flex' : 'none')};
56 | flex-direction: column;
57 | ${breakpoints.tablet(css`
58 | display: block;
59 | `)}
60 | `;
61 |
62 | export default function Menu() {
63 | const [showing, setShowing] = useState(false);
64 | const clickOutsideRef = useClickOutside(() => {
65 | if (showing) setShowing(false);
66 | });
67 |
68 | return (
69 |
70 |
71 | setShowing(prev => !prev)}
73 | title={`${showing ? 'Close' : 'Expand'} menu`}
74 | >
75 | {showing ? : }
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/HotKeys.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import useHotKeys from 'hooks/useHotKeys';
3 | import { fireHotKey } from 'helpers';
4 | import styled from 'styled-components';
5 | import { CopyToClipboard } from 'react-copy-to-clipboard';
6 | import config from 'config';
7 | import { usePreferences } from 'contexts/preferencesContext';
8 | import { useBaseColor } from 'contexts/baseColorContext';
9 |
10 | const Hidden = styled.div`
11 | display: none;
12 | `;
13 |
14 | export default function HotKeys({ callbacks }) {
15 | const { preferences, toggleTheme } = usePreferences();
16 | const {
17 | baseColor,
18 | randomizeBase,
19 | adjustBaseHue,
20 | adjustBaseSat,
21 | adjustBaseLum
22 | } = useBaseColor();
23 | const { addMessage } = callbacks;
24 | const copierRef = useRef();
25 |
26 | useHotKeys({
27 | r: e => {
28 | fireHotKey(e, () => {
29 | randomizeBase();
30 | addMessage('Randomized!');
31 | });
32 | },
33 | t: e => {
34 | fireHotKey(e, () => {
35 | toggleTheme();
36 | });
37 | },
38 | c: e => {
39 | fireHotKey(e, () => {
40 | copierRef.current.click();
41 | addMessage('Copied CSS hex value!');
42 | });
43 | },
44 | ArrowUp: e => {
45 | fireHotKey(e, () => {
46 | adjustBaseLum(5);
47 | addMessage('Lum +5%');
48 | });
49 | },
50 | ArrowDown: e => {
51 | fireHotKey(e, () => {
52 | adjustBaseLum(-5);
53 | addMessage('Lum -5%');
54 | });
55 | },
56 | ArrowRight: e => {
57 | fireHotKey(e, () => {
58 | adjustBaseHue(12);
59 | addMessage('Hue +12deg');
60 | });
61 | },
62 | ArrowLeft: e => {
63 | fireHotKey(e, () => {
64 | adjustBaseHue(-12);
65 | addMessage('Hue -12deg');
66 | });
67 | },
68 | s: e => {
69 | fireHotKey(e, () => {
70 | adjustBaseSat(5);
71 | addMessage('Sat +5%');
72 | });
73 | },
74 | d: e => {
75 | fireHotKey(e, () => {
76 | adjustBaseSat(-5);
77 | addMessage('Sat -5%');
78 | });
79 | },
80 | l: e => {
81 | if (config.env !== 'production')
82 | fireHotKey(e, () => {
83 | console.log('prefs', preferences);
84 | console.log('base color', baseColor);
85 | });
86 | }
87 | });
88 |
89 | return (
90 |
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/ValuesDisplay.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import colorConverter from 'colorConverter';
4 | import { animated, useSpring } from 'react-spring';
5 | import { CopyToClipboard } from 'react-copy-to-clipboard';
6 | import { recordGAEvent } from 'helpers';
7 | import { isBright } from 'colorUtils';
8 | import config from 'config';
9 |
10 | const StyledDiv = styled.div`
11 | display: flex;
12 | flex-direction: column;
13 | position: absolute;
14 | top: 1rem;
15 | right: 2rem;
16 | font-size: 0.9em;
17 | .values {
18 | margin: 1rem 0 0 auto;
19 | padding: 0.4em 0.5em;
20 | line-height: 1;
21 | font-family: ${p => p.theme.fontFixed};
22 | background: ${p =>
23 | p.isBright ? p.theme.preview.brightOverlayBg : p.theme.preview.darkOverlayBg};
24 | color: ${p => (p.isBright ? p.theme.colors.offBlack : p.theme.colors.offWhite)};
25 | border-radius: 0.3em;
26 | cursor: copy;
27 | }
28 | `;
29 |
30 | const CopyOnClick = ({ string, children, addMessage }) => {
31 | return (
32 | {
35 | recordGAEvent('User', 'Clicked', 'Copy color');
36 | addMessage('Copied CSS value!');
37 | }}
38 | >
39 |
40 | {children}
41 |
42 |
43 | );
44 | };
45 |
46 | export default function ValuesDisplay({ baseColor, addMessage }) {
47 | const values = useSpring({
48 | config: { duration: config.transitionDurationMs },
49 | rgb: baseColor.rgb,
50 | hsl: baseColor.hsl
51 | });
52 |
53 | const cssStrings = {
54 | rgb: `rgb(${baseColor.rgb[0]}, ${baseColor.rgb[1]}, ${baseColor.rgb[2]})`,
55 | hsl: `hsl(${baseColor.hsl[0]}, ${baseColor.hsl[1]}%, ${baseColor.hsl[2]}%)`,
56 | hex: `#${baseColor.hex}`
57 | };
58 |
59 | return (
60 |
61 |
62 | RGB
63 |
64 | {values.rgb.interpolate((...rgb) => {
65 | const [r, g, b] = rgb.map(v => Math.round(v));
66 | return ` ${r}, ${g}, ${b}`;
67 | })}
68 |
69 |
70 |
71 | HSL
72 |
73 | {values.hsl.interpolate((...hsl) => {
74 | const [h, s, l] = hsl.map(v => Math.round(v));
75 | return ` ${h}, ${s}%, ${l}%`;
76 | })}
77 |
78 |
79 |
80 | #
81 |
82 | {values.rgb.interpolate((r, g, b) => colorConverter.rgb.toHex([r, g, b]))}
83 |
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/HarmonyToggle.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import IconButton from 'components/common/IconButton';
4 | import { ReactComponent as CompIcon } from 'icons/harm-comp.svg';
5 | import { ReactComponent as MonoIcon } from 'icons/harm-mono.svg';
6 | import { ReactComponent as AnaloIcon } from 'icons/harm-anl.svg';
7 | import { ReactComponent as SplitIcon } from 'icons/harm-spt.svg';
8 | import { ReactComponent as TriIcon } from 'icons/harm-tri.svg';
9 | import { ReactComponent as TetIcon } from 'icons/harm-tet.svg';
10 | import config from 'config';
11 | import { recordGAEvent } from 'helpers';
12 |
13 | const Styles = styled.div``;
14 |
15 | const Toggle = styled.div`
16 | display: flex;
17 | margin-top: 1rem;
18 | left: 0;
19 | justify-content: space-between;
20 | transform-origin: center right;
21 | button.active {
22 | background: ${p => p.theme.buttonHoverColor};
23 | }
24 | `;
25 |
26 | export default function HarmonyToggle({ showing, setShowing }) {
27 | const { harmonyConstants } = config;
28 | const toggle = harmony => {
29 | if (showing === harmony) {
30 | setShowing(null);
31 | } else {
32 | setShowing(harmony);
33 | }
34 | };
35 |
36 | const getTitle = harmony => {
37 | return `${showing === harmony ? 'Hide' : 'Show'} ${harmony} harmony`;
38 | };
39 |
40 | return (
41 |
42 | recordGAEvent('User', 'Clicked', 'Harmony toggle')}>
43 | toggle(harmonyConstants.CO)}
45 | title={getTitle(harmonyConstants.CO)}
46 | className={showing === harmonyConstants.CO ? 'active' : null}
47 | >
48 |
49 |
50 | toggle(harmonyConstants.SP)}
52 | title={getTitle(harmonyConstants.SP)}
53 | className={showing === harmonyConstants.SP ? 'active' : null}
54 | >
55 |
56 |
57 | toggle(harmonyConstants.MO)}
59 | title={getTitle(harmonyConstants.MO)}
60 | className={showing === harmonyConstants.MO ? 'active' : null}
61 | >
62 |
63 |
64 | toggle(harmonyConstants.AN)}
66 | title={getTitle(harmonyConstants.AN)}
67 | className={showing === harmonyConstants.AN ? 'active' : null}
68 | >
69 |
70 |
71 | toggle(harmonyConstants.TR)}
73 | title={getTitle(harmonyConstants.TR)}
74 | className={showing === harmonyConstants.TR ? 'active' : null}
75 | >
76 |
77 |
78 | toggle(harmonyConstants.TE)}
80 | title={getTitle(harmonyConstants.TE)}
81 | className={showing === harmonyConstants.TE ? 'active' : null}
82 | >
83 |
84 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/components/ColorAdjustControls.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled, { css } from 'styled-components';
3 | import breakpoints from 'styles/breakpoints';
4 | import { recordGAEvent } from 'helpers';
5 | import { hslTo4x } from 'colorUtils';
6 | import { useBaseColor } from 'contexts/baseColorContext';
7 |
8 | const Styles = styled.div`
9 | margin: 0 -2rem 0;
10 | ${breakpoints.tablet(css`
11 | display: flex;
12 | margin: 0;
13 | .lum,
14 | .sat {
15 | width: 50%;
16 | }
17 | .lum {
18 | margin-right: 1.5rem;
19 | }
20 | .sat {
21 | margin-left: 1.5rem;
22 | }
23 | `)};
24 | `;
25 |
26 | const Display = styled.div`
27 | display: flex;
28 | width: 100%;
29 | overflow: hidden;
30 | margin-bottom: 1.5rem;
31 | ${breakpoints.tablet(css`
32 | border-radius: 0.5rem;
33 | margin-bottom: 0;
34 | `)};
35 | `;
36 |
37 | const DisplayButton = styled.button`
38 | margin: 0;
39 | border: none;
40 | display: block;
41 | height: 4rem;
42 | flex-grow: 1;
43 | cursor: pointer;
44 | &:focus {
45 | outline: none;
46 | }
47 | body.using-keyboard &:focus {
48 | outline: none;
49 | box-shadow: 0 0 0 0.2rem ${p => p.theme.textColor};
50 | }
51 | `;
52 |
53 | const Labels = styled.div`
54 | margin: 0 2rem;
55 | font-weight: bold;
56 | display: flex;
57 | justify-content: space-between;
58 | ${breakpoints.tablet(css`
59 | margin: 0;
60 | `)};
61 | `;
62 |
63 | const ColorAdjustControls = () => {
64 | const { baseColor, setBaseHslPrecise } = useBaseColor();
65 | let [h, s, l] = baseColor.hslNormalized;
66 | const lumAdjusts = [12, 24, 36, 50, 62, 74, 86];
67 | const satAdjusts = [10, 25, 50, 75, 90];
68 |
69 | const scaleUpAndSet = hslValues => {
70 | setBaseHslPrecise(hslTo4x(hslValues));
71 | };
72 |
73 | return (
74 |
75 |
76 |
77 | Shade
78 | Tint
79 |
80 |
recordGAEvent('User', 'Clicked', 'Shade/tint controls')}>
81 | {lumAdjusts.map(lum => {
82 | return (
83 | scaleUpAndSet([h, s, lum])}
89 | title={`Set lightness to ${lum}%`}
90 | style={{
91 | background: `hsl(${h}, ${s}%, ${lum}%)`
92 | }}
93 | />
94 | );
95 | })}
96 |
97 |
98 |
99 |
100 | Desaturate
101 | Saturate
102 |
103 |
recordGAEvent('User', 'Clicked', 'Sat/desat controls')}>
104 | {satAdjusts.map(sat => {
105 | return (
106 | scaleUpAndSet([h, sat, l])}
112 | title={`Set saturation to ${sat}%`}
113 | style={{
114 | background: `hsl(${h}, ${sat}%, 50%)`
115 | }}
116 | />
117 | );
118 | })}
119 |
120 |
121 |
122 | );
123 | };
124 |
125 | export default ColorAdjustControls;
126 |
--------------------------------------------------------------------------------
/src/components/ColorDisplay.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import breakpoints from 'styles/breakpoints';
4 | import colorConverter from 'colorConverter';
5 | import ValuesDisplay from 'components/ValuesDisplay';
6 | import { useSpring, animated } from 'react-spring';
7 | import HarmonyDisplay from 'components/HarmonyDisplay';
8 | import UserNotify from 'components/UserNotify';
9 | import HarmonyToggle from 'components/HarmonyToggle';
10 | import IconButton from 'components/common/IconButton';
11 | import { useBaseColor } from 'contexts/baseColorContext';
12 | import { ReactComponent as LinkIcon } from 'icons/link.svg';
13 | import { CopyToClipboard } from 'react-copy-to-clipboard';
14 | import config from 'config';
15 | import { recordGAEvent } from 'helpers';
16 | import { isBright } from 'colorUtils';
17 |
18 | const Styles = styled(animated.div)`
19 | height: 28vh;
20 | min-height: 25rem;
21 | margin: 0 -2rem 0;
22 | position: relative;
23 | overflow: hidden;
24 | ${breakpoints.tall`
25 | height: 33vh;
26 | `}
27 | ${breakpoints.tablet`
28 | border-radius: 1rem;
29 | margin: 0;
30 | `}
31 | `;
32 |
33 | const Container = styled.div`
34 | margin-bottom: 1.5rem;
35 | `;
36 |
37 | const LinkToStyles = styled.div`
38 | position: absolute;
39 | top: 0;
40 | left: 1rem;
41 | transform: scale(0.8);
42 | transform-origin: left bottom;
43 | svg {
44 | color: ${p => (p.isBright ? p.theme.colors.offBlack : p.theme.colors.offWhite)};
45 | }
46 |
47 | ${breakpoints.tablet(css`
48 | right: 0;
49 | bottom: 1rem;
50 | top: auto;
51 | left: auto;
52 | `)}
53 | `;
54 |
55 | const LinkTo = ({ hex, addMessage, isBright }) => {
56 | const link = `${config.publicURL}/hex/${hex}`;
57 |
58 | return (
59 |
60 | {
63 | recordGAEvent('User', 'Clicked', 'Copy link');
64 | addMessage('Copied link!');
65 | }}
66 | >
67 |
68 |
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | export default function ColorDisplay({ userMessages }) {
76 | const { baseColor, setBaseHslPrecise } = useBaseColor();
77 | const [showingHarmony, setShowingHarmony] = useState(null);
78 | const isBrightBg = isBright(...baseColor.rgb);
79 | const rgbCSS = colorConverter.rgb.toCSS(baseColor.rgb);
80 | const colorTransition = useSpring({
81 | config: { duration: config.transitionDurationMs },
82 | background: rgbCSS
83 | });
84 |
85 | const { add } = userMessages;
86 | useEffect(() => {
87 | if (showingHarmony) add(showingHarmony);
88 | }, [add, showingHarmony]);
89 |
90 | return (
91 |
92 |
93 |
94 |
95 |
101 |
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/components/ValueSliders.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import styled from 'styled-components';
3 | import ColorInputRange from 'components/common/ColorInputRange';
4 | import { recordGAEvent } from 'helpers';
5 | import { useBaseColor } from 'contexts/baseColorContext';
6 |
7 | const StyledDiv = styled.div`
8 | margin-top: 1.5rem;
9 | `;
10 |
11 | const SliderContainer = styled.div`
12 | margin-bottom: 0.5rem;
13 | label {
14 | font-weight: bold;
15 | }
16 | `;
17 |
18 | const SatScale = styled(ColorInputRange)`
19 | .input-range__track--background {
20 | background: linear-gradient(
21 | to right,
22 | hsl(${p => p.hsl.h}, 0%, 50%),
23 | hsl(${p => p.hsl.h}, 100%, 50%)
24 | );
25 | }
26 | .input-range__slider {
27 | background: ${p => `hsl(${p.hsl.h}, ${p.hsl.s}%, 50%)`};
28 | }
29 | `;
30 |
31 | const LumScale = styled(ColorInputRange)`
32 | .input-range__track--background {
33 | background: linear-gradient(
34 | to right,
35 | hsl(${p => p.hsl.h}, 100%, 0%),
36 | hsl(${p => p.hsl.h}, 100%, 50%),
37 | hsl(${p => p.hsl.h}, 100%, 100%)
38 | );
39 | }
40 | .input-range__slider {
41 | background: ${p => `hsl(${p.hsl.h}, 100%, ${p.hsl.l}%)`};
42 | }
43 | `;
44 |
45 | const HueScale = styled(ColorInputRange)`
46 | .input-range__track--background {
47 | background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
48 | }
49 | .input-range__slider {
50 | background: ${p => `hsl(${p.hsl.h}, 100%, 50%)`};
51 | }
52 | `;
53 |
54 | const ValueSlider = () => {
55 | const { baseColor, setBaseHslPrecise } = useBaseColor();
56 | const [h4x, s4x, l4x] = baseColor.hsl4x;
57 | const [normalH, normalS, normalL] = baseColor.hslNormalized;
58 | const normalHsl = { h: normalH, s: normalS, l: normalL };
59 | const [values, setValues] = useState({ h: h4x, s: s4x, l: l4x });
60 |
61 | React.useEffect(() => {
62 | const [h, s, l] = baseColor.hsl4x;
63 | setValues({ h, s, l });
64 | }, [baseColor]);
65 |
66 | const set = ({ h, s, l }) => {
67 | setBaseHslPrecise([h, s, l]);
68 | };
69 |
70 | const handleSetHue = h => {
71 | setValues(prev => {
72 | const values = { ...prev, h };
73 | set(values);
74 | return values;
75 | });
76 | };
77 |
78 | const handleSetSat = s => {
79 | setValues(prev => {
80 | const values = { ...prev, s };
81 | set(values);
82 | return values;
83 | });
84 | };
85 |
86 | const handleSetLum = l => {
87 | setValues(prev => {
88 | const values = { ...prev, l };
89 | set(values);
90 | return values;
91 | });
92 | };
93 |
94 | return (
95 | recordGAEvent('User', 'Clicked', 'Slider controls')}>
96 |
97 | Hue
98 |
104 |
105 |
106 | Saturation
107 |
114 |
115 |
116 | Luminance
117 |
124 |
125 |
126 | );
127 | };
128 |
129 | export default ValueSlider;
130 |
--------------------------------------------------------------------------------
/src/contexts/baseColorContext.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useMemo, useCallback, useContext } from 'react';
2 | import useLocalStorageState from 'hooks/useLocalStorageState';
3 | import { useDebounce } from 'hooks/useDebounce';
4 | import colorConverter from 'colorConverter';
5 | import { randomRgbValues, hslTo4x } from 'colorUtils';
6 | import { trueMod } from 'helpers';
7 | import config from 'config';
8 | import useRouter from 'hooks/useRouter';
9 |
10 | // store hsl values at 4x precision - H 360x4, S 100x4, L 100x4
11 | const calcColorState = (hslValues4x = [0, 0, 0]) => {
12 | const hslValues = hslValues4x.map(v => v / 4);
13 | const hslValuesRounded = hslValues.map(v => Math.round(v));
14 | const rgb = colorConverter.hsl4x.toRgb(hslValues4x);
15 | const hex = colorConverter.rgb.toHex(rgb);
16 | return {
17 | hsl4x: hslValues4x,
18 | hslNormalized: hslValues,
19 | hsl: hslValuesRounded,
20 | rgb,
21 | hex
22 | };
23 | };
24 |
25 | const randomizeColorState = () => {
26 | const rgb = randomRgbValues();
27 | const hsl = colorConverter.rgb.toHsl(rgb);
28 | return calcColorState(hslTo4x(hsl));
29 | };
30 |
31 | const BaseColorContext = createContext();
32 |
33 | const BaseColorProvider = ({ children }) => {
34 | const { localStorageKeys } = config;
35 | const [baseColor, setBaseColor] = useLocalStorageState(
36 | localStorageKeys.baseColor,
37 | randomizeColorState()
38 | );
39 | const { replace } = useRouter();
40 |
41 | // don't want to update route too frequently and cause a browser security violation
42 | // more than ~100x in 30s
43 | const routeValue = useDebounce(baseColor.hsl, 150);
44 | React.useEffect(() => {
45 | replace(`/hsl/${routeValue[0]}/${routeValue[1]}/${routeValue[2]}`);
46 | }, [routeValue, replace]);
47 |
48 | const setHsl = useCallback(
49 | hslValues => {
50 | const [h, s, l] = hslTo4x(hslValues);
51 | setBaseColor(calcColorState([h || 0, s || 0, l || 0]));
52 | },
53 | [setBaseColor]
54 | );
55 |
56 | const setHslPrecise = useCallback(
57 | hslValues4x => {
58 | const [h, s, l] = hslValues4x;
59 | setBaseColor(calcColorState([h || 0, s || 0, l || 0]));
60 | },
61 | [setBaseColor]
62 | );
63 |
64 | const randomize = useCallback(() => {
65 | setBaseColor(randomizeColorState());
66 | }, [setBaseColor]);
67 |
68 | const contextValue = useMemo(() => {
69 | const adjustHue = hue => {
70 | const [h, s, l] = baseColor.hsl4x;
71 | const adjustment = hue * 4;
72 | const newHue = trueMod(h + adjustment, 360 * 4);
73 | setHslPrecise([newHue, s, l]);
74 | };
75 |
76 | const adjustSat = sat => {
77 | const [h, s, l] = baseColor.hsl4x;
78 | const adjustment = sat * 4;
79 | const newSat = s + adjustment > 400 ? 400 : s + adjustment < 0 ? 0 : s + adjustment;
80 | setHslPrecise([h, newSat, l]);
81 | };
82 |
83 | const adjustLum = lum => {
84 | const [h, s, l] = baseColor.hsl4x;
85 | const adjustment = lum * 4;
86 | const newLum = l + adjustment > 400 ? 400 : l + adjustment < 0 ? 0 : l + adjustment;
87 | setHslPrecise([h, s, newLum]);
88 | };
89 |
90 | return {
91 | baseColor,
92 | setBaseHsl: setHsl,
93 | setBaseHslPrecise: setHslPrecise,
94 | adjustBaseHue: adjustHue,
95 | adjustBaseSat: adjustSat,
96 | adjustBaseLum: adjustLum,
97 | randomizeBase: randomize
98 | };
99 | }, [baseColor, randomize, setHsl, setHslPrecise]);
100 |
101 | return (
102 | {children}
103 | );
104 | };
105 |
106 | const useBaseColor = () => useContext(BaseColorContext);
107 |
108 | export { BaseColorProvider, useBaseColor };
109 |
--------------------------------------------------------------------------------
/src/colorConvertAlternate.js:
--------------------------------------------------------------------------------
1 | // https://jsfiddle.net/Lamik/9rky6fco/
2 |
3 | export const hsv2hsl = (h, s, v, l = v - (v * s) / 2, m = Math.min(l, 1 - l)) => [
4 | h,
5 | m ? (v - l) / m : 0,
6 | l
7 | ];
8 |
9 | export function hsv2hsl_indirect(h, s, v) {
10 | return rgb2hsl_wiki(...hsv2rgb_wiki(h, s, v));
11 | }
12 |
13 | export function hsv2rgb_wiki(h, s, v) {
14 | let c = v * s;
15 | let k = h / 60;
16 | let x = c * (1 - Math.abs((k % 2) - 1));
17 |
18 | let [r1, g1, b1] = [0, 0, 0];
19 |
20 | // let r1 = (g1 = b1 = 0);
21 |
22 | if (k >= 0 && k <= 1) {
23 | r1 = c;
24 | g1 = x;
25 | }
26 | if (k > 1 && k <= 2) {
27 | r1 = x;
28 | g1 = c;
29 | }
30 | if (k > 2 && k <= 3) {
31 | g1 = c;
32 | b1 = x;
33 | }
34 | if (k > 3 && k <= 4) {
35 | g1 = x;
36 | b1 = c;
37 | }
38 | if (k > 4 && k <= 5) {
39 | r1 = x;
40 | b1 = c;
41 | }
42 | if (k > 5 && k <= 6) {
43 | r1 = c;
44 | b1 = x;
45 | }
46 |
47 | let m = v - c;
48 |
49 | return [r1 + m, g1 + m, b1 + m];
50 | }
51 |
52 | export const rgb2hsl_wiki = (r, g, b) => {
53 | let a = Math.max(r, g, b); //max
54 | let i = Math.min(r, g, b); //min
55 | let n = a - i; //chroma
56 | let l = (a + i) / 2; //lum
57 | let f = 1 - Math.abs(a + i - 1);
58 | let s = f ? n / f : 0;
59 | let h = 0;
60 | if (n) {
61 | if (a === r) h = 60 * (0 + (g - b) / n);
62 | if (a === g) h = 60 * (2 + (b - r) / n);
63 | if (a === b) h = 60 * (4 + (r - g) / n);
64 | }
65 |
66 | return [(h < 0 ? h + 360 : h) % 360, s, l];
67 | };
68 |
69 | // https://github.com/Qix-/color-convert/blob/HEAD/conversions.js
70 | export const rgb_2_hsl = rgb => {
71 | const r = rgb[0] / 255;
72 | const g = rgb[1] / 255;
73 | const b = rgb[2] / 255;
74 | const min = Math.min(r, g, b);
75 | const max = Math.max(r, g, b);
76 | const delta = max - min;
77 | let h;
78 | let s;
79 |
80 | if (max === min) {
81 | h = 0;
82 | } else if (r === max) {
83 | h = (g - b) / delta;
84 | } else if (g === max) {
85 | h = 2 + (b - r) / delta;
86 | } else if (b === max) {
87 | h = 4 + (r - g) / delta;
88 | }
89 |
90 | h = Math.min(h * 60, 360);
91 |
92 | if (h < 0) {
93 | h += 360;
94 | }
95 |
96 | const l = (min + max) / 2;
97 |
98 | if (max === min) {
99 | s = 0;
100 | } else if (l <= 0.5) {
101 | s = delta / (max + min);
102 | } else {
103 | s = delta / (2 - max - min);
104 | }
105 |
106 | return [h, s * 100, l * 100];
107 | };
108 |
109 | //http://jscolor.com/examples/
110 | // line ~1133
111 | // line ~1312
112 |
113 | // r: 0-255
114 | // g: 0-255
115 | // b: 0-255
116 | //
117 | // returns: [ 0-360, 0-100, 0-100 ]
118 | //
119 | function RGB_HSV(r, g, b) {
120 | r /= 255;
121 | g /= 255;
122 | b /= 255;
123 | var n = Math.min(Math.min(r, g), b);
124 | var v = Math.max(Math.max(r, g), b);
125 | var m = v - n;
126 | if (m === 0) {
127 | return [null, 0, 100 * v];
128 | }
129 | var h = r === n ? 3 + (b - g) / m : g === n ? 5 + (r - b) / m : 1 + (g - r) / m;
130 | return [60 * (h === 6 ? 0 : h), 100 * (m / v), 100 * v];
131 | }
132 |
133 | // h: 0-360
134 | // s: 0-100
135 | // v: 0-100
136 | //
137 | // returns: [ 0-255, 0-255, 0-255 ]
138 | //
139 | function HSV_RGB(h, s, v) {
140 | var u = 255 * (v / 100);
141 |
142 | if (h === null) {
143 | return [u, u, u];
144 | }
145 |
146 | h /= 60;
147 | s /= 100;
148 |
149 | var i = Math.floor(h);
150 | var f = i % 2 ? h - i : 1 - (h - i);
151 | var m = u * (1 - s);
152 | var n = u * (1 - s * f);
153 | switch (i) {
154 | case 6:
155 | case 0:
156 | return [u, n, m];
157 | case 1:
158 | return [n, u, m];
159 | case 2:
160 | return [m, u, n];
161 | case 3:
162 | return [m, n, u];
163 | case 4:
164 | return [n, m, u];
165 | case 5:
166 | return [u, m, n];
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/colorConverter.js:
--------------------------------------------------------------------------------
1 | import { trueMod } from 'helpers';
2 | import config from 'config';
3 |
4 | const HSL_SCALE = config.hslScaleFactor;
5 |
6 | // 'fff'/'FFFFFF' -> ['FF', 'FF', 'FF']
7 | const splitHex = hexValue => {
8 | const val = hexValue.toUpperCase();
9 | const length = val.length;
10 |
11 | if (length === 3) return [...val].map(v => v + v);
12 | if (length === 6) return [val.slice(0, 2), val.slice(2, 4), val.slice(4, 6)];
13 |
14 | throw new Error(`Invalid hex value "${hexValue}"`);
15 | };
16 |
17 | // [255, 255, 255] -> 'FFFFFF'
18 | const rgbToHex = rgbValues => {
19 | return rgbValues
20 | .map(n =>
21 | parseInt(n)
22 | .toString(16)
23 | .padStart(2, '0')
24 | )
25 | .join('')
26 | .toUpperCase();
27 | };
28 |
29 | // [255, 255, 255] -> [1440, 400, 400]
30 | // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
31 | // https://stackoverflow.com/questions/39118528/rgb-to-hsl-conversion
32 | // https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
33 | const rgbToHsl4x = rgbValues => {
34 | // convert to value between 0 and 1
35 | const rgb = rgbValues.map(n => parseInt(n) / 255);
36 | const [min, max] = [Math.min(...rgb), Math.max(...rgb)];
37 | const L = (min + max) / 2;
38 | const lum = Math.round(L * 100 * HSL_SCALE);
39 |
40 | // no sat, so no hue, we have a shade of gray
41 | if (max === min) return [0, 0, lum];
42 |
43 | const chroma = max - min;
44 | const S = chroma / (1 - Math.abs(2 * L - 1));
45 | const sat = Math.round(S * 100 * HSL_SCALE);
46 |
47 | const [R, G, B] = rgb;
48 | const hueMaxChannel = {
49 | '0': (G - B) / chroma + (G < B ? 6 : 0),
50 | '1': (B - R) / chroma + 2,
51 | '2': (R - G) / chroma + 4
52 | };
53 | const H = hueMaxChannel[rgb.indexOf(max)];
54 | const hue = Math.round(H * 60 * HSL_SCALE);
55 | return [hue, sat, lum];
56 | };
57 |
58 | // [255, 255, 255] -> [360, 100, 100]
59 | const rgbToHsl = rgbValues => {
60 | const hsl4x = rgbToHsl4x(rgbValues);
61 | return hsl4x.map(v => Math.round(v / 4));
62 | };
63 |
64 | // 'FFF'/'FFFFFF' -> [255, 255, 255]
65 | const hexToRgb = hexValue => {
66 | return splitHex(hexValue).map(h => parseInt(h, 16));
67 | };
68 |
69 | // 'FFF'/'FFFFFF' -> [360, 100, 100]
70 | const hexToHsl = hexValue => {
71 | const rgbValues = hexToRgb(hexValue);
72 | return rgbToHsl(rgbValues);
73 | };
74 |
75 | // 'FFF'/'FFFFFF' -> [1440, 400, 400]
76 | const hexToHsl4x = hexValue => {
77 | const rgbValues = hexToRgb(hexValue);
78 | return rgbToHsl4x(rgbValues);
79 | };
80 |
81 | // [1440, 400, 400] -> '255, 255, 255'
82 | // https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
83 | const hsl4xToRgb = hslValues4x => {
84 | let [H, S, L] = hslValues4x.map(v => parseInt(v) / HSL_SCALE);
85 |
86 | const sat = S / 100;
87 | const lum = L / 100;
88 | const hue = H / 360;
89 |
90 | // no sat, so no hue, we have a shade of gray
91 | if (sat === 0) {
92 | const v = Math.round(255 * lum);
93 | return [v, v, v];
94 | }
95 |
96 | // intermediate calculations
97 | const x = lum < 0.5 ? lum * (1 + sat) : lum + sat - lum * sat;
98 | const y = 2 * lum - x;
99 | const protoRBG = [hue + 1 / 3, hue, hue - 1 / 3].map(v => {
100 | if (v < 0) return v + 1;
101 | if (v > 1) return v - 1;
102 | return v;
103 | });
104 |
105 | const calcValue = val => {
106 | if (6 * val < 1) return y + (x - y) * 6 * val;
107 | if (2 * val < 1) return x;
108 | if (3 * val < 2) return y + (x - y) * (2 / 3 - val) * 6;
109 | return y;
110 | };
111 | // beware modulo and off-by-1s! 256 here, not 255
112 | return protoRBG.map(calcValue).map(v => trueMod(Math.round(255 * v), 256));
113 | };
114 |
115 | const hslToRgb = hslValues => {
116 | return hsl4xToRgb(hslValues.map(v => v * 4));
117 | };
118 |
119 | // [360, 100, 100] -> 'ffffff'
120 | const hslToHex = hslValues => {
121 | const rgbValues = hslToRgb(hslValues.map(v => parseInt(v)));
122 | return rgbToHex(rgbValues);
123 | };
124 |
125 | // [1440, 400, 400] -> 'ffffff'
126 | const hsl4xToHex = hslValues4x => {
127 | const rgbValues = hsl4xToRgb(hslValues4x);
128 | return rgbToHex(rgbValues);
129 | };
130 |
131 | const hslValuesToCSS = hslValues => {
132 | const [H, S, L] = hslValues;
133 | return `hsl(${H}, ${S}%, ${L}%)`;
134 | };
135 |
136 | const hslValues4xToCSS = hslValues4x => {
137 | const [H, S, L] = hslValues4x;
138 | return `hsl(${H / 4}, ${S / 4}%, ${L / 4}%)`;
139 | };
140 |
141 | const rgbValuesToCSS = rgbValues => {
142 | const [R, G, B] = rgbValues;
143 | return `rgb(${R}, ${G}, ${B})`;
144 | };
145 |
146 | const hexValuesToCSS = hexValue => {
147 | return `#${hexValue}`;
148 | };
149 |
150 | export default {
151 | hsl4x: {
152 | toRgb: hsl4xToRgb,
153 | toHex: hsl4xToHex,
154 | toCSS: hslValues4xToCSS
155 | },
156 | hsl: {
157 | toRgb: hslToRgb,
158 | toHex: hslToHex,
159 | toCSS: hslValuesToCSS
160 | },
161 | rgb: {
162 | toHsl: rgbToHsl,
163 | toHsl4x: rgbToHsl4x,
164 | toHex: rgbToHex,
165 | toCSS: rgbValuesToCSS
166 | },
167 | hex: {
168 | toHsl: hexToHsl,
169 | toHsl4x: hexToHsl4x,
170 | toRgb: hexToRgb,
171 | toCSS: hexValuesToCSS,
172 | normalize: hex => splitHex(hex).join('')
173 | }
174 | };
175 |
--------------------------------------------------------------------------------
/src/components/HarmonyDisplay.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled, { css } from 'styled-components';
3 | import colorConverter from 'colorConverter';
4 | import breakpoints from 'styles/breakpoints';
5 | import { useSpring, animated } from 'react-spring';
6 | import { trueMod, recordGAEvent } from 'helpers';
7 | import { isBright } from 'colorUtils';
8 | import config from 'config';
9 | import IconButton from 'components/common/IconButton';
10 | import { ReactComponent as MaxIcon } from 'icons/maximize.svg';
11 | import { ReactComponent as LinkIcon } from 'icons/link.svg';
12 | import { CopyToClipboard } from 'react-copy-to-clipboard';
13 |
14 | const Styles = styled.div``;
15 |
16 | const Display = styled.div`
17 | display: flex;
18 | flex: 1;
19 | position: absolute;
20 | left: 0;
21 | bottom: 0;
22 | width: 100%;
23 | height: 8rem;
24 | ${breakpoints.tablet(css`
25 | width: 25%;
26 | height: 100%;
27 | flex-direction: column;
28 | border-radius: 0.5em;
29 | `)}
30 | `;
31 |
32 | const SwatchStyles = styled.div`
33 | flex: 1;
34 | padding: 1rem 2rem 0 0;
35 | text-align: right;
36 | position: relative;
37 | > span {
38 | padding: 0.25em 0.5em;
39 | border-radius: 0.3em;
40 | background: ${p =>
41 | p.isBright ? p.theme.preview.brightOverlayBg : p.theme.preview.darkOverlayBg};
42 | color: ${p => (p.isBright ? p.theme.colors.offBlack : p.theme.colors.offWhite)};
43 | font-family: ${p => p.theme.fontFixed};
44 | font-size: 0.7em;
45 | cursor: copy;
46 | }
47 | `;
48 |
49 | const AnimatedSwatch = animated(SwatchStyles);
50 |
51 | const Buttons = styled.div`
52 | position: absolute;
53 | bottom: 1rem;
54 | left: 1rem;
55 | transform: scale(0.7);
56 | transform-origin: left bottom;
57 | svg {
58 | color: ${p => (p.isBright ? p.theme.colors.offBlack : p.theme.colors.offWhite)};
59 | }
60 | ${IconButton} {
61 | margin: 0;
62 | }
63 | ${breakpoints.tablet(css`
64 | display: flex;
65 | flex-direction: column-reverse;
66 | `)}
67 | `;
68 |
69 | const Swatch = ({ hex, setColor, addMessage }) => {
70 | const [r, g, b] = colorConverter.hex.toRgb(hex);
71 | const isBrightBg = isBright(r, g, b);
72 | const duration = config.transitionDurationMs;
73 | const valuesSpring = useSpring({
74 | config: { duration },
75 | rgb: [r, g, b]
76 | });
77 | const backgroundSpring = useSpring({
78 | config: { duration },
79 | background: `#${hex}`
80 | });
81 | const link = `${config.publicURL}/hex/${hex}`;
82 |
83 | return (
84 |
85 | {
88 | recordGAEvent('User', 'Clicked', 'Copy color');
89 | addMessage('Copied CSS value!');
90 | }}
91 | >
92 |
93 | #
94 |
95 | {valuesSpring.rgb.interpolate((r, g, b) =>
96 | colorConverter.rgb.toHex([r, g, b])
97 | )}
98 |
99 |
100 |
101 |
102 | {
105 | setColor(colorConverter.rgb.toHsl4x([r, g, b]));
106 | recordGAEvent('User', 'Clicked', 'Set base color');
107 | }}
108 | >
109 |
110 |
111 | {
114 | recordGAEvent('User', 'Clicked', 'Copy link');
115 | addMessage('Copied link!');
116 | }}
117 | >
118 |
119 |
120 |
121 |
122 |
123 |
124 | );
125 | };
126 |
127 | const getComplimentHex = ([h, s, l]) => {
128 | const complementHue = h - 180;
129 | return colorConverter.hsl.toHex([trueMod(complementHue, 360), s, l]);
130 | };
131 |
132 | const getMonochromaticHexValues = ([h, s, l]) => {
133 | const lumAdjust = l > 50 ? (100 - l) * 0.5 : l * 0.5;
134 | const hex1 = colorConverter.hsl.toHex([h, s, l + lumAdjust]);
135 | const hex2 = colorConverter.hsl.toHex([h, s, l - lumAdjust]);
136 | return [hex1, hex2];
137 | };
138 |
139 | const getAnalogousValues = ([h, s, l]) => {
140 | const hex1 = colorConverter.hsl.toHex([trueMod(h - 30, 360), s, l]);
141 | const hex2 = colorConverter.hsl.toHex([trueMod(h + 30, 360), s, l]);
142 | return [hex1, hex2];
143 | };
144 |
145 | const getSplitComplementValues = ([h, s, l]) => {
146 | const complementHue = h - 180;
147 | const hex1 = colorConverter.hsl.toHex([trueMod(complementHue - 30, 360), s, l]);
148 | const hex2 = colorConverter.hsl.toHex([trueMod(complementHue + 30, 360), s, l]);
149 | return [hex1, hex2];
150 | };
151 |
152 | const getTriadicValues = ([h, s, l]) => {
153 | const complementHue = h - 180;
154 | const hex1 = colorConverter.hsl.toHex([trueMod(complementHue - 60, 360), s, l]);
155 | const hex2 = colorConverter.hsl.toHex([trueMod(complementHue + 60, 360), s, l]);
156 | return [hex1, hex2];
157 | };
158 |
159 | const getTetradicValues = ([h, s, l]) => {
160 | const hex1 = colorConverter.hsl.toHex([trueMod(h - 180, 360), s, l]);
161 | const hex2 = colorConverter.hsl.toHex([trueMod(h - 120, 360), s, l]);
162 | const hex3 = colorConverter.hsl.toHex([trueMod(h - 300, 360), s, l]);
163 | return [hex1, hex2, hex3];
164 | };
165 |
166 | export default function HarmonyDisplay({ baseColor, showing, setColor, addMessage }) {
167 | const { hsl } = baseColor;
168 | const { harmonyConstants } = config;
169 |
170 | // key must be index for spring animations to work
171 | return (
172 |
173 | {showing !== null && (
174 |
175 | {showing === harmonyConstants.CO && (
176 |
181 | )}
182 |
183 | {showing === harmonyConstants.MO &&
184 | getMonochromaticHexValues(hsl).map((hex, i) => (
185 |
186 | ))}
187 |
188 | {showing === harmonyConstants.AN &&
189 | getAnalogousValues(hsl).map((hex, i) => (
190 |
191 | ))}
192 |
193 | {showing === harmonyConstants.SP &&
194 | getSplitComplementValues(hsl).map((hex, i) => (
195 |
196 | ))}
197 |
198 | {showing === harmonyConstants.TR &&
199 | getTriadicValues(hsl).map((hex, i) => (
200 |
201 | ))}
202 |
203 | {showing === harmonyConstants.TE &&
204 | getTetradicValues(hsl).map((hex, i) => (
205 |
206 | ))}
207 |
208 | )}
209 |
210 | );
211 | }
212 |
--------------------------------------------------------------------------------
/src/components/About.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { ReactComponent as CompIcon } from 'icons/harm-comp.svg';
4 | import { ReactComponent as MonoIcon } from 'icons/harm-mono.svg';
5 | import { ReactComponent as AnaloIcon } from 'icons/harm-anl.svg';
6 | import { ReactComponent as SplitIcon } from 'icons/harm-spt.svg';
7 | import { ReactComponent as TriIcon } from 'icons/harm-tri.svg';
8 | import { ReactComponent as TetIcon } from 'icons/harm-tet.svg';
9 |
10 | const Styles = styled.div`
11 | /* padding: 1rem; */
12 | max-width: 60ch;
13 | margin: 15rem auto 0;
14 |
15 | h3 {
16 | margin-bottom: 0.5rem;
17 | }
18 |
19 | ul {
20 | padding-left: 3rem;
21 | }
22 | li {
23 | font-size: 0.9em;
24 | margin-bottom: 0.5rem;
25 | }
26 |
27 | .shortcuts {
28 | font-size: 0.9em;
29 | display: flex;
30 | flex-wrap: wrap;
31 | dt {
32 | width: 7rem;
33 | display: block;
34 | text-align: right;
35 | }
36 | dd {
37 | width: calc(100% - 7rem);
38 | display: block;
39 | margin: 0 0 0.75rem 0;
40 | padding-left: 1rem;
41 | }
42 | }
43 | kbd {
44 | font-size: 1.25em;
45 | background: ${p => p.theme.textColor};
46 | color: ${p => p.theme.backgroundColor};
47 | padding: 0.15em 0.35em;
48 | border-radius: 0.25em;
49 | }
50 | `;
51 |
52 | const Harmony = styled.div`
53 | display: flex;
54 | margin-bottom: 3rem;
55 | .icon {
56 | background: ${p => p.theme.buttonHoverColor};
57 | border-radius: 50%;
58 | padding: 1rem;
59 | width: 8rem;
60 | height: 8rem;
61 | svg {
62 | width: 6rem;
63 | }
64 | }
65 | .desc {
66 | padding-left: 1.5rem;
67 | h4 {
68 | margin: 0 0 0.5rem;
69 | }
70 | }
71 | p {
72 | margin: 0;
73 | font-size: 0.9em;
74 | }
75 | `;
76 |
77 | export default function About() {
78 | return (
79 |
80 | A color tool for developers
81 |
82 | Pick a base color, tweak it until just right, explore harmonies, and copy the CSS
83 | values for use in your projects.{' '}
84 |
85 |
86 | Please{' '}
87 |
88 | share your feedback
89 | {' '}
90 | — let me know if this tool is useful for you, or if you find any bugs. I'd
91 | like to add additional functionality in the future. What features would you like
92 | to see?
93 |
94 | How to
95 |
96 | Set a base color using the HSL sliders or input your values directly
97 |
98 | Tweak your color until it's just right - try adjusting the shade/tint &
99 | saturation
100 |
101 | Explore harmonies to discover new color combinations
102 | Click the values in the color display to copy the CSS to your clipboard
103 |
104 | Harmonies
105 |
106 | Explore color combinations by toggling one of the six harmonies via these symbols.
107 | Each harmony has its own mood. Use harmonies to brainstorm color combos that work
108 | well together.
109 |
110 |
111 |
112 |
113 |
114 |
115 |
Complementary
116 |
117 | A color and its opposite on the color wheel, +180 degrees of hue. High
118 | contrast.
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
Monochromatic
128 |
129 | Three colors of the same hue with luminance values +/-50%. Subtle and refined.
130 |
131 |
132 |
133 |
134 |
137 |
138 |
Analogous
139 |
140 | Three colors of the same luminance and saturation with hues that are adjacent
141 | on the color wheel, 30 degrees apart. Smooth transitions.
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
Split-complementary
151 |
152 | A color and two adjacent to its complement, +/-30 degrees of hue from the
153 | value opposite the main color. Bold like a straight complement, but more
154 | versatile.
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
Triadic
164 |
165 | Three colors spaced evenly along the color wheel, each 120 degrees of hue
166 | apart. Best to allow one color to dominate and use the others as accents.
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
Tetradic
176 |
Two sets of complementary colors, separated by 60 degrees of hue.
177 |
178 |
179 | Keyboard shortcuts
180 |
181 |
182 | R
183 |
184 | randomize values for base color
185 |
186 | T
187 |
188 | toggle theme light/dark
189 |
190 | C
191 |
192 | copy hex value of base color
193 |
194 | Up
195 |
196 | tint base color
197 |
198 | Down
199 |
200 | shade base color
201 |
202 | Right
203 |
204 | increment hue of base color
205 |
206 | Left
207 |
208 | decrement hue of base color
209 |
210 | S
211 |
212 | increase saturation of base color
213 |
214 | D
215 |
216 | decrease saturation of base color
217 |
218 | Other resources
219 |
266 |
267 | );
268 | }
269 |
--------------------------------------------------------------------------------
/src/components/AboutModal.old.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import styled from 'styled-components';
3 | import IconButton from 'components/common/IconButton';
4 | import Button from 'components/common/Button';
5 | import ButtonRow from 'components/common/ButtonRow';
6 | import FullScreenModal from 'components/common/FullScreenModal';
7 | import { ReactComponent as InfoIcon } from 'icons/info.svg';
8 | import { ReactComponent as CompIcon } from 'icons/harm-comp.svg';
9 | import { ReactComponent as MonoIcon } from 'icons/harm-mono.svg';
10 | import { ReactComponent as AnaloIcon } from 'icons/harm-anl.svg';
11 | import { ReactComponent as SplitIcon } from 'icons/harm-spt.svg';
12 | import { ReactComponent as TriIcon } from 'icons/harm-tri.svg';
13 | import { ReactComponent as TetIcon } from 'icons/harm-tet.svg';
14 |
15 | const AboutModalStyles = styled.div`
16 | padding: 1rem;
17 |
18 | h3 {
19 | margin-bottom: 0.5rem;
20 | }
21 |
22 | ul {
23 | padding-left: 3rem;
24 | }
25 | li {
26 | font-size: 0.9em;
27 | margin-bottom: 0.5rem;
28 | }
29 |
30 | .shortcuts {
31 | font-size: 0.9em;
32 | display: flex;
33 | flex-wrap: wrap;
34 | dt {
35 | width: 7rem;
36 | display: block;
37 | text-align: right;
38 | }
39 | dd {
40 | width: calc(100% - 7rem);
41 | display: block;
42 | margin: 0 0 0.75rem 0;
43 | padding-left: 1rem;
44 | }
45 | }
46 | kbd {
47 | font-size: 1.25em;
48 | background: ${p => p.theme.textColor};
49 | color: ${p => p.theme.backgroundColor};
50 | padding: 0.15em 0.35em;
51 | border-radius: 0.25em;
52 | }
53 | `;
54 |
55 | const Harmony = styled.div`
56 | display: flex;
57 | margin-bottom: 3rem;
58 | .icon {
59 | background: ${p => p.theme.buttonHoverColor};
60 | border-radius: 50%;
61 | padding: 1rem;
62 | width: 8rem;
63 | height: 8rem;
64 | svg {
65 | width: 6rem;
66 | }
67 | }
68 | .desc {
69 | padding-left: 1.5rem;
70 | h4 {
71 | margin: 0 0 0.5rem;
72 | }
73 | }
74 | p {
75 | margin: 0;
76 | font-size: 0.9em;
77 | }
78 | `;
79 |
80 | export default function AboutModal({ isShowing = false }) {
81 | const [showModal, setShowModal] = useState(isShowing);
82 |
83 | useEffect(() => {
84 | setShowModal(isShowing);
85 | }, [isShowing]);
86 |
87 | return (
88 | <>
89 | {
91 | setShowModal(true);
92 | }}
93 | title="About this app"
94 | className="control"
95 | >
96 |
97 |
98 | setShowModal(false)} isShowing={showModal}>
99 |
100 | A color tool for developers
101 |
102 | Pick a base color, tweak it until just right, explore harmonies, and copy the
103 | CSS values for use in your projects.{' '}
104 |
105 |
106 | Please{' '}
107 |
112 | share your feedback
113 | {' '}
114 | — let me know if this tool is useful for you, or if you find any bugs.
115 | I'd like to add additional functionality in the future. What features would
116 | you like to see?
117 |
118 | How to
119 |
120 | Set a base color using the HSL sliders or input your values directly
121 |
122 | Tweak your color until it's just right - try adjusting the shade/tint &
123 | saturation
124 |
125 | Explore harmonies to discover new color combinations
126 |
127 | Click the values in the color display to copy the CSS to your clipboard
128 |
129 |
130 | Harmonies
131 |
132 | Explore color combinations by toggling one of the six harmonies via these
133 | symbols. Each harmony has its own mood. Use harmonies to brainstorm color
134 | combos that work well together.
135 |
136 |
137 |
138 |
139 |
140 |
141 |
Complementary
142 |
143 | A color and its opposite on the color wheel, +180 degrees of hue. High
144 | contrast.
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
Monochromatic
154 |
155 | Three colors of the same hue with luminance values +/-50%. Subtle and
156 | refined.
157 |
158 |
159 |
160 |
161 |
164 |
165 |
Analogous
166 |
167 | Three colors of the same luminance and saturation with hues that are
168 | adjacent on the color wheel, 30 degrees apart. Smooth transitions.
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
Split-complementary
178 |
179 | A color and two adjacent to its complement, +/-30 degrees of hue from the
180 | value opposite the main color. Bold like a straight complement, but more
181 | versatile.
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
Triadic
191 |
192 | Three colors spaced evenly along the color wheel, each 120 degrees of hue
193 | apart. Best to allow one color to dominate and use the others as accents.
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
Tetradic
203 |
Two sets of complementary colors, separated by 60 degrees of hue.
204 |
205 |
206 | Keyboard shortcuts
207 |
208 |
209 | R
210 |
211 | randomize values for base color
212 |
213 | T
214 |
215 | toggle theme light/dark
216 |
217 | C
218 |
219 | copy hex value of base color
220 |
221 | Up
222 |
223 | tint base color
224 |
225 | Down
226 |
227 | shade base color
228 |
229 | Right
230 |
231 | increment hue of base color
232 |
233 | Left
234 |
235 | decrement hue of base color
236 |
237 | S
238 |
239 | increase saturation of base color
240 |
241 | D
242 |
243 | decrease saturation of base color
244 |
245 | Other resources
246 |
293 |
294 | setShowModal(false)}>Ok
295 |
296 |
297 |
298 | >
299 | );
300 | }
301 |
--------------------------------------------------------------------------------