├── .gitignore ├── README.md ├── license.txt ├── media ├── screenshot-0.jpg └── screenshot-1.jpg ├── package-lock.json ├── package.json ├── public ├── favicon.png ├── index.html └── manifest.json ├── src ├── App.css ├── App.js ├── App.test.js ├── Components │ ├── ButtonControls.jsx │ ├── CodeOutput.jsx │ ├── ColorCube.jsx │ ├── ColorPickers.jsx │ ├── Controls.jsx │ ├── DegreeInput.jsx │ ├── GradientSelection.jsx │ ├── Header.jsx │ ├── NavButtons.jsx │ └── SavedGradient.jsx ├── Models │ ├── Gradient.js │ └── Store.js ├── Pages │ ├── Main.jsx │ └── Settings.jsx ├── Utility │ ├── debounce.js │ └── evaluate.js ├── defaultStore.js ├── index.css ├── index.js ├── logo.png └── registerServiceWorker.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | GradientLab 4 |
5 | GradientLab 6 |
7 |

https://gradientlab.space

8 | 9 |

10 | What Is This? • 11 | Screenshots • 12 | Usage • 13 | Motivation • 14 | Dependencies • 15 | License 16 |

17 | 18 | ## What is this? 19 | GradientLab is a gradient picker made with React, aiming to let you choose aesthetically pleasing gradients quickly and intuitively. 20 | 21 | Please note this is currently work in progress, and far from a final version. 22 | 23 | ## Screenshots 24 | ![Home](/media/screenshot-0.jpg) 25 | 26 | ![Settings](/media/screenshot-1.jpg) 27 | 28 | ## Usage 29 | The UI should be quite intuitive and self explanatory. At the moment, there is no support for adjusting the location of the colours within the gradient. 30 | 31 | You may define your own output function written in javascript, which will be evaluated and shown on the UI. The default output is a CSS linear gradient rule. 32 | 33 | The application store and [chroma.js](https://github.com/gka/chroma.js/) objects are exposed in this function. 34 | 35 | ## Motivation 36 | This was made foremost as a learning experience, but I thought it turned out quite well so I decided to polish it up a bit and open source it. 37 | 38 | At the moment, it focuses on LAB and LCH color spaces for interpolation, as they generally look the best to us aesthetically. See the following article for a bit more information:\ 39 | https://www.vis4.net/blog/2011/12/avoid-equidistant-hsv-colors/ 40 | 41 | ## Dependencies 42 | • [mobx-state-tree](https://github.com/mobxjs/mobx-state-tree) for state management\ 43 | • [mst-react-router](https://github.com/alisd23/mst-react-router) for routing\ 44 | • [pose](https://popmotion.io/pose/) for animations\ 45 | • [chroma.js](https://github.com/gka/chroma.js/) for colour calculations\ 46 | • [react-toastify](https://github.com/fkhadra/react-toastify) for notifications\ 47 | • [react-copy-to-clipboard](https://github.com/nkbt/react-copy-to-clipboard)\ 48 | • [react-color](https://casesandberg.github.io/react-color/) 49 | 50 | ## License 51 | MIT -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Boris Popik 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /media/screenshot-0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enlyth/GradientLab/a35a0f6af3100e9612d188c898de961e8183ffcf/media/screenshot-0.jpg -------------------------------------------------------------------------------- /media/screenshot-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enlyth/GradientLab/a35a0f6af3100e9612d188c898de961e8183ffcf/media/screenshot-1.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gradientlab", 3 | "version": "0.2.0", 4 | "private": true, 5 | "dependencies": { 6 | "brace": "^0.11.1", 7 | "chroma-js": "^2.0.4", 8 | "history": "^4.9.0", 9 | "mobx": "^5.10.1", 10 | "mobx-react": "^6.1.1", 11 | "mobx-state-tree": "^3.14.0", 12 | "mst-react-router": "^2.3.1", 13 | "popmotion": "^8.6.10", 14 | "react": "^16.8.6", 15 | "react-ace": "^7.0.2", 16 | "react-color": "^2.17.3", 17 | "react-copy-to-clipboard": "^5.0.1", 18 | "react-dom": "^16.8.6", 19 | "react-icons": "^3.7.0", 20 | "react-inspector": "^3.0.2", 21 | "react-pose": "^4.0.8", 22 | "react-router": "^5.0.1", 23 | "react-router-dom": "^5.0.1", 24 | "react-scripts": "3.0.1", 25 | "react-toastify": "^5.3.1" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test --env=jsdom", 31 | "eject": "react-scripts eject" 32 | }, 33 | "devDependencies": { 34 | "enzyme": "^3.10.0", 35 | "enzyme-adapter-react-16": "^1.14.0", 36 | "sinon": "^7.3.2" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enlyth/GradientLab/a35a0f6af3100e9612d188c898de961e8183ffcf/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | GradientLab 10 | 11 | 12 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "GradientLab", 3 | "name": "GradientLab", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Montserrat|Open+Sans'); 2 | 3 | html, body, #root, .App { 4 | display: flex; 5 | flex-direction: column; 6 | height: 100vh; 7 | background-color: #333; 8 | } 9 | 10 | .App { 11 | text-align: center; 12 | font-family: 'Open Sans', sans-serif; 13 | } 14 | 15 | .App-logo { 16 | font-family: 'Montserrat', cursive; 17 | margin: auto; 18 | display: inline-block; 19 | padding: .5em; 20 | font-size: .9em; 21 | cursor: default; 22 | transition: background-color 0.25s; 23 | color: #fff; 24 | user-select: none; 25 | } 26 | 27 | .header-top { 28 | background-color: #222;/* #3065ff;*/ 29 | text-align: center; 30 | padding: .75em .25em .75em .25em; 31 | 32 | z-index: 999; 33 | } 34 | 35 | button { 36 | font-weight: 500; 37 | font-family: 'Open Sans', sans-serif; 38 | padding: .6em 1.2em; 39 | background-color: rgba(0, 0, 0, 0.436); 40 | border: none; 41 | border-radius: 0px; 42 | position: relative; 43 | transition: all .2s ease; 44 | color: #eee; 45 | margin: .25em .25em; 46 | } 47 | 48 | button:hover { 49 | background-color: rgb(59, 110, 250); 50 | } 51 | 52 | button:focus { 53 | outline: none; 54 | } 55 | 56 | form { 57 | display: inline-block; 58 | 59 | } 60 | 61 | input { 62 | width: 52px; 63 | border-radius: 0px; 64 | border: none; 65 | box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 0px 1px !important; 66 | padding: .6em; 67 | font-family: 'Open Sans', sans-serif; 68 | background-color: rgba(255,255,255,0.75); 69 | color: #333; 70 | margin: .25em .25em; 71 | } 72 | 73 | input:focus { 74 | outline: none; 75 | } 76 | 77 | .sketch-picker { 78 | display: inline-block; 79 | margin: 0em 1em 1em 1em; 80 | background-color: rgba(255,255,255,0.75) !important; 81 | box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 0px 1px !important; 82 | border-radius: 0px !important; 83 | } 84 | 85 | .colorBlocks { 86 | margin: 0em 1em 1em 1em; 87 | background-color: rgba(255,255,255,0.75); 88 | border-radius: 0px; 89 | padding: .5em .5em .2em .5em; 90 | width: auto; 91 | display: inline-block; 92 | box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 0px 1px; 93 | } 94 | 95 | .savedGradients { 96 | text-align: center; 97 | background-color: rgba(255, 255, 255, 0.75); 98 | box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 0px 1px; 99 | border-radius: 0px; 100 | padding: .25em .5em 0em .5em; 101 | margin: 1em auto; 102 | 103 | } 104 | 105 | .saved-gradients-button { 106 | padding: .45em .75em; 107 | margin: .4em; 108 | float: right; 109 | } 110 | 111 | .savedGradientCube:hover { 112 | transition: transform 0.1s ease; 113 | transform: scale(1.1); 114 | 115 | } 116 | 117 | .selected { 118 | position: relative; 119 | box-shadow: rgba(255, 255, 255, 1) 0px 0px 0px 2px !important; 120 | } 121 | 122 | @keyframes float { 123 | 0% {top: -28px;} 124 | 100% {top: -16px;} 125 | } 126 | 127 | @keyframes shrinkToNormal { 128 | 0% {transform: scale(1.4)} 129 | 100% {transform: scale(1)} 130 | } 131 | 132 | .savedGradientCube:hover::after { 133 | position: absolute; 134 | content:''; 135 | top: -24px; 136 | right: 11px; 137 | animation: 1s infinite alternate float; 138 | border-bottom: solid 12px transparent; 139 | border-right: solid 12px transparent; 140 | border-left: solid 12px transparent; 141 | border-top: solid 12px rgba(255, 255, 255, .3); 142 | } 143 | 144 | .selected::after, .selected:hover::after { 145 | position: absolute; 146 | content:''; 147 | bottom: 0px; 148 | right: 5.5px; 149 | border-bottom: solid 16px rgba(255, 255, 255, .6); 150 | border-right: solid 16px transparent; 151 | border-left: solid 16px transparent; 152 | border-top: solid 16px transparent; 153 | animation-name: shrinkToNormal; 154 | animation-duration: .2s; 155 | animation-iteration-count: 1; 156 | } 157 | 158 | 159 | .ui-toggler>button>.uiLock { 160 | display: none; 161 | } 162 | 163 | .ui-toggler>button:hover>.uiLock { 164 | display: block; 165 | } 166 | 167 | .codeBlock { 168 | background-color: rgba(255,255,255,0.75); 169 | display: inline-block; 170 | padding: 1em; 171 | margin: auto; 172 | border-radius: 0px; 173 | box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 0px 1px; 174 | color: #333; 175 | max-width: 640px; 176 | } 177 | 178 | .grid-container { 179 | display: grid; 180 | grid-template-columns: 1fr 2fr; 181 | grid-template-rows: 1fr; 182 | grid-template-areas: "HeaderLogo HeaderRight"; 183 | } 184 | 185 | .HeaderRight { grid-area: HeaderRight; } 186 | 187 | .HeaderLogo { grid-area: HeaderLogo; } 188 | 189 | .HeaderMiddle { grid-area: HeaderMiddle; } 190 | 191 | .activeButton { 192 | background-color: #3065ff; 193 | } 194 | 195 | #settings-code-output-editor > div.ace_scroller > div { 196 | background-color: #171717; 197 | } 198 | .container-controls { 199 | position: relative; 200 | } 201 | 202 | .controls { 203 | background-color: rgba(255, 255, 255, 0.4); 204 | opacity: 1; 205 | position: absolute; 206 | top: 0px; 207 | left: auto; 208 | text-align: center; 209 | margin: auto; 210 | height: auto; 211 | width: 100%; 212 | padding: .6em 0em .6em 0em; 213 | } 214 | code { 215 | font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace; 216 | } 217 | .header-controls { 218 | background-color: #333; 219 | } 220 | 221 | #contentBlock { 222 | flex: 1; 223 | padding-top: 6em; 224 | } 225 | 226 | .settings { 227 | color: #eee; 228 | background-color: #242424; 229 | text-align: left; 230 | padding: 2em; 231 | 232 | max-width: 640px; 233 | margin: auto; 234 | margin-top: 2em; 235 | font-weight: normal; 236 | } 237 | 238 | h1, h2, h3, h4, h5 { 239 | font-weight: normal; 240 | margin-top: 0em; 241 | margin-bottom: .5em; 242 | } 243 | 244 | h4 { 245 | color: #aaa; 246 | } 247 | textarea { 248 | background-color: #222; 249 | color:white; 250 | width: 640px; 251 | height: auto; 252 | } 253 | 254 | textarea:focus, textarea:active { 255 | outline: black; 256 | border: 1px black; 257 | } 258 | 259 | .Toastify__toast { 260 | background-color: #222 !important; 261 | } 262 | .Toastify__toast-body { 263 | color: #eee !important; 264 | } 265 | 266 | .Toastify__close-button--default { 267 | color: #fff !important; 268 | opacity: 0.8 !important; 269 | padding-left: 4px !important; 270 | padding-right: 4px !important; 271 | } 272 | 273 | .Toastify__progress-bar--default { 274 | background: linear-gradient(to right,#00ff87,#00d1ff,#de00ff) !important; 275 | } 276 | 277 | .github-button { 278 | position: fixed; 279 | bottom: 1.5em; 280 | left: 1.5em; 281 | border-radius: 50%; 282 | width: 52px; 283 | height: 52px; 284 | text-align: center; 285 | padding: 0em; 286 | padding-top: .2em; 287 | } 288 | 289 | button.github-button > svg { 290 | transform: scale(1.9); 291 | } 292 | 293 | code { 294 | word-break: break-all; 295 | } 296 | 297 | .uiLock { 298 | position: absolute; 299 | top: 2.25em; 300 | left: -0.25em; 301 | } 302 | 303 | .copy-button { 304 | display: block; 305 | margin: 1em auto; 306 | } 307 | 308 | .colorCube { 309 | width: .3em; 310 | height: .3em; 311 | padding: 1em; 312 | margin: auto; 313 | display: inline-block; 314 | } -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { applySnapshot } from 'mobx-state-tree' 3 | import { Router } from 'react-router' 4 | import { Route } from 'react-router-dom' 5 | import Header from './Components/Header' 6 | import Controls from './Components/Controls' 7 | import Main from './Pages/Main' 8 | import { RouterModel, syncHistoryWithStore } from 'mst-react-router' 9 | import Settings from './Pages/Settings' 10 | import { observer } from 'mobx-react' 11 | import Store from './Models/Store' 12 | import defaultStore from './defaultStore' 13 | import createBrowserHistory from 'history/createBrowserHistory' 14 | import './App.css' 15 | import { ToastContainer } from 'react-toastify' 16 | import 'react-toastify/dist/ReactToastify.css' 17 | 18 | const routerModel = RouterModel.create() 19 | const history = syncHistoryWithStore(createBrowserHistory(), routerModel) 20 | const store = Store.create({ ...defaultStore, router: routerModel }) 21 | 22 | class App extends Component { 23 | componentDidMount = () => { 24 | let snapShot 25 | try { 26 | snapShot = JSON.parse( 27 | window.localStorage.getItem('__GRADIENTLAB_STORE__') 28 | ) 29 | if (snapShot) { 30 | if (store.router.location.pathname === '/') { 31 | snapShot.uiHidden = false 32 | snapShot.uiHiddenLocked = false 33 | } else { 34 | snapShot.uiHidden = true 35 | snapShot.uiHiddenLocked = true 36 | } 37 | applySnapshot(store, snapShot) 38 | } 39 | } catch (err) { 40 | console.error('Could not load application state from snapshot.') 41 | } 42 | } 43 | 44 | render() { 45 | const selected = store.selectedGradient 46 | const visibility = 47 | store.uiHidden || store.uiHiddenLocked ? 'hidden' : 'visible' 48 | 49 | return ( 50 | 51 |
52 |
53 |
54 | 55 |
56 | 57 | } /> 58 |
} /> 59 | 70 |
71 |
72 | ) 73 | } 74 | } 75 | export default observer(App) 76 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { shallow, mount, render, configure } from 'enzyme' 2 | import React from 'react' 3 | import Adapter from 'enzyme-adapter-react-16' 4 | import Store from './Models/Store' 5 | import defaultStore from './defaultStore' 6 | import { RouterModel } from 'mst-react-router' 7 | 8 | import DegreeInput from './Components/DegreeInput' 9 | import GradientSelection from './Components/GradientSelection' 10 | import SavedGradient from './Components/SavedGradient' 11 | import NavButtons from './Components/NavButtons' 12 | import Header from './Components/Header' 13 | import ColorCube from './Components/ColorCube' 14 | 15 | const routerModel = RouterModel.create() 16 | // const history = syncHistoryWithStore(createBrowserHistory(), routerModel) 17 | const store = Store.create({ ...defaultStore, router: routerModel }) 18 | configure({ adapter: new Adapter() }) 19 | 20 | describe('Degree Input', () => { 21 | const wrapper = shallow() 22 | it('contains an number input form for degrees', () => { 23 | expect(wrapper.find('input').length).toBe(1) 24 | }) 25 | it('the input form takes numbers', () => { 26 | expect(wrapper.find('input').prop('type')).toBe('number') 27 | }) 28 | }) 29 | 30 | describe('GradientSelection', () => { 31 | const wrapper = shallow() 32 | it('renders the same number of s as store', () => { 33 | expect(wrapper.find(SavedGradient).length).toBe(store.gradients.length) 34 | }) 35 | it('contains two buttons to add or remove gradient', () => { 36 | expect(wrapper.find('button').length).toBe(2) 37 | }) 38 | it('exactly one gradient is selected', () => { 39 | expect(wrapper.findWhere(e => e.prop('isSelected')).length).toBe(1) 40 | }) 41 | }) 42 | 43 | describe('Header', () => { 44 | const wrapper = shallow(
) 45 | it('contains logo', () => { 46 | expect(wrapper.find('.App-logo').length).toBe(1) 47 | }) 48 | it('contains nav buttons', () => { 49 | expect(wrapper.find(NavButtons).length).toBe(1) 50 | }) 51 | }) 52 | 53 | describe('ColorCube', () => { 54 | const wrapper = shallow() 55 | it('receives background color via props.color', () => { 56 | expect(wrapper.props().style.backgroundColor).toBe('red') 57 | }) 58 | }) -------------------------------------------------------------------------------- /src/Components/ButtonControls.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DegreeInput from './DegreeInput' 3 | import { MdLibraryAdd, MdIndeterminateCheckBox } from 'react-icons/md' 4 | 5 | const ButtonControls = ({ store, gradient }) => { 6 | return ( 7 |
8 | 14 | 20 | 23 | 26 | 27 | 28 | 29 |
30 | ) 31 | } 32 | 33 | export default ButtonControls 34 | -------------------------------------------------------------------------------- /src/Components/CodeOutput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chroma from 'chroma-js' 3 | import evaluate from '../Utility/evaluate' 4 | 5 | const CodeOutput = ({ store }) => ( 6 |
7 | 8 | {' '} 9 | {evaluate(store, chroma)} 10 | 11 |
12 | ) 13 | 14 | export default CodeOutput 15 | -------------------------------------------------------------------------------- /src/Components/ColorCube.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default props => { 4 | return ( 5 |
6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/Components/ColorPickers.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { SketchPicker } from 'react-color' 3 | 4 | const ColorPickers = ({ gradient, store }) => 5 | gradient.colors.map((color, index) => ( 6 |
11 | gradient.changeColor(color.hex, index)} 14 | disableAlpha 15 | presetColors={[]} 16 | /> 17 |
18 | )) 19 | 20 | export default ColorPickers 21 | -------------------------------------------------------------------------------- /src/Components/Controls.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ButtonControls from './ButtonControls' 3 | import posed from 'react-pose' 4 | 5 | const AnimatedHeader = posed.div({ 6 | visible: { opacity: 1, top: 0, delay: 150 }, 7 | hidden: { opacity: 1, top: -102 } 8 | }) 9 | 10 | export default ({ store }) => { 11 | const visibility = 12 | store.uiHidden || store.uiHiddenLocked ? 'hidden' : 'visible' 13 | 14 | return ( 15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/Components/DegreeInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { observer } from 'mobx-react' 3 | 4 | const DegreeInput = ({store}) => { 5 | return ( 6 |
7 | 15 |
16 | ) 17 | } 18 | 19 | export default observer(DegreeInput) 20 | -------------------------------------------------------------------------------- /src/Components/GradientSelection.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SavedGradient from './SavedGradient' 3 | import { MdAdd, MdDelete } from 'react-icons/md' 4 | import { observer } from 'mobx-react' 5 | 6 | const GradientSelection = ({ store }) => { 7 | return ( 8 |
9 |
10 | {store.gradients.map((key, index) => ( 11 | store.selectGradient(index)} 14 | gradient={key} 15 | key={index} 16 | /> 17 | ))} 18 |
19 | 22 | 25 |
26 | ) 27 | } 28 | 29 | export default observer(GradientSelection) 30 | -------------------------------------------------------------------------------- /src/Components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import NavButtons from './NavButtons' 3 | import Logo from '../logo.png' 4 | 5 | const Header = ({ store }) => { 6 | return ( 7 |
8 |
9 |
10 | GradientLab 11 | GradientLab 12 |
13 |
14 |
15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | export default Header 22 | -------------------------------------------------------------------------------- /src/Components/NavButtons.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { getSnapshot } from 'mobx-state-tree' 4 | import { 5 | MdSave, 6 | MdHome, 7 | MdSettings, 8 | MdVisibility, 9 | MdVisibilityOff 10 | /* MdLock */ 11 | } from 'react-icons/md' 12 | import { GoMarkGithub } from 'react-icons/go' 13 | import { toast } from 'react-toastify' 14 | 15 | const NavButtons = ({ store }) => { 16 | return ( 17 |
18 | 28 | 46 | 47 | 50 | 51 | 52 | 55 | 56 | 57 | 60 | 61 |
62 | ) 63 | } 64 | 65 | export default NavButtons 66 | -------------------------------------------------------------------------------- /src/Components/SavedGradient.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chroma from 'chroma-js' 3 | import { observer } from 'mobx-react' 4 | 5 | const SavedGradient = ({ gradient, isSelected, onClickHandler, color }) => { 6 | const linearGradient = chroma 7 | .scale(gradient.colors) 8 | .mode(gradient.mode) 9 | .colors(gradient.grades) 10 | 11 | return ( 12 |
console.log('dragStart')} 16 | onDrop={() => console.log('drop')} 17 | onDrag={() => console.log('drag')} 18 | onDragEnd={() => console.log('dragEnd')} 19 | style={{ 20 | backgroundColor: color, 21 | width: '.75em', 22 | height: '.75em', 23 | padding: '1em', 24 | margin: '.4em', 25 | display: 'inline-block', 26 | background: `linear-gradient(${ 27 | gradient.degrees.length === 0 ? '160' : gradient.degrees 28 | }deg,${linearGradient.toString()})` 29 | }} 30 | /> 31 | ) 32 | } 33 | 34 | export default observer(SavedGradient) 35 | -------------------------------------------------------------------------------- /src/Models/Gradient.js: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | import chroma from 'chroma-js' 3 | 4 | const Gradient = types 5 | .model('Gradient', { 6 | colors: types.array(types.string), 7 | grades: 8, 8 | degrees: 160, 9 | mode: types.string 10 | }) 11 | .actions(self => ({ 12 | addGrade: () => ++self.grades, 13 | removeGrade: () => self.grades > 2 && self.colors.length <= self.grades && --self.grades, 14 | addColor: () => self.colors.push(chroma.random().hex()), 15 | removeColor: () => self.colors.length > 2 && self.colors.pop(), 16 | changeColor: (color, index) => (self.colors[index] = color), 17 | setMode: mode => (self.mode = mode), 18 | changeDegrees: deg => (self.degrees = parseInt(deg, 10)) 19 | })) 20 | 21 | export default Gradient 22 | -------------------------------------------------------------------------------- /src/Models/Store.js: -------------------------------------------------------------------------------- 1 | import { types, applySnapshot } from 'mobx-state-tree' 2 | import Gradient from './Gradient' 3 | import { RouterModel } from 'mst-react-router' 4 | import defaultStore from '../defaultStore' 5 | 6 | export const defaultCode = `const selected = store.selectedGradient 7 | const linearGradient = chroma 8 | .scale(selected.colors) 9 | .mode(selected.mode) 10 | .colors(selected.grades) 11 | 12 | const deg = selected.degrees.length === 0 ? '160' : selected.degrees 13 | const gradient = linearGradient.toString() 14 | const backgroundStyle = \`linear-gradient($\{deg}deg,$\{gradient});\` 15 | return backgroundStyle` 16 | 17 | const Store = types 18 | .model('Store', { 19 | selected: types.optional(types.number, 0), 20 | gradients: types.array(Gradient), 21 | uiHidden: types.optional(types.boolean, true), 22 | uiHiddenLocked: types.optional(types.boolean, false), 23 | router: RouterModel, 24 | outputCode: types.optional(types.string, defaultCode) 25 | }) 26 | .views(self => ({ 27 | get selectedGradient () { 28 | if (self.selected >= self.gradients.length) { 29 | return self.gradients[self.gradients.length - 1] 30 | } else { 31 | return self.gradients[self.selected] 32 | } 33 | } 34 | })) 35 | .actions(self => ({ 36 | selectGradient: index => { 37 | self.selected = index 38 | }, 39 | reset: () => { 40 | /* I know this is a bit hacky, but has to do for now */ 41 | const current = JSON.parse(JSON.stringify(self)) 42 | Object.assign(current, {...defaultStore, uiHidden: true, uiHiddenLocked: true, selected: 0, outputCode: defaultCode}) 43 | applySnapshot(self, current) 44 | }, 45 | addGradient: () => { 46 | self.gradients.push({ 47 | colors: ['#ffffff', '#000000'], 48 | grades: 2, 49 | mode: 'lch' 50 | }) 51 | }, 52 | deleteSelectedGradient: () => { 53 | self.gradients.length > 1 && self.gradients.splice(self.selected, 1) 54 | }, 55 | hideUI: () => { 56 | self.uiHidden = true 57 | }, 58 | showUI: () => { 59 | if (self.router.location.pathname === '/') { 60 | self.uiHidden = false 61 | } 62 | }, 63 | toggleUILock: () => { 64 | self.uiHiddenLocked = !self.uiHiddenLocked 65 | }, 66 | lockUIHidden: () => { 67 | self.uiHiddenLocked = true 68 | }, 69 | unlockUIHidden: () => { 70 | self.uiHiddenLocked = false 71 | }, 72 | setOutputCode: code => { 73 | self.outputCode = code 74 | } 75 | })) 76 | 77 | export default Store 78 | -------------------------------------------------------------------------------- /src/Pages/Main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chroma from 'chroma-js' 3 | import posed from 'react-pose' 4 | import ColorCube from '../Components/ColorCube' 5 | import ColorPickers from '../Components/ColorPickers' 6 | import GradientSelection from '../Components/GradientSelection' 7 | import CodeOutput from '../Components/CodeOutput' 8 | import { CopyToClipboard } from 'react-copy-to-clipboard' 9 | import { toast } from 'react-toastify' 10 | import { MdContentCopy } from 'react-icons/md' 11 | import { observer } from 'mobx-react' 12 | import evaluate from '../Utility/evaluate'; 13 | 14 | export const AnimatedGroup = posed.div({ 15 | visible: { delayChildren: 100, staggerChildren: 150 } 16 | }) 17 | 18 | export const AnimatedDiv = posed.div({ 19 | visible: { opacity: 1, y: 0 }, 20 | hidden: { opacity: 0, y: 32 } 21 | }) 22 | 23 | class Main extends React.Component { 24 | componentDidMount = () => { 25 | this.props.store.showUI() 26 | this.props.store.unlockUIHidden() 27 | } 28 | render() { 29 | const store = this.props.store 30 | const selected = store.selectedGradient 31 | const linearGradient = chroma 32 | .scale(selected.colors) 33 | .mode(selected.mode) 34 | .colors(selected.grades) 35 | 36 | const backgroundStyle = { 37 | background: `linear-gradient(${ 38 | selected.degrees.length === 0 ? '160' : selected.degrees 39 | }deg,${linearGradient.toString()})`, 40 | width: '100%' 41 | } 42 | 43 | const visibility = 44 | store.uiHidden || store.uiHiddenLocked ? 'hidden' : 'visible' 45 | 46 | return ( 47 |
48 | 49 | 54 | {linearGradient.map((c, idx) => )} 55 | 56 | 57 | 58 | 59 | 60 | 61 | 65 | toast('Copied to clipboard', { position: 'bottom-right' }) 66 | } 67 | > 68 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | ) 79 | } 80 | } 81 | 82 | export default observer(Main) 83 | -------------------------------------------------------------------------------- /src/Pages/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chroma from 'chroma-js' 3 | import { ObjectInspector } from 'react-inspector' 4 | import AceEditor from 'react-ace' 5 | import { observer } from 'mobx-react' 6 | import { toast } from 'react-toastify' 7 | import 'brace/mode/javascript' 8 | import 'brace/theme/pastel_on_dark' 9 | 10 | class Settings extends React.Component { 11 | componentDidMount = () => { 12 | this.props.store.hideUI() 13 | this.props.store.lockUIHidden() 14 | } 15 | 16 | render() { 17 | const store = this.props.store 18 | return ( 19 |
20 |

Settings

21 |

Output

22 |
23 | function output(chroma, store){' {'} 24 | store.setOutputCode(code)} 33 | fontSize={14} 34 | showPrintMargin={false} 35 | showGutter={false} 36 | highlightActiveLine 37 | value={store.outputCode} 38 | setOptions={{ 39 | enableBasicAutocompletion: true, 40 | enableLiveAutocompletion: true, 41 | enableSnippets: false, 42 | showLineNumbers: true, 43 | tabSize: 2 44 | }} 45 | /> 46 | {'}'} 47 |
48 |
49 |
50 |

Example

51 | 52 | {(() => { 53 | let codeBeforeEval = '((chroma, store) => {' 54 | codeBeforeEval += store.outputCode + '})' 55 | try { 56 | const builtFunction = eval(codeBeforeEval) 57 | const result = builtFunction(chroma, store) 58 | return result 59 | } catch (err) { 60 | return err.toString() 61 | } 62 | })()} 63 | 64 |
65 |
66 |
67 |

68 | Application State{' '} 69 | 77 |

78 | 79 |
80 |
81 | ) 82 | } 83 | } 84 | 85 | export default observer(Settings) 86 | -------------------------------------------------------------------------------- /src/Utility/debounce.js: -------------------------------------------------------------------------------- 1 | function debounce (func, wait, immediate) { 2 | let timeout 3 | return function () { 4 | const context = this 5 | const args = arguments 6 | 7 | const later = function () { 8 | timeout = null 9 | if (!immediate) { 10 | func.apply(context, args) 11 | } 12 | } 13 | 14 | const callNow = immediate && !timeout 15 | clearTimeout(timeout) 16 | timeout = setTimeout(later, wait) 17 | if (callNow) { 18 | func.apply(context, args) 19 | } 20 | } 21 | } 22 | 23 | export default debounce 24 | -------------------------------------------------------------------------------- /src/Utility/evaluate.js: -------------------------------------------------------------------------------- 1 | const evaluate = (store, chroma) => { 2 | try { 3 | const result = eval('((chroma, store) => {' + store.outputCode + '})')( 4 | chroma, 5 | store 6 | ).toString() 7 | return result 8 | } catch (err) { 9 | return err.toString() 10 | } 11 | } 12 | 13 | export default evaluate 14 | -------------------------------------------------------------------------------- /src/defaultStore.js: -------------------------------------------------------------------------------- 1 | const defaultStore = { 2 | gradients: [ 3 | { 4 | colors: ['#00FF87', '#2C69BD', '#3a0e37'], 5 | degrees: 160, 6 | grades: 8, 7 | mode: 'lch' 8 | }, 9 | { 10 | colors: ['#FFEBD6', '#370B7F'], 11 | degrees: 160, 12 | grades: 8, 13 | mode: 'lch' 14 | }, 15 | { 16 | colors: ['#650909', '#00AFFF'], 17 | degrees: 160, 18 | grades: 8, 19 | mode: 'lch' 20 | }, 21 | { 22 | colors: ['#FFEB00', '#0C3776'], 23 | degrees: 160, 24 | grades: 8, 25 | mode: 'lch' 26 | }, 27 | { 28 | colors: ['#00F6FF', '#FF001B'], 29 | degrees: 160, 30 | grades: 8, 31 | mode: 'lab' 32 | }, 33 | { 34 | colors: ['#262E4C', '#4D754B', '#FFCD00'], 35 | degrees: 160, 36 | grades: 8, 37 | mode: 'lab' 38 | }, 39 | { 40 | colors: ['#760F0F', '#DC2DE2', '#4FB7B4', '#00FFEF'], 41 | degrees: 160, 42 | grades: 8, 43 | mode: 'lab' 44 | } 45 | ] 46 | } 47 | 48 | export default defaultStore 49 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | import registerServiceWorker from './registerServiceWorker' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | 9 | registerServiceWorker() 10 | -------------------------------------------------------------------------------- /src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enlyth/GradientLab/a35a0f6af3100e9612d188c898de961e8183ffcf/src/logo.png -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | --------------------------------------------------------------------------------