├── .eslintrc
├── .gitignore
├── LICENSE.md
├── README.md
├── assets
└── screen.png
├── package-lock.json
├── package.json
├── public
└── index.html
└── src
├── app.js
├── ast-transforms.js
├── codemirror-base16-grayscale-dark.css
├── commands.js
├── editor.js
├── errors.js
├── examples.js
├── help.js
├── hooks.js
├── index.js
├── inspector.js
├── math.js
├── optimise.js
├── overlay.js
├── panel.js
├── sketch-container.js
├── sketch.js
├── slider.js
├── style.css
└── topbar.js
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["node-style-guide", "prettier"],
3 | "plugins": ["react", "react-hooks", "prettier"],
4 | "parserOptions": {
5 | "sourceType": "module",
6 | "ecmaVersion": 2018,
7 | "ecmaFeatures": {
8 | "jsx": true
9 | }
10 | },
11 | "rules": {
12 | "max-statements": [0],
13 | "react/jsx-uses-vars": [1],
14 | "react/jsx-uses-react": [1]
15 | },
16 | "env": {
17 | "browser": true,
18 | "es6": true,
19 | "node": true,
20 | "worker": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Szymon Kaliski
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 | # Dacein
2 |
3 |
4 |
5 | An experimental creative coding IDE combining:
6 |
7 | - functional creative coding library
8 | - time travel abilities
9 | - livecoding editor
10 | - direct manipulation
11 |
12 | Live: [https://szymonkaliski.github.io/dacein/](https://szymonkaliski.github.io/dacein/)
13 |
14 | You can check out lenghty blog post about why, how it was made here: [building dacein](http://szymonkaliski.com/log/2019-03-01-building-dacein/)
15 |
16 | ## Run
17 |
18 | 1. clone this repo
19 | 2. `npm install`
20 | 3. `npm start`
21 |
22 |
--------------------------------------------------------------------------------
/assets/screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szymonkaliski/dacein/35beb76fdbc1d10ebc0a8deda11b02e3b816d6e5/assets/screen.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dacein",
3 | "version": "1.0.0",
4 | "private": true,
5 | "homepage": "http://szymonkaliski.github.io/dacein",
6 | "scripts": {
7 | "start": "FORCE_COLOR=true BROWSER=none react-scripts start",
8 | "build": "react-scripts build",
9 | "deploy": "npm run build && gh-pages -d build"
10 | },
11 | "dependencies": {
12 | "@rehooks/component-size": "^1.0.2",
13 | "@rehooks/window-size": "^1.0.2",
14 | "ast-types": "^0.12.2",
15 | "codemirror": "^5.43.0",
16 | "d3-require": "^1.2.2",
17 | "file-saver": "^2.0.0",
18 | "immer": "^2.0.0",
19 | "left-pad": "^1.3.0",
20 | "lodash": "^4.17.11",
21 | "numeric": "^1.2.6",
22 | "react": "^16.8.2",
23 | "react-codemirror2": "^5.1.0",
24 | "react-color": "^3.0.0-beta.3",
25 | "react-dom": "^16.8.2",
26 | "react-json-view": "^1.19.1",
27 | "react-outside-click-handler": "^1.2.2",
28 | "react-scripts": "^2.1.5",
29 | "recast": "^0.17.3",
30 | "tachyons": "^4.11.1"
31 | },
32 | "devDependencies": {
33 | "eslint-config-node-style-guide": "^3.0.0",
34 | "eslint-config-prettier": "^4.0.0",
35 | "eslint-plugin-prettier": "^3.0.1",
36 | "eslint-plugin-react-hooks": "^1.0.2",
37 | "gh-pages": "^2.0.1"
38 | },
39 | "browserslist": [
40 | ">0.2%",
41 | "not dead",
42 | "not ie <= 11",
43 | "not op_mini all"
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Dacein
8 |
9 |
10 | You need to enable JavaScript to run this app.
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { requireFrom } from "d3-require";
3 |
4 | import { EXAMPLES } from "./examples";
5 | import { Editor } from "./editor";
6 | import { Errors } from "./errors";
7 | import { Panel, DIRECTION } from "./panel";
8 | import { Sketch } from "./sketch";
9 | import { Topbar } from "./topbar";
10 |
11 | import {
12 | addMeta,
13 | processRequire,
14 | pullOutConstants,
15 | replaceConstants
16 | } from "./ast-transforms";
17 |
18 | import "tachyons";
19 | import "./style.css";
20 |
21 | export const App = () => {
22 | const [code, setCode] = useState(EXAMPLES["animated rectangle"]);
23 | const [constants, setConstants] = useState(null);
24 | const [sketch, setSketch] = useState(null);
25 | const [errors, setErrors] = useState(null);
26 | const [highlight, setHighlight] = useState(null);
27 |
28 | useEffect(() => {
29 | let tmpErrors = [];
30 | let pulledConstants = null;
31 | let finalCode = null;
32 |
33 | window.require = requireFrom(name =>
34 | Promise.resolve(`https://bundle.run/${name}`)
35 | );
36 |
37 | window.sketch = sketch => {
38 | try {
39 | if (sketch.update) {
40 | sketch.update(sketch.initialState || {}, []);
41 | }
42 |
43 | if (sketch.draw) {
44 | sketch.draw(sketch.initialState || {}, pulledConstants || []);
45 | }
46 | } catch (e) {
47 | console.warn(e);
48 | tmpErrors.push(e.description);
49 | }
50 |
51 | if (tmpErrors.length > 0) {
52 | setErrors(tmpErrors);
53 | } else {
54 | setSketch({
55 | initialState: {},
56 | update: () => {},
57 | draw: () => [],
58 | ...sketch
59 | });
60 | }
61 | };
62 |
63 | // ast
64 | try {
65 | const {
66 | code: codeWithoutConstants,
67 | constants: pulledOutConstants
68 | } = pullOutConstants(code);
69 |
70 | const codeWithMeta = addMeta(codeWithoutConstants);
71 | const codeWithRequires = processRequire(codeWithMeta);
72 |
73 | pulledConstants = pulledOutConstants;
74 | finalCode = codeWithRequires;
75 | } catch (e) {
76 | console.warn(e);
77 | tmpErrors.push(e.description);
78 | }
79 |
80 | if (pulledConstants) {
81 | setConstants(pulledConstants);
82 | }
83 |
84 | // eval only if we have something worth evaling
85 | if (finalCode) {
86 | try {
87 | eval(`
88 | const sketch = window.sketch;
89 | ${finalCode}
90 | `);
91 | } catch (e) {
92 | console.warn(e);
93 | tmpErrors.push(e.description);
94 | }
95 | }
96 |
97 | setErrors(tmpErrors);
98 |
99 | return () => {
100 | delete window.sketch;
101 | delete window.require;
102 | };
103 | }, [code]);
104 |
105 | return (
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 | {sketch && (
114 |
119 | setCode(replaceConstants(code, newConstants))
120 | }
121 | setHighlight={setHighlight}
122 | />
123 | )}
124 |
125 |
126 |
127 |
128 | setCode(e)}
131 | highlight={highlight}
132 | />
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 | );
142 | };
143 |
--------------------------------------------------------------------------------
/src/ast-transforms.js:
--------------------------------------------------------------------------------
1 | import recast from "recast";
2 | import types from "ast-types";
3 | import { get, isNumber } from "lodash";
4 |
5 | import { COMMANDS } from "./commands";
6 |
7 | const Builders = recast.types.builders;
8 | const isCommand = key => COMMANDS[key] !== undefined;
9 |
10 | export const addMeta = code => {
11 | const ast = recast.parse(code);
12 |
13 | types.visit(ast, {
14 | visitExpressionStatement: function(path) {
15 | if (get(path, "value.expression.callee.name") === "sketch") {
16 | this.traverse(path);
17 | } else {
18 | return false;
19 | }
20 | },
21 |
22 | visitProperty: function(path) {
23 | if (path.value.key.name === "draw") {
24 | this.traverse(path);
25 | } else {
26 | return false;
27 | }
28 | },
29 |
30 | visitReturnStatement: function(path) {
31 | this.traverse(path);
32 | },
33 |
34 | visitArrayExpression: function(path) {
35 | const elements = path.value.elements || [];
36 | const maybeCommand = elements[0];
37 |
38 | if (isCommand(get(maybeCommand, "value"))) {
39 | let loc = maybeCommand.loc;
40 | let searchPath = path;
41 |
42 | while (searchPath) {
43 | if (
44 | get(searchPath, ["value", "body", "type"]) === "ArrayExpression" ||
45 | get(searchPath, ["value", "type"]) === "ArrayExpression"
46 | ) {
47 | loc = {
48 | start: get(searchPath, ["value", "loc", "start"]),
49 | end: get(searchPath, ["value", "loc", "end"])
50 | };
51 |
52 | searchPath = undefined;
53 | } else {
54 | searchPath = get(searchPath, "parentPath");
55 | }
56 | }
57 |
58 | if (elements[1].type === "ObjectExpression") {
59 | return Builders.arrayExpression([
60 | elements[0],
61 | Builders.objectExpression([
62 | ...elements[1].properties,
63 | Builders.property(
64 | "init",
65 | Builders.identifier("__meta"),
66 | Builders.objectExpression([
67 | Builders.property(
68 | "init",
69 | Builders.identifier("lineStart"),
70 | Builders.literal(loc.start.line)
71 | ),
72 | Builders.property(
73 | "init",
74 | Builders.identifier("lineEnd"),
75 | Builders.literal(loc.end.line)
76 | )
77 | ])
78 | )
79 | ])
80 | ]);
81 | }
82 |
83 | return false;
84 | } else {
85 | this.traverse(path);
86 | }
87 | }
88 | });
89 |
90 | const { code: finalCode } = recast.print(ast);
91 |
92 | return finalCode;
93 | };
94 |
95 | export const processRequire = code => {
96 | const ast = recast.parse(code);
97 |
98 | const requires = [];
99 |
100 | types.visit(ast, {
101 | visitIdentifier: function(path) {
102 | if (path.value.name === "require") {
103 | const arg = path.parentPath.value.arguments[0].value;
104 | const name = path.parentPath.parentPath.value.id.name;
105 | const loc = path.parentPath.parentPath.parentPath.value[0].loc;
106 |
107 | requires.push({
108 | start: loc.start.line - 1,
109 | end: loc.end.line - 1,
110 | arg,
111 | name
112 | });
113 |
114 | return false;
115 | }
116 |
117 | this.traverse(path);
118 | }
119 | });
120 |
121 | if (!requires.length) {
122 | return code;
123 | }
124 |
125 | // TODO: this should be done with recast as well, but I'm lazy
126 | const codeWithoutRequire = code
127 | .split("\n")
128 | .map((line, i) => {
129 | const shouldBeBlank = requires.some(
130 | ({ start, end }) => i >= start && i <= end
131 | );
132 |
133 | if (shouldBeBlank) {
134 | return "";
135 | }
136 |
137 | return line;
138 | })
139 | .join("\n");
140 |
141 | const finalCode = `
142 | ${requires
143 | .map(({ name, arg }) => `window.require("${arg}").then(${name} => {`)
144 | .join("\n")}
145 |
146 | ${codeWithoutRequire}
147 |
148 | ${requires
149 | // TODO: set error from here
150 | .map(({ name, arg }) => `}).catch(e => console.warn(e));`)
151 | .join("\n")}
152 | `;
153 |
154 | return finalCode;
155 | };
156 |
157 | export const replaceConstants = (code, constants) => {
158 | let idx = 0;
159 |
160 | const ast = recast.parse(code);
161 |
162 | types.visit(ast, {
163 | visitLiteral: function(path) {
164 | if (isNumber(path.value.value)) {
165 | let searchPath = path;
166 | let isInsideDraw = false;
167 |
168 | while (searchPath) {
169 | if (get(searchPath, ["value", "key", "name"]) === "draw") {
170 | searchPath = null;
171 | isInsideDraw = true;
172 | }
173 | searchPath = get(searchPath, "parentPath");
174 | }
175 |
176 | if (!isInsideDraw) {
177 | this.traverse(path);
178 | return;
179 | }
180 |
181 | if (constants && constants[idx]) {
182 | const number = constants[idx];
183 |
184 | path.value.value = number;
185 | path.value.raw = `${number}`;
186 |
187 | idx++;
188 | }
189 | }
190 |
191 | this.traverse(path);
192 | }
193 | });
194 |
195 | return recast.print(ast).code;
196 | };
197 |
198 | export const pullOutConstants = code => {
199 | const ast = recast.parse(code);
200 |
201 | let idx = 0;
202 | const pulledConstants = [];
203 |
204 | types.visit(ast, {
205 | visitArrowFunctionExpression: function(path) {
206 | if (get(path, "parentPath.value.key.name") === "draw") {
207 | return Builders.arrowFunctionExpression(
208 | [Builders.identifier("state"), Builders.identifier("__constants")],
209 | path.value.body
210 | );
211 | }
212 |
213 | this.traverse(path);
214 | },
215 |
216 | visitFunctionExpression: function(path) {
217 | if (get(path, "parentPath.value.key.name") === "draw") {
218 | return Builders.functionExpression(
219 | null,
220 | [Builders.identifier("state"), Builders.identifier("__constants")],
221 | path.value.body
222 | );
223 | }
224 |
225 | this.traverse(path);
226 | },
227 |
228 | visitLiteral: function(path) {
229 | if (isNumber(path.value.value)) {
230 | // protect from recursively updating in place
231 | if (
232 | get(path, ["parentPath", "value", "object", "name"]) === "__constants"
233 | ) {
234 | return false;
235 | }
236 |
237 | let searchPath = path;
238 | let isInsideDraw = false;
239 |
240 | while (searchPath) {
241 | if (get(searchPath, ["value", "key", "name"]) === "draw") {
242 | searchPath = null;
243 | isInsideDraw = true;
244 | }
245 | searchPath = get(searchPath, "parentPath");
246 | }
247 |
248 | if (!isInsideDraw) {
249 | this.traverse(path);
250 | return;
251 | }
252 |
253 | pulledConstants.push(path.value.value);
254 |
255 | return Builders.memberExpression(
256 | Builders.identifier("__constants"),
257 | Builders.literal(idx++)
258 | );
259 | }
260 |
261 | this.traverse(path);
262 | }
263 | });
264 |
265 | return {
266 | code: recast.print(ast).code,
267 | constants: pulledConstants
268 | };
269 | };
270 |
--------------------------------------------------------------------------------
/src/codemirror-base16-grayscale-dark.css:
--------------------------------------------------------------------------------
1 | /*
2 | *
3 | * Name: Base16 Grayscale Dark
4 | * Author: Alexandre Gavioli (https://github.com/Alexx2/)
5 | *
6 | * CodeMirror template by Jan T. Sott (https://github.com/idleberg)
7 | * Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16)
8 | *
9 | */
10 |
11 | .cm-s-base16-grayscale-dark.CodeMirror {
12 | background: #101010;
13 | color: #e3e3e3;
14 | }
15 | .cm-s-base16-grayscale-dark div.CodeMirror-selected {
16 | background: #252525 !important;
17 | }
18 | .cm-s-base16-grayscale-dark .CodeMirror-gutters {
19 | background: #101010;
20 | border-right: 0px;
21 | }
22 | .cm-s-base16-grayscale-dark .CodeMirror-linenumber {
23 | color: #525252;
24 | }
25 | .cm-s-base16-grayscale-dark .CodeMirror-cursor {
26 | border-left: 1px solid #ababab !important;
27 | }
28 |
29 | .cm-s-base16-grayscale-dark span.cm-comment {
30 | color: #5e5e5e;
31 | }
32 | .cm-s-base16-grayscale-dark span.cm-atom {
33 | color: #747474;
34 | }
35 | .cm-s-base16-grayscale-dark span.cm-number {
36 | color: #747474;
37 | }
38 |
39 | .cm-s-base16-grayscale-dark span.cm-property,
40 | .cm-s-base16-grayscale-dark span.cm-attribute {
41 | color: #8e8e8e;
42 | }
43 | .cm-s-base16-grayscale-dark span.cm-keyword {
44 | color: #7c7c7c;
45 | }
46 | .cm-s-base16-grayscale-dark span.cm-string {
47 | color: #a0a0a0;
48 | }
49 |
50 | .cm-s-base16-grayscale-dark span.cm-variable {
51 | color: #8e8e8e;
52 | }
53 | .cm-s-base16-grayscale-dark span.cm-variable-2 {
54 | color: #686868;
55 | }
56 | .cm-s-base16-grayscale-dark span.cm-def {
57 | color: #999999;
58 | }
59 | .cm-s-base16-grayscale-dark span.cm-error {
60 | background: #7c7c7c;
61 | color: #ababab;
62 | }
63 | .cm-s-base16-grayscale-dark span.cm-bracket {
64 | color: #e3e3e3;
65 | }
66 | .cm-s-base16-grayscale-dark span.cm-tag {
67 | color: #7c7c7c;
68 | }
69 | .cm-s-base16-grayscale-dark span.cm-link {
70 | color: #747474;
71 | }
72 |
73 | .cm-s-base16-grayscale-dark .CodeMirror-matchingbracket {
74 | text-decoration: underline;
75 | color: white !important;
76 | }
77 |
--------------------------------------------------------------------------------
/src/commands.js:
--------------------------------------------------------------------------------
1 | export const COMMANDS = {
2 | background: (ctx, { fill }, { width, height }) => {
3 | ctx.fillStyle = fill;
4 | ctx.fillRect(0, 0, width, height);
5 | },
6 |
7 | line: (ctx, { a, b, stroke }) => {
8 | if (!stroke) {
9 | return;
10 | }
11 |
12 | ctx.strokeStyle = stroke;
13 |
14 | ctx.beginPath();
15 | ctx.moveTo(a[0], a[1]);
16 | ctx.lineTo(b[0], b[1]);
17 | ctx.stroke();
18 | },
19 |
20 | path: (ctx, { points, stroke, fill }) => {
21 | if (stroke) {
22 | ctx.strokeStyle = stroke;
23 | }
24 | if (fill) {
25 | ctx.fillStyle = fill;
26 | }
27 |
28 | ctx.beginPath();
29 | ctx.moveTo(points[0][0], points[0][1]);
30 | for (const point of points) {
31 | ctx.lineTo(point[0], point[1]);
32 | }
33 | ctx.stroke();
34 |
35 | if (fill) {
36 | ctx.fill();
37 | }
38 | if (stroke) {
39 | ctx.stroke();
40 | }
41 | },
42 |
43 | ellipse: (ctx, { pos, size, fill, stroke }) => {
44 | if (fill) {
45 | ctx.fillStyle = fill;
46 | }
47 | if (stroke) {
48 | ctx.strokeStyle = stroke;
49 | }
50 |
51 | const [x, y] = pos || [0, 0];
52 | const [w, h] = size || [0, 0];
53 |
54 | ctx.beginPath();
55 | ctx.ellipse(x, y, w, h, 0, 0, Math.PI * 2);
56 |
57 | if (fill) {
58 | ctx.fill();
59 | }
60 | if (stroke) {
61 | ctx.stroke();
62 | }
63 | },
64 |
65 | rect: (ctx, { pos, size, fill, stroke }) => {
66 | if (fill) {
67 | ctx.fillStyle = fill;
68 | }
69 | if (stroke) {
70 | ctx.strokeStyle = stroke;
71 | }
72 |
73 | const [x, y] = pos || [0, 0];
74 | const [w, h] = size || [0, 0];
75 |
76 | ctx.beginPath();
77 | ctx.rect(x, y, w, h);
78 |
79 | if (fill) {
80 | ctx.fill();
81 | }
82 | if (stroke) {
83 | ctx.stroke();
84 | }
85 | }
86 | };
87 |
--------------------------------------------------------------------------------
/src/editor.js:
--------------------------------------------------------------------------------
1 | import OutsideClickHandler from "react-outside-click-handler";
2 | import React, { useState, useEffect, useRef } from "react";
3 | import { ChromePicker } from "react-color";
4 | import { Controlled as CodeMirror } from "react-codemirror2";
5 |
6 | import "codemirror/lib/codemirror.css";
7 | import "codemirror/mode/javascript/javascript";
8 |
9 | import "./codemirror-base16-grayscale-dark.css";
10 | import { Slider } from "./slider";
11 | import { scale } from "./math";
12 |
13 | const getColorFormat = str => {
14 | const RE_HSL = new RegExp(
15 | /hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3}%)\s*,\s*(\d{1,3}%)\s*(?:\s*,\s*(\d+(?:\.\d+)?)\s*)?\)/g
16 | );
17 | const RE_RGB = new RegExp(/rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)/g);
18 | const RE_HEX = new RegExp(/#[a-fA-F0-9]{3,6}/g);
19 |
20 | if (str.match(RE_HSL)) {
21 | return "hsl";
22 | }
23 |
24 | if (str.match(RE_RGB)) {
25 | return "rgb";
26 | }
27 |
28 | if (str.match(RE_HEX)) {
29 | return "hex";
30 | }
31 |
32 | return null;
33 | };
34 |
35 | const stringifyColorFormat = (v, format) => {
36 | if (format === "hex") {
37 | return `"${v.hex}"`;
38 | }
39 |
40 | if (format === "hsl") {
41 | return `"hsl(${v.hsl.h}, ${v.hsl.s}, ${v.hsl.l}, ${v.hsl.a})"`;
42 | }
43 |
44 | if (format === "rgb") {
45 | return `"rgb(${v.rgb.r}, ${v.rgb.g}, ${v.rgb.b}, ${v.rgb.a})"`;
46 | }
47 |
48 | return null;
49 | };
50 |
51 | const NumberPicker = ({ value, coords, onChange }) => {
52 | const [draftValue, setDraftValue] = useState(value);
53 |
54 | const exp = Math.round(Math.log10(Math.abs(value)));
55 | const min = 0;
56 | const max = Math.pow(10, exp + 1);
57 |
58 | return (
59 | 30 ? coords.top - 12 : coords.top + 16,
63 | left: coords.left - 10,
64 | zIndex: 10,
65 | width: 120
66 | }}
67 | >
68 | {
71 | const out = scale(value, 0, 1, min, max);
72 | setDraftValue(out);
73 | onChange(`${out}`);
74 | }}
75 | />
76 |
77 | );
78 | };
79 |
80 | const ColorPicker = ({ value, coords, onChange }) => {
81 | const [draftValue, setDraftValue] = useState(value.replace(/"/g, ""));
82 |
83 | const format = getColorFormat(value);
84 |
85 | return (
86 | 300 ? coords.top - 250 : coords.top + 20,
90 | left: coords.left - 40,
91 | zIndex: 10
92 | }}
93 | >
94 | {
97 | setDraftValue(e[format]);
98 | onChange(stringifyColorFormat(e, format));
99 | }}
100 | />
101 |
102 | );
103 | };
104 |
105 | const Picker = ({ type, coords, value, onChange }) => {
106 | if (type === "number") {
107 | return ;
108 | }
109 |
110 | if (getColorFormat(value) !== null) {
111 | return ;
112 | }
113 |
114 | return null;
115 | };
116 |
117 | export const Editor = ({ code, highlight, onChange }) => {
118 | const [picker, setPicker] = useState(null);
119 | const [editorCode, setEditorCode] = useState(null);
120 |
121 | const instance = useRef(null);
122 | const tokenLength = useRef(null);
123 | const highlightMarker = useRef(null);
124 |
125 | // FIXME
126 | useEffect(() => {
127 | if (code !== editorCode) {
128 | setEditorCode(code);
129 | }
130 | }, [code]);
131 |
132 | useEffect(() => {
133 | if (!instance.current) {
134 | return;
135 | }
136 |
137 | const clearMarker = () => {
138 | if (highlightMarker.current) {
139 | highlightMarker.current.clear();
140 | highlightMarker.current = null;
141 | }
142 | };
143 |
144 | clearMarker();
145 |
146 | if (highlight) {
147 | highlightMarker.current = instance.current.markText(
148 | { line: highlight.start },
149 | { line: highlight.end },
150 | { className: "inspector-highlight" }
151 | );
152 | }
153 |
154 | return clearMarker;
155 | }, [instance, highlight]);
156 |
157 | return (
158 |
159 |
(instance.current = e)}
161 | value={editorCode}
162 | onBeforeChange={(editor, data, value) => setEditorCode(value)}
163 | onChange={(editor, data, value) => onChange(value)}
164 | onCursor={e => {
165 | const cursor = e.getCursor();
166 | const token = e.getTokenAt(cursor);
167 | const coords = e.charCoords(
168 | { line: cursor.line, ch: token.start },
169 | "local"
170 | );
171 |
172 | if (!token.type) {
173 | return;
174 | }
175 |
176 | tokenLength.current = token.string.length;
177 |
178 | setPicker({
179 | key: `${token.type}-${token.string}`,
180 | type: token.type,
181 | value:
182 | token.type === "number" ? Number(token.string) : token.string,
183 | coords,
184 | text: {
185 | line: cursor.line,
186 | start: token.start
187 | }
188 | });
189 | }}
190 | options={{
191 | theme: "base16-grayscale-dark"
192 | }}
193 | />
194 |
195 | {picker && (
196 | setPicker(null)}>
197 | {
203 | const { start, line } = picker.text;
204 |
205 | const newCode = editorCode
206 | .split("\n")
207 | .map((codeLine, i) => {
208 | if (i !== line) {
209 | return codeLine;
210 | }
211 |
212 | return (
213 | codeLine.substr(0, start) +
214 | value +
215 | codeLine.substr(start + tokenLength.current)
216 | );
217 | })
218 | .join("\n");
219 |
220 | tokenLength.current = `${value}`.length;
221 | setEditorCode(newCode);
222 | }}
223 | />
224 |
225 | )}
226 |
227 | );
228 | };
229 |
--------------------------------------------------------------------------------
/src/errors.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { identity } from "lodash";
3 |
4 | export const Errors = ({ errors }) => {
5 | return (
6 |
7 | {!errors || (!errors.length &&
no errors
)}
8 |
9 | {(errors || []).filter(identity).map(text => (
10 |
11 | {text}
12 |
13 | ))}
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/src/examples.js:
--------------------------------------------------------------------------------
1 | export const EXAMPLES = {
2 | "animated rectangle": `sketch({
3 | size: [600, 600],
4 |
5 | initialState: {
6 | rectSize: 100,
7 | direction: 1
8 | },
9 |
10 | update: state => {
11 | state.rectSize = state.rectSize + state.direction;
12 |
13 | if (state.rectSize > 220) {
14 | state.direction = -1;
15 | }
16 |
17 | if (state.rectSize < 20) {
18 | state.direction = 1;
19 | }
20 |
21 | return state;
22 | },
23 |
24 | draw: state => {
25 | const pos = [
26 | 300 - state.rectSize / 2,
27 | 300 - state.rectSize / 2,
28 | ];
29 |
30 | const size = [
31 | state.rectSize,
32 | state.rectSize
33 | ];
34 |
35 | return [
36 | ["background", { fill: "#d2d2d2" }],
37 | ["rect", { fill: "#050505", pos, size }]
38 | ];
39 | }
40 | });`,
41 |
42 | "brownian motion": `const MAX_STEPS = 1000;
43 | const RANGE = 10;
44 |
45 | const rand = (min, max) => Math.random() * (max - min) + min;
46 |
47 | sketch({
48 | size: [600, 600],
49 |
50 | initialState: {
51 | path: [
52 | [300, 300]
53 | ]
54 | },
55 |
56 | update: state => {
57 | state.path.push([
58 | state.path[state.path.length - 1][0] + rand(-RANGE, RANGE),
59 | state.path[state.path.length - 1][1] + rand(-RANGE, RANGE)
60 | ]);
61 |
62 | if (state.path.length > MAX_STEPS) {
63 | state.path.shift();
64 | }
65 |
66 | return state;
67 | },
68 |
69 | draw: state => {
70 | return [
71 | ["background", { fill: "#d2d2d2" }],
72 | ...state.path.slice(1).map((pos, i) => [
73 | "line",
74 | {
75 | a: pos,
76 | b: state.path[i],
77 | stroke: "#050505"
78 | }
79 | ])
80 | ]
81 | }
82 | });`,
83 |
84 | events: `sketch({
85 | size: [600, 600],
86 |
87 | initialState: {
88 | mousePos: [0, 0],
89 | mouseClicked: false
90 | },
91 |
92 | update: (state, events) => {
93 | events.forEach(event => {
94 | if (event.source == "mousemove") {
95 | state.mousePos = event.pos;
96 | }
97 |
98 | if (event.source == "mousedown") {
99 | state.mouseClicked = true;
100 | }
101 |
102 | if (event.source == "mouseup") {
103 | state.mouseClicked = false;
104 | }
105 | });
106 |
107 | return state;
108 | },
109 |
110 | draw: state => {
111 | return [
112 | ["background", { fill: state.mouseClicked ? "#d2d2d2" : "#a2a2a2" }],
113 | ["line", {
114 | a: state.mousePos,
115 | b: [300, 300]
116 | stroke: "#050505"
117 | }]
118 | ];
119 | }
120 | });`,
121 |
122 | "particle system": `// adapted from https://p5js.org/examples/simulate-particle-system.htmlconst
123 |
124 | const vec2 = require("gl-vec2"); // require works
125 |
126 | const rand = (min, max) => Math.random() * (max - min) + min;
127 |
128 | const makeParticle = position => ({
129 | acceleration: [0, 0.05],
130 | position: position.slice(0),
131 | velocity: [rand(-1, 1), rand(-1, 0)],
132 | lifespan: 255
133 | });
134 |
135 | const updateParticle = particle => {
136 | const velocity = vec2.create();
137 | const position = vec2.create();
138 |
139 | vec2.add(velocity, particle.velocity, particle.acceleration);
140 | vec2.add(position, particle.position, velocity);
141 |
142 | return {
143 | lifespan: particle.lifespan - 2,
144 | acceleration: particle.acceleration,
145 | velocity,
146 | position
147 | };
148 | };
149 |
150 | const renderParticle = particle => [
151 | "ellipse",
152 | {
153 | pos: particle.position,
154 | size: [12, 12],
155 | stroke: \`rgba(255, 255, 255, \${particle.lifespan})\`,
156 | fill: \`rgba(127, 127, 127, \${particle.lifespan})\`
157 | }
158 | ];
159 |
160 | sketch({
161 | size: [600, 600],
162 |
163 | initialState: {
164 | particles: []
165 | },
166 |
167 | update: state => {
168 | state.particles.push(makeParticle([300, 50]));
169 |
170 | for (let i = state.particles.length - 1; i >= 0; i--) {
171 | state.particles[i] = updateParticle(state.particles[i]);
172 |
173 | if (state.particles[i].lifespan < 0) {
174 | state.particles.splice(i, 1);
175 | }
176 | }
177 |
178 | return state;
179 | },
180 |
181 | draw: state => {
182 | return [
183 | ["background", { fill: "#333333" }],
184 | ...state.particles.map(renderParticle)
185 | ];
186 | }
187 | });`,
188 |
189 | spring: `
190 | const clamp = (v, min, max) => Math.max(min, Math.min(v, max));
191 |
192 | const MIN_HEIGHT = 100;
193 | const MAX_HEIGHT = 200;
194 |
195 | const M = 0.8; // mass
196 | const K = 0.2; // spring constant
197 | const D = 0.92; // damping
198 | const R = 150; // rest position
199 |
200 | const updateSpring = spring => {
201 | const f = -K * (spring.ps - R); // f=-ky
202 | const as = f / M; // set the acceleration, f=ma == a=f/m
203 | let vs = D * (spring.vs + as); // set the velocity
204 | const ps = spring.ps + vs; // updated position
205 |
206 | if (Math.abs(vs) < 0.1) {
207 | vs = 0.0;
208 | }
209 |
210 | return Object.assign(spring, { f, as, vs, ps });
211 | };
212 |
213 | sketch({
214 | size: [710, 400],
215 |
216 | initialState: {
217 | spring: {
218 | left: 710 / 2 - 100,
219 | width: 200,
220 | height: 50,
221 | ps: R, // position
222 | vs: 0, // velocity
223 | as: 0, // acceleration
224 | f: 0 // force
225 | },
226 | mousePos: [0, 0],
227 | isOver: false,
228 | isDragging: false
229 | },
230 |
231 | update: (state, events) => {
232 | if (!state.isDragging) {
233 | state.spring = updateSpring(state.spring);
234 | }
235 |
236 | events.forEach(e => {
237 | if (e.source === "mousemove") {
238 | state.mousePos = e.pos;
239 | }
240 |
241 | if (e.source === "mousedown") {
242 | state.isDragging = true;
243 | }
244 |
245 | if (e.source === "mouseup") {
246 | state.isDragging = false;
247 | }
248 | });
249 |
250 | if (
251 | state.mousePos[0] > state.spring.left &&
252 | state.mousePos[0] < state.spring.left + state.spring.width &&
253 | state.mousePos[1] > state.spring.ps &&
254 | state.mousePos[1] < state.spring.ps + state.spring.height
255 | ) {
256 | state.isOver = true;
257 | } else {
258 | state.isOver = false;
259 | }
260 |
261 | if (state.isDragging) {
262 | state.spring.ps = state.mousePos[1] - state.spring.height / 2;
263 | state.spring.ps = clamp(state.spring.ps, MIN_HEIGHT, MAX_HEIGHT);
264 | }
265 | },
266 |
267 | draw: state => {
268 | return [
269 | ["background", { fill: "#656565" }],
270 | [
271 | "rect",
272 | {
273 | pos: [state.spring.left, state.spring.ps],
274 | size: [state.spring.width, state.spring.height],
275 | fill: state.isOver ? "#ffffff" : "#cccccc"
276 | }
277 | ]
278 | ];
279 | }
280 | });
281 | `
282 | };
283 |
--------------------------------------------------------------------------------
/src/help.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Overlay } from "./overlay";
4 |
5 | const A = ({ href, children }) => (
6 |
7 | {children}
8 |
9 | );
10 |
11 | const LANG_HELP = [
12 | ["background", ["fill"]],
13 | ["line", ["a", "b", "stroke"]],
14 | ["path", ["points", "stroke", "fill"]],
15 | ["ellipse", ["pos", "size", "fill", "stroke"]],
16 | ["rect", ["pos", "size", "fill", "stroke"]]
17 | ];
18 |
19 | const SAMPLE_CODE = `sketch({
20 | // sketch size
21 | size: [600, 600],
22 |
23 | // starting state
24 | initialState: { size: 10 },
25 |
26 | // update state into a new state, called before every draw
27 | update: state => {
28 | state.size += 10;
29 | return state;
30 | },
31 |
32 | draw: state => {
33 | // return an array of objects to be placed on the sketch
34 | return [
35 | ["background", { fill: "#fff" }],
36 | ["ellipse", {
37 | pos: [ 300 ,300 ],
38 | size: [ state.size, state.size ]
39 | }]
40 | ];
41 | }
42 | })
43 | `;
44 |
45 | export const Help = ({ onClose }) => (
46 |
47 |
48 |
49 |
50 | dacein is an experimental IDE and
51 | library for creative coding made by{" "}
52 | Szymon Kaliski .
53 |
54 |
55 |
56 | In addition to declarative canvas-based graphics library it provides{" "}
57 | color and number pickers in editor for{" "}
58 | live coding ,{" "}
59 | time travel through the sketch updates and{" "}
60 | direct manipulation from canvas back into
61 | code.
62 |
63 |
64 |
65 | Both time travel and{" "}
66 | direct manipulation are only available when
67 | the sketch is paused.
68 |
69 |
70 |
71 |
72 | to travel through time, use the slider near play/pause buttons
73 |
74 |
75 | to manipulte the code, drag an ellipse {" "}
76 | or rect
77 |
78 |
79 |
80 |
81 | It is heavily inspired by{" "}
82 | Processing ,{" "}
83 |
84 | @thi.ng/hdom-canvas
85 |
86 | , and numerous other tools.
87 |
88 |
89 |
90 | The sketch library is a global function
91 | requiring an object with at least a draw {" "}
92 | property:
93 |
94 |
95 |
96 |
99 |
100 |
101 |
Commands available in the language:
102 |
103 |
104 | {LANG_HELP.map(([command, args]) => (
105 |
106 | {command} ({"{"}{" "}
107 | {args.map((arg, i) => (
108 | <>
109 |
110 | {arg}
111 |
112 | {i < args.length - 1 ? ", " : ""}
113 | >
114 | ))}{" "}
115 | {"}"}})
116 |
117 | ))}
118 |
119 |
120 |
121 |
122 | );
123 |
--------------------------------------------------------------------------------
/src/hooks.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import immer from "immer";
3 |
4 | export const useImmer = initialValue => {
5 | const [val, updateValue] = useState(initialValue);
6 | return [val, updater => updateValue(immer(updater))];
7 | };
8 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | import { App } from "./app";
5 |
6 | const USE_CREATE_ROOT = false;
7 |
8 | if (USE_CREATE_ROOT) {
9 | ReactDOM.unstable_createRoot(document.getElementById("root")).render( );
10 | } else {
11 | ReactDOM.render( , document.getElementById("root"));
12 | }
13 |
--------------------------------------------------------------------------------
/src/inspector.js:
--------------------------------------------------------------------------------
1 | import leftPad from "left-pad";
2 | import { get } from "lodash";
3 |
4 | import { COMMANDS } from "./commands";
5 |
6 | const encodeInColor = num => {
7 | const hex = num.toString(16).substr(0, 6);
8 | return `#${leftPad(hex, 6, "0")}`;
9 | };
10 |
11 | const decodeFromColor = hex => {
12 | return parseInt(`0x${hex}`);
13 | };
14 |
15 | export const makeInspector = ({ sketch, globals }) => {
16 | const canvas = document.createElement("canvas");
17 |
18 | canvas.width = globals.width;
19 | canvas.height = globals.height;
20 |
21 | const ctx = canvas.getContext("2d");
22 |
23 | let memo;
24 |
25 | const draw = (state, constants) => {
26 | let i = 0;
27 |
28 | memo = sketch.draw(state, constants);
29 |
30 | for (const operation of memo) {
31 | const [command, args] = operation;
32 |
33 | const argsModded = Object.assign(args, {
34 | fill: args.fill ? encodeInColor(i) : undefined,
35 | stroke: args.stroke ? encodeInColor(i) : undefined
36 | });
37 |
38 | if (COMMANDS[command]) {
39 | COMMANDS[command](ctx, argsModded, globals);
40 | }
41 |
42 | i++;
43 | }
44 | };
45 |
46 | const onHover = (x, y) => {
47 | const data = ctx.getImageData(x, y, 1, 1).data.slice(0, 3);
48 | const hex = Array.from(data)
49 | .map(n => leftPad(n.toString(16), 2, "0"))
50 | .join("");
51 | const id = decodeFromColor(hex);
52 |
53 | return id;
54 | };
55 |
56 | const getMetaForId = id => get(memo, id);
57 |
58 | return {
59 | draw,
60 | onHover,
61 | getMetaForId
62 | };
63 | };
64 |
--------------------------------------------------------------------------------
/src/math.js:
--------------------------------------------------------------------------------
1 | export const clamp = (v, min, max) => Math.max(min, Math.min(v, max));
2 |
3 | export const scale = (val, inputMin, inputMax, outputMin, outputMax) => {
4 | return (
5 | (outputMax - outputMin) * ((val - inputMin) / (inputMax - inputMin)) +
6 | outputMin
7 | );
8 | };
9 |
10 | export const dist = ([ax, ay], [bx, by]) => Math.hypot(bx - ax, by - ay);
11 |
12 | export const add = ([ax, ay], [bx, by]) => [ax + bx, ay + by];
13 |
--------------------------------------------------------------------------------
/src/optimise.js:
--------------------------------------------------------------------------------
1 | import { uncmin } from "numeric";
2 | import { get } from "lodash";
3 |
4 | import { add, dist } from "./math";
5 |
6 | export const optimise = ({ constants, sketch, state, id, target, delta }) => {
7 | const x0 = constants;
8 |
9 | let minimised;
10 |
11 | try {
12 | minimised = uncmin(
13 | x => {
14 | const drawCalls = sketch.draw(state, x);
15 | const pos = get(drawCalls, [id, 1, "pos"]);
16 |
17 | if (pos !== undefined) {
18 | return dist(add(pos, delta), target);
19 | }
20 |
21 | return 0;
22 | },
23 | x0,
24 | 0.01,
25 | undefined,
26 | 100
27 | );
28 | } catch (e) {
29 | console.warn(e);
30 | }
31 |
32 | if (minimised && minimised.solution) {
33 | return minimised.solution;
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/src/overlay.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import useWindowSize from "@rehooks/window-size";
3 |
4 | export const Overlay = ({ width, height, children, onClose }) => {
5 | const { innerWidth, innerHeight } = useWindowSize();
6 |
7 | return (
8 | <>
9 | {
16 | if (onClose) {
17 | onClose();
18 | }
19 | }}
20 | />
21 |
22 |
32 | {children}
33 |
34 | >
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/panel.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from "react";
2 | import useComponentSize from "@rehooks/component-size";
3 |
4 | export const DIRECTION = {
5 | HORIZONTAL: "HORIZONTAL",
6 | VERTICAL: "VERTICAL"
7 | };
8 |
9 | export const Panel = ({
10 | children,
11 | direction = DIRECTION.HORIZONTAL,
12 | defaultDivide = 0.5
13 | }) => {
14 | const ref = useRef(null);
15 | const size = useComponentSize(ref);
16 | const [isDragging, setIsDragging] = useState(false);
17 | const [divider, setDivider] = useState(defaultDivide);
18 |
19 | useEffect(() => {
20 | if (!ref) {
21 | return;
22 | }
23 |
24 | const bbox = ref.current.getBoundingClientRect();
25 |
26 | const onMouseMove = e => {
27 | e.preventDefault();
28 |
29 | if (direction === DIRECTION.HORIZONTAL) {
30 | if (e.clientX === 0) {
31 | return;
32 | }
33 |
34 | setDivider((e.clientX - bbox.left) / size.width);
35 | } else {
36 | if (e.clientY === 0) {
37 | return;
38 | }
39 |
40 | setDivider((e.clientY - bbox.top) / size.height);
41 | }
42 | };
43 |
44 | const onMouseUp = () => {
45 | window.removeEventListener("mousemove", onMouseMove);
46 | window.removeEventListener("mouseup", onMouseUp);
47 |
48 | setIsDragging(false);
49 | };
50 |
51 | if (isDragging !== false) {
52 | window.addEventListener("mousemove", onMouseMove);
53 | window.addEventListener("mouseup", onMouseUp);
54 | }
55 |
56 | return () => {
57 | window.removeEventListener("mousemove", onMouseMove);
58 | window.removeEventListener("mouseup", onMouseUp);
59 | };
60 | }, [isDragging, direction, ref]);
61 |
62 | const dividerSize = 10;
63 | const handleSize = 1;
64 |
65 | const styles =
66 | direction === DIRECTION.HORIZONTAL
67 | ? [
68 | { width: Math.round(size.width * divider - dividerSize / 2) },
69 | { width: Math.round(size.width * (1 - divider) - dividerSize / 2) }
70 | ]
71 | : [
72 | { height: Math.round(size.height * divider - dividerSize / 2) },
73 | { height: Math.round(size.height * (1 - divider) - dividerSize / 2) }
74 | ];
75 |
76 | const handleWrapperStyle =
77 | direction === DIRECTION.HORIZONTAL
78 | ? { width: dividerSize, cursor: "ew-resize" }
79 | : { height: dividerSize, cursor: "ns-resize" };
80 |
81 | const handleStyle =
82 | direction === DIRECTION.HORIZONTAL
83 | ? { width: handleSize, marginLeft: (dividerSize - handleSize) / 2 }
84 | : { height: handleSize, marginTop: (dividerSize - handleSize) / 2 };
85 |
86 | const wrapperClassName =
87 | direction === DIRECTION.HORIZONTAL ? "flex" : "flex flex-column";
88 |
89 | return (
90 |
91 |
92 | {children[0]}
93 |
94 |
95 |
{
98 | e.preventDefault();
99 | setIsDragging(true);
100 | }}
101 | style={handleWrapperStyle}
102 | >
103 |
104 |
105 |
106 |
107 | {children[1]}
108 |
109 |
110 | );
111 | };
112 |
--------------------------------------------------------------------------------
/src/sketch-container.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import immer from "immer";
3 | import { cloneDeep, xor, debounce } from "lodash";
4 |
5 | import { COMMANDS } from "./commands";
6 | import { makeInspector } from "./inspector";
7 |
8 | const MAX_HISTORY_LEN = 1000;
9 |
10 | export class SketchContainer extends React.Component {
11 | setupCanvas = props => {
12 | this.ref.width = (props || this.props).width;
13 | this.ref.height = (props || this.props).height;
14 |
15 | this.events = [];
16 |
17 | const makeOnMouse = type => e => {
18 | const bbox = this.ref.getBoundingClientRect();
19 |
20 | this.events.push({
21 | source: type,
22 | pos: [e.clientX - bbox.left, e.clientY - bbox.top]
23 | });
24 | };
25 |
26 | this.onMouseMove = makeOnMouse("mousemove");
27 | this.onMouseDown = makeOnMouse("mousedown");
28 | this.onMouseUp = makeOnMouse("mouseup");
29 | this.onMouseClick = makeOnMouse("mouseclick");
30 |
31 | this.onKeyDown = e => {
32 | this.events.push({
33 | source: "keydown",
34 | key: e.key,
35 | code: e.code,
36 | ctrlKey: e.ctrlKey,
37 | shiftKey: e.shiftKey,
38 | altKey: e.altKey,
39 | metaKey: e.metaKey
40 | });
41 | };
42 |
43 | this.onKeyUp = e => {
44 | this.events.push({
45 | source: "keydown",
46 | key: e.key,
47 | code: e.code,
48 | ctrlKey: e.ctrlKey,
49 | shiftKey: e.shiftKey,
50 | altKey: e.altKey,
51 | metaKey: e.metaKey
52 | });
53 | };
54 |
55 | this.ref.addEventListener("mousemove", this.onMouseMove);
56 | this.ref.addEventListener("mousedown", this.onMouseDown);
57 | this.ref.addEventListener("mouseup", this.onMouseUp);
58 | this.ref.addEventListener("click", this.onMouseClick);
59 | this.ref.addEventListener("keydown", this.onKeyDown);
60 | this.ref.addEventListener("keyup", this.onKeyUp);
61 | };
62 |
63 | removeCanvasEvents = () => {
64 | this.ref.removeEventListener("mousemove", this.onMouseMove);
65 | this.ref.removeEventListener("mousedown", this.onMouseDown);
66 | this.ref.removeEventListener("mouseup", this.onMouseUp);
67 | this.ref.removeEventListener("click", this.onMouseClick);
68 | this.ref.removeEventListener("keydown", this.onKeyDown);
69 | this.ref.removeEventListener("keyup", this.onKeyUp);
70 | };
71 |
72 | resetCanvas = () => {
73 | this.removeCanvasEvents();
74 | this.setupCanvas();
75 | };
76 |
77 | resetState = props => {
78 | this.currentState = cloneDeep((props || this.props).sketch.initialState);
79 |
80 | this.props.setHistory(draft => {
81 | draft.stateHistory = [this.currentState];
82 | draft.eventsHistory = [[]];
83 |
84 | draft.idx = 0;
85 | });
86 | };
87 |
88 | setupInspector = props => {
89 | if (this.props.isPlaying) {
90 | return;
91 | }
92 |
93 | this.inspector = makeInspector(props || this.props);
94 |
95 | this.onMouseDownInspector = e => {
96 | if (this.props.isPlaying) {
97 | console.warning("onMouseDownInspector while playing!");
98 | }
99 |
100 | const bbox = this.ref.getBoundingClientRect();
101 |
102 | const [mx, my] = [
103 | Math.floor(e.clientX - bbox.left),
104 | Math.floor(e.clientY - bbox.top)
105 | ];
106 |
107 | const id = this.inspector.onHover(mx, my);
108 | const [command, args] = this.inspector.getMetaForId(id);
109 | const delta = args.pos ? [mx - args.pos[0], my - args.pos[1]] : [0, 0];
110 |
111 | const optimiseArgs = {
112 | id,
113 | command,
114 | args,
115 | delta,
116 | target: [mx, my]
117 | };
118 |
119 | this.props.setOptimiser(optimiseArgs);
120 | };
121 |
122 | this.onMouseUpInspector = () => {
123 | if (this.props.isPlaying) {
124 | console.warning("onMouseUpInspector while playing!");
125 | }
126 |
127 | this.props.setOptimiser(false);
128 | };
129 |
130 | this.onMouseMoveInspector = e => {
131 | if (this.props.isPlaying) {
132 | console.warning("onMouseMoveInspector while playing!");
133 | }
134 |
135 | const bbox = this.ref.getBoundingClientRect();
136 |
137 | const [mx, my] = [
138 | Math.floor(e.clientX - bbox.left),
139 | Math.floor(e.clientY - bbox.top)
140 | ];
141 |
142 | if (this.props.optimiser) {
143 | this.props.setOptimiser({ ...this.props.optimiser, target: [mx, my] });
144 | } else {
145 | const id = this.inspector.onHover(mx, my);
146 | const metaForId = this.inspector.getMetaForId(id);
147 |
148 | if (!metaForId || metaForId.length <= 1) {
149 | return;
150 | }
151 |
152 | const args = metaForId[1];
153 | const meta = args.__meta;
154 |
155 | this.props.setHighlight(
156 | meta ? { start: meta.lineStart - 2, end: meta.lineEnd - 1 } : null
157 | );
158 | }
159 | };
160 |
161 | this.onMouseOutInspector = () => this.props.setHighlight(null);
162 |
163 | this.ref.addEventListener("mousemove", this.onMouseMoveInspector);
164 | this.ref.addEventListener("mousedown", this.onMouseDownInspector);
165 | this.ref.addEventListener("mouseout", this.onMouseOutInspector);
166 |
167 | window.addEventListener("mouseup", this.onMouseUpInspector);
168 | };
169 |
170 | removeInspector = () => {
171 | this.ref.removeEventListener("mousemove", this.onMouseMoveInspector);
172 | this.ref.removeEventListener("mousedown", this.onMouseDownInspector);
173 | this.ref.removeEventListener("mouseout", this.onMouseOutInspector);
174 |
175 | window.removeEventListener("mouseup", this.onMouseUpInspector);
176 |
177 | this.inspector = undefined;
178 | };
179 |
180 | resetInspector = () => {
181 | this.removeInspector();
182 | this.setupInspector();
183 | };
184 |
185 | runExecute = () => {
186 | if (this.toExecute.size === 0) {
187 | return;
188 | }
189 |
190 | if (this.toExecute.size > 0) {
191 | this.toExecute.forEach(key => this[key]());
192 | this.toExecute = new Set();
193 | }
194 | };
195 |
196 | tick = () => {
197 | if (this.toExecute.size > 0) {
198 | this.frame = requestAnimationFrame(this.tick.bind(this));
199 | return;
200 | }
201 |
202 | const { sketch, constants, globals, setHistory, isPlaying } = this.props;
203 |
204 | const ctx = this.ref.getContext("2d");
205 |
206 | if (isPlaying) {
207 | this.currentState = immer(this.currentState, draft =>
208 | sketch.update(draft, this.events)
209 | );
210 |
211 | setHistory(draft => {
212 | draft.stateHistory.push(this.currentState);
213 | draft.eventsHistory.push(this.events);
214 |
215 | while (draft.stateHistory.length > MAX_HISTORY_LEN + 1) {
216 | draft.stateHistory.shift();
217 | draft.eventsHistory.shift();
218 | }
219 |
220 | draft.idx = Math.min(MAX_HISTORY_LEN, draft.idx + 1);
221 | });
222 | }
223 |
224 | if (this.inspector) {
225 | this.inspector.draw(this.currentState, constants);
226 | }
227 |
228 | for (const operation of sketch.draw(this.currentState, constants)) {
229 | const [command, args] = operation;
230 |
231 | if (COMMANDS[command]) {
232 | COMMANDS[command](ctx, args, globals);
233 | }
234 | }
235 |
236 | this.events = [];
237 |
238 | this.frame = requestAnimationFrame(this.tick.bind(this));
239 | };
240 |
241 | componentDidMount() {
242 | this.toExecute = new Set();
243 | this.runExecute = debounce(this.runExecute, 16);
244 |
245 | this.setupCanvas(this.props);
246 | this.resetState(this.props);
247 |
248 | this.frame = requestAnimationFrame(this.tick);
249 | }
250 |
251 | componentWillUmount() {
252 | if (this.frame) {
253 | cancelAnimationFrame(this.frame);
254 | }
255 |
256 | this.removeCanvasEvents();
257 | this.removeInspector();
258 | }
259 |
260 | UNSAFE_componentWillReceiveProps(nextProps) {
261 | if (
262 | xor(
263 | Object.keys(nextProps.sketch.initialState),
264 | Object.keys(this.currentState)
265 | ).length > 0
266 | ) {
267 | this.toExecute.add("resetState");
268 | this.toExecute.add("resetInspector");
269 | }
270 |
271 | if (
272 | nextProps.width !== this.props.width ||
273 | nextProps.height !== this.props.height
274 | ) {
275 | this.toExecute.add("resetCanvas");
276 | this.toExecute.add("resetInspector");
277 | }
278 |
279 | if (
280 | nextProps.historyIdx !== this.props.historyIdx &&
281 | !nextProps.isPlaying
282 | ) {
283 | this.currentState = nextProps.stateHistory[nextProps.historyIdx];
284 | this.currentEvents = nextProps.eventsHistory[nextProps.historyIdx];
285 | }
286 |
287 | if (nextProps.isPlaying && !this.props.isPlaying) {
288 | this.toExecute.add("removeInspector");
289 | }
290 |
291 | if (!nextProps.isPlaying && this.props.isPlaying) {
292 | this.toExecute.add("resetInspector");
293 | }
294 |
295 | this.runExecute();
296 | }
297 |
298 | shouldComponentUpdate() {
299 | return false;
300 | }
301 |
302 | render() {
303 | return
(this.ref = ref)} />;
304 | }
305 | }
306 |
--------------------------------------------------------------------------------
/src/sketch.js:
--------------------------------------------------------------------------------
1 | import JSON from "react-json-view";
2 | import React, { useEffect, useState } from "react";
3 | import { get } from "lodash";
4 |
5 | import { Panel, DIRECTION } from "./panel";
6 | import { SketchContainer } from "./sketch-container";
7 | import { Slider } from "./slider";
8 | import { clamp } from "./math";
9 | import { optimise } from "./optimise";
10 | import { useImmer } from "./hooks";
11 |
12 | const DEFAULT_IS_PLAYING = true;
13 |
14 | const RoundButton = ({ onClick, children }) => (
15 |
16 |
21 |
22 | {children}
23 |
24 |
25 |
26 | );
27 |
28 | const SketchControls = ({
29 | isPlaying,
30 | historyIdx,
31 | stateHistory,
32 | setIsPlaying,
33 | setHistory,
34 | onReset
35 | }) => (
36 |
37 |
{
39 | if (!isPlaying) {
40 | setHistory(draft => {
41 | draft.stateHistory = draft.stateHistory.slice(0, draft.idx + 1);
42 | });
43 | }
44 |
45 | setIsPlaying(!isPlaying);
46 | }}
47 | >
48 | {isPlaying ? "❚❚" : "▶︎"}
49 |
50 |
51 |
52 | ◼
53 |
54 |
55 |
56 | 1
60 | ? Math.max(0, historyIdx / (stateHistory.length - 1))
61 | : 0
62 | }
63 | onChange={v =>
64 | setHistory(draft => {
65 | draft.idx = clamp(
66 | Math.floor(v * stateHistory.length),
67 | 0,
68 | stateHistory.length - 1
69 | );
70 | })
71 | }
72 | />
73 |
74 |
75 | );
76 |
77 | export const Sketch = ({
78 | sketch,
79 | constants,
80 | code,
81 | setConstants,
82 | setHighlight
83 | }) => {
84 | const [isPlaying, setIsPlaying] = useState(DEFAULT_IS_PLAYING);
85 | const [optimiser, setOptimiser] = useState(false);
86 |
87 | const [
88 | { stateHistory, eventsHistory, idx: historyIdx },
89 | setHistory
90 | ] = useImmer({
91 | stateHistory: [sketch.initialState || {}],
92 | eventsHistory: [[]],
93 | idx: 0
94 | });
95 |
96 | const [width, height] = get(sketch, "size", [800, 600]);
97 | const globals = { width, height };
98 |
99 | useEffect(() => {
100 | if (!optimiser) {
101 | return;
102 | }
103 |
104 | const state = stateHistory[historyIdx];
105 |
106 | const newConstants = optimise({
107 | ...optimiser,
108 | sketch,
109 | state,
110 | globals,
111 | constants
112 | });
113 |
114 | if (newConstants) {
115 | setConstants(newConstants);
116 | }
117 | }, [optimiser]);
118 |
119 | return (
120 |
121 |
122 |
123 |
124 | {
131 | setHistory(draft => {
132 | draft.stateHistory = [sketch.initialState || {}];
133 | draft.eventsHistory = [[]];
134 | draft.idx = 0;
135 | });
136 |
137 | setIsPlaying(false);
138 | }}
139 | />
140 |
141 |
142 |
143 |
159 |
160 |
161 |
162 |
163 |
173 |
174 |
175 |
176 | );
177 | };
178 |
--------------------------------------------------------------------------------
/src/slider.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useRef } from "react";
2 | import useComponentSize from "@rehooks/component-size";
3 |
4 | import { clamp, scale } from "./math";
5 |
6 | export const Slider = ({
7 | disabled = false,
8 | position = 0.0,
9 | onChange = () => {},
10 | height = 10
11 | }) => {
12 | const ref = useRef(null);
13 | const bbox = useRef(null);
14 | const size = useComponentSize(ref);
15 | const [isDragging, setIsDragging] = useState(false);
16 |
17 | useEffect(() => {
18 | if (!ref) {
19 | return;
20 | }
21 |
22 | if (disabled) {
23 | return;
24 | }
25 |
26 | bbox.current = ref.current.getBoundingClientRect();
27 |
28 | const onMouseMove = e => {
29 | e.preventDefault();
30 |
31 | if (e.clientX === 0) {
32 | return;
33 | }
34 |
35 | const value = (e.clientX - bbox.current.left) / size.width;
36 | onChange(clamp(value, 0, 1));
37 | };
38 |
39 | const onMouseUp = () => {
40 | window.removeEventListener("mousemove", onMouseMove);
41 | window.removeEventListener("mouseup", onMouseUp);
42 |
43 | setIsDragging(false);
44 | };
45 |
46 | if (isDragging !== false) {
47 | window.addEventListener("mousemove", onMouseMove);
48 | window.addEventListener("mouseup", onMouseUp);
49 | }
50 |
51 | return () => {
52 | window.removeEventListener("mousemove", onMouseMove);
53 | window.removeEventListener("mouseup", onMouseUp);
54 | };
55 | });
56 |
57 | const left = clamp(
58 | scale(position, 0, 1, 2, size.width - 1 - height),
59 | 2,
60 | size.width - 1 - height
61 | );
62 |
63 | return (
64 | {
69 | const value = (e.clientX - bbox.current.left) / size.width;
70 | onChange(clamp(value, 0, 1));
71 | }}
72 | >
73 |
{
83 | e.preventDefault();
84 | setIsDragging(true);
85 | }}
86 | />
87 |
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/src/style.css:
--------------------------------------------------------------------------------
1 | .CodeMirror {
2 | height: 100% !important;
3 | font-family: Consolas, monaco, monospace !important;
4 | font-size: 0.75rem;
5 | }
6 |
7 | .react-json-view {
8 | font-family: Consolas, monaco, monospace !important;
9 | font-size: 0.75rem !important;
10 | }
11 |
12 | .inspector-highlight {
13 | background: #252525;
14 | }
15 |
16 | .bg-custom-dark {
17 | background: #101010;
18 | }
19 |
20 | .custom-dark {
21 | color: #101010;
22 | }
23 |
--------------------------------------------------------------------------------
/src/topbar.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from "react";
2 | import { saveAs } from "file-saver";
3 |
4 | import { Help } from "./help";
5 | import { EXAMPLES } from "./examples";
6 |
7 | export const Topbar = ({ setCode, code }) => {
8 | const [isHelpVisible, setHelpVisible] = useState(false);
9 | const [isExamplesMenuVisible, setExamplesMenuVisible] = useState(false);
10 |
11 | const fileRef = useRef(null);
12 |
13 | return (
14 | <>
15 |
16 |
dacein
17 |
18 |
19 |
{
22 | if (!fileRef.current) {
23 | return;
24 | }
25 |
26 | fileRef.current.click();
27 | }}
28 | >
29 | open
30 |
31 |
{
34 | saveAs(
35 | new Blob([code], {
36 | type: "text/plain;charset=utf-8"
37 | }),
38 | "sketch.js"
39 | );
40 | }}
41 | >
42 | save
43 |
44 |
45 |
46 |
setExamplesMenuVisible(!isExamplesMenuVisible)}
49 | >
50 | examples
51 |
52 |
53 | {isExamplesMenuVisible && (
54 |
58 |
59 | {Object.entries(EXAMPLES).map(([key, exampleCode]) => (
60 | {
64 | setCode(exampleCode);
65 | setExamplesMenuVisible(false);
66 | }}
67 | >
68 | {key}
69 |
70 | ))}
71 |
72 |
73 | )}
74 |
75 |
76 |
setHelpVisible(!isHelpVisible)}
79 | >
80 | help
81 |
82 |
83 |
84 |
{
89 | const [file] = e.target.files;
90 |
91 | if (!file) {
92 | return;
93 | }
94 |
95 | const reader = new FileReader();
96 |
97 | reader.onload = e => {
98 | const content = e.target.result;
99 | setCode(content);
100 | };
101 |
102 | reader.readAsText(file);
103 | }}
104 | />
105 |
106 |
107 | {isHelpVisible &&
setHelpVisible(false)} />}
108 | >
109 | );
110 | };
111 |
--------------------------------------------------------------------------------