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