├── .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 |

screenshot

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 | 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 |
  1. 72 | to travel through time, use the slider near play/pause buttons 73 |
  2. 74 |
  3. 75 | to manipulte the code, drag an ellipse{" "} 76 | or rect 77 |
  4. 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 |
97 |
{SAMPLE_CODE}
98 |
99 | 100 |
101 |

Commands available in the language:

102 | 103 |
    104 | {LANG_HELP.map(([command, args]) => ( 105 |
  1. 106 | {command} ({"{"}{" "} 107 | {args.map((arg, i) => ( 108 | <> 109 | 110 | {arg} 111 | 112 | {i < args.length - 1 ? ", " : ""} 113 | 114 | ))}{" "} 115 | {"}"}}) 116 |
  2. 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 | 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 |
  1. { 64 | setCode(exampleCode); 65 | setExamplesMenuVisible(false); 66 | }} 67 | > 68 | {key} 69 |
  2. 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 | --------------------------------------------------------------------------------