├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── components │ ├── BrushPreview.tsx │ ├── Canvas.tsx │ ├── Goo.tsx │ ├── Intro.tsx │ └── Toolbar.tsx ├── hooks │ └── usePainter.ts ├── index.tsx ├── react-app-env.d.ts └── style.css ├── tsconfig.json └── yarn.lock /.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 | .eslintcache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Adrian Bece 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Magic painter 2 | 3 | Simple paint app made with React, custom hooks, Canvas API, font awesome and lots of :heart: 4 | 5 | # Demo 6 | 7 | https://magic-painter.netlify.app/ 8 | 9 | # More info 10 | 11 | https://codeadrian.hashnode.dev/magic-painter-christmas-hackathon-project 12 | 13 | # Support my work 14 | 15 | Buy Me A Coffee 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dream-painer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 7 | "@fortawesome/free-solid-svg-icons": "^5.15.1", 8 | "@fortawesome/react-fontawesome": "^0.1.14", 9 | "@types/node": "^12.0.0", 10 | "@types/react": "^16.9.53", 11 | "@types/react-dom": "^16.9.8", 12 | "normalize.css": "^8.0.1", 13 | "react": "^17.0.1", 14 | "react-dom": "^17.0.1", 15 | "react-scripts": "4.0.1", 16 | "typescript": "^4.0.3" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app" 26 | ] 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeAdrian/react-magic-painter/e793066233961a2fe6f78c0454ba7a720e0e7c23/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Magic Painter | By Adrian Bece 28 | 29 | 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeAdrian/react-magic-painter/e793066233961a2fe6f78c0454ba7a720e0e7c23/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeAdrian/react-magic-painter/e793066233961a2fe6f78c0454ba7a720e0e7c23/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Magic Painter", 3 | "name": "Magic Painter by Adrian Bece", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from "react"; 2 | import { Canvas } from "./components/Canvas"; 3 | import { Goo } from "./components/Goo"; 4 | import { Intro } from "./components/Intro"; 5 | import { Toolbar } from "./components/Toolbar"; 6 | import { usePainter } from "./hooks/usePainter"; 7 | 8 | const App = () => { 9 | const [dateUrl, setDataUrl] = useState("#"); 10 | const [{ canvas, isReady, ...state }, { init, ...api }] = usePainter(); 11 | 12 | const handleDownload = useCallback(() => { 13 | if (!canvas || !canvas.current) return; 14 | 15 | setDataUrl(canvas.current.toDataURL("image/png")); 16 | }, [canvas]); 17 | 18 | const toolbarProps = { ...state, ...api, dateUrl, handleDownload }; 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default App; 31 | -------------------------------------------------------------------------------- /src/components/BrushPreview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | currentColor: string; 5 | currentWidth: number; 6 | } 7 | 8 | export const BrushPreview: React.FC = ({ 9 | currentColor, 10 | currentWidth, 11 | }) => { 12 | const styles = { 13 | backgroundColor: currentColor, 14 | width: `${currentWidth}px`, 15 | height: `${currentWidth}px`, 16 | }; 17 | return ( 18 |
19 | 20 | Brush Preview 21 | 22 |
23 |
24 |
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | canvasRef?: React.MutableRefObject; 5 | width?: number; 6 | } 7 | 8 | export const Canvas: React.FC = ({ canvasRef, width }) => { 9 | const widthHalf = width ? width / 2 : 0; 10 | const cursor = `url('data:image/svg+xml;utf8,') ${widthHalf} ${widthHalf}, auto`; 11 | 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Goo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Goo = () => { 4 | return ( 5 | 10 | 11 | 12 | 17 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Intro.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | init?: any; 5 | isReady?: boolean; 6 | } 7 | 8 | export const Intro: React.FC = ({ init, isReady }) => { 9 | return ( 10 |
11 |
12 |

Magic Painter

13 | 24 |

25 | Created by Adrian Bece 26 |

27 |

28 | 33 | Hashnode 34 | {" "} 35 | | 36 | 41 | Twitter 42 | {" "} 43 | | 44 | 49 | Support my work 50 | 51 | | 52 | 57 | Personal website 58 | 59 |

60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faArrowsAltH, 3 | faEraser, 4 | faMagic, 5 | faPaintBrush, 6 | } from "@fortawesome/free-solid-svg-icons"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | import React from "react"; 9 | import { BrushPreview } from "./BrushPreview"; 10 | 11 | export const Toolbar: React.FC = ({ 12 | currentWidth, 13 | currentColor, 14 | handleDownload, 15 | dateUrl, 16 | handleClear, 17 | handleSpecialMode, 18 | handleEraserMode, 19 | setAutoWidth, 20 | handleRegularMode, 21 | handleColor, 22 | handleWidth, 23 | setCurrentSaturation, 24 | setCurrentLightness, 25 | isRegularMode, 26 | isAutoWidth, 27 | isEraser, 28 | }) => { 29 | return ( 30 | 166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /src/hooks/usePainter.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from "react"; 2 | 3 | export const usePainter = () => { 4 | const canvas = useRef(); 5 | const [isReady, setIsReady] = useState(false); 6 | const [isRegularMode, setIsRegularMode] = useState(true); 7 | const [isAutoWidth, setIsAutoWidth] = useState(false); 8 | const [isEraser, setIsEraser] = useState(false); 9 | 10 | const [currentColor, setCurrentColor] = useState("#000000"); 11 | const [currentWidth, setCurrentWidth] = useState(50); 12 | 13 | const autoWidth = useRef(false); 14 | const selectedSaturation = useRef(100); 15 | const selectedLightness = useRef(50); 16 | const selectedColor = useRef("#000000"); 17 | const selectedLineWidth = useRef(50); 18 | const lastX = useRef(0); 19 | const lastY = useRef(0); 20 | const hue = useRef(0); 21 | const isDrawing = useRef(false); 22 | const direction = useRef(true); 23 | const isRegularPaintMode = useRef(true); 24 | const isEraserMode = useRef(false); 25 | 26 | const ctx = useRef(canvas?.current?.getContext("2d")); 27 | 28 | const drawOnCanvas = useCallback((event: any) => { 29 | if (!ctx || !ctx.current) { 30 | return; 31 | } 32 | ctx.current.beginPath(); 33 | ctx.current.moveTo(lastX.current, lastY.current); 34 | ctx.current.lineTo(event.offsetX, event.offsetY); 35 | ctx.current.stroke(); 36 | 37 | [lastX.current, lastY.current] = [event.offsetX, event.offsetY]; 38 | }, []); 39 | 40 | const handleMouseDown = useCallback((e: any) => { 41 | isDrawing.current = true; 42 | [lastX.current, lastY.current] = [e.offsetX, e.offsetY]; 43 | }, []); 44 | 45 | const dynamicLineWidth = useCallback(() => { 46 | if (!ctx || !ctx.current) { 47 | return; 48 | } 49 | if (ctx.current.lineWidth > 90 || ctx.current.lineWidth < 10) { 50 | direction.current = !direction.current; 51 | } 52 | direction.current ? ctx.current.lineWidth++ : ctx.current.lineWidth--; 53 | setCurrentWidth(ctx.current.lineWidth); 54 | }, []); 55 | 56 | const drawNormal = useCallback( 57 | (e: any) => { 58 | if (!isDrawing.current || !ctx.current) return; 59 | 60 | if (isRegularPaintMode.current || isEraserMode.current) { 61 | ctx.current.strokeStyle = selectedColor.current; 62 | 63 | setCurrentColor(selectedColor.current); 64 | 65 | autoWidth.current && !isEraserMode.current 66 | ? dynamicLineWidth() 67 | : (ctx.current.lineWidth = selectedLineWidth.current); 68 | 69 | isEraserMode.current 70 | ? (ctx.current.globalCompositeOperation = "destination-out") 71 | : (ctx.current.globalCompositeOperation = "source-over"); 72 | } else { 73 | setCurrentColor( 74 | `hsl(${hue.current},${selectedSaturation.current}%,${selectedLightness.current}%)`, 75 | ); 76 | ctx.current.strokeStyle = `hsl(${hue.current},${selectedSaturation.current}%,${selectedLightness.current}%)`; 77 | ctx.current.globalCompositeOperation = "source-over"; 78 | 79 | hue.current++; 80 | 81 | if (hue.current >= 360) hue.current = 0; 82 | 83 | autoWidth.current 84 | ? dynamicLineWidth() 85 | : (ctx.current.lineWidth = selectedLineWidth.current); 86 | } 87 | drawOnCanvas(e); 88 | }, 89 | [drawOnCanvas, dynamicLineWidth], 90 | ); 91 | 92 | const stopDrawing = useCallback(() => { 93 | isDrawing.current = false; 94 | }, []); 95 | 96 | const init = useCallback(() => { 97 | ctx.current = canvas?.current?.getContext("2d"); 98 | if (canvas && canvas.current && ctx && ctx.current) { 99 | canvas.current.addEventListener("mousedown", handleMouseDown); 100 | canvas.current.addEventListener("mousemove", drawNormal); 101 | canvas.current.addEventListener("mouseup", stopDrawing); 102 | canvas.current.addEventListener("mouseout", stopDrawing); 103 | 104 | canvas.current.width = window.innerWidth - 196; 105 | canvas.current.height = window.innerHeight; 106 | 107 | ctx.current.strokeStyle = "#000"; 108 | ctx.current.lineJoin = "round"; 109 | ctx.current.lineCap = "round"; 110 | ctx.current.lineWidth = 10; 111 | setIsReady(true); 112 | } 113 | }, [drawNormal, handleMouseDown, stopDrawing]); 114 | 115 | const handleRegularMode = useCallback(() => { 116 | setIsRegularMode(true); 117 | isEraserMode.current = false; 118 | setIsEraser(false); 119 | isRegularPaintMode.current = true; 120 | }, []); 121 | 122 | const handleSpecialMode = useCallback(() => { 123 | setIsRegularMode(false); 124 | isEraserMode.current = false; 125 | setIsEraser(false); 126 | isRegularPaintMode.current = false; 127 | }, []); 128 | 129 | const handleColor = (e: any) => { 130 | setCurrentColor(e.currentTarget.value); 131 | selectedColor.current = e.currentTarget.value; 132 | }; 133 | 134 | const handleWidth = (e: any) => { 135 | setCurrentWidth(e.currentTarget.value); 136 | selectedLineWidth.current = e.currentTarget.value; 137 | }; 138 | 139 | const handleClear = useCallback(() => { 140 | if (!ctx || !ctx.current || !canvas || !canvas.current) { 141 | return; 142 | } 143 | ctx.current.clearRect(0, 0, canvas.current.width, canvas.current.height); 144 | }, []); 145 | 146 | const handleEraserMode = (e: any) => { 147 | autoWidth.current = false; 148 | setIsAutoWidth(false); 149 | setIsRegularMode(true); 150 | isEraserMode.current = true; 151 | setIsEraser(true); 152 | }; 153 | 154 | const setCurrentSaturation = (e: any) => { 155 | setCurrentColor( 156 | `hsl(${hue.current},${e.currentTarget.value}%,${selectedLightness.current}%)`, 157 | ); 158 | selectedSaturation.current = e.currentTarget.value; 159 | }; 160 | 161 | const setCurrentLightness = (e: any) => { 162 | setCurrentColor( 163 | `hsl(${hue.current},${selectedSaturation.current}%,${e.currentTarget.value}%)`, 164 | ); 165 | selectedLightness.current = e.currentTarget.value; 166 | }; 167 | 168 | const setAutoWidth = (e: any) => { 169 | autoWidth.current = e.currentTarget.checked; 170 | setIsAutoWidth(e.currentTarget.checked); 171 | 172 | if (!e.currentTarget.checked) { 173 | setCurrentWidth(selectedLineWidth.current); 174 | } else { 175 | setCurrentWidth(ctx?.current?.lineWidth ?? selectedLineWidth.current); 176 | } 177 | }; 178 | 179 | return [ 180 | { 181 | canvas, 182 | isReady, 183 | currentWidth, 184 | currentColor, 185 | isRegularMode, 186 | isAutoWidth, 187 | isEraser, 188 | }, 189 | { 190 | init, 191 | handleRegularMode, 192 | handleSpecialMode, 193 | handleColor, 194 | handleWidth, 195 | handleClear, 196 | handleEraserMode, 197 | setAutoWidth, 198 | setCurrentSaturation, 199 | setCurrentLightness, 200 | }, 201 | ] as any; 202 | }; 203 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | import "normalize.css"; 6 | import "./style.css"; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById("root"), 13 | ); 14 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-size-base: 18px; 3 | --line-height-base: 1.5; 4 | 5 | --color-text: #1d1d1d; 6 | --color-background: #ffffff; 7 | } 8 | 9 | html { 10 | box-sizing: border-box; 11 | } 12 | 13 | *, 14 | *:before, 15 | *:after { 16 | box-sizing: inherit; 17 | } 18 | 19 | body { 20 | min-width: 1100px; 21 | color: var(--color-text); 22 | font-size: var(--font-size-base); 23 | line-height: var(--line-height-base); 24 | font-family: "Lato", Avenir, Adobe Heiti Std, Segoe UI, Trebuchet MS, 25 | sans‑serif; 26 | } 27 | 28 | html, 29 | body, 30 | canvas, 31 | section, 32 | main { 33 | width: 100%; 34 | height: 100%; 35 | } 36 | 37 | section { 38 | flex-grow: 1; 39 | height: 100%; 40 | } 41 | 42 | input { 43 | outline: 0; 44 | border: 0; 45 | } 46 | 47 | input[type="range"] { 48 | display: block; 49 | width: 100%; 50 | } 51 | 52 | aside { 53 | flex-basis: 196px; 54 | background-color: #f1f1f1; 55 | padding: 1.2em; 56 | display: flex; 57 | flex-direction: column; 58 | overflow: auto; 59 | } 60 | 61 | aside > div:first-of-type { 62 | flex-grow: 1; 63 | } 64 | 65 | main { 66 | display: flex; 67 | } 68 | 69 | header { 70 | min-width: 1100px; 71 | position: fixed; 72 | background-color: #f5f5f5; 73 | top: 0; 74 | left: 0; 75 | width: 100vw; 76 | height: 100vh; 77 | z-index: 5; 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | text-align: center; 82 | padding: 4rem; 83 | border: 2rem solid; 84 | border-image-slice: 1; 85 | border-image-source: repeating-conic-gradient( 86 | hsl(0, 100%, 50%), 87 | hsl(36, 100%, 50%), 88 | hsl(108, 100%, 50%), 89 | hsl(211, 100%, 50%), 90 | hsl(247, 100%, 50%), 91 | hsl(277, 100%, 50%), 92 | hsl(320, 100%, 50%), 93 | hsl(0, 100%, 50%) 94 | ); 95 | } 96 | 97 | header.hidden { 98 | animation: fadeOut 1s ease-in-out forwards; 99 | pointer-events: none; 100 | } 101 | 102 | header a { 103 | padding: 0 1rem; 104 | font-weight: 700; 105 | color: var(--color-text); 106 | text-decoration: none; 107 | opacity: 0.8; 108 | transition: opacity 0.3s ease; 109 | } 110 | 111 | header a:nth-of-type(1) { 112 | color: hsl(36, 100%, 40%); 113 | } 114 | 115 | header a:nth-of-type(2) { 116 | color: hsl(108, 100%, 25%); 117 | } 118 | 119 | header a:nth-of-type(3) { 120 | color: hsl(211, 100%, 40%); 121 | } 122 | 123 | header a:nth-of-type(4) { 124 | color: hsl(277, 100%, 40%); 125 | } 126 | 127 | header a:hover, 128 | header a:active { 129 | opacity: 1; 130 | } 131 | 132 | h1 { 133 | font-weight: 400; 134 | font-family: "Finger Paint"; 135 | font-size: 8rem; 136 | letter-spacing: -0.3rem; 137 | margin: 0 0 3rem; 138 | background: linear-gradient( 139 | 90deg, 140 | hsl(0, 100%, 50%), 141 | hsl(211, 100%, 50%) 50%, 142 | hsl(108, 100%, 40%) 143 | ); 144 | -webkit-background-clip: text; 145 | -webkit-text-fill-color: transparent; 146 | } 147 | 148 | .blob-btn { 149 | z-index: 1; 150 | position: relative; 151 | padding: 1.5rem 4rem; 152 | margin-bottom: 30px; 153 | font-size: 1.25rem; 154 | text-align: center; 155 | background-color: transparent; 156 | outline: none; 157 | border: none; 158 | cursor: pointer; 159 | border-radius: 3rem; 160 | animation: colorShift 13s ease infinite forwards; 161 | box-shadow: 0 0px 15px currentColor; 162 | margin-bottom: 3rem; 163 | display: inline-flex; 164 | text-decoration: none; 165 | font-weight: 400; 166 | } 167 | .blob-text { 168 | font-family: "Finger Paint"; 169 | letter-spacing: 1px; 170 | color: currentColor; 171 | } 172 | .blob-btn:before { 173 | content: ""; 174 | z-index: 1; 175 | position: absolute; 176 | left: 0; 177 | top: 0; 178 | width: 100%; 179 | height: 100%; 180 | border: 4px solid currentColor; 181 | border-radius: 30px; 182 | } 183 | .blob-btn:after { 184 | content: ""; 185 | z-index: -2; 186 | position: absolute; 187 | left: 3px; 188 | top: 3px; 189 | width: 100%; 190 | height: 100%; 191 | transition: all 0.3s 0.2s; 192 | border-radius: 30px; 193 | } 194 | .blob-btn:hover .blob-text { 195 | color: #ffffff; 196 | border-radius: 30px; 197 | } 198 | .blob-btn:hover:after { 199 | transition: all 0.3s; 200 | left: 0; 201 | top: 0; 202 | border-radius: 30px; 203 | } 204 | .blob-btn__inner { 205 | z-index: -1; 206 | overflow: hidden; 207 | position: absolute; 208 | left: 0; 209 | top: 0; 210 | width: 100%; 211 | height: 100%; 212 | border-radius: 30px; 213 | background: #ffffff; 214 | } 215 | .blob-btn__blobs { 216 | position: relative; 217 | display: block; 218 | height: 100%; 219 | filter: url("#goo"); 220 | } 221 | 222 | .blob-btn__blob { 223 | position: absolute; 224 | top: 2px; 225 | width: 25%; 226 | height: 100%; 227 | background: currentColor; 228 | border-radius: 100%; 229 | transform: translate3d(0, 150%, 0) scale(1.7); 230 | transition: transform 0.4s ease; 231 | } 232 | @supports (filter: url("#goo")) { 233 | .blob-btn__blob { 234 | transform: translate3d(0, 150%, 0) scale(1.4); 235 | } 236 | } 237 | .blob-btn__blob:nth-child(1) { 238 | left: 0%; 239 | transition-delay: 0s; 240 | } 241 | .blob-btn__blob:nth-child(2) { 242 | left: 30%; 243 | transition-delay: 0.08s; 244 | } 245 | .blob-btn__blob:nth-child(3) { 246 | left: 60%; 247 | transition-delay: 0.16s; 248 | } 249 | .blob-btn__blob:nth-child(4) { 250 | left: 90%; 251 | transition-delay: 0.24s; 252 | } 253 | .blob-btn:hover .blob-btn__blob { 254 | transform: translateZ(0) scale(1.7); 255 | } 256 | @supports (filter: url("#goo")) { 257 | .blob-btn:hover .blob-btn__blob { 258 | transform: translateZ(0) scale(1.4); 259 | } 260 | } 261 | 262 | .preview { 263 | width: 130px; 264 | height: 130px; 265 | border: 2px solid var(--color-text); 266 | position: relative; 267 | } 268 | 269 | .preview__brush { 270 | position: absolute; 271 | top: 50%; 272 | left: 50%; 273 | transform: translate3d(-50%, -50%, 0); 274 | border-radius: 100%; 275 | } 276 | 277 | @keyframes colorShift { 278 | 0% { 279 | color: hsl(0, 100%, 40%); 280 | } 281 | 10% { 282 | color: hsl(36, 100%, 40%); 283 | } 284 | 20% { 285 | color: hsl(72, 100%, 30%); 286 | } 287 | 30% { 288 | color: hsl(108, 100%, 30%); 289 | } 290 | 40% { 291 | color: hsl(144, 100%, 30%); 292 | } 293 | 50% { 294 | color: hsl(180, 100%, 20%); 295 | } 296 | 60% { 297 | color: hsl(211, 100%, 40%); 298 | } 299 | 70% { 300 | color: hsl(247, 100%, 40%); 301 | } 302 | 80% { 303 | color: hsl(277, 100%, 50%); 304 | } 305 | 90% { 306 | color: hsl(301, 100%, 40%); 307 | } 308 | 100% { 309 | color: hsl(320, 100%, 30%); 310 | } 311 | 100% { 312 | color: hsl(350, 100%, 40%); 313 | } 314 | } 315 | 316 | @keyframes fadeOut { 317 | from { 318 | opacity: 1; 319 | } 320 | 321 | to { 322 | opacity: 0; 323 | } 324 | } 325 | 326 | .btn { 327 | display: inline-flex; 328 | cursor: pointer; 329 | justify-content: center; 330 | align-items: center; 331 | border: 0; 332 | outline: 0; 333 | border-radius: 0; 334 | text-decoration: none; 335 | padding: 0.5em; 336 | color: var(--color-text); 337 | background-color: #bbb; 338 | height: 45px; 339 | } 340 | 341 | .btn--main { 342 | background-color: hsl(211, 100%, 75%); 343 | margin-bottom: 0.75rem; 344 | } 345 | 346 | .btn--block { 347 | display: flex; 348 | width: 100%; 349 | } 350 | 351 | .btn--tool { 352 | background-color: #c1c1c1; 353 | width: 100%; 354 | height: 100%; 355 | padding: 0.25em 0.3em; 356 | } 357 | 358 | .tool-grid { 359 | display: grid; 360 | grid-template-columns: 1fr 1fr 1fr 1fr; 361 | grid-template-rows: 1fr; 362 | grid-gap: 0.25rem; 363 | margin-bottom: 0.5em; 364 | } 365 | 366 | .btn--color { 367 | -webkit-appearance: none; 368 | border: none; 369 | width: 100%; 370 | height: 28px; 371 | padding: 0; 372 | cursor: pointer; 373 | } 374 | 375 | input[type="color"]::-webkit-color-swatch-wrapper { 376 | padding: 0; 377 | } 378 | input[type="color"]::-webkit-color-swatch { 379 | border: none; 380 | } 381 | 382 | .btn--main { 383 | } 384 | 385 | input[type="checkbox"] { 386 | display: none; 387 | } 388 | 389 | .tool-section { 390 | padding-bottom: 0.5rem; 391 | } 392 | 393 | .tool-section--lrg { 394 | padding-bottom: 1rem; 395 | } 396 | 397 | .btn--active { 398 | background-color: hsl(211, 100%, 70%); 399 | } 400 | 401 | .btn--dream-active { 402 | background-image: repeating-conic-gradient( 403 | hsl(0, 100%, 70%), 404 | hsl(36, 100%, 70%), 405 | hsl(108, 100%, 70%), 406 | hsl(211, 100%, 70%), 407 | hsl(247, 100%, 70%), 408 | hsl(277, 100%, 70%), 409 | hsl(320, 100%, 70%), 410 | hsl(0, 100%, 70%) 411 | ); 412 | } 413 | 414 | .btn--eraser-active { 415 | background-color: hsl(108, 100%, 70%); 416 | } 417 | 418 | .btn--width-active { 419 | background-color: hsl(0, 100%, 70%); 420 | } 421 | 422 | *[disabled], 423 | *[disabled] + label { 424 | opacity: 0.6; 425 | cursor: not-allowed; 426 | } 427 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------