├── .gitignore ├── public ├── counter.gif ├── temp-converter.gif ├── FontWithASyntaxHighlighter-Regular.woff2 ├── star.svg ├── holograph-favicon.svg └── holograph-icon.svg ├── vite.config.js ├── src ├── main.jsx ├── appendCreatedAt.js ├── useMediaQuery.js ├── deepDiff.js ├── getUniqueName.js ├── index.css ├── castInput.js ├── HelpMenu.jsx ├── Toolbar.jsx ├── App.css ├── assets │ └── react.svg ├── overrides.js ├── SharePanel.jsx ├── MainMenu.jsx ├── App.jsx └── update.js ├── _gitignore ├── index.html ├── .eslintrc.cjs ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .ds_store -------------------------------------------------------------------------------- /public/counter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dennishansen/holograph/HEAD/public/counter.gif -------------------------------------------------------------------------------- /public/temp-converter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dennishansen/holograph/HEAD/public/temp-converter.gif -------------------------------------------------------------------------------- /public/FontWithASyntaxHighlighter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dennishansen/holograph/HEAD/public/FontWithASyntaxHighlighter-Regular.woff2 -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.jsx"; 4 | import "./index.css"; 5 | ReactDOM.createRoot(document.getElementById("root")).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/appendCreatedAt.js: -------------------------------------------------------------------------------- 1 | const appendCreatedAt = (jsonData) => { 2 | const now = Date.now(); 3 | jsonData.shapes = jsonData.shapes.map((shape) => ({ 4 | ...shape, 5 | meta: { createdAt: now }, 6 | })); 7 | return jsonData; 8 | }; 9 | 10 | export default appendCreatedAt; 11 | -------------------------------------------------------------------------------- /public/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /_gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Holograph 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/useMediaQuery.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | // Custom hook for media queries 4 | const useMediaQuery = (query) => { 5 | const [matches, setMatches] = useState(false); 6 | 7 | useEffect(() => { 8 | const media = window.matchMedia(query); 9 | if (media.matches !== matches) { 10 | setMatches(media.matches); 11 | } 12 | const listener = () => setMatches(media.matches); 13 | window.addEventListener("resize", listener); 14 | return () => window.removeEventListener("resize", listener); 15 | }, [matches, query]); 16 | 17 | return matches; 18 | }; 19 | 20 | export default useMediaQuery; 21 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /src/deepDiff.js: -------------------------------------------------------------------------------- 1 | function deepDiff(obj1, obj2, parentKey = "", result = {}) { 2 | for (let key in obj2) { 3 | const fullKey = parentKey ? `${parentKey}.${key}` : key; 4 | if (obj1[key] === undefined) { 5 | result[fullKey] = obj2[key]; 6 | } else if (typeof obj2[key] === "object" && obj2[key] !== null) { 7 | if (typeof obj1[key] !== "object" || obj1[key] === null) { 8 | result[fullKey] = obj2[key]; 9 | } else { 10 | deepDiff(obj1[key], obj2[key], fullKey, result); 11 | } 12 | } else if (obj1[key] !== obj2[key]) { 13 | result[fullKey] = obj2[key]; 14 | } 15 | } 16 | return result; 17 | } 18 | 19 | export default deepDiff; 20 | -------------------------------------------------------------------------------- /src/getUniqueName.js: -------------------------------------------------------------------------------- 1 | function incrementString(str) { 2 | let i = str.length - 1; 3 | while (i >= 0) { 4 | if (str[i] === "z") { 5 | str = str.substring(0, i) + "a" + str.substring(i + 1); 6 | i--; 7 | } else { 8 | str = 9 | str.substring(0, i) + 10 | String.fromCharCode(str.charCodeAt(i) + 1) + 11 | str.substring(i + 1); 12 | return str; 13 | } 14 | } 15 | return "a" + str; 16 | } 17 | 18 | function getUniqueName(existingStrings = []) { 19 | let newString = "a"; 20 | while (existingStrings.includes(newString)) { 21 | newString = incrementString(newString); 22 | } 23 | return newString; 24 | } 25 | 26 | export default getUniqueName; 27 | -------------------------------------------------------------------------------- /public/holograph-favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "holograph", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@vercel/analytics": "^1.3.1", 14 | "lodash": "^4.17.21", 15 | "react": "^18.3.1", 16 | "react-dom": "^18.3.1", 17 | "tldraw": "^2.1.4" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^18.3.2", 21 | "@types/react-dom": "^18.3.0", 22 | "@vitejs/plugin-react": "^4.2.1", 23 | "eslint": "^8.57.0", 24 | "eslint-plugin-react": "^7.34.1", 25 | "eslint-plugin-react-hooks": "^4.6.2", 26 | "eslint-plugin-react-refresh": "^0.4.7", 27 | "vite": "^5.2.11" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/holograph-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Holograph 2 | 3 | Holograph is a visual coding tool built on tldraw. 4 | 5 | ![Bidirectional temperature converter](https://github.com/dennishansen/propagator-draw/blob/main/public/temp-converter.gif) 6 | 7 | ## Play with it live 8 | [holograph.so](https://www.holograph.so) 9 | 10 | ## Run it 11 | ``` 12 | npm run dev 13 | ``` 14 | 15 | ## How it works 16 | Holograph is based on [Propagator Networks](https://dspace.mit.edu/handle/1721.1/54635). Propagators (rectangles) listen to changing input values (circles), run code, and update other values (other circles). 17 | 18 | ### To use it 19 | - Put your variables in circles 20 | - Put your JavaScript in rectangles (you can write a return or not) 21 | - Connect inputs by drawing arrows from circles to rectangles with text that matches the code's variables 22 | - Connect output by drawing arrows from rectangles to circles. 23 | 24 | Download and import the [tutorial](https://github.com/dennishansen/holograph/blob/main/public/tutorial.json) to learn more. Also, click the explore button in the top-right of the site to download examples. 25 | 26 | ### Fun stuff to try 27 | There's a lot of awesome stuff that can be made with these (maybe everything?). 28 | 29 | - A timer! 30 | - A conditional and switch! 31 | - A new simulated universe! 32 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | margin: 0; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | min-height: 100vh; 30 | } 31 | 32 | h1 { 33 | font-size: 3.2em; 34 | line-height: 1.1; 35 | } 36 | 37 | button { 38 | border-radius: 8px; 39 | border: 1px solid transparent; 40 | padding: 0.6em 1.2em; 41 | font-size: 1em; 42 | font-weight: 500; 43 | font-family: inherit; 44 | background-color: #1a1a1a; 45 | cursor: pointer; 46 | transition: border-color 0.25s; 47 | } 48 | button:hover { 49 | border-color: #646cff; 50 | } 51 | button:focus, 52 | button:focus-visible { 53 | outline: 4px auto -webkit-focus-ring-color; 54 | } 55 | 56 | @media (prefers-color-scheme: light) { 57 | :root { 58 | color: #213547; 59 | background-color: #ffffff; 60 | } 61 | a:hover { 62 | color: #747bff; 63 | } 64 | button { 65 | background-color: #f9f9f9; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/castInput.js: -------------------------------------------------------------------------------- 1 | function castInput(input) { 2 | // undefined 3 | if (input === undefined) { 4 | return undefined; 5 | } 6 | 7 | // Try to parse input as JSON) 8 | try { 9 | const parsedInput = JSON.parse(input); 10 | 11 | if (Array.isArray(parsedInput)) { 12 | return parsedInput; // array 13 | } 14 | 15 | if (parsedInput === null) { 16 | return null; // null 17 | } 18 | 19 | if (typeof parsedInput === "object") { 20 | return parsedInput; // object 21 | } 22 | 23 | if (typeof parsedInput === "boolean") { 24 | return parsedInput; // boolean 25 | } 26 | 27 | if (typeof parsedInput === "number") { 28 | return parsedInput; // integer or float 29 | } 30 | } catch (e) { 31 | // Not JSON parsable 32 | } 33 | 34 | // Check for boolean strings 35 | if (input.toLowerCase() === "true") { 36 | return true; // boolean 37 | } 38 | if (input.toLowerCase() === "false") { 39 | return false; // boolean 40 | } 41 | 42 | // Check for null string 43 | if (input.toLowerCase() === "null") { 44 | return null; // null 45 | } 46 | 47 | // Check for number 48 | const num = parseFloat(input); 49 | if (!isNaN(num)) { 50 | return num; // integer or float 51 | } 52 | 53 | // Default to string 54 | if (input.length > 1 && input.startsWith('"') && input.endsWith('"')) { 55 | // Remove quotes if they exist (TODO: Cleanup) 56 | input = input.slice(1, -1); 57 | } 58 | return input; // string 59 | } 60 | 61 | export default castInput; 62 | -------------------------------------------------------------------------------- /src/HelpMenu.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultHelpMenu, 3 | DefaultHelpMenuContent, 4 | TldrawUiMenuGroup, 5 | TldrawUiMenuItem, 6 | } from "tldraw"; 7 | 8 | const CustomHelpMenu = () => { 9 | return ( 10 | 11 | 12 | { 18 | window.open("https://github.com/dennishansen/holograph", "_blank"); 19 | }} 20 | /> 21 | 22 | 23 |
30 |
38 |
39 | Holograph logo 40 |

Holograph

41 |
42 |
43 |

49 | dennis@holograph.so 50 |

51 |
52 |
53 | ); 54 | }; 55 | 56 | export default CustomHelpMenu; 57 | -------------------------------------------------------------------------------- /src/Toolbar.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultToolbar, 3 | SelectToolbarItem, 4 | HandToolbarItem, 5 | DrawToolbarItem, 6 | EraserToolbarItem, 7 | ArrowToolbarItem, 8 | TextToolbarItem, 9 | NoteToolbarItem, 10 | AssetToolbarItem, 11 | RectangleToolbarItem, 12 | EllipseToolbarItem, 13 | TriangleToolbarItem, 14 | DiamondToolbarItem, 15 | HexagonToolbarItem, 16 | OvalToolbarItem, 17 | RhombusToolbarItem, 18 | StarToolbarItem, 19 | CloudToolbarItem, 20 | XBoxToolbarItem, 21 | CheckBoxToolbarItem, 22 | ArrowLeftToolbarItem, 23 | ArrowUpToolbarItem, 24 | ArrowDownToolbarItem, 25 | ArrowRightToolbarItem, 26 | LineToolbarItem, 27 | HighlightToolbarItem, 28 | LaserToolbarItem, 29 | FrameToolbarItem, 30 | } from "tldraw"; 31 | 32 | const Toolbar = (props) => { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default Toolbar; 67 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap"); 2 | @import url("tldraw/tldraw.css"); 3 | 4 | @font-face { 5 | font-family: "Monaspace"; 6 | src: url("/FontWithASyntaxHighlighter-Regular.woff2") format("woff2"); 7 | } 8 | /* prettier-ignore */ 9 | @font-palette-values --kung-fury-light { 10 | font-family: "Monaspace"; 11 | override-colors: 12 | 0 #3A6DF5, /* keywords, {} */ 13 | 1 #0E7C54, /* comments */ 14 | 2 #2E51B3, /* literals */ 15 | 3 #1C3A87, /* numbers */ 16 | 4 #9C2FB7, /* functions, [] */ 17 | 5 #000000, /* js others */ 18 | 6 #000000, /* not in use */ 19 | 7 #E14F0C; /* inside quotes, css properties, few chars */ 20 | } 21 | 22 | /* prettier-ignore */ 23 | @font-palette-values --kung-fury-dark { 24 | font-family: "Monaspace"; 25 | override-colors: 26 | 0 #4EAEF5, /* keywords, {} */ 27 | 1 #179169, /* comments */ 28 | 2 #4EAEF5, /* literals */ 29 | 3 #4EAEF5, /* numbers */ 30 | 4 #FFBE41, /* functions, [] */ 31 | 5 #F2F2F2, /* js others */ 32 | 6 #9399BB, /* not in use */ 33 | 7 #FE8788; /* inside quotes, css properties, few chars */ 34 | } 35 | 36 | /* Default (light) mode */ 37 | div[data-font="mono"] * { 38 | font-family: "Monaspace", monospace; 39 | font-palette: --kung-fury-light; 40 | color: #000000; 41 | font-size-adjust: 0.49; 42 | } 43 | 44 | /* Dark mode */ 45 | [data-color-mode="dark"] div[data-font="mono"] * { 46 | font-family: "Monaspace", monospace; 47 | font-palette: --kung-fury-dark; 48 | color: #ffffff; 49 | font-size-adjust: 0.49; 50 | } 51 | 52 | [class*="is-propagating-"] { 53 | outline: 3px solid #f14b4b; 54 | } 55 | 56 | body { 57 | font-family: "Inter"; 58 | } 59 | 60 | #root { 61 | display: flex; 62 | width: 100%; 63 | text-align: center; 64 | } 65 | 66 | .logo { 67 | height: 6em; 68 | padding: 1.5em; 69 | will-change: filter; 70 | transition: filter 300ms; 71 | } 72 | .logo:hover { 73 | filter: drop-shadow(0 0 2em #646cffaa); 74 | } 75 | .logo.react:hover { 76 | filter: drop-shadow(0 0 2em #61dafbaa); 77 | } 78 | 79 | @keyframes logo-spin { 80 | from { 81 | transform: rotate(0deg); 82 | } 83 | to { 84 | transform: rotate(360deg); 85 | } 86 | } 87 | 88 | @media (prefers-reduced-motion: no-preference) { 89 | a:nth-of-type(2) .logo { 90 | animation: logo-spin infinite 20s linear; 91 | } 92 | } 93 | 94 | .card { 95 | padding: 2em; 96 | } 97 | 98 | .read-the-docs { 99 | color: #888; 100 | } 101 | 102 | /* TLraw overrides */ 103 | 104 | .tlui-menu-zone { 105 | margin-top: 8px; 106 | margin-left: 8px; 107 | border-radius: 11px; 108 | } 109 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/overrides.js: -------------------------------------------------------------------------------- 1 | const removeIndexes = (shapes) => { 2 | // eslint-disable-next-line no-unused-vars 3 | const newShapes = shapes.map(({ index, ...shape }) => shape); 4 | return newShapes; 5 | }; 6 | 7 | const overrides = { 8 | actions(_editor, actions) { 9 | const newActions = { 10 | ...actions, 11 | "duplicate-with-connections": { 12 | id: "duplicate-with-connections", 13 | label: "Duplicate with Connections", 14 | readonlyOk: true, 15 | kbd: "$b", 16 | onSelect() { 17 | let selectedShapes = _editor.getSelectedShapes(); 18 | console.log("selectedShapes", selectedShapes); 19 | let idLookup = {}; 20 | let newShapes = []; 21 | 22 | for (let shape of selectedShapes) { 23 | let { id, type, x, y } = shape; 24 | id = getId(); 25 | idLookup[shape.id] = id; 26 | if (type === "geo") { 27 | x += 100; 28 | y += 100; 29 | newShapes.push({ ...shape, id, x, y }); 30 | } 31 | } 32 | 33 | const records = _editor.store.allRecords(); 34 | for (let record of records) { 35 | let { type, props } = record; 36 | if (type === "arrow") { 37 | const { start, end } = props; 38 | const startIdOfNewShape = idLookup[start.boundShapeId]; 39 | const endIdOfNewShape = idLookup[end.boundShapeId]; 40 | if (startIdOfNewShape || endIdOfNewShape) { 41 | const newStartId = startIdOfNewShape || start.boundShapeId; 42 | const newEndId = endIdOfNewShape || end.boundShapeId; 43 | const newStart = { ...start, boundShapeId: newStartId }; 44 | const newEnd = { ...end, boundShapeId: newEndId }; 45 | const id = getId(); 46 | props = { ...props, start: newStart, end: newEnd }; 47 | newShapes.push({ ...record, id, props }); 48 | } 49 | } 50 | } 51 | 52 | const shapesWithoutIndexes = removeIndexes(newShapes); 53 | _editor.createShapes(shapesWithoutIndexes, { select: true }); 54 | _editor.deselect(...Object.keys(idLookup)); 55 | _editor.select(...Object.keys(idLookup).map((id) => idLookup[id])); 56 | }, 57 | }, 58 | "Delete with connections": { 59 | id: "delete-with-connections", 60 | label: "Delete with Connections", 61 | readonlyOk: true, 62 | kbd: "$j", 63 | onSelect() { 64 | const selectedShapes = _editor 65 | .getSelectedShapes() 66 | .filter(({ type }) => type === "geo"); 67 | const connectedArrows = _editor.store 68 | .allRecords() 69 | .filter(({ type, props }) => { 70 | if (type === "arrow") { 71 | const endId = props.end.boundShapeId; 72 | const startId = props.start.boundShapeId; 73 | for (let shape of selectedShapes) { 74 | if (shape.id === endId || shape.id === startId) { 75 | return true; 76 | } 77 | } 78 | } 79 | }); 80 | _editor.deleteShapes([...connectedArrows, ...selectedShapes]); 81 | }, 82 | }, 83 | "Toggle debug mode": { 84 | id: "toggle-debug-mode", 85 | label: "Toggle debug mode", 86 | readonlyOk: true, 87 | kbd: "$k", 88 | onSelect() { 89 | document.debugPropagation = !document.debugPropagation; 90 | document.toasts.addToast({ 91 | id: "debug-propagation", 92 | description: `Debug propagation mode ${ 93 | document.debugPropagation ? "enabled" : "disabled" 94 | }`, 95 | keepOpen: false, 96 | }); 97 | }, 98 | }, 99 | }; 100 | 101 | return newActions; 102 | }, 103 | }; 104 | 105 | const getId = (prefix = "shape") => 106 | `${prefix}:${Math.random().toString(36).split(".")[1]}`; 107 | export default overrides; 108 | -------------------------------------------------------------------------------- /src/SharePanel.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | TldrawUiButton, 3 | TldrawUiPopover, 4 | TldrawUiPopoverContent, 5 | TldrawUiPopoverTrigger, 6 | useIsDarkMode, 7 | } from "tldraw"; 8 | 9 | // import { get } from "@vercel/edge-config"; 10 | 11 | const SharePanel = () => { 12 | const isDarkMode = useIsDarkMode(); 13 | return ( 14 |
15 | 16 | 17 | 26 | Explore 27 | 28 | 29 | 30 |
31 |

38 | Explore creations 39 |

40 |

49 | Dowload and import some cool creations from our public google 50 | drive. 51 |

52 | { 59 | window.open( 60 | "https://drive.google.com/drive/folders/1ddDGEl5p1L0G-bDnIblqxUUMOoD6hqqX?usp=sharing", 61 | "_blank" 62 | ); 63 | }} 64 | > 65 | Open Google Drive 66 | 67 |
68 |
69 |
70 |
71 | 72 | 73 | 80 | Publish 81 | 82 | 83 | 84 |
85 |

92 | Publish your creation 93 |

94 |

103 | Get your creation into the public google drive by tweeting it at 104 | @dennizor or exporting it as JSON and emailing it to 105 | dennis@holograph.so. 106 |

107 |
108 | window.open("https://x.com/dennizor")} 115 | > 116 | Tweet at me 117 | 118 |
119 | window.open("mailto:dennis@holograph.so")} 123 | > 124 | Email me 125 | 126 |
127 |
128 | 129 | 130 |
131 | ); 132 | }; 133 | 134 | export default SharePanel; 135 | -------------------------------------------------------------------------------- /src/MainMenu.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import { 3 | DefaultMainMenu, 4 | TldrawUiMenuGroup, 5 | TldrawUiMenuItem, 6 | EditSubmenu, 7 | ViewSubmenu, 8 | ToggleTransparentBgMenuItem, 9 | TldrawUiMenuSubmenu, 10 | ExtrasGroup, 11 | PreferencesGroup, 12 | TldrawUiButton, 13 | useActions, 14 | useExportAs, 15 | useUiEvents, 16 | useIsDarkMode, 17 | useToasts, 18 | } from "tldraw"; 19 | import useMediaQuery from "./useMediaQuery"; 20 | import appendCreatedAt from "./appendCreatedAt"; 21 | import { useEffect } from "react"; 22 | 23 | const Star = ({ style }) => ( 24 | New updates! 34 | ); 35 | 36 | const CustomMainMenu = ({ 37 | editor, 38 | showUpdate, 39 | setShowUpdate, 40 | latestUpdateTime, 41 | }) => { 42 | const actions = useActions(); 43 | const exportAs = useExportAs(); 44 | const trackEvent = useUiEvents(); 45 | const isDarkMode = useIsDarkMode(); 46 | const toasts = useToasts(); 47 | 48 | useEffect(() => { 49 | document.toasts = toasts; 50 | return () => { 51 | document.toasts = () => {}; 52 | }; 53 | }, []); 54 | 55 | const isMobile = useMediaQuery("(max-width: 414px)"); 56 | 57 | const openFile = () => { 58 | // Open file selection dialog 59 | const input = document.createElement("input"); 60 | input.type = "file"; 61 | input.accept = ".json"; 62 | input.onchange = (event) => { 63 | const file = event.target.files[0]; 64 | const reader = new FileReader(); 65 | reader.onload = (event) => { 66 | const hasShapesOnPage = 67 | Array.from(editor.getCurrentPageShapeIds().values()).length > 0; 68 | let name = file.name.replace(".json", ""); 69 | if (hasShapesOnPage) { 70 | const seed = Date.now(); 71 | const id = "page:" + seed; 72 | editor.createPage({ name, id }); 73 | editor.setCurrentPage(id); 74 | } else { 75 | editor.updatePage({ id: editor.getCurrentPageId(), name }); 76 | } 77 | const jsonData = JSON.parse(event.target.result); 78 | // Backwards compatability: Append createdAt so defaults work 79 | const jsonDataWithCreatedAt = appendCreatedAt(jsonData); 80 | editor.putContentOntoCurrentPage(jsonDataWithCreatedAt, { 81 | select: true, 82 | }); 83 | }; 84 | reader.readAsText(file); 85 | }; 86 | input.click(); 87 | }; 88 | 89 | const saveFile = () => { 90 | let ids = Array.from(editor.getCurrentPageShapeIds().values()); 91 | if (ids.length === 0) return; 92 | let name = editor.getCurrentPage()?.name || "Untitled"; 93 | trackEvent("export-as", { format: "json", source: "user" }); 94 | exportAs(ids, "json", name); 95 | }; 96 | 97 | const whatsNew = () => { 98 | fetch("/tutorial.json") 99 | .then((response) => { 100 | if (response.ok) return response.json(); 101 | }) 102 | .then((tutorial) => { 103 | const seed = Date.now(); 104 | const id = "page:how-to" + seed; 105 | editor.createPage({ name: "How to", id }); 106 | editor.setCurrentPage(id); 107 | const tutorialWithCreatedAt = appendCreatedAt(tutorial); 108 | editor.putContentOntoCurrentPage(tutorialWithCreatedAt); 109 | // Set local item that visited update 110 | localStorage.setItem("lastUpdateSeen", latestUpdateTime); 111 | setShowUpdate(false); 112 | }); 113 | }; 114 | 115 | return ( 116 |
117 | 118 | 119 | 126 | 133 | 134 | 135 | 136 | 137 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | { 161 | window.open( 162 | "https://github.com/dennishansen/holograph", 163 | "_blank" 164 | ); 165 | }} 166 | /> 167 | window.open("https://x.com/dennizor")} 173 | /> 174 | {isMobile && ( 175 |
176 | } 180 | readonlyOk 181 | onSelect={whatsNew} 182 | style={{ 183 | backgroundColor: isDarkMode 184 | ? "rgb(26, 26, 28)" 185 | : "rgb(237, 240, 242)", 186 | }} 187 | /> 188 |
189 | )} 190 |
191 |
192 | {!isMobile && ( 193 | 206 | {showUpdate ? ( 207 | <> 208 | {"New stuff!"} 209 | 210 | 211 | ) : ( 212 | "How to" 213 | )} 214 | 215 | )} 216 | {showUpdate && isMobile && ( 217 | 223 | )} 224 |
225 | ); 226 | }; 227 | 228 | export default CustomMainMenu; 229 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import "./App.css"; 3 | 4 | import { useCallback, useEffect, useState } from "react"; 5 | import { Tldraw } from "tldraw"; 6 | import deepDiff from "./deepDiff"; 7 | import Toolbar from "./Toolbar"; 8 | import HelpMenu from "./HelpMenu"; 9 | import MainMenu from "./MainMenu"; 10 | import SharePanel from "./SharePanel"; 11 | import { Analytics } from "@vercel/analytics/react"; 12 | import update from "./update"; 13 | import overrides from "./overrides"; 14 | import appendCreatedAt from "./appendCreatedAt"; 15 | 16 | const latestUpdateTime = 1721928965296; 17 | 18 | let mounted = false; 19 | 20 | document.toasts = () => {}; 21 | 22 | const ignoredKeys = [ 23 | "meta.result", 24 | "meta.code", 25 | "meta.nextClick", 26 | "meta.click", 27 | "meta.errorColorCache", 28 | ]; 29 | 30 | const allKeysInArray = (obj, arr) => { 31 | return Object.keys(obj).every((key) => arr.some((str) => key.includes(str))); 32 | }; 33 | 34 | const App = () => { 35 | const [editor, setEditor] = useState(); 36 | const [showUpdate, setShowUpdate] = useState(false); 37 | const [isDarkMode, setIsDarkMode] = useState(false); 38 | // Last update: lazy arrows 39 | 40 | const setAppToState = useCallback((editor) => { 41 | setEditor(editor); 42 | }, []); 43 | 44 | // Load logic 45 | useEffect(() => { 46 | if (!editor) return; 47 | if (mounted) return; 48 | mounted = true; // prevent rerunning and screwing this up 49 | 50 | const allRecords = editor.store.allRecords(); 51 | 52 | const lastVisit = localStorage.getItem("lastVisit"); 53 | const backwardsCompatVisited = localStorage.getItem("visited"); 54 | const isFirstVisit = lastVisit === null && backwardsCompatVisited !== true; // backwards compatibility 55 | 56 | const canvasRecords = allRecords.filter( 57 | ({ id }) => id.startsWith("shape") || id.startsWith("asset") 58 | ); 59 | // Load tutorial to current page if its empty and its the first load 60 | const showTutorial = canvasRecords.length === 0 && isFirstVisit; 61 | if (showTutorial) { 62 | fetch("/tutorial.json") 63 | .then((response) => { 64 | if (response.ok) return response.json(); 65 | }) 66 | .then((tutorial) => { 67 | tutorial = appendCreatedAt(tutorial); 68 | editor.createAssets(tutorial.assets); 69 | editor.createShapes(tutorial.shapes); 70 | }); 71 | // .catch((error) => console.error(error)); 72 | } 73 | 74 | // Set last visit to now 75 | const visitTime = Date.now(); 76 | localStorage.setItem("lastVisit", visitTime); 77 | 78 | // Show an update if the last update seen is older than the latest update 79 | let lastUpdateSeen = localStorage.getItem("lastUpdateSeen"); 80 | if (showTutorial) { 81 | lastUpdateSeen = visitTime; 82 | } 83 | setShowUpdate(!showTutorial && lastUpdateSeen < latestUpdateTime); 84 | 85 | localStorage.setItem("lastUpdateSeen", lastUpdateSeen); 86 | 87 | // Observe dark mode changes 88 | const targetNode = document.querySelector("#root > div > div.tl-container"); 89 | 90 | // Create a MutationObserver instance 91 | const observer = new MutationObserver((mutationsList) => { 92 | for (const mutation of mutationsList) { 93 | if ( 94 | mutation.type === "attributes" && 95 | mutation.attributeName === "data-color-mode" 96 | ) { 97 | // Handle the change 98 | const newMode = mutation.target.getAttribute("data-color-mode"); 99 | const newIsDarkMode = newMode === "dark"; 100 | setIsDarkMode(newIsDarkMode); 101 | } 102 | } 103 | }); 104 | 105 | // Configuration for the observer 106 | const config = { 107 | attributes: true, // Watch for attribute changes 108 | attributeFilter: ["data-color-mode"], // Only observe the specific attribute 109 | }; 110 | 111 | // Start observing the target node 112 | observer.observe(targetNode, config); 113 | 114 | // Add created meta for backwards compat with setting defaults 115 | let shapesWithNewMeta = []; 116 | for (let record of allRecords) { 117 | if (record.typeName === "shape" && !record.meta.createdAt) { 118 | shapesWithNewMeta.push({ 119 | id: record.id, 120 | meta: { createdAt: Date.now() }, 121 | }); 122 | } 123 | } 124 | editor.updateShapes(shapesWithNewMeta, isDarkMode); 125 | }, [editor, isDarkMode]); 126 | 127 | useEffect(() => { 128 | if (!editor) return; 129 | 130 | //[1] 131 | const handleChangeEvent = (change) => { 132 | // Added 133 | for (const record of Object.values(change.changes.added)) { 134 | if (record.typeName === "shape") { 135 | // Add code formatting to rectangles 136 | if (record.props.geo === "rectangle") { 137 | // Set defaults 138 | if (!editor.getShape(record.id).meta.createdAt) { 139 | // Set defaults if its newly created 140 | editor.updateShape({ 141 | id: record.id, 142 | props: { fill: "semi", font: "mono" }, // NOTE: always adding classes 143 | meta: { createdAt: Date.now() }, 144 | }); 145 | } 146 | } else if (record.props.geo === "ellipse") { 147 | // Set defaults 148 | if (!editor.getShape(record.id).meta.createdAt) { 149 | // Set defaults if its newly created 150 | editor.updateShape({ 151 | id: record.id, 152 | props: { fill: "semi", font: "mono" }, 153 | meta: { createdAt: Date.now() }, 154 | }); 155 | } 156 | } else if (record.type === "text") { 157 | // Set defaults 158 | if (!editor.getShape(record.id).meta.createdAt) { 159 | // Set defaults if its newly created 160 | editor.updateShape({ 161 | id: record.id, 162 | props: { font: "draw" }, 163 | meta: { createdAt: Date.now() }, 164 | }); 165 | } 166 | } 167 | 168 | // console.log(`created shape (${JSON.stringify(record)})\n`); 169 | } 170 | } 171 | 172 | // Updated 173 | for (const [from, to] of Object.values(change.changes.updated)) { 174 | if (from.id.startsWith("shape") && to.id.startsWith("shape")) { 175 | let diff = deepDiff(from, to); 176 | let ignore = allKeysInArray(diff, ignoredKeys); 177 | 178 | // // console.log("metalast: ", diff["meta.lastUpdated"]); 179 | // if (diff["meta.lastUpdated"]) { 180 | // ignore = true; 181 | // } 182 | // console.log("Ignoring change", ignore); 183 | if (ignore) { 184 | // Ignore changes that should not trigger a re-propagation 185 | } else if (to.typeName === "shape") { 186 | if (to.type === "arrow") { 187 | let startId = to.props.start.boundShapeId; 188 | let endId = to.props.end.boundShapeId; 189 | let newStart = diff["props.start.boundShapeId"] && endId; 190 | let newEnd = diff["props.end.boundShapeId"] && startId; 191 | let newlySolid = diff["props.dash"] && to.props.dash === "draw"; 192 | if (newStart || newEnd || newlySolid) { 193 | update(startId, editor); 194 | } 195 | if (diff["props.text"] && startId && endId) { 196 | update(startId, editor); 197 | } 198 | } else { 199 | // All other changes trigger propagation. 200 | // This can be optimized to only updating based on connected arrows. 201 | update(to.id, editor); 202 | } 203 | } 204 | } 205 | } 206 | 207 | // Removed 208 | for (const record of Object.values(change.changes.removed)) { 209 | if (record.typeName === "shape") { 210 | import.meta.env.DEV && console.log(`deleted shape: `, record); 211 | } 212 | } 213 | }; 214 | 215 | const cleanupFunction = editor.store.listen(handleChangeEvent, { 216 | source: "user", 217 | scope: "all", 218 | }); 219 | 220 | return () => { 221 | cleanupFunction(); 222 | }; 223 | }, [editor]); 224 | 225 | useEffect(() => { 226 | if (!editor) return; 227 | 228 | const handleEvent = (data) => { 229 | if (data.name === "pointer_down") { 230 | const point = data.point; 231 | const pagePoint = editor.screenToPage(point); 232 | const shape = editor.getShapeAtPoint(pagePoint, { 233 | hitInside: true, 234 | // hitLocked: true, // Can't update locked shapes 235 | }); 236 | if (shape !== undefined) { 237 | editor.updateShape({ 238 | id: shape.id, 239 | meta: { nextClick: { ...pagePoint, timeStamp: Date.now() } }, 240 | }); 241 | update(shape.id, editor); 242 | } 243 | } else if (data.name === "pointer_move") { 244 | // Hover events, etc 245 | } 246 | }; 247 | 248 | editor.on("event", handleEvent); 249 | }, [editor]); 250 | 251 | const components = { 252 | HelpMenu, 253 | MainMenu: (...props) => ( 254 | 261 | ), 262 | SharePanel, 263 | Toolbar, 264 | }; 265 | 266 | return ( 267 |
268 | 274 | 275 |
276 | ); 277 | }; 278 | 279 | export default App; 280 | -------------------------------------------------------------------------------- /src/update.js: -------------------------------------------------------------------------------- 1 | import getUniqueName from "./getUniqueName"; 2 | import castInput from "./castInput"; 3 | import _ from "lodash"; 4 | 5 | const basePropsKeys = [ 6 | "parentId", 7 | "id", 8 | "typeName", 9 | "type", 10 | "x", 11 | "y", 12 | "rotation", 13 | "index", 14 | "opacity", 15 | "isLocked", 16 | ]; 17 | 18 | const wait = async (arg, delay) => { 19 | return new Promise((resolve) => setTimeout(() => resolve(arg), delay)); 20 | }; 21 | 22 | const errorString = "error-3n5al"; 23 | 24 | const propTypes = { 25 | x: "number", 26 | y: "number", 27 | rotation: "number", 28 | isLocked: "boolean", 29 | opacity: "number", 30 | id: "string", 31 | type: "string", 32 | w: "number", 33 | h: "number", 34 | geo: "string", 35 | color: "string", 36 | labelColor: "string", 37 | fill: "string", 38 | dash: "string", 39 | size: "string", 40 | font: "string", 41 | text: "string", 42 | align: "string", 43 | verticalAlign: "string", 44 | growY: "number", 45 | url: "string", 46 | parentId: "string", 47 | index: "string", 48 | typeName: "string", 49 | points: "object", 50 | }; 51 | 52 | const colors = [ 53 | "black", 54 | "grey", 55 | "light-violet", 56 | "violet", 57 | "blue", 58 | "light-blue", 59 | "yellow", 60 | "orange", 61 | "green", 62 | "light-green", 63 | "light-red", 64 | "red", 65 | "white", 66 | ]; 67 | 68 | const getValue = (obj, path) => { 69 | return path.split(".").reduce((acc, key) => acc && acc[key], obj); 70 | }; 71 | 72 | const isInQuotes = (str) => { 73 | return str.length > 1 && str.startsWith('"') && str.endsWith('"'); 74 | }; 75 | 76 | const isInSingleQuotes = (str) => { 77 | return str.length > 1 && str.startsWith("'") && str.endsWith("'"); 78 | }; 79 | 80 | const truncateDecimals = (value) => { 81 | if (typeof value === "number") { 82 | value = parseFloat(Math.round(value * 100) / 100); 83 | } 84 | return value; 85 | }; 86 | 87 | const getValueFromShape = (arrowText, shape, result) => { 88 | if (isInSingleQuotes(arrowText)) { 89 | // Prop 90 | return getPropValue(arrowText, shape); 91 | } else if (shape.props.geo === "ellipse") { 92 | // Text 93 | return shape.props.text; 94 | } else if (shape.props.geo === "rectangle") { 95 | // Return 96 | return result; 97 | } 98 | return undefined; 99 | }; 100 | 101 | const getPropValue = (arrowText, shape) => { 102 | if (arrowText === "'click'") { 103 | if (shape?.meta?.click) { 104 | return JSON.stringify(shape.meta.click); 105 | } 106 | } else if (arrowText === "'self'") { 107 | return JSON.stringify(shape); 108 | } else { 109 | const propKey = arrowText.slice(1, -1); 110 | const isBaseProp = basePropsKeys.includes(propKey); 111 | const propValuePath = isBaseProp ? propKey : "props." + propKey; 112 | const propValue = getValue(shape, propValuePath); 113 | 114 | if (propValuePath === "props.text") { 115 | return propValue; 116 | } else { 117 | return JSON.stringify(truncateDecimals(propValue)); 118 | } 119 | } 120 | }; 121 | 122 | const splitProps = (newProps) => { 123 | let baseProps = {}; 124 | let customProps = {}; 125 | Object.entries(newProps).forEach(([key, value]) => { 126 | if (basePropsKeys.includes(key)) { 127 | baseProps[key] = value; 128 | } else { 129 | customProps[key] = value; 130 | } 131 | }); 132 | return { baseProps, customProps }; 133 | }; 134 | 135 | const setNewProps = (arrowText, source, newProps) => { 136 | // Prop 137 | const propName = arrowText.slice(1, -1); 138 | let value; 139 | const propType = propTypes[propName]; 140 | if (propType === "string") { 141 | if (source.length > 1 && source.startsWith('"') && source.endsWith('"')) { 142 | // Remove quotes if they exist (TODO: Cleanup) 143 | source = source.slice(1, -1); 144 | } 145 | value = source; // Allow all text to come in as a string 146 | } else if (source === "") { 147 | // Do nothing if its an empty string 148 | } else if (propType === "number") { 149 | value = Number(source); 150 | } else if (propType === "boolean") { 151 | value = Boolean(castInput(source)); // Enable dynamic JS typing 152 | } else if (propType === "object") { 153 | value = castInput(source); 154 | } else { 155 | value = castInput(source); // Catch all 156 | } 157 | 158 | // Throw any prop value errors 159 | if (propName === "color" && value !== undefined && !colors.includes(value)) { 160 | // Alert showing the valid colors 161 | document.toasts.addToast({ 162 | id: "bad-color", 163 | title: "Invalid Color", 164 | description: "Please choose from: " + colors.join(", "), 165 | severity: "error", 166 | }); 167 | value = undefined; 168 | } 169 | 170 | // newProps = setNestedProperty(newProps, propName, value); 171 | if (value !== undefined) { 172 | newProps[propName] = value; 173 | } 174 | return newProps; 175 | }; 176 | 177 | const sizeMap = { 178 | s: { 179 | offset: 4, 180 | borderRadius: 4, 181 | }, 182 | m: { 183 | offset: 6, 184 | borderRadius: 6, 185 | }, 186 | l: { 187 | offset: 7, 188 | borderRadius: 8, 189 | }, 190 | xl: { 191 | offset: 10, 192 | borderRadius: 12, 193 | }, 194 | }; 195 | 196 | const highlightShape = (currentShape, propagationId) => { 197 | const { 198 | id, 199 | props: { geo, size }, 200 | } = currentShape; 201 | const svg = document.getElementById(id); 202 | if (!svg) return; 203 | svg.classList.add("is-propagating-" + propagationId); 204 | const { offset, borderRadius } = sizeMap[size]; 205 | if (geo === "rectangle") { 206 | svg.style.outlineOffset = `${offset}px`; 207 | svg.style.borderRadius = `${borderRadius}px`; 208 | } else if (geo === "ellipse") { 209 | svg.style.outlineOffset = `${offset}px`; 210 | svg.style.borderRadius = "50%"; 211 | } 212 | }; 213 | 214 | const unhightlightShapes = (propagationId) => { 215 | const classId = "is-propagating-" + propagationId; 216 | const propagatingShapes = document.getElementsByClassName(classId); 217 | while (propagatingShapes.length) { 218 | propagatingShapes[0].classList.remove(classId); 219 | } 220 | }; 221 | 222 | const update = async (id, editor) => { 223 | const currentShape = editor.getShape(id); 224 | 225 | if (!currentShape) return; 226 | 227 | const propagationId = performance.now().toString().replace(".", ""); 228 | const debugPropagation = document.debugPropagation; 229 | debugPropagation && highlightShape(currentShape, propagationId); 230 | 231 | const arrows = editor.getArrowsBoundTo(id); 232 | let inputArrows = []; 233 | let outputArrows = []; 234 | arrows.forEach(({ arrowId, handleId }) => { 235 | if (handleId === "start") { 236 | outputArrows.push(editor.getShape(arrowId)); 237 | } else { 238 | inputArrows.push(editor.getShape(arrowId)); 239 | } 240 | }); 241 | 242 | const { props, meta = {} } = currentShape; 243 | let code = meta.code; 244 | let newCode; 245 | let codeHasChanged = false; 246 | let result = meta.result; 247 | let newResult; 248 | let resultHasChanged = false; 249 | let lastArgUpdate = meta.lastArgUpdate; 250 | let nextClick = meta.nextClick; 251 | let click = meta.click; 252 | let errorColorCache = meta.errorColorCache || "none"; 253 | let nextErrorColorCache; 254 | let errorColorCacheHasChanged = false; 255 | let nextColor; 256 | 257 | // Log red shapes 258 | let debug = false; 259 | if (currentShape?.props?.fill === "pattern" && import.meta.env.DEV) 260 | debug = true; 261 | const log = (...args) => debug && console.log(...args); 262 | log("-------------------------------"); 263 | log("update ", currentShape?.props?.text, currentShape?.props?.geo, id); 264 | 265 | // Try to rerun propagator function if its a rectangle 266 | if (props?.geo === "rectangle") { 267 | const nextArgUpdate = meta.nextArgUpdate; 268 | // TODO: THis is always true i think 269 | const argsHaveChanged = nextArgUpdate !== lastArgUpdate; 270 | const neverRan = !("result" in meta); 271 | 272 | // Check code and update code 273 | newCode = props.text; 274 | codeHasChanged = code !== newCode; 275 | code = newCode; 276 | let error; 277 | 278 | // Rerun function if args have changed or if its never ran 279 | if (argsHaveChanged || codeHasChanged || neverRan) { 280 | log("argsHaveChanged", argsHaveChanged); 281 | log("codeHasChanged", codeHasChanged); 282 | log("neverRan", neverRan); 283 | // Get new args 284 | let argNames = []; 285 | let argValues = []; 286 | inputArrows.forEach((arrow) => { 287 | const { text: arrowText, start } = arrow.props; 288 | const isSettingProp = isInQuotes(arrowText); 289 | if (start.boundShapeId && !isSettingProp) { 290 | const shape = editor.getShape(start.boundShapeId); 291 | const { meta = {} } = shape; 292 | const source = getValueFromShape(arrowText, shape, meta.result); 293 | let name; 294 | if (isInSingleQuotes(arrowText)) { 295 | // Allow props to come in as arguments 296 | name = arrowText.slice(1, -1); 297 | } else if (arrowText !== "") { 298 | name = arrowText; 299 | } else { 300 | // Give anonymous args a unique name 301 | name = getUniqueName(argNames); 302 | } 303 | argNames.push(name); 304 | argValues.push(castInput(source)); 305 | } 306 | }); 307 | let functionBody = code.includes("return") ? code : `return ${code}`; 308 | // Run function 309 | let newResultRaw; 310 | 311 | try { 312 | log("argNames", argNames); 313 | log("argValues", argValues); 314 | log("functionBody", functionBody); 315 | argNames.push("fetch"); 316 | argValues.push(fetch); 317 | argNames.push("wait"); 318 | argValues.push(wait); 319 | argNames.push("editor"); 320 | argValues.push(editor); 321 | // argNames.push("currentShape"); 322 | // argValues.push(currentShape); 323 | const AsyncFunction = Object.getPrototypeOf( 324 | async function () {} 325 | ).constructor; 326 | const func = new AsyncFunction(argNames, functionBody); 327 | newResultRaw = await func(...argValues); 328 | } catch (newError) { 329 | error = newError; 330 | log("error", error); 331 | } 332 | 333 | log("newResultRaw", newResultRaw); 334 | 335 | // Update the result if it is valid 336 | if (newResultRaw !== undefined) { 337 | let newResultString = JSON.stringify(truncateDecimals(newResultRaw)); 338 | if (typeof newResultString === "string") { 339 | // Valid result 340 | newResult = newResultString; 341 | log("newResult", newResult); 342 | } 343 | } 344 | 345 | // Assign error string if there is an error 346 | if (error) { 347 | newResult = errorString; 348 | } 349 | 350 | resultHasChanged = result !== newResult; 351 | 352 | // Handle any function errors 353 | if (error) { 354 | log("the error is: ", error); 355 | // Set error if it isn't already set 356 | if (errorColorCache === "none") { 357 | nextColor = "red"; 358 | nextErrorColorCache = props.color; 359 | } 360 | // Set results to error code 361 | } else { 362 | // Succeeded. Set color if there was an error last run 363 | if (errorColorCache !== "none") { 364 | // console.log("errorColorCache", errorColorCache); 365 | nextColor = errorColorCache; 366 | nextErrorColorCache = "none"; 367 | } 368 | } 369 | 370 | // Detect if color cache has changed 371 | if (nextErrorColorCache !== errorColorCache) { 372 | errorColorCacheHasChanged = true; 373 | } 374 | } else { 375 | // Otherwise send through old result 376 | newResult = result; 377 | } 378 | } 379 | 380 | // Check if there's a click fired 381 | const firstClick = !click && nextClick !== undefined; 382 | const clickFired = !_.isEqual(click, nextClick) || firstClick; 383 | if (clickFired) { 384 | click = nextClick; 385 | } else { 386 | // Clicks should only propagate when clicked 387 | outputArrows = outputArrows.filter( 388 | (arrow) => arrow.props.text !== "'click'" 389 | ); 390 | } 391 | 392 | // Collect downstream changes 393 | let downstreamShapes = []; 394 | outputArrows.forEach((arrow) => { 395 | const { text: arrowText, end, dash } = arrow.props; 396 | let endShape; 397 | try { 398 | endShape = editor.getShape(end.boundShapeId); 399 | } catch (e) { 400 | // console.log("error", e); 401 | } 402 | if (dash !== "dashed" && endShape) { 403 | // let { nextArgUpdate } = meta; 404 | let nextArgUpdate; 405 | 406 | // Get source value 407 | let source = getValueFromShape(arrowText, currentShape, newResult, click); 408 | 409 | // Set to desintation 410 | let newProps = {}; 411 | if (source === undefined || source === errorString) { 412 | // No result or code error 413 | } else if (isInQuotes(arrowText)) { 414 | // Prop 415 | newProps = setNewProps(arrowText, source, newProps); 416 | } else if (endShape.props.geo === "rectangle") { 417 | // Arg 418 | log("nextArgUpdate getting updated"); 419 | nextArgUpdate = Date.now(); // Notify node to recompute 420 | } else if ( 421 | source !== undefined && 422 | (!arrowText || isInSingleQuotes(arrowText)) 423 | ) { 424 | // Text 425 | if (isInQuotes(source)) { 426 | // Strip quotes when appearing as text 427 | source = source.slice(1, -1); 428 | } 429 | newProps.text = source; 430 | } 431 | 432 | log("source", source); 433 | log("newProps", newProps); 434 | log("nextArgUpdate", nextArgUpdate); 435 | 436 | const { baseProps, customProps } = splitProps(newProps); 437 | 438 | const nextArgUpdateObject = nextArgUpdate ? { nextArgUpdate } : {}; 439 | const newMeta = { 440 | ...nextArgUpdateObject, 441 | // Tell tldraw handler not to fire PERFORMANCE RELEASE 442 | // lastUpdated: Date.now(), 443 | }; 444 | const metaObject = 445 | Object.keys(newMeta).length > 0 ? { meta: newMeta } : {}; // TODO: delete 446 | const propsObject = 447 | Object.keys(customProps).length > 0 ? { props: customProps } : {}; 448 | 449 | let numberBaseProps = Object.keys(baseProps).length; 450 | let numberProps = Object.keys(propsObject).length; 451 | let numberMeta = Object.keys(metaObject).length; 452 | if (numberBaseProps + numberProps + numberMeta > 0) { 453 | downstreamShapes.push({ 454 | id: endShape.id, 455 | ...baseProps, 456 | ...propsObject, 457 | ...metaObject, 458 | }); 459 | } 460 | } 461 | }); 462 | 463 | // Update current shape and Propagate to downstream shapes 464 | const resultObject = 465 | resultHasChanged && newResult !== undefined ? { result: newResult } : {}; 466 | const codeObject = 467 | codeHasChanged && newCode !== undefined ? { code: newCode } : {}; 468 | const clickObject = clickFired ? { click } : {}; 469 | const errorColorCacheObject = errorColorCacheHasChanged 470 | ? { errorColorCache: nextErrorColorCache } 471 | : {}; 472 | const newMeta = { 473 | ...codeObject, 474 | ...resultObject, 475 | ...clickObject, 476 | ...errorColorCacheObject, 477 | }; 478 | const newProps = nextColor ? { color: nextColor } : {}; 479 | let newCurrentShape; 480 | let areNewMeta = Object.keys(newMeta).length > 0; 481 | let newMetaObject = areNewMeta ? { meta: newMeta } : {}; 482 | let areNewProps = Object.keys(newProps).length > 0; 483 | let newPropsObject = areNewProps ? { props: newProps } : {}; 484 | if (areNewMeta || areNewProps) { 485 | newCurrentShape = { id, ...newMetaObject, ...newPropsObject }; 486 | } 487 | 488 | log("newCurrentShape", newCurrentShape); 489 | log("downstreamShapes", downstreamShapes); 490 | 491 | let newShapes = downstreamShapes; 492 | if (newCurrentShape) { 493 | newShapes = [newCurrentShape, ...downstreamShapes]; 494 | } 495 | 496 | if (debugPropagation) { 497 | await wait(null, 1000); 498 | } 499 | 500 | if (newShapes.length > 0) { 501 | editor.updateShapes(newShapes); 502 | } 503 | 504 | unhightlightShapes(propagationId); 505 | }; 506 | 507 | export default update; 508 | --------------------------------------------------------------------------------