├── .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 |
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 |
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 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/components/Goo.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const Goo = () => {
4 | return (
5 |
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 |
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 |
--------------------------------------------------------------------------------