├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.tsx │ ├── main.tsx │ └── vite-env.d.ts ├── tsconfig.json └── tsconfig.node.json ├── docs └── img │ └── screenshot.png ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── components │ ├── editable-on-click │ │ └── index.tsx │ ├── flow-graph │ │ ├── arrow-marker.tsx │ │ ├── embedded-arrow-marker.tsx │ │ ├── embedded-circle-marker.tsx │ │ ├── embedded-through-marker.tsx │ │ ├── flow-graph.module.css │ │ ├── flow-item-list │ │ │ ├── flow-item-list.module.css │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── initial-edge │ │ │ └── index.tsx │ │ ├── routing-layer │ │ │ ├── index.tsx │ │ │ └── routing-layer.module.css │ │ ├── state-node │ │ │ ├── index.tsx │ │ │ └── state-node.module.css │ │ ├── svg-mask.tsx │ │ ├── transition-node │ │ │ ├── index.tsx │ │ │ └── transition-node.module.css │ │ ├── transitions-view │ │ │ └── index.tsx │ │ ├── types.ts │ │ └── use-pan-and-zoom.ts │ ├── flow-items │ │ ├── action-filled.svg │ │ ├── action.svg │ │ ├── assertion.svg │ │ ├── condition-filled.svg │ │ ├── condition.svg │ │ ├── entry-action.svg │ │ ├── event-filled.svg │ │ ├── event.svg │ │ ├── exit-action.svg │ │ ├── flow-icon.module.css │ │ ├── index.tsx │ │ ├── state-filled.svg │ │ └── state.svg │ ├── grid-background │ │ ├── grid-background.module.css │ │ └── index.tsx │ ├── icon-button │ │ ├── icon-button.module.css │ │ └── index.tsx │ ├── popup │ │ ├── index.tsx │ │ └── popup.module.css │ ├── select │ │ ├── chevron-down.svg │ │ ├── index.tsx │ │ └── select.module.css │ ├── svg-flow-graph │ │ ├── arrow-marker.tsx │ │ ├── embedded-arrow-marker.tsx │ │ ├── embedded-circle-marker.tsx │ │ ├── embedded-through-marker.tsx │ │ ├── flow-item-list │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── initial-edge │ │ │ └── index.tsx │ │ ├── routing-layer │ │ │ └── index.tsx │ │ ├── sized-text.tsx │ │ ├── sizes.ts │ │ ├── state-node │ │ │ └── index.tsx │ │ ├── svg-mask.tsx │ │ ├── transition-node │ │ │ └── index.tsx │ │ ├── transitions-view │ │ │ └── index.tsx │ │ ├── types.ts │ │ └── use-pan-and-zoom.ts │ └── svg-flow-items │ │ └── index.tsx ├── custom.d.ts ├── data │ └── flows.ts ├── debounce-utils.ts ├── flow-utils │ └── index.ts ├── hooks │ ├── use-click-away.ts │ ├── use-modal.ts │ ├── use-position.ts │ └── use-reposition-visibly.ts ├── index.ts ├── schema.ts └── transformers │ ├── elk.ts │ ├── svg.ts │ └── xstate.ts └── tsconfig.json /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 18.x 15 | cache: "npm" 16 | - run: npm ci 17 | - run: npm run build 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to npm 2 | 3 | on: 4 | workflow_dispatch: {} 5 | 6 | permissions: 7 | contents: read 8 | 9 | concurrency: 10 | group: ${{ github.workflow }} 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: "18" 21 | registry-url: "https://registry.npmjs.org" 22 | 23 | - run: npm ci 24 | - run: npm run build 25 | - run: npm publish --access public 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 State Backed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @statebacked/react-statechart · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/statebacked/react-statechart/blob/main/LICENSE) [![npm version](https://img.shields.io/npm/v/@statebacked/react-statechart.svg?style=flat)](https://www.npmjs.com/package/@statebacked/react-statechart) [![CI](https://github.com/statebacked/react-statechart/actions/workflows/ci.yaml/badge.svg)](https://github.com/statebacked/react-statechart/actions/workflows/ci.yaml) 2 | 3 | React components for visualizing and editing statecharts (hierarchical state machines). 4 | 5 | These components are in production use in [State Backed](https://app.statebacked.dev), the first platform for deploying state machine-based real-time backends and workflows, as well as [Team Pando](https://www.teampando.com), the collaborative way to define how your product works. 6 | 7 | However, you should consider this initial public release to be beta quality software as we work towards genericizing and hardening the components and adding support for improved customizability. 8 | 9 | PRs and issues are welcome! 10 | 11 | # Example 12 | 13 | Check out [the example app](https://github.com/statebacked/react-statechart/tree/main/app) for a complete code example. 14 | 15 | ![Example](./docs/img/screenshot.png) 16 | 17 | # Installation 18 | 19 | ```bash 20 | npm install --save @statebacked/react-statechart 21 | ``` 22 | 23 | # Usage 24 | 25 | The primary exported component is `FlowGraph` and you will likely want to import or customize the bundled react-statechart.css. 26 | 27 | `FlowGraph` expects a flow definition in a simplified representation that we define in the `schema` export. 28 | 29 | To convert an XState machine definition into the @statebacked/react-statechart representation, you can use the provided conversion functions from the `xstate` export. 30 | 31 | Here's some example code to render an XState statechart: 32 | 33 | ```javascript 34 | import { 35 | FlowGraph, 36 | GridBackground, 37 | xstate, 38 | } from "@statebacked/react-statechart"; 39 | import { createMachine } from "xstate"; 40 | import "@statebacked/react-statechart/index.css"; 41 | 42 | // any XState machine 43 | const machine = createMachine({ 44 | initial: "state1", 45 | states: { 46 | state1: { 47 | initial: "state2", 48 | states: { 49 | state2: { 50 | on: { 51 | somethingHappened: "state3", 52 | }, 53 | }, 54 | state3: {}, 55 | }, 56 | }, 57 | }, 58 | }); 59 | 60 | // convert to @statebacked/react-statechart representation 61 | const flow = xstate.machineToFlow(machine); 62 | 63 | export const YourComponent = () => { 64 | return ; 65 | }; 66 | ``` 67 | 68 | To support editing, pass an object with the necessary callbacks to the `editable` property. 69 | 70 | # State Backed 71 | 72 | This component is provided by [State Backed](https://app.statebacked.dev), the first backend-as-a-service centered around statecharts and state machines. 73 | We're confident that statecharts are a crucial abstraction that can help us all build better software, faster. 74 | So our goal is to make it as easy as possible for all engineers to experience the power of declarative logic. 75 | Use this for anything you'd like! If possible, please tell us about what you build by opening a Github issue. 76 | And if you'd like to experience the power and fun of a statechart-native backend, try out the [State Backed](https://app.statebacked.dev) free plan. 77 | 78 | # License 79 | 80 | @statebacked/react-statechart is [MIT licensed](https://github.com/statebacked/react-statechart/blob/main/LICENSE). 81 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | State Backed - state machine backend as a service 8 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statebacked/react-statechart-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "prettier": "prettier --write *.{js,json,ts} src/" 12 | }, 13 | "dependencies": { 14 | "@statebacked/react-statechart": "file:..", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "xstate": "^4.38.2" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^18.2.15", 21 | "@types/react-dom": "^18.2.7", 22 | "@typescript-eslint/eslint-plugin": "^6.0.0", 23 | "@typescript-eslint/parser": "^6.0.0", 24 | "@vitejs/plugin-react": "^4.0.3", 25 | "eslint": "^8.45.0", 26 | "eslint-plugin-react-hooks": "^4.6.0", 27 | "eslint-plugin-react-refresh": "^0.4.3", 28 | "prettier": "^3.0.2", 29 | "typescript": "^5.0.2", 30 | "vite": "^4.4.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FlowGraph, 3 | GridBackground, 4 | xstate, 5 | } from "@statebacked/react-statechart"; 6 | import { createMachine } from "xstate"; 7 | import "@statebacked/react-statechart/index.css"; 8 | 9 | const machine = createMachine({ 10 | initial: "state1", 11 | states: { 12 | state1: { 13 | initial: "state2", 14 | states: { 15 | state2: { 16 | on: { 17 | somethingHappened: "state3", 18 | }, 19 | }, 20 | state3: {}, 21 | }, 22 | }, 23 | }, 24 | }); 25 | 26 | const flow = xstate.machineToFlow(machine); 27 | 28 | function App() { 29 | return ( 30 | <> 31 |

32 | @statebacked/react-statechart - React statechart viewer and editor from{" "} 33 | StateBacked.dev 34 |

35 |
36 | 37 | 78 | 79 |
80 | 81 | ); 82 | } 83 | 84 | export default App; 85 | -------------------------------------------------------------------------------- /app/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /app/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | 21 | /* Linting */ 22 | "strict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /docs/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statebacked/react-statechart/fd2219bc14c50a28d3281de86bc5c43d25476434/docs/img/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statebacked/react-statechart", 3 | "version": "0.1.4", 4 | "description": "Statechart viewer and editor from StateBacked.dev", 5 | "main": "dist/cjs/index.js", 6 | "types": "./dist/dts/index.d.ts", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": { 11 | "import": "./dist/dts/index.d.ts", 12 | "require": "./dist/dts/index.d.ts" 13 | }, 14 | "default": { 15 | "import": "./dist/mjs/index.js", 16 | "require": "./dist/cjs/index.js" 17 | } 18 | }, 19 | "./index.css": { 20 | "default": { 21 | "import": "./dist/mjs/index.css", 22 | "require": "./dist/cjs/index.css" 23 | } 24 | } 25 | }, 26 | "scripts": { 27 | "build": "rollup --config ./rollup.config.mjs && tsc --emitDeclarationOnly --declaration --outDir dist/dts && echo '{ \"type\": \"commonjs\" }' > dist/cjs/package.json", 28 | "prettier": "prettier --write .", 29 | "test": "echo \"Error: no test specified\" && exit 1" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/statebacked/react-statechart.git" 34 | }, 35 | "author": "", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/statebacked/react-statechart/issues" 39 | }, 40 | "homepage": "https://github.com/statebacked/react-statechart#readme", 41 | "dependencies": { 42 | "elkjs": "^0.8.2", 43 | "react-icons": "^4.3.1", 44 | "uuid": "^9.0.0", 45 | "web-worker": "^1.2.0", 46 | "zod": "^3.22.2" 47 | }, 48 | "files": [ 49 | "packackge.json", 50 | "README.md", 51 | "LICENSE", 52 | "dist/**/*" 53 | ], 54 | "devDependencies": { 55 | "@rollup/plugin-typescript": "^11.1.3", 56 | "@types/react": "^18.0.26", 57 | "@types/react-dom": "^18.0.9", 58 | "@types/uuid": "^9.0.2", 59 | "autoprefixer": "^10.4.15", 60 | "postcss-svg": "^3.0.0", 61 | "prettier": "^2.8.1", 62 | "rollup": "^3.28.1", 63 | "rollup-plugin-postcss": "^4.0.2", 64 | "typescript": "^4.9.3", 65 | "typescript-plugin-css-modules": "^5.0.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import autoprefixer from "autoprefixer"; 3 | import postcss from "rollup-plugin-postcss"; 4 | import postcssSvg from "postcss-svg"; 5 | 6 | export default { 7 | input: "src/index.ts", 8 | output: [ 9 | { 10 | dir: "dist/cjs", 11 | format: "cjs", 12 | sourcemap: true, 13 | }, 14 | { 15 | dir: "dist/mjs", 16 | format: "es", 17 | sourcemap: true, 18 | }, 19 | ], 20 | plugins: [ 21 | typescript(), 22 | postcss({ 23 | plugins: [autoprefixer(), postcssSvg()], 24 | sourceMap: true, 25 | extract: true, 26 | minimize: true, 27 | }), 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/editable-on-click/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const EditableOnClick = ({ 4 | text, 5 | className, 6 | onChange, 7 | onEmptied, 8 | initiallyEditable, 9 | }: { 10 | text: string; 11 | className?: string; 12 | onChange: (text: string) => void; 13 | onEmptied?: () => void; 14 | initiallyEditable?: boolean; 15 | }) => { 16 | const [editingText, setEditingText] = useState( 17 | initiallyEditable ? text : null 18 | ); 19 | 20 | // onBlur is called immediately after rendering, even if autoFocus 21 | // is set. We don't want that. 22 | const [attachBlur, setAttachBlur] = useState(false); 23 | useEffect(() => { 24 | setAttachBlur(true); 25 | }, []); 26 | 27 | const saveText = () => { 28 | if (editingText !== null && editingText !== text) { 29 | onChange(editingText); 30 | } 31 | 32 | if (editingText === "" && onEmptied) { 33 | onEmptied(); 34 | } 35 | setEditingText(null); 36 | }; 37 | 38 | if (editingText !== null) { 39 | return ( 40 | setEditingText(e.target.value)} 45 | onBlur={attachBlur ? saveText : undefined} 46 | onDoubleClick={(e) => { 47 | e.stopPropagation(); 48 | }} 49 | onMouseMove={(e) => { 50 | e.stopPropagation(); 51 | }} 52 | onKeyDown={(e) => { 53 | if (e.key === "Enter") { 54 | saveText(); 55 | } else if (e.key === "Escape") { 56 | setEditingText(null); 57 | e.stopPropagation(); 58 | if (text.length === 0 && onEmptied) { 59 | onEmptied(); 60 | } 61 | } 62 | }} 63 | /> 64 | ); 65 | } 66 | 67 | return ( 68 | { 71 | setEditingText(text); 72 | e.stopPropagation(); 73 | }} 74 | > 75 | {text} 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/flow-graph/arrow-marker.tsx: -------------------------------------------------------------------------------- 1 | export const ArrowMarker = ({ id }: { id: string }) => { 2 | return ( 3 | 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/flow-graph/embedded-arrow-marker.tsx: -------------------------------------------------------------------------------- 1 | export const EmbeddedArrowMarker = ({ 2 | id, 3 | color, 4 | }: { 5 | id: string; 6 | color?: string; 7 | }) => { 8 | return ( 9 | 19 | 24 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/flow-graph/embedded-circle-marker.tsx: -------------------------------------------------------------------------------- 1 | export const EmbeddedCircleMarker = ({ id }: { id: string }) => { 2 | return ( 3 | 13 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/flow-graph/embedded-through-marker.tsx: -------------------------------------------------------------------------------- 1 | export const EmbeddedThroughMarker = ({ 2 | id, 3 | color, 4 | }: { 5 | id: string; 6 | color?: string; 7 | }) => { 8 | return ( 9 | 19 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/flow-graph/flow-graph.module.css: -------------------------------------------------------------------------------- 1 | .flowGraph { 2 | width: 100%; 3 | height: 100%; 4 | position: relative; 5 | text-align: center; 6 | display: flex; 7 | flex-direction: column; 8 | user-select: none; 9 | } 10 | 11 | .flowGraph { 12 | --grid-background-color: var(--grid-background-color-override, #f4f7f8); 13 | --grid-background-50p-color: var( 14 | --grid-background-50p-color-override, 15 | #f4f7f880 16 | ); 17 | --flow-header-background-color: var( 18 | --flow-header-background-color-override, 19 | #fff 20 | ); 21 | --flow-header-text-color: var(--flow-header-text-color-override, #29273d); 22 | 23 | --top-level-node-border-color: var( 24 | --top-level-node-border-color-override, 25 | #dadada 26 | ); 27 | 28 | --popup-background-color: var(--popup-background-color-override, #ffffff); 29 | --popup-border-color: var(--popup-border-color-override, #f2f2f2); 30 | 31 | --select-background-color: var(--select-background-color-override, #ffffff); 32 | --select-divider-color: var(--select-divider-color-override, #e4e3e5); 33 | --select-hover-background-color: var( 34 | --select-hover-background-color-override, 35 | #f2f2f2 36 | ); 37 | 38 | --state-primary-color: var(--state-primary-color-override, #ffcf5c); 39 | --state-primary-50p-color: var(--state-primary-50p-color-override, #ffcf5c80); 40 | --state-background-color: var(--state-background-color-override, #ffefc966); 41 | --state-background-10p-color: var( 42 | --state-background-10p-color-override, 43 | #ffefc919 44 | ); 45 | --state-border-color: var(--state-border-color-override, #ffdf92); 46 | 47 | --action-primary-color: var(--action-primary-color-override, #00a3ff); 48 | --action-primary-50p-color: var( 49 | --action-primary-50p-color-override, 50 | #00a3ff80 51 | ); 52 | --action-background-color: var(--action-background-color-override, #a6d6ff66); 53 | --action-background-10p-color: var( 54 | --action-background-10p-color-override, 55 | #a6d6ff19 56 | ); 57 | --action-border-color: var(--action-border-color-override, #3eb9ff); 58 | 59 | --event-primary-color: var(--event-primary-color-override, #8753dd); 60 | --event-primary-50p-color: var(--event-primary-50p-color-override, #8753dd80); 61 | --event-background-color: var(--event-background-color-override, #cebdff66); 62 | --event-background-10p-color: var( 63 | --event-background-10p-color-override, 64 | #cebdff19 65 | ); 66 | --event-border-color: var(--event-border-color-override, #a88aff); 67 | 68 | --condition-primary-color: var(--condition-primary-color-override, #00c48c); 69 | --condition-primary-50p-color: var( 70 | --condition-primary-50p-color-override, 71 | #00c48c80 72 | ); 73 | --condition-background-color: var( 74 | --condition-background-color-override, 75 | #96ffe166 76 | ); 77 | --condition-background-10p-color: var( 78 | --condition-background-10p-color-override, 79 | #96ffe119 80 | ); 81 | --condition-border-color: var(--condition-border-color-override, #2effc3); 82 | 83 | --expectation-primary-color: var( 84 | --expectation-primary-color-override, 85 | #fe8799 86 | ); 87 | --expectation-primary-50p-color: var( 88 | --expectation-primary-50p-color-override, 89 | #fe879980 90 | ); 91 | --expectation-background-color: var( 92 | --expectation-background-color-override, 93 | #ffcbd366 94 | ); 95 | --expectation-background-10p-color: var( 96 | --expectation-background-10p-color-override, 97 | #ffcbd319 98 | ); 99 | --expectation-border-color: var(--expectation-border-color-override, #ff98a8); 100 | } 101 | 102 | .flowGraph .sizing { 103 | position: absolute; 104 | pointer-events: none; 105 | opacity: 0.001; 106 | z-index: -1; 107 | transition: none; 108 | } 109 | 110 | .flowGraph .sizing > * { 111 | width: 999999999px; 112 | } 113 | 114 | .flowGraph figure { 115 | flex: 1; 116 | overflow: hidden; 117 | margin: 0; 118 | cursor: grab; 119 | } 120 | 121 | .flowGraph.editable figure { 122 | overflow: visible; 123 | } 124 | 125 | .flowGraph:not(.editable) figure > :not(.buttonContainer):active { 126 | pointer-events: none; /* make sure pointer events flow through to the flow graph itself */ 127 | } 128 | 129 | .flowGraph .selected { 130 | box-shadow: rgb(255 255 0 / 25%) 0px 0px 10px 10px; 131 | } 132 | 133 | .flowGraph * { 134 | transition: height 500ms ease, width 500ms ease, top 500ms ease, 135 | left 500ms ease; 136 | } 137 | -------------------------------------------------------------------------------- /src/components/flow-graph/flow-item-list/flow-item-list.module.css: -------------------------------------------------------------------------------- 1 | .flowItemList { 2 | margin: 0; 3 | padding-inline-start: 0; 4 | } 5 | 6 | .flowItemListItem { 7 | text-align: left; 8 | list-style-type: none; 9 | display: flex; 10 | flex-direction: row; 11 | align-items: flex-start; 12 | gap: 3px; 13 | max-width: 500px; 14 | } 15 | 16 | .iconContainer { 17 | display: flex; 18 | flex-direction: row; 19 | align-items: center; 20 | height: 24px; 21 | } 22 | 23 | .flowItemIcon { 24 | width: 20px; 25 | height: 20px; 26 | } 27 | 28 | .addItemPopup { 29 | top: 100%; 30 | width: max-content; 31 | transform: scale(var(--inverse-zoom)); 32 | transform-origin: 0 0; 33 | } 34 | 35 | .addItemMenuItem { 36 | display: flex; 37 | flex-direction: row; 38 | gap: 3px; 39 | align-items: center; 40 | padding: 12px 0; 41 | cursor: pointer; 42 | } 43 | 44 | .addItemMenuItem:not(:last-child) { 45 | border-bottom: 1px solid var(--select-divider-color); 46 | } 47 | 48 | .editableName { 49 | width: 100%; 50 | height: auto; 51 | min-height: 20px; 52 | border: none; 53 | outline: none; 54 | cursor: pointer; 55 | } 56 | 57 | input.editableName { 58 | cursor: text; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/flow-graph/flow-item-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DrawableFlow, 3 | FlowItemIdentifier, 4 | FlowItem, 5 | getMetadata, 6 | } from "../../../flow-utils"; 7 | import { FlowItemIcon } from "../../flow-items"; 8 | import styles from "./flow-item-list.module.css"; 9 | import { RiAddCircleLine, RiDeleteBinLine } from "react-icons/ri"; 10 | import { IconButton } from "../../icon-button"; 11 | import { useModal } from "../../../hooks/use-modal"; 12 | import { Popup, PopupWrapper } from "../../popup"; 13 | import { flowItemTypePresentation, freshFlowItemId } from "../../../data/flows"; 14 | import * as schema from "../../../schema"; 15 | import { EditableOnClick } from "../../editable-on-click"; 16 | 17 | const newItemId = ""; 18 | 19 | export const FlowItemList = ({ 20 | flow, 21 | items, 22 | nonEmptyClassName, 23 | editable, 24 | }: { 25 | flow: DrawableFlow; 26 | items: Array; 27 | nonEmptyClassName?: string; 28 | editable?: { 29 | eligibleTypes: Array; 30 | typeLabelOverride?: Partial>; 31 | onUpsertItem: (item: FlowItem) => void; 32 | onDeleteItem: (itemId: FlowItemIdentifier) => void; 33 | }; 34 | }) => { 35 | const newItems = items.filter((item) => item.flowItemId === newItemId); 36 | const realItems = items.filter((item) => item.flowItemId !== newItemId); 37 | const fullItems: Array = realItems.concat(newItems).map((item) => ({ 38 | ...item, 39 | flowItemName: getMetadata(flow, item)?.name ?? "", 40 | })); 41 | 42 | return ( 43 |
    48 | {fullItems.map((item) => ( 49 |
  • 50 |
    51 | {editable ? ( 52 |
    53 | } 55 | title="Delete" 56 | size={16} 57 | onClick={() => editable.onDeleteItem(item)} 58 | /> 59 |
    60 | ) : null} 61 | 66 |
    {" "} 67 | {editable ? ( 68 | editable.onDeleteItem(item)} 73 | onChange={(name) => { 74 | if (!name) { 75 | editable.onDeleteItem(item); 76 | return; 77 | } 78 | 79 | const flowItemId = 80 | item.flowItemId === newItemId 81 | ? freshFlowItemId() 82 | : item.flowItemId; 83 | 84 | editable.onUpsertItem({ 85 | flowItemId: flowItemId as any, 86 | flowItemName: name, 87 | flowItemType: item.flowItemType, 88 | }); 89 | if (flowItemId !== item.flowItemId) { 90 | editable.onDeleteItem(item); 91 | } 92 | }} 93 | /> 94 | ) : ( 95 | {item.flowItemName} 96 | )} 97 |
  • 98 | ))} 99 | {editable ? ( 100 | { 104 | editable.onUpsertItem({ 105 | flowItemId: newItemId, 106 | flowItemType: type, 107 | flowItemName: "", 108 | } as FlowItem); 109 | }} 110 | /> 111 | ) : null} 112 |
113 | ); 114 | }; 115 | 116 | const AddListItem = ({ 117 | onAddItem, 118 | eligibleTypes, 119 | typeLabelOverride, 120 | }: { 121 | onAddItem: (type: schema.FlowItemType) => void; 122 | eligibleTypes: Array; 123 | typeLabelOverride: Partial>; 124 | }) => { 125 | const { open, onOpen, onClose } = useModal(); 126 | 127 | return ( 128 |
  • 129 | 130 | } 132 | size={16} 133 | title="Add" 134 | onClick={onOpen} 135 | /> 136 | 137 |
    138 | {eligibleTypes?.map((type) => ( 139 |
    { 143 | onAddItem(type); 144 | onClose(); 145 | }} 146 | > 147 | {" "} 148 | {typeLabelOverride[type] ?? 149 | `Add ${flowItemTypePresentation[type].title}`} 150 |
    151 | ))} 152 |
    153 |
    154 |
    155 |
  • 156 | ); 157 | }; 158 | -------------------------------------------------------------------------------- /src/components/flow-graph/index.tsx: -------------------------------------------------------------------------------- 1 | import ELK, { ELK as iELK } from "elkjs"; 2 | import { transitionCount } from "../../flow-utils"; 3 | import { DrawableFlow, FlowItemIdentifier } from "../../flow-utils"; 4 | import { StateNode } from "./state-node"; 5 | import styles from "./flow-graph.module.css"; 6 | import { 7 | EnrichedElkNode, 8 | flowToElkGraph, 9 | getFullFlow, 10 | getStatePositionId, 11 | getTransitionPositionId, 12 | PositionedItemId, 13 | PositionInfo, 14 | Size, 15 | toLayoutMap, 16 | } from "../../transformers/elk"; 17 | import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; 18 | import * as schema from "../../schema"; 19 | import { debounceWithLimit } from "../../debounce-utils"; 20 | import { usePanAndZoom } from "./use-pan-and-zoom"; 21 | import { TransitionNode } from "./transition-node"; 22 | import { TransitionsView } from "./transitions-view"; 23 | import { usePosition } from "../../hooks/use-position"; 24 | import { FlowItem } from "../../flow-utils"; 25 | 26 | const getElk = (() => { 27 | let elk: iELK | undefined; 28 | return () => { 29 | if (!elk) { 30 | elk = new ELK(); 31 | } 32 | return elk; 33 | }; 34 | })(); 35 | 36 | const nextLayoutId = (() => { 37 | let nextId = 0; 38 | return () => nextId++; 39 | })(); 40 | 41 | type Layout = Map; 42 | 43 | type State = null | { layout: Layout; layoutId: number; flowId: schema.FlowId }; 44 | type Action = 45 | | { 46 | type: "received-layout"; 47 | flowId: schema.FlowId; 48 | layout: EnrichedElkNode; 49 | layoutId: number; 50 | } 51 | | { type: "reset" }; 52 | 53 | export type FlowGraphProps = { 54 | flow: DrawableFlow; 55 | selectedItems?: Array; 56 | direction?: "horizontal" | "vertical"; 57 | editable?: { 58 | getAvailableStates: () => Array<{ id: schema.StateId; name: string }>; 59 | onUpdateTransitionTarget: ( 60 | previousTargetId: schema.StateId, 61 | newTargetId: schema.StateId 62 | ) => void; 63 | onUpdateState: ( 64 | stateId: schema.StateId | null, 65 | state: DrawableFlow["states"][schema.StateId] 66 | ) => void; 67 | onRemoveState: (stateId: schema.StateId) => void; 68 | onUpsertStateItem: (stateId: schema.StateId | null, item: FlowItem) => void; 69 | onDeleteStateItem: ( 70 | stateId: schema.StateId | null, 71 | itemId: FlowItemIdentifier 72 | ) => void; 73 | onAddTransition: (sourceState: schema.StateId | undefined) => void; 74 | onUpdateTransition: ( 75 | sourceState: schema.StateId | undefined, 76 | targetState: schema.StateId | undefined, 77 | event: schema.EventId | undefined, 78 | condition: schema.ConditionId | undefined, 79 | updated: { 80 | event?: { 81 | id: schema.EventId; 82 | name: string; 83 | }; 84 | condition?: { 85 | id: schema.ConditionId; 86 | name: string; 87 | }; 88 | } 89 | ) => void; 90 | onDeleteTransition: ( 91 | sourceState: schema.StateId | undefined, 92 | targetState: schema.StateId | undefined, 93 | event: schema.EventId | undefined, 94 | condition: schema.ConditionId | undefined 95 | ) => void; 96 | onUpsertTransitionItem: ( 97 | sourceState: schema.StateId | undefined, 98 | targetState: schema.StateId | undefined, 99 | event: schema.EventId | undefined, 100 | condition: schema.ConditionId | undefined, 101 | item: FlowItem 102 | ) => void; 103 | onDeleteTransitionItem: ( 104 | sourceState: schema.StateId | undefined, 105 | targetState: schema.StateId | undefined, 106 | event: schema.EventId | undefined, 107 | condition: schema.ConditionId | undefined, 108 | itemId: FlowItemIdentifier 109 | ) => void; 110 | }; 111 | header?: React.ReactNode; 112 | renderButtons?: (props: { 113 | onZoomIn: () => void; 114 | onZoomOut: () => void; 115 | onResetZoom: () => void; 116 | }) => React.ReactNode; 117 | }; 118 | 119 | const rootId = "root" as schema.StateId; 120 | const rootPosId = getStatePositionId(rootId); 121 | const flowStateId = "flow" as schema.StateId; 122 | const emptySelectedItems: Array = []; 123 | 124 | export const FlowGraph = ({ 125 | flow, 126 | selectedItems: providedSelectedItems, 127 | editable, 128 | header, 129 | direction = "vertical", 130 | renderButtons, 131 | }: FlowGraphProps) => { 132 | const selectedItems = providedSelectedItems ?? emptySelectedItems; 133 | const sizeMap = useRef(new Map()); 134 | const [componentState, dispatch] = useReducer( 135 | (state: State, action: Action): State => { 136 | if (action.type === "reset") { 137 | return null; 138 | } 139 | 140 | if ( 141 | action.flowId === flow.id && 142 | action.layoutId > (state?.layoutId ?? -1) 143 | ) { 144 | return { 145 | flowId: flow.id, 146 | layout: toLayoutMap(sizeMap.current, action.layout), 147 | layoutId: action.layoutId, 148 | }; 149 | } 150 | 151 | return state; 152 | }, 153 | null 154 | ); 155 | 156 | if (componentState?.flowId && componentState.flowId !== flow.id) { 157 | sizeMap.current.clear(); 158 | } 159 | 160 | const positions = 161 | componentState && componentState.flowId === flow.id 162 | ? componentState.layout 163 | : (new Map() as Layout); 164 | 165 | const rootPos = positions.get(rootPosId); 166 | 167 | const isSizingRendering = () => 168 | sizeMap.current.size < 169 | Object.keys(flow.states).length + 170 | transitionCount(flow) + 171 | (sizeMap.current.get(rootPosId) ? 1 : 0); 172 | 173 | const { 174 | styles: panAndZoomStyles, 175 | zoom, 176 | ref: zoomRef, 177 | dragStyles, 178 | onMouseMove, 179 | onMouseDown, 180 | onMouseUp, 181 | onDoubleClick, 182 | onReset, 183 | onZoomIn, 184 | onZoomOut, 185 | } = usePanAndZoom(rootPos); 186 | 187 | useEffect(() => { 188 | if (editable) { 189 | return; 190 | } 191 | 192 | onReset(); 193 | }, [componentState?.layoutId]); 194 | 195 | const fullFlow = useMemo( 196 | () => getFullFlow(rootId, flowStateId, flow), 197 | [flow] 198 | ); 199 | 200 | const onUpdateLayout = useCallback( 201 | debounceWithLimit(100, 5000, (flowId: schema.FlowId) => { 202 | if (isSizingRendering()) { 203 | return; 204 | } 205 | 206 | const thisLayoutId = nextLayoutId(); 207 | const graph = flowToElkGraph( 208 | sizeMap.current!, 209 | rootId, 210 | fullFlow, 211 | direction 212 | ); 213 | const elk = getElk(); 214 | elk.layout(graph).then((layout) => { 215 | dispatch({ 216 | type: "received-layout", 217 | layout: layout as EnrichedElkNode, 218 | layoutId: thisLayoutId, 219 | flowId, 220 | }); 221 | }); 222 | }), 223 | [dispatch, fullFlow, nextLayoutId, isSizingRendering()] 224 | ); 225 | 226 | const onReportSize = useCallback( 227 | (positionId: PositionedItemId, size: Size) => { 228 | const adjustedSize = size; 229 | const prevSize = sizeMap.current.get(positionId); 230 | sizeMap.current.set(positionId, adjustedSize); 231 | 232 | if ( 233 | !prevSize || 234 | prevSize.height !== adjustedSize.height || 235 | prevSize.width !== adjustedSize.width || 236 | prevSize.padding?.top !== adjustedSize.padding?.top || 237 | prevSize.padding?.bottom !== adjustedSize.padding?.bottom || 238 | prevSize.padding?.left !== adjustedSize.padding?.left || 239 | prevSize.padding?.right !== adjustedSize.padding?.right 240 | ) { 241 | onUpdateLayout(flow.id, undefined); 242 | } 243 | }, 244 | [flow, zoom] 245 | ); 246 | 247 | useEffect(() => { 248 | onReset(); 249 | dispatch({ type: "reset" }); 250 | }, [flow.id]); 251 | 252 | const sizingRef = usePosition((pos) => onReportSize(rootPosId, pos)); 253 | 254 | const allTransitions = Object.entries(fullFlow.states).flatMap( 255 | ([sourceState, state]) => 256 | state?.transitions.map((transition, transitionIdx) => ({ 257 | transition, 258 | transitionIdx, 259 | sourceState: sourceState as schema.StateId, 260 | })) ?? [] 261 | ); 262 | const transitionsByPosId = new Map( 263 | allTransitions.map(({ transition, sourceState, transitionIdx }, idx) => [ 264 | getTransitionPositionId(sourceState, transitionIdx, transition.target), 265 | { transition, sourceState, transitionIdx }, 266 | ]) 267 | ); 268 | 269 | const topLevelStates = Object.entries(fullFlow.states).filter( 270 | ([_, state]) => state!.parent === rootId 271 | ); 272 | 273 | const effectiveEditable: FlowGraphProps["editable"] = editable 274 | ? { 275 | ...editable, 276 | onUpdateState(stateId, state) { 277 | editable.onUpdateState( 278 | stateId === flowStateId ? null : stateId, 279 | state 280 | ); 281 | }, 282 | onUpsertStateItem(stateId, item) { 283 | editable.onUpsertStateItem( 284 | stateId === flowStateId ? null : stateId, 285 | item 286 | ); 287 | }, 288 | onDeleteStateItem(stateId, itemId) { 289 | editable.onDeleteStateItem( 290 | stateId === flowStateId ? null : stateId, 291 | itemId 292 | ); 293 | }, 294 | onAddTransition: (sourceState: schema.StateId | undefined) => { 295 | editable.onAddTransition( 296 | sourceState === flowStateId ? undefined : sourceState 297 | ); 298 | }, 299 | onUpdateTransition: ( 300 | sourceState: schema.StateId | undefined, 301 | targetState: schema.StateId | undefined, 302 | event: schema.EventId | undefined, 303 | condition: schema.ConditionId | undefined, 304 | updated: { 305 | event?: { 306 | id: schema.EventId; 307 | name: string; 308 | }; 309 | condition?: { 310 | id: schema.ConditionId; 311 | name: string; 312 | }; 313 | } 314 | ) => { 315 | editable.onUpdateTransition( 316 | sourceState === flowStateId ? undefined : sourceState, 317 | targetState === flowStateId ? undefined : targetState, 318 | event, 319 | condition, 320 | updated 321 | ); 322 | }, 323 | onDeleteTransition: ( 324 | sourceState: schema.StateId | undefined, 325 | targetState: schema.StateId | undefined, 326 | event: schema.EventId | undefined, 327 | condition: schema.ConditionId | undefined 328 | ) => { 329 | editable.onDeleteTransition( 330 | sourceState === flowStateId ? undefined : sourceState, 331 | targetState === flowStateId ? undefined : targetState, 332 | event, 333 | condition 334 | ); 335 | }, 336 | onUpsertTransitionItem( 337 | sourceState, 338 | targetState, 339 | event, 340 | condition, 341 | item 342 | ) { 343 | editable.onUpsertTransitionItem( 344 | sourceState === flowStateId ? undefined : sourceState, 345 | targetState === flowStateId ? undefined : targetState, 346 | event, 347 | condition, 348 | item 349 | ); 350 | }, 351 | onDeleteTransitionItem( 352 | sourceState, 353 | targetState, 354 | event, 355 | condition, 356 | itemId 357 | ) { 358 | editable.onDeleteTransitionItem( 359 | sourceState === flowStateId ? undefined : sourceState, 360 | targetState === flowStateId ? undefined : targetState, 361 | event, 362 | condition, 363 | itemId 364 | ); 365 | }, 366 | } 367 | : undefined; 368 | 369 | // to avoid weird feedback loops, we render twice, once for sizing and once for viewing 370 | 371 | return ( 372 |
    373 | {header ?? null} 374 |
    375 |
    376 | {topLevelStates.map(([stateId, state]) => ( 377 | 388 | ))} 389 | {allTransitions.map( 390 | ({ transition, sourceState, transitionIdx }, idx) => ( 391 | 402 | ) 403 | )} 404 |
    405 |
    406 |
    414 |
    425 | {topLevelStates.map(([stateId, state]) => ( 426 | 437 | ))} 438 | 446 |
    447 |
    448 | {renderButtons ? ( 449 |
    450 | {renderButtons({ onZoomIn, onZoomOut, onResetZoom: onReset })} 451 |
    452 | ) : null} 453 |
    454 | ); 455 | }; 456 | -------------------------------------------------------------------------------- /src/components/flow-graph/initial-edge/index.tsx: -------------------------------------------------------------------------------- 1 | import { PositionInfo } from "../../../transformers/elk"; 2 | import { ArrowMarker } from "../arrow-marker"; 3 | 4 | export const InitialEdge = ({ pos }: { pos: PositionInfo }) => { 5 | const endPoint = { 6 | x: pos.x - 10, 7 | y: pos.y + 10, 8 | }; 9 | 10 | const startPoint = { 11 | x: endPoint.x - 5, 12 | y: endPoint.y - 10, 13 | }; 14 | 15 | const markerId = `n${Math.floor(Math.random() * 1000)}`; 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/flow-graph/routing-layer/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { 3 | Connector, 4 | getStatePositionId, 5 | PositionedItemId, 6 | PositionInfo, 7 | } from "../../../transformers/elk"; 8 | import { pathToD, pointsToPath, roundPath } from "../../../transformers/svg"; 9 | import { EmbeddedArrowMarker } from "../embedded-arrow-marker"; 10 | import { EmbeddedCircleMarker } from "../embedded-circle-marker"; 11 | import { EmbeddedThroughMarker } from "../embedded-through-marker"; 12 | import { InitialEdge } from "../initial-edge"; 13 | import styles from "./routing-layer.module.css"; 14 | 15 | export const RoutingLayer = ({ 16 | sourceState, 17 | positions, 18 | initialState, 19 | transitions, 20 | }: { 21 | sourceState: schema.StateId; 22 | positions: Map; 23 | initialState?: schema.StateId; 24 | transitions: { has: (transitionId: PositionedItemId) => boolean }; 25 | }) => { 26 | const connectorPoss = Array.from(positions.values()).filter( 27 | (pos): pos is PositionInfo & { connector: Connector } => 28 | pos.connector?.container === sourceState 29 | ); 30 | 31 | const initialStatePos = initialState 32 | ? positions.get(getStatePositionId(initialState)) 33 | : undefined; 34 | 35 | return ( 36 | 37 | {connectorPoss.map(({ connector }, connIdx) => { 38 | const sourceId = sourceState.replace(/[^a-zA-Z0-9_-]/g, "-"); 39 | const endMarkerId = `end-${sourceId}-${connIdx}`; 40 | const startMarkerId = `start-${sourceId}-${connIdx}`; 41 | const path = pointsToPath(connector.points); 42 | const reversePath = pointsToPath( 43 | Array.from(connector.points).reverse() 44 | ); 45 | 46 | if (!path || !reversePath || !transitions.has(connector.transitionId)) { 47 | return null; 48 | } 49 | 50 | return ( 51 | 52 | 53 | {connector.targetIsEvent ? ( 54 | 55 | ) : ( 56 | 57 | )} 58 | {connector.sourceIsEvent ? ( 59 | 60 | ) : ( 61 | 62 | )} 63 | 64 | 71 | {/* pretty weird - markerStart doesn't orient properly but markerEnd does... */} 72 | 79 | 80 | ); 81 | })} 82 | {initialStatePos ? : null} 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/flow-graph/routing-layer/routing-layer.module.css: -------------------------------------------------------------------------------- 1 | .routing { 2 | position: absolute; 3 | pointer-events: none; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/flow-graph/state-node/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { useCallback, useEffect, useReducer, useRef } from "react"; 3 | import { 4 | getStatePositionId, 5 | PositionedItemId, 6 | PositionInfo, 7 | Size, 8 | } from "../../../transformers/elk"; 9 | import { 10 | DrawableFlow, 11 | FlowItemIdentifier, 12 | relevantToFlowItems, 13 | } from "../../../flow-utils"; 14 | import styles from "./state-node.module.css"; 15 | import { FlowItemIcon } from "../../flow-items"; 16 | import { TransitionInfo, TransitionsView } from "../transitions-view"; 17 | import { FlowItemList } from "../flow-item-list"; 18 | import { Position, usePosition } from "../../../hooks/use-position"; 19 | import { EditableOnClick } from "../../editable-on-click"; 20 | import { Select } from "../../select"; 21 | import { flowItemTypePresentation } from "../../../data/flows"; 22 | import { IconButton } from "../../icon-button"; 23 | import { RiDeleteBinLine } from "react-icons/ri"; 24 | import { Editable } from "../types"; 25 | 26 | const extraContentPadding = 25; 27 | 28 | type Action = 29 | | { type: "set-pos"; position: Position } 30 | | { type: "set-padding"; position: Position }; 31 | type State = { 32 | padding?: Position; 33 | position?: Position; 34 | }; 35 | 36 | const newStateId = "__new__" as schema.StateId; 37 | 38 | export const StateNode = ({ 39 | flow, 40 | stateId, 41 | state, 42 | positions, 43 | selectedItems, 44 | isTopLevel, 45 | onReportSize, 46 | transitionsByPosId, 47 | editable, 48 | }: { 49 | flow: DrawableFlow; 50 | stateId: schema.StateId; 51 | state: NonNullable; 52 | positions: Map; 53 | selectedItems: Array; 54 | isTopLevel?: boolean; 55 | onReportSize?: (positionId: PositionedItemId, size: Size) => void; 56 | transitionsByPosId: Map; 57 | editable?: Editable; 58 | }) => { 59 | const [posState, dispatch] = useReducer( 60 | (state: State, action: Action): State => { 61 | switch (action.type) { 62 | case "set-padding": 63 | return { 64 | ...state, 65 | padding: action.position, 66 | }; 67 | case "set-pos": 68 | return { 69 | ...state, 70 | position: action.position, 71 | }; 72 | } 73 | }, 74 | {} 75 | ); 76 | const reportPosition = useCallback((position: Position) => { 77 | dispatch({ type: "set-pos", position }); 78 | }, []); 79 | const reportPadding = useCallback((position: Position) => { 80 | dispatch({ type: "set-padding", position }); 81 | }, []); 82 | const ref = usePosition(reportPosition); 83 | const paddingRef = usePosition(reportPadding); 84 | const statePosId = getStatePositionId(stateId); 85 | 86 | const pos = positions.get(statePosId); 87 | 88 | useEffect(() => { 89 | if (!onReportSize || !posState.padding || !posState.position) { 90 | return; 91 | } 92 | 93 | const topPadding = posState.padding.height; 94 | onReportSize(statePosId, { 95 | width: posState.position.width, 96 | height: posState.position.height, 97 | padding: { 98 | top: Math.max(topPadding + extraContentPadding, extraContentPadding), 99 | bottom: extraContentPadding, 100 | left: extraContentPadding, 101 | right: extraContentPadding, 102 | }, 103 | }); 104 | }, [statePosId, flow.id, posState]); 105 | 106 | const isSelected = relevantToFlowItems(flow, selectedItems, { 107 | flowItemType: "state", 108 | flowItemId: stateId, 109 | }); 110 | 111 | const childStates = Object.entries(flow.states).filter( 112 | ([_childId, state]) => state!.parent === stateId 113 | ); 114 | 115 | const flowItems = state.entryActions 116 | .map( 117 | (action): FlowItemIdentifier => ({ 118 | flowItemId: action, 119 | flowItemType: "entry-action", 120 | }) 121 | ) 122 | .concat( 123 | state.exitActions.map( 124 | (action): FlowItemIdentifier => ({ 125 | flowItemId: action, 126 | flowItemType: "exit-action", 127 | }) 128 | ) 129 | ) 130 | .concat( 131 | state.assertions.map((assertion) => ({ 132 | flowItemId: assertion, 133 | flowItemType: "assertion", 134 | })) 135 | ); 136 | 137 | return ( 138 |
    149 | {editable && !isTopLevel ? ( 150 | } 153 | title="Delete" 154 | onClick={() => editable.onRemoveState(stateId)} 155 | /> 156 | ) : null} 157 |
    158 |
    0 ? styles.hasChildren : "" 162 | }`} 163 | > 164 |
    165 | {isTopLevel ? null : ( 166 | 167 | )} 168 |

    169 | {editable && !isTopLevel ? ( 170 | state.name ? ( 171 | { 175 | editable.onUpdateState(stateId, { ...state, name }); 176 | }} 177 | /> 178 | ) : ( 179 | 204 | ) 205 | ) : ( 206 | state.name 207 | )} 208 |

    209 |
    210 | 0 215 | ? styles.nonEmptyFlowListItemsWithChildren 216 | : styles.nonEmptyFlowListItems 217 | } 218 | editable={ 219 | editable && { 220 | eligibleTypes: ["action", "assertion", "event"], 221 | onUpsertItem(item) { 222 | if (item.flowItemType === "event") { 223 | editable.onAddTransition(stateId); 224 | return; 225 | } 226 | 227 | editable.onUpsertStateItem(stateId, item); 228 | }, 229 | onDeleteItem(itemId) { 230 | editable.onDeleteStateItem(stateId, itemId); 231 | }, 232 | } 233 | } 234 | /> 235 |
    236 | {childStates.length > 0 ? ( 237 |
    242 | {childStates.map(([childId, childState]) => ( 243 | 254 | ))} 255 | 265 |
    266 | ) : null} 267 |
    268 |
    269 | ); 270 | }; 271 | -------------------------------------------------------------------------------- /src/components/flow-graph/state-node/state-node.module.css: -------------------------------------------------------------------------------- 1 | .stateNode { 2 | position: absolute; 3 | box-shadow: 0px 1px 4px rgba(135, 83, 221, 0.08); 4 | display: inline-block; 5 | margin-inline: 0; 6 | margin: 0; 7 | background-color: var(--state-background-10p-color); 8 | border: 1px solid var(--state-border-color); 9 | z-index: 1; 10 | } 11 | 12 | .parallel .stateNode { 13 | border-style: dashed; 14 | } 15 | 16 | .stateNode.topLevel { 17 | background-color: unset; 18 | border-color: var(--top-level-node-border-color); 19 | } 20 | 21 | .stateNode.topLevel > .stateNodeContent > .stateContent.hasChildren::after { 22 | border-color: var(--top-level-node-border-color); 23 | } 24 | 25 | .stateNode.sizing { 26 | position: static; 27 | opacity: 0.001; 28 | /* there is no good explanation but it's better for the non-sizing rendering 29 | to be too big than too small and sometimes it is measured .01px too small */ 30 | padding: 0.5px; 31 | } 32 | 33 | .selected { 34 | box-shadow: var(--state-primary-50p-color) 0px 0px 8px 8px; 35 | } 36 | 37 | .stateNodeContent { 38 | display: flex; 39 | flex-direction: column; 40 | } 41 | 42 | .stateContent { 43 | padding: 24px 22px; 44 | position: relative; 45 | } 46 | 47 | .stateContent.hasChildren::after { 48 | content: " "; 49 | border-bottom: 1px solid var(--state-border-color); 50 | position: absolute; 51 | left: 20px; 52 | right: 20px; 53 | bottom: 0; 54 | height: 1px; 55 | } 56 | 57 | .stateNodeContent header { 58 | display: flex; 59 | flex-direction: row; 60 | align-items: center; 61 | justify-content: center; 62 | max-width: 500px; 63 | margin: auto; 64 | } 65 | 66 | .stateNodeContent header > h2 { 67 | margin-left: 3px; 68 | display: inline-block; 69 | text-align: left; 70 | } 71 | 72 | .sizing .childStatesContainer { 73 | flex: 1; 74 | position: relative; 75 | } 76 | 77 | .childStatesContainer { 78 | position: absolute; 79 | left: 0; 80 | right: 0; 81 | top: 0; 82 | bottom: 0; 83 | z-index: -1; 84 | } 85 | 86 | .name { 87 | font-size: 1.2em; 88 | width: 100%; 89 | } 90 | 91 | .nonEmptyFlowListItems { 92 | border-top: 1px solid var(--state-border-color); 93 | padding-top: 12px; 94 | } 95 | 96 | .nonEmptyFlowListItemsWithChildren { 97 | border-top: 1px solid var(--state-border-color); 98 | padding-top: 24px; /* has to match the implied bottom padding */ 99 | } 100 | 101 | .editable { 102 | display: inline-block; 103 | cursor: pointer; 104 | width: 100%; 105 | max-width: 100%; 106 | border: none; 107 | outline: none; 108 | padding: 0; 109 | border-radius: 4px; 110 | } 111 | 112 | input.editable { 113 | cursor: text; 114 | } 115 | 116 | .deleteButton { 117 | position: absolute; 118 | right: 10px; 119 | top: 10px; 120 | cursor: pointer; 121 | z-index: 1; 122 | } 123 | -------------------------------------------------------------------------------- /src/components/flow-graph/svg-mask.tsx: -------------------------------------------------------------------------------- 1 | import { PositionInfo } from "../../transformers/elk"; 2 | 3 | export const SvgMask = ({ 4 | maskId, 5 | masks, 6 | }: { 7 | maskId: string; 8 | masks: Array; 9 | }) => { 10 | if (masks.length === 0) { 11 | return null; 12 | } 13 | 14 | const hidden = masks.map((mask, idx) => ( 15 | 23 | )); 24 | 25 | return ( 26 | 27 | 28 | {hidden} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/flow-graph/transition-node/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { useCallback } from "react"; 3 | import { 4 | getTransitionPositionId, 5 | PositionedItemId, 6 | } from "../../../transformers/elk"; 7 | import { 8 | DrawableFlow, 9 | FlowItemIdentifier, 10 | transitionRelevantToFlowItems, 11 | } from "../../../flow-utils"; 12 | import styles from "./transition-node.module.css"; 13 | import { FlowItemIcon } from "../../flow-items"; 14 | import { FlowItemList } from "../flow-item-list"; 15 | import { Position, usePosition } from "../../../hooks/use-position"; 16 | import * as flows from "../../../data/flows"; 17 | import { EditableOnClick } from "../../editable-on-click"; 18 | import { IconButton } from "../../icon-button"; 19 | import { RiDeleteBinLine } from "react-icons/ri"; 20 | import { Editable } from "../types"; 21 | 22 | export const TransitionNode = ({ 23 | flow, 24 | sourceStateId, 25 | transitionIdx, 26 | transition, 27 | positions, 28 | selectedItems, 29 | onReportSize, 30 | editable, 31 | }: { 32 | flow: DrawableFlow; 33 | sourceStateId: schema.StateId; 34 | transitionIdx: number; 35 | transition: NonNullable; 36 | positions: Map; 37 | selectedItems: Array; 38 | onReportSize?: ( 39 | positionId: PositionedItemId, 40 | size: { width: number; height: number } 41 | ) => void; 42 | editable?: Editable; 43 | }) => { 44 | const eventName = flows.eventName(flow, transition); 45 | 46 | const transitionPosId = getTransitionPositionId( 47 | sourceStateId, 48 | transitionIdx, 49 | transition.target 50 | ); 51 | 52 | const pos = positions.get(transitionPosId); 53 | 54 | const reportPos = useCallback( 55 | (pos: Position) => { 56 | if (!onReportSize) { 57 | return; 58 | } 59 | 60 | onReportSize(transitionPosId, { height: pos.height, width: pos.width }); 61 | }, 62 | [onReportSize] 63 | ); 64 | const ref = usePosition(reportPos); 65 | 66 | const isSelected = transitionRelevantToFlowItems( 67 | flow, 68 | selectedItems, 69 | sourceStateId, 70 | transition 71 | ); 72 | 73 | const conditionItems: Array = 74 | typeof transition.condition === "string" 75 | ? [{ flowItemType: "condition", flowItemId: transition.condition }] 76 | : []; 77 | const actionItems = transition.actions.map( 78 | (flowItemId): FlowItemIdentifier => ({ 79 | flowItemType: "action", 80 | flowItemId, 81 | }) 82 | ); 83 | const assertionItems = transition.assertions.map( 84 | (flowItemId): FlowItemIdentifier => ({ 85 | flowItemType: "assertion", 86 | flowItemId, 87 | }) 88 | ); 89 | const items = conditionItems.concat(actionItems).concat(assertionItems); 90 | 91 | return ( 92 |
    99 | {editable ? ( 100 | } 103 | title="Delete" 104 | onClick={() => 105 | editable.onDeleteTransition( 106 | sourceStateId, 107 | transition.target, 108 | transition.event, 109 | transition.condition 110 | ) 111 | } 112 | /> 113 | ) : null} 114 |
    115 | 116 |

    117 | {editable ? ( 118 | { 122 | editable.onUpdateTransition( 123 | sourceStateId, 124 | transition.target, 125 | transition.event, 126 | transition.condition, 127 | { 128 | event: { 129 | id: 130 | transition.event ?? 131 | (flows.freshFlowItemId() as schema.EventId), 132 | name, 133 | }, 134 | } 135 | ); 136 | }} 137 | /> 138 | ) : ( 139 | eventName 140 | )} 141 |

    142 |
    143 | 176 |
    177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /src/components/flow-graph/transition-node/transition-node.module.css: -------------------------------------------------------------------------------- 1 | .transitionNode { 2 | position: absolute; 3 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.08); 4 | padding: 22px 24px; 5 | display: inline-block; 6 | margin-inline: 0; 7 | margin: 0; 8 | background-color: var(--event-background-10p-color); 9 | border: 1px solid var(--event-border-color); 10 | border-radius: 4px; 11 | max-width: 500px; 12 | } 13 | 14 | .transitionNode.sizing { 15 | position: static; 16 | opacity: 0.001; 17 | /* there is no good explanation but it's better for the non-sizing rendering 18 | to be too big than too small and sometimes it is measured .01px too small */ 19 | padding: 22.5px 24.5px; 20 | } 21 | 22 | .selected { 23 | box-shadow: var(--event-primary-50p-color) 0px 0px 8px 8px; 24 | } 25 | 26 | .transitionNode header { 27 | display: flex; 28 | flex-direction: row; 29 | align-items: center; 30 | justify-content: center; 31 | max-width: 500px; 32 | margin: auto; 33 | } 34 | 35 | .transitionNode header > h2 { 36 | margin-left: 3px; 37 | display: inline-block; 38 | text-align: left; 39 | } 40 | 41 | .name { 42 | font-size: 1.2em; 43 | } 44 | 45 | .actions, 46 | .condition { 47 | white-space: nowrap; 48 | text-overflow: ellipsis; 49 | overflow-x: hidden; 50 | } 51 | 52 | .entryActions::before { 53 | content: "ACTIONS / "; 54 | color: #dcdcdc; 55 | } 56 | 57 | .condition::before { 58 | content: "IF / "; 59 | color: #dcdcdc; 60 | } 61 | 62 | .nonEmptyFlowListItems { 63 | border-top: 1px solid var(--event-border-color); 64 | padding-top: 12px; 65 | } 66 | 67 | .editable { 68 | display: inline-block; 69 | cursor: pointer; 70 | width: 100%; 71 | max-width: 100%; 72 | border: none; 73 | outline: none; 74 | padding: 0; 75 | border-radius: 4px; 76 | } 77 | 78 | input.editable { 79 | cursor: text; 80 | max-width: calc(100% - 20px); 81 | margin: 0; 82 | } 83 | 84 | .deleteButton { 85 | position: absolute; 86 | right: 10px; 87 | top: 10px; 88 | cursor: pointer; 89 | z-index: 1; 90 | } 91 | -------------------------------------------------------------------------------- /src/components/flow-graph/transitions-view/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { FlowItemIdentifier, DrawableFlow } from "../../../flow-utils"; 3 | import { 4 | PositionedItemId, 5 | PositionInfo, 6 | Size, 7 | } from "../../../transformers/elk"; 8 | import { RoutingLayer } from "../routing-layer"; 9 | import { TransitionNode } from "../transition-node"; 10 | import { Editable } from "../types"; 11 | 12 | export type Transition = DrawableFlow["transitions"][number]; 13 | export type TransitionInfo = { 14 | transition: Transition; 15 | sourceState: schema.StateId; 16 | transitionIdx: number; 17 | }; 18 | 19 | export const TransitionsView = ({ 20 | containerStateId, 21 | positions, 22 | transitionsByPosId, 23 | flow, 24 | selectedItems, 25 | onReportSize, 26 | initialState, 27 | editable, 28 | }: { 29 | containerStateId: schema.StateId; 30 | positions: Map; 31 | transitionsByPosId: Map; 32 | flow: DrawableFlow; 33 | selectedItems: Array; 34 | initialState?: schema.StateId; 35 | onReportSize?: (positionId: PositionedItemId, size: Size) => void; 36 | editable?: Editable; 37 | }) => { 38 | const transitions = 39 | positions.size > 0 40 | ? Array.from(positions.entries()) 41 | .filter( 42 | ([_posId, pos]) => pos.connector?.container === containerStateId 43 | ) 44 | .map(([_posId, pos]) => { 45 | if (!pos.connector) { 46 | return undefined; 47 | } 48 | const t = transitionsByPosId.get(pos.connector.transitionId); 49 | if (!t) { 50 | return undefined; 51 | } 52 | return { ...t, transitionId: pos.connector.transitionId }; 53 | }) 54 | .filter( 55 | (t): t is TransitionInfo & { transitionId: PositionedItemId } => !!t 56 | ) 57 | .reduce( 58 | ( 59 | [transitions, seen], 60 | transition 61 | ): [Array, Set] => { 62 | if (seen.has(transition.transitionId)) { 63 | return [transitions, seen]; 64 | } 65 | 66 | seen.add(transition.transitionId); 67 | return [transitions.concat([transition]), seen]; 68 | }, 69 | [[], new Set()] as [Array, Set] 70 | )[0] 71 | : []; 72 | 73 | return ( 74 | <> 75 | {transitions.map(({ transition, sourceState, transitionIdx }, idx) => ( 76 | 87 | ))} 88 | 94 | 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /src/components/flow-graph/types.ts: -------------------------------------------------------------------------------- 1 | import * as schema from "../../schema"; 2 | import { DrawableFlow, FlowItemIdentifier, FlowItem } from "../../flow-utils"; 3 | 4 | export type Editable = { 5 | getAvailableStates: () => Array<{ id: schema.StateId; name: string }>; 6 | onUpdateState: ( 7 | stateId: schema.StateId, 8 | state: DrawableFlow["states"][schema.StateId] 9 | ) => void; 10 | onUpsertStateItem: (stateId: schema.StateId, item: FlowItem) => void; 11 | onRemoveState: (stateId: schema.StateId) => void; 12 | onDeleteStateItem: ( 13 | stateId: schema.StateId, 14 | itemId: FlowItemIdentifier 15 | ) => void; 16 | onUpdateTransitionTarget: ( 17 | previousTargetId: schema.StateId, 18 | newTargetId: schema.StateId 19 | ) => void; 20 | onAddTransition: (sourceState: schema.StateId | undefined) => void; 21 | onUpdateTransition: ( 22 | sourceState: schema.StateId | undefined, 23 | targetState: schema.StateId | undefined, 24 | event: schema.EventId | undefined, 25 | condition: schema.ConditionId | undefined, 26 | updated: { 27 | event?: { 28 | id: schema.EventId; 29 | name: string; 30 | }; 31 | condition?: { 32 | id: schema.ConditionId; 33 | name: string; 34 | }; 35 | } 36 | ) => void; 37 | onDeleteTransition: ( 38 | sourceState: schema.StateId | undefined, 39 | targetState: schema.StateId | undefined, 40 | event: schema.EventId | undefined, 41 | condition: schema.ConditionId | undefined 42 | ) => void; 43 | onUpsertTransitionItem: ( 44 | sourceState: schema.StateId | undefined, 45 | targetState: schema.StateId | undefined, 46 | event: schema.EventId | undefined, 47 | condition: schema.ConditionId | undefined, 48 | item: FlowItem 49 | ) => void; 50 | onDeleteTransitionItem: ( 51 | sourceState: schema.StateId | undefined, 52 | targetState: schema.StateId | undefined, 53 | event: schema.EventId | undefined, 54 | condition: schema.ConditionId | undefined, 55 | itemId: FlowItemIdentifier 56 | ) => void; 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/flow-graph/use-pan-and-zoom.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useEffect, useReducer, useRef } from "react"; 2 | import { debounceWithLimit } from "../../debounce-utils"; 3 | 4 | type ZoomState = { 5 | zoom: number; 6 | dx: number; 7 | dy: number; 8 | awaitingSizes: boolean; 9 | dragging?: boolean; 10 | containerSize?: Size; 11 | }; 12 | type ZoomAction = 13 | | { type: "update-container-size"; containerSize: Size } 14 | | { 15 | type: "wheel"; 16 | deltaX: number; 17 | deltaY: number; 18 | controlPressed: boolean; 19 | } 20 | | { 21 | type: "drag"; 22 | movementX: number; 23 | movementY: number; 24 | } 25 | | { type: "mouse-down" } 26 | | { type: "mouse-up" } 27 | | { type: "discrete-zoom"; direction: "in" | "out" } 28 | | { type: "reset" } 29 | | { type: "sizes-changed" }; 30 | 31 | type Size = { width: number; height: number }; 32 | 33 | export const usePanAndZoom = (rootPos: Size | undefined) => { 34 | const ref = useRef(null); 35 | 36 | const [zoomState, zoomDispatch] = useReducer( 37 | (state: ZoomState, action: ZoomAction): ZoomState => { 38 | switch (action.type) { 39 | case "update-container-size": 40 | return { 41 | ...state, 42 | containerSize: action.containerSize, 43 | }; 44 | case "reset": 45 | return sizeToFit(rootPos, state.containerSize); 46 | case "discrete-zoom": 47 | return clamp(rootPos, state.containerSize, { 48 | ...state, 49 | zoom: state.zoom + (action.direction === "in" ? 1 : -1) * 0.3, 50 | }); 51 | case "drag": 52 | if (!state.dragging) { 53 | return state; 54 | } 55 | 56 | return clamp(rootPos, state.containerSize, { 57 | ...state, 58 | dx: state.dx + action.movementX / state.zoom, 59 | dy: state.dy + action.movementY / state.zoom, 60 | }); 61 | case "mouse-down": 62 | return { 63 | ...state, 64 | dragging: true, 65 | }; 66 | case "mouse-up": 67 | return { 68 | ...state, 69 | dragging: false, 70 | }; 71 | case "sizes-changed": 72 | return state.awaitingSizes 73 | ? sizeToFit(rootPos, state.containerSize) 74 | : state; 75 | case "wheel": { 76 | if (action.controlPressed) { 77 | const delta = Math.max(-1, Math.min(1, action.deltaY)); 78 | return clamp(rootPos, state.containerSize, { 79 | ...state, 80 | zoom: state.zoom - delta / 20, 81 | }); 82 | } else { 83 | return clamp(rootPos, state.containerSize, { 84 | ...state, 85 | dx: state.dx - action.deltaX, 86 | dy: state.dy - action.deltaY, 87 | }); 88 | } 89 | } 90 | } 91 | return state; 92 | }, 93 | null, 94 | () => sizeToFit(rootPos, undefined) 95 | ); 96 | 97 | // this ensures that our first real render after a sizing rendering will be sized to fit 98 | useEffect(() => zoomDispatch({ type: "sizes-changed" }), [!rootPos]); 99 | 100 | // we need to add the wheel handler directly because React makes it passive and we need to prevent window zooming 101 | useEffect(() => { 102 | const el = ref.current; 103 | if (!el) { 104 | return; 105 | } 106 | 107 | const onWheel = (e: WheelEvent) => { 108 | zoomDispatch({ 109 | type: "wheel", 110 | deltaX: e.deltaX, 111 | deltaY: e.deltaY, 112 | controlPressed: e.getModifierState("Control"), 113 | }); 114 | e.preventDefault(); 115 | }; 116 | 117 | el.addEventListener("wheel", onWheel); 118 | 119 | return () => { 120 | el.removeEventListener("wheel", onWheel); 121 | }; 122 | }, []); 123 | 124 | useEffect(() => { 125 | if (!ref.current) { 126 | return; 127 | } 128 | 129 | // have to debounce/wait because resize observer doesn't always call with the final size 130 | const debouncedUpdateContainerSize = debounceWithLimit(20, 50, () => { 131 | if (!ref.current) { 132 | return; 133 | } 134 | zoomDispatch({ 135 | type: "update-container-size", 136 | containerSize: ref.current.getBoundingClientRect(), 137 | }); 138 | }); 139 | 140 | const resizeObserver = new ResizeObserver(() => { 141 | debouncedUpdateContainerSize(undefined, undefined); 142 | }); 143 | 144 | resizeObserver.observe(ref.current); 145 | 146 | debouncedUpdateContainerSize(undefined, undefined); 147 | 148 | return () => { 149 | resizeObserver.disconnect(); 150 | }; 151 | }, []); 152 | 153 | const { zoom, dx, dy } = zoomState; 154 | 155 | return { 156 | zoom, 157 | dx, 158 | dy, 159 | styles: { 160 | transform: `scale(${zoom}) translate3d(${dx}px, ${dy}px, 0)`, 161 | transformOrigin: "0 0", 162 | "--inverse-zoom": `${1 / zoom}`, 163 | ...(zoomState.dragging ? { transition: "none" } : {}), 164 | }, 165 | ref, 166 | dragStyles: zoomState.dragging ? { cursor: "grabbing" } : {}, 167 | onMouseMove: (e: MouseEvent) => { 168 | if ((e.buttons & 1) !== 1) { 169 | return; 170 | } 171 | zoomDispatch({ 172 | type: "drag", 173 | movementX: e.movementX, 174 | movementY: e.movementY, 175 | }); 176 | }, 177 | onDoubleClick: (e: MouseEvent) => { 178 | zoomDispatch({ type: "discrete-zoom", direction: "in" }); 179 | }, 180 | onMouseDown: (e: MouseEvent) => { 181 | zoomDispatch({ type: "mouse-down" }); 182 | }, 183 | onMouseUp: (e: MouseEvent) => { 184 | zoomDispatch({ type: "mouse-up" }); 185 | }, 186 | onZoomIn: () => { 187 | zoomDispatch({ type: "discrete-zoom", direction: "in" }); 188 | }, 189 | onZoomOut: () => { 190 | zoomDispatch({ type: "discrete-zoom", direction: "out" }); 191 | }, 192 | onReset: () => { 193 | zoomDispatch({ type: "reset" }); 194 | }, 195 | }; 196 | }; 197 | 198 | const clamp = ( 199 | rootPos: Size | undefined, 200 | containerSize: Size | undefined, 201 | proposed: ZoomState 202 | ): ZoomState => { 203 | const maxDy = (containerSize?.height ?? 1000) / proposed.zoom; 204 | const maxDx = (containerSize?.width ?? 750) / proposed.zoom; 205 | 206 | return { 207 | ...proposed, 208 | zoom: clampZoom(proposed.zoom), 209 | dx: Math.max( 210 | Math.min(proposed.dx, maxDx), 211 | -(rootPos?.width ?? 0) / proposed.zoom 212 | ), 213 | dy: Math.max( 214 | Math.min(proposed.dy, maxDy), 215 | -(rootPos?.height ?? 0) / proposed.zoom 216 | ), 217 | }; 218 | }; 219 | 220 | const clampZoom = (zoom: number): number => Math.max(Math.min(zoom, 5), 0.2); 221 | 222 | const sizeToFit = ( 223 | rootPos: Size | undefined, 224 | containerSize: Size | undefined 225 | ): ZoomState => { 226 | if (!rootPos || !containerSize) { 227 | return { zoom: 1.0, dx: 0, dy: 0, awaitingSizes: true, containerSize }; 228 | } 229 | 230 | const paddingFactor = 1.2; 231 | const widthZoom = 232 | (containerSize.width - paddingFactor) / (rootPos.width * paddingFactor); 233 | const heightZoom = 234 | (containerSize.height - paddingFactor) / (rootPos.height * paddingFactor); 235 | 236 | const zoom = clampZoom(Math.min(widthZoom, heightZoom)); 237 | 238 | return clamp(rootPos, containerSize, { 239 | zoom, 240 | dx: (containerSize.width - rootPos.width * zoom) / 2 / zoom, 241 | dy: (containerSize.height - rootPos.height * zoom) / 2 / zoom, 242 | awaitingSizes: false, 243 | containerSize, 244 | }); 245 | }; 246 | -------------------------------------------------------------------------------- /src/components/flow-items/action-filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/action.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/assertion.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/condition-filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/condition.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/entry-action.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/flow-items/event-filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/event.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/exit-action.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/flow-items/flow-icon.module.css: -------------------------------------------------------------------------------- 1 | .flowItemIcon { 2 | display: inline-flex; 3 | width: 20px; 4 | height: 20px; 5 | min-width: 20px; 6 | min-height: 20px; 7 | border-radius: 4px; 8 | background-size: contain; 9 | background-position-x: center; 10 | background-position-y: center; 11 | background-repeat: no-repeat; 12 | } 13 | 14 | .action { 15 | composes: flowItemIcon; 16 | background-color: var(--action-background-color); 17 | border: 1px solid var(--action-border-color); 18 | background-image: url(./action.svg); 19 | } 20 | 21 | .action.filled { 22 | background-image: url(./action-filled.svg); 23 | } 24 | 25 | .action.transparent { 26 | background-color: transparent; 27 | border: none; 28 | } 29 | 30 | .entryAction { 31 | composes: flowItemIcon; 32 | background-color: var(--action-background-color); 33 | border: 1px solid var(--action-border-color); 34 | background-image: url(./entry-action.svg); 35 | } 36 | 37 | .entryAction.filled { 38 | background-image: url(./entry-action.svg); 39 | } 40 | 41 | .entryAction.transparent { 42 | background-color: transparent; 43 | border: none; 44 | } 45 | 46 | .exitAction { 47 | composes: flowItemIcon; 48 | background-color: var(--action-background-color); 49 | border: 1px solid var(--action-border-color); 50 | background-image: url(./exit-action.svg); 51 | } 52 | 53 | .exitAction.filled { 54 | background-image: url(./exit-action.svg); 55 | } 56 | 57 | .exitAction.transparent { 58 | background-color: transparent; 59 | border: none; 60 | } 61 | 62 | .assertion { 63 | composes: flowItemIcon; 64 | background-color: var(--expectation-background-color); 65 | border: 1px solid var(--expectation-border-color); 66 | background-image: url(./assertion.svg); 67 | } 68 | 69 | .assertion.filled { 70 | background-image: url(./assertion.svg); 71 | } 72 | 73 | .assertion.transparent { 74 | background-color: transparent; 75 | border: none; 76 | } 77 | 78 | .condition { 79 | composes: flowItemIcon; 80 | background-color: var(--condition-background-color); 81 | border: 1px solid var(--condition-border-color); 82 | background-image: url(./condition.svg); 83 | } 84 | 85 | .condition.filled { 86 | background-image: url(./condition-filled.svg); 87 | } 88 | 89 | .condition.transparent { 90 | background-color: transparent; 91 | border: none; 92 | } 93 | 94 | .event { 95 | composes: flowItemIcon; 96 | background-color: var(--event-background-color); 97 | border: 1px solid var(--event-border-color); 98 | background-image: url(./event.svg); 99 | } 100 | 101 | .event.filled { 102 | background-image: url(./event-filled.svg); 103 | } 104 | 105 | .event.transparent { 106 | background-color: transparent; 107 | border: none; 108 | } 109 | 110 | .state { 111 | composes: flowItemIcon; 112 | background-color: var(--state-background-color); 113 | border: 1px solid var(--state-border-color); 114 | background-image: url(./state.svg); 115 | } 116 | 117 | .state.filled { 118 | background-image: url(./state-filled.svg); 119 | } 120 | 121 | .state.transparent { 122 | background-color: transparent; 123 | border: none; 124 | } 125 | -------------------------------------------------------------------------------- /src/components/flow-items/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../schema"; 2 | import styles from "./flow-icon.module.css"; 3 | 4 | import { flowItemTypePresentation } from "../../data/flows"; 5 | 6 | type FlowItemIconProps = IconProps & { 7 | flowItemType: schema.FlowItemType; 8 | }; 9 | 10 | export const FlowItemIcon = ({ 11 | flowItemType, 12 | transparent, 13 | className, 14 | filled, 15 | }: FlowItemIconProps) => { 16 | const Component = flowItemIconComponentForType(flowItemType); 17 | return ( 18 | 23 | ); 24 | }; 25 | 26 | export const flowItemIconComponentForType = ( 27 | flowItemType: schema.FlowItemType 28 | ) => { 29 | switch (flowItemType) { 30 | case "action": 31 | return ActionIcon; 32 | case "entry-action": 33 | return EntryActionIcon; 34 | case "exit-action": 35 | return ExitActionIcon; 36 | case "assertion": 37 | return AssertionIcon; 38 | case "condition": 39 | return ConditionIcon; 40 | case "event": 41 | return EventIcon; 42 | case "state": 43 | return StateIcon; 44 | } 45 | return exhastive(flowItemType); 46 | }; 47 | 48 | function exhastive(x: never): (p: IconProps) => JSX.Element { 49 | console.error("non-exhaustive match", x); 50 | // just make typescript happy... 51 | return () => <>; 52 | } 53 | 54 | type IconProps = { 55 | className?: string; 56 | transparent?: boolean; 57 | filled?: boolean; 58 | }; 59 | 60 | export const ActionIcon = ({ className, transparent, filled }: IconProps) => { 61 | return ( 62 | 68 | ); 69 | }; 70 | 71 | export const EntryActionIcon = ({ 72 | className, 73 | transparent, 74 | filled, 75 | }: IconProps) => { 76 | return ( 77 | 83 | ); 84 | }; 85 | 86 | export const ExitActionIcon = ({ 87 | className, 88 | transparent, 89 | filled, 90 | }: IconProps) => { 91 | return ( 92 | 98 | ); 99 | }; 100 | 101 | export const AssertionIcon = ({ 102 | className, 103 | transparent, 104 | filled, 105 | }: IconProps) => { 106 | return ( 107 | 113 | ); 114 | }; 115 | 116 | export const ConditionIcon = ({ 117 | className, 118 | transparent, 119 | filled, 120 | }: IconProps) => { 121 | return ( 122 | 128 | ); 129 | }; 130 | 131 | export const EventIcon = ({ className, transparent, filled }: IconProps) => { 132 | return ( 133 | 139 | ); 140 | }; 141 | 142 | export const StateIcon = ({ className, transparent, filled }: IconProps) => { 143 | return ( 144 | 150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /src/components/flow-items/state-filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/flow-items/state.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/grid-background/grid-background.module.css: -------------------------------------------------------------------------------- 1 | .gridBackground { 2 | background-size: 25px 25px; 3 | background-image: radial-gradient(circle, #dadada 1px, rgba(0, 0, 0, 0) 2px); 4 | background-color: var(--grid-background-color); 5 | width: 100%; 6 | height: 100%; 7 | display: flex; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/grid-background/index.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ElementType, ReactNode } from "react"; 2 | import styles from "./grid-background.module.css"; 3 | 4 | export const GridBackground = ({ 5 | as, 6 | className, 7 | children, 8 | style, 9 | }: { 10 | as?: ElementType; 11 | className?: string; 12 | style?: CSSProperties; 13 | children: ReactNode; 14 | }) => { 15 | const El = as ?? "div"; 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/icon-button/icon-button.module.css: -------------------------------------------------------------------------------- 1 | .iconButton { 2 | display: flex; 3 | cursor: pointer; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/icon-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import styles from "./icon-button.module.css"; 3 | 4 | type CommonProps = { 5 | title: string; 6 | className?: string; 7 | size?: number; 8 | disabled?: boolean; 9 | onClick?: () => void; 10 | }; 11 | 12 | type IconProps = 13 | | ({ 14 | iconSrc: string; 15 | } & CommonProps) 16 | | ({ icon: ReactNode } & CommonProps); 17 | 18 | export const IconButton = (props: IconProps) => { 19 | const { title, className, size, onClick } = props; 20 | const style = size ? { width: size, height: size } : {}; 21 | 22 | return ( 23 |
    { 27 | if (!props.disabled && onClick) { 28 | e.preventDefault(); 29 | e.stopPropagation(); 30 | onClick(); 31 | } 32 | }} 33 | > 34 | {"iconSrc" in props ? ( 35 | {title} 36 | ) : ( 37 | props.icon 38 | )} 39 |
    40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ReactElement, 3 | ReactNode, 4 | forwardRef, 5 | useEffect, 6 | useRef, 7 | useState, 8 | } from "react"; 9 | import { useClickAway } from "../../hooks/use-click-away"; 10 | import { useRepositionVisibly } from "../../hooks/use-reposition-visibly"; 11 | import styles from "./popup.module.css"; 12 | 13 | export const PopupWrapper = forwardRef( 14 | ({ children }, ref) => { 15 | return ( 16 |
    17 | {children} 18 |
    19 | ); 20 | } 21 | ); 22 | 23 | type Props = InnerProps & { 24 | isOpen: boolean; 25 | }; 26 | 27 | export const Popup = forwardRef( 28 | ( 29 | { 30 | isOpen, 31 | onClose, 32 | className, 33 | containerClassName, 34 | disableRepositioning, 35 | children, 36 | }, 37 | ref 38 | ) => { 39 | const [show, setShow] = useState(isOpen); 40 | 41 | // we slighlty delay hiding in case we have event handlers attached to things in our popup that would disappear 42 | useEffect(() => { 43 | if (isOpen) { 44 | setShow(isOpen); 45 | } else { 46 | const t = setTimeout(() => { 47 | setShow(isOpen); 48 | }, 1); 49 | 50 | return () => { 51 | clearTimeout(t); 52 | }; 53 | } 54 | }, [isOpen]); 55 | 56 | if (!show) { 57 | return null; 58 | } 59 | 60 | return ( 61 | 68 | {children} 69 | 70 | ); 71 | } 72 | ); 73 | 74 | type InnerProps = { 75 | className?: string; 76 | containerClassName?: string; 77 | onClose: () => void; 78 | disableRepositioning?: boolean; 79 | children: ReactElement; 80 | }; 81 | 82 | const Inner = forwardRef( 83 | ( 84 | { className, onClose, disableRepositioning, containerClassName, children }, 85 | ref 86 | ) => { 87 | const clickAwayRef = useRef(null); 88 | useClickAway(clickAwayRef, onClose); 89 | const popupRef = useRef(null); 90 | const { xAdjustment, yAdjustment } = useRepositionVisibly(popupRef, 24); 91 | 92 | return ( 93 | 117 | ); 118 | } 119 | ); 120 | -------------------------------------------------------------------------------- /src/components/popup/popup.module.css: -------------------------------------------------------------------------------- 1 | .popupWrapper { 2 | display: inline; 3 | position: relative; 4 | } 5 | 6 | .container { 7 | position: absolute; 8 | overflow: visible; 9 | width: 100%; 10 | pointer-events: none; 11 | } 12 | 13 | .popup { 14 | padding: 20px; 15 | background: var(--popup-background-color); 16 | border: 1px solid var(--popup-border-color); 17 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.08); 18 | border-radius: 4px; 19 | position: absolute; 20 | z-index: 3; 21 | pointer-events: all; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/select/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/select/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useRef } from "react"; 2 | import { useClickAway } from "../../hooks/use-click-away"; 3 | import { useModal } from "../../hooks/use-modal"; 4 | import styles from "./select.module.css"; 5 | 6 | export const Select = ({ 7 | selected, 8 | items, 9 | className, 10 | containerClassName, 11 | optionsContainerClassName, 12 | onChange, 13 | children, 14 | }: { 15 | selected?: Id; 16 | items: Array; 17 | className?: string; 18 | containerClassName?: string; 19 | optionsContainerClassName?: string; 20 | onChange: (id: Id) => void; 21 | children: (item: Item) => ReactNode; 22 | }) => { 23 | const { open, onClose, onToggle } = useModal(); 24 | const ref = useRef(null); 25 | useClickAway(ref, onClose); 26 | 27 | const selectedItem = items.find((i) => i.id === selected); 28 | 29 | return ( 30 |
    34 |
    35 | {selectedItem ? children(selectedItem) : null} 36 |
    37 |
    42 | {items 43 | .filter(({ id }) => id !== selected) 44 | .map((item) => ( 45 |
    { 49 | e.preventDefault(); 50 | // we remove our element and close on the next turn 51 | // just to make sure the clicked item is still connected to the dom 52 | // when we evaluate it in use click away 53 | // (e.g. if we're in a popup, the click on this element should not close the popup) 54 | setTimeout(() => { 55 | onClose(); 56 | onChange(item.id); 57 | }, 1); 58 | }} 59 | > 60 | {children(item)} 61 |
    62 | ))} 63 |
    64 |
    65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/select/select.module.css: -------------------------------------------------------------------------------- 1 | .selectContainer { 2 | position: relative; 3 | flex: 1; 4 | } 5 | 6 | .select { 7 | padding-right: 40px; 8 | border: 1px solid var(--select-divider-color); 9 | border-radius: 4px; 10 | position: relative; 11 | cursor: pointer; 12 | overflow: visible; 13 | } 14 | 15 | .select::after { 16 | content: " "; 17 | background: url(./chevron-down.svg) center no-repeat; 18 | background-size: 24px 24px; 19 | position: absolute; 20 | right: 20px; 21 | top: 0; 22 | bottom: 0; 23 | width: 12px; 24 | height: 100%; 25 | pointer-events: none; 26 | } 27 | 28 | .optionsContainer { 29 | position: absolute; 30 | background-color: var(--select-background-color); 31 | overflow-y: auto; 32 | max-height: 350px; 33 | transition: height 200ms; 34 | border-radius: 4px; 35 | z-index: 1; 36 | } 37 | 38 | .optionsContainer::-webkit-scrollbar { 39 | display: none; 40 | } 41 | 42 | .optionsContainer.closed { 43 | height: 0; 44 | } 45 | 46 | .optionsContainer.open { 47 | height: auto; 48 | border: 1px solid var(--select-divider-color); 49 | border-top: none; 50 | } 51 | 52 | .option { 53 | padding: 12px; 54 | border-bottom: 1px solid var(--select-divider-color); 55 | cursor: pointer; 56 | } 57 | 58 | .option:first-child { 59 | border-top: 1px solid var(--select-divider-color); 60 | } 61 | 62 | .option:hover { 63 | background-color: var(--select-hover-background-color); 64 | } 65 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/arrow-marker.tsx: -------------------------------------------------------------------------------- 1 | export const ArrowMarker = ({ id }: { id: string }) => { 2 | return ( 3 | 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/embedded-arrow-marker.tsx: -------------------------------------------------------------------------------- 1 | export const EmbeddedArrowMarker = ({ 2 | id, 3 | color, 4 | }: { 5 | id: string; 6 | color?: string; 7 | }) => { 8 | return ( 9 | 19 | 24 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/embedded-circle-marker.tsx: -------------------------------------------------------------------------------- 1 | export const EmbeddedCircleMarker = ({ id }: { id: string }) => { 2 | return ( 3 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/embedded-through-marker.tsx: -------------------------------------------------------------------------------- 1 | export const EmbeddedThroughMarker = ({ 2 | id, 3 | color, 4 | }: { 5 | id: string; 6 | color?: string; 7 | }) => { 8 | return ( 9 | 19 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/flow-item-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { 3 | DrawableFlow, 4 | FlowItemIdentifier, 5 | FlowItem, 6 | getMetadata, 7 | } from "../../../flow-utils"; 8 | import { SvgFlowItemIcon } from "../../svg-flow-items"; 9 | import { iconSize } from "../sizes"; 10 | import { SizedText } from "../sized-text"; 11 | 12 | export const SvgFlowItemList = ({ 13 | flow, 14 | items, 15 | x1, 16 | x2, 17 | y1, 18 | y2, 19 | }: { 20 | flow: DrawableFlow; 21 | items: Array; 22 | x1: number; 23 | x2: number; 24 | y1: number; 25 | y2: number; 26 | }) => { 27 | if (items.length === 0) { 28 | return null; 29 | } 30 | 31 | const fullItems: Array = items.map((item) => ({ 32 | ...item, 33 | flowItemName: getMetadata(flow, item)?.name ?? "", 34 | })); 35 | 36 | const fullElemHeight = (y2 - y1) / fullItems.length; 37 | const elemHeight = fullElemHeight * 0.9; 38 | 39 | return ( 40 | <> 41 | {fullItems.map((item, idx) => ( 42 | 43 | 49 | 55 | {item.flowItemName} 56 | 57 | 58 | ))} 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/index.tsx: -------------------------------------------------------------------------------- 1 | import ELK, { ELK as iELK } from "elkjs"; 2 | import { Transition } from "../../flow-utils"; 3 | import { DrawableFlow } from "../../flow-utils"; 4 | import { StateNode } from "./state-node"; 5 | import { 6 | DrawableFlowWithTopLevelState, 7 | EnrichedElkNode, 8 | flowToElkGraph, 9 | getFullFlow, 10 | getStatePositionId, 11 | getTransitionPositionId, 12 | PositionedItemId, 13 | PositionInfo, 14 | Size, 15 | toLayoutMap, 16 | } from "../../transformers/elk"; 17 | import * as schema from "../../schema"; 18 | import { SvgTransitionsView } from "./transitions-view"; 19 | import { 20 | headingBottomMargin, 21 | headingHeight, 22 | iconSize, 23 | itemHeight, 24 | letterWidth, 25 | padding, 26 | } from "./sizes"; 27 | import * as flows from "../../data/flows"; 28 | 29 | const getElk = (() => { 30 | let elk: iELK | undefined; 31 | return () => { 32 | if (!elk) { 33 | elk = new ELK(); 34 | } 35 | return elk; 36 | }; 37 | })(); 38 | 39 | type Layout = Map; 40 | 41 | export type SvgFlowGraphProps = { 42 | fullFlow: DrawableFlowWithTopLevelState; 43 | layout: Layout; 44 | }; 45 | 46 | const rootId = "root" as schema.StateId; 47 | const rootPosId = getStatePositionId(rootId); 48 | const flowStateId = "flow" as schema.StateId; 49 | 50 | const sizeForTransition = ( 51 | flow: DrawableFlow, 52 | transition: Transition 53 | ): Size => { 54 | const name = flows.eventName(flow, transition); 55 | 56 | const itemCount = 57 | transition.actions.length + 58 | transition.assertions.length + 59 | (transition.condition ? 1 : 0); 60 | 61 | const maxChars = transition.assertions 62 | .map((a) => flow.metadata.assertions[a]?.name.length ?? 0) 63 | .concat( 64 | transition.actions.map((a) => flow.metadata.actions[a]?.name.length ?? 0) 65 | ) 66 | .concat(transition.condition ? [transition.condition.length] : []) 67 | .concat([name.length]) 68 | .reduce((a, b) => Math.max(a, b), 0); 69 | 70 | return { 71 | width: 72 | padding * 2 + 73 | iconSize + 74 | Math.max(Math.min(maxChars * letterWidth, 500), 50), 75 | height: 76 | padding * 2 + 77 | itemCount * itemHeight + 78 | headingHeight + 79 | (itemCount > 0 ? headingBottomMargin : 0), 80 | padding: { 81 | top: padding, 82 | bottom: padding, 83 | left: padding, 84 | right: padding, 85 | }, 86 | }; 87 | }; 88 | 89 | const sizeForState = ( 90 | flow: DrawableFlow, 91 | state: Pick< 92 | NonNullable, 93 | "name" | "assertions" | "entryActions" | "exitActions" 94 | > 95 | ): Size => { 96 | const itemCount = 97 | state.assertions.length + 98 | state.entryActions.length + 99 | state.exitActions.length; 100 | 101 | const maxChars = state.assertions 102 | .map((a) => flow.metadata.assertions[a]?.name.length ?? 0) 103 | .concat( 104 | state.entryActions.map((a) => flow.metadata.actions[a]?.name.length ?? 0) 105 | ) 106 | .concat( 107 | state.exitActions.map((a) => flow.metadata.actions[a]?.name.length ?? 0) 108 | ) 109 | .concat(state.name ? [state.name.length] : []) 110 | .reduce((a, b) => Math.max(a, b), 0); 111 | 112 | return { 113 | width: 114 | padding * 2 + 115 | iconSize + 116 | Math.max(Math.min(maxChars * letterWidth, 500), 50), 117 | height: 118 | padding * 2 + 119 | itemCount * itemHeight + 120 | headingHeight + 121 | (itemCount > 0 ? headingBottomMargin : 0), 122 | padding: { 123 | top: padding + headingHeight + headingBottomMargin, 124 | bottom: padding, 125 | left: padding, 126 | right: padding, 127 | }, 128 | }; 129 | }; 130 | 131 | export const getSvgFlowGraphProps = async ({ 132 | flow, 133 | direction, 134 | }: { 135 | flow: DrawableFlow; 136 | direction: "horizontal" | "vertical"; 137 | }): Promise => { 138 | const fullFlow = getFullFlow(rootId, flowStateId, flow); 139 | 140 | const sizeMap = new Map(); 141 | 142 | sizeMap.set( 143 | getStatePositionId(flowStateId), 144 | sizeForState(fullFlow, { ...fullFlow, name: fullFlow.name ?? "Flow" }) 145 | ); 146 | 147 | for (let idx = 0; idx < fullFlow.transitions.length; ++idx) { 148 | const transition = fullFlow.transitions[idx]; 149 | const transitionPosId = getTransitionPositionId( 150 | flowStateId, 151 | idx, 152 | transition.target 153 | ); 154 | sizeMap.set(transitionPosId, sizeForTransition(fullFlow, transition)); 155 | } 156 | 157 | for (const [stateId, s] of Object.entries(fullFlow.states)) { 158 | const state = s!; 159 | const statePosId = getStatePositionId(stateId as schema.StateId); 160 | 161 | sizeMap.set(statePosId, sizeForState(fullFlow, state)); 162 | 163 | for (let idx = 0; idx < state.transitions.length; ++idx) { 164 | const transition = state.transitions[idx]; 165 | const transitionPosId = getTransitionPositionId( 166 | stateId as schema.StateId, 167 | idx, 168 | transition.target 169 | ); 170 | sizeMap.set(transitionPosId, sizeForTransition(fullFlow, transition)); 171 | } 172 | } 173 | 174 | const graph = flowToElkGraph(sizeMap, rootId, fullFlow, direction); 175 | 176 | const elk = getElk(); 177 | const layout = await elk.layout(graph); 178 | 179 | return { 180 | layout: toLayoutMap(sizeMap, layout as EnrichedElkNode), 181 | fullFlow, 182 | }; 183 | }; 184 | 185 | export const SvgFlowGraph = ({ fullFlow, layout }: SvgFlowGraphProps) => { 186 | const positions = layout; 187 | 188 | const rootPos = positions.get(rootPosId); 189 | 190 | const allTransitions = Object.entries(fullFlow.states).flatMap( 191 | ([sourceState, state]) => 192 | state?.transitions.map((transition, transitionIdx) => ({ 193 | transition, 194 | transitionIdx, 195 | sourceState: sourceState as schema.StateId, 196 | })) ?? [] 197 | ); 198 | const transitionsByPosId = new Map( 199 | allTransitions.map(({ transition, sourceState, transitionIdx }, idx) => [ 200 | getTransitionPositionId(sourceState, transitionIdx, transition.target), 201 | { transition, sourceState, transitionIdx }, 202 | ]) 203 | ); 204 | 205 | const topLevelStates = Object.entries(fullFlow.states).filter( 206 | ([_, state]) => state!.parent === rootId 207 | ); 208 | 209 | return ( 210 | 216 | {topLevelStates.map(([stateId, state]) => ( 217 | 226 | ))} 227 | 233 | 234 | ); 235 | }; 236 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/initial-edge/index.tsx: -------------------------------------------------------------------------------- 1 | import { PositionInfo } from "../../../transformers/elk"; 2 | import { ArrowMarker } from "../arrow-marker"; 3 | 4 | export const InitialEdge = ({ pos }: { pos: PositionInfo }) => { 5 | const endPoint = { 6 | x: pos.x - 7, 7 | y: pos.y + 7, 8 | }; 9 | 10 | const startPoint = { 11 | x: endPoint.x - 3.5, 12 | y: endPoint.y - 7, 13 | }; 14 | 15 | const markerId = `n${Math.floor(Math.random() * 1000)}`; 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/routing-layer/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { 3 | Connector, 4 | getStatePositionId, 5 | PositionedItemId, 6 | PositionInfo, 7 | } from "../../../transformers/elk"; 8 | import { pathToD, pointsToPath, roundPath } from "../../../transformers/svg"; 9 | import { EmbeddedArrowMarker } from "../embedded-arrow-marker"; 10 | import { EmbeddedCircleMarker } from "../embedded-circle-marker"; 11 | import { EmbeddedThroughMarker } from "../embedded-through-marker"; 12 | import { InitialEdge } from "../initial-edge"; 13 | 14 | export const RoutingLayer = ({ 15 | sourceState, 16 | positions, 17 | initialState, 18 | transitions, 19 | }: { 20 | sourceState: schema.StateId; 21 | positions: Map; 22 | initialState?: schema.StateId; 23 | transitions: { has: (transitionId: PositionedItemId) => boolean }; 24 | }) => { 25 | const connectorPoss = Array.from(positions.values()).filter( 26 | (pos): pos is PositionInfo & { connector: Connector } => 27 | pos.connector?.container === sourceState 28 | ); 29 | 30 | const initialStatePos = initialState 31 | ? positions.get(getStatePositionId(initialState)) 32 | : undefined; 33 | 34 | return ( 35 | 36 | {connectorPoss.map(({ connector }, connIdx) => { 37 | const sourceId = sourceState.replace(/[^a-zA-Z0-9_-]/g, "-"); 38 | const endMarkerId = `end-${sourceId}-${connIdx}`; 39 | const startMarkerId = `start-${sourceId}-${connIdx}`; 40 | const path = pointsToPath(connector.points); 41 | const reversePath = pointsToPath( 42 | Array.from(connector.points).reverse() 43 | ); 44 | 45 | if (!path || !reversePath || !transitions.has(connector.transitionId)) { 46 | return null; 47 | } 48 | 49 | return ( 50 | 51 | 52 | {connector.targetIsEvent ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 | {connector.sourceIsEvent ? ( 58 | 59 | ) : ( 60 | 61 | )} 62 | 63 | 70 | {/* pretty weird - markerStart doesn't orient properly but markerEnd does... */} 71 | 78 | 79 | ); 80 | })} 81 | {initialStatePos ? : null} 82 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/sized-text.tsx: -------------------------------------------------------------------------------- 1 | import { SVGTextElementAttributes } from "react"; 2 | import { letterWidth } from "./sizes"; 3 | 4 | export const SizedText = ({ 5 | x, 6 | y, 7 | width, 8 | height, 9 | children, 10 | ...props 11 | }: { 12 | children: string; 13 | x: number; 14 | y: number; 15 | width: number; 16 | height: number; 17 | } & SVGTextElementAttributes) => { 18 | const maxChars = Math.floor(width / letterWidth); 19 | const str = 20 | children.length > maxChars 21 | ? children.slice(0, maxChars - 3) + "..." 22 | : children; 23 | 24 | return ( 25 | 26 | {str} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/sizes.ts: -------------------------------------------------------------------------------- 1 | export const letterWidth = 8; 2 | export const headingHeight = 20; 3 | export const headingBottomMargin = 5; 4 | export const iconSize = 20; 5 | export const itemHeight = 18; 6 | export const padding = 25; 7 | export const cornerRadius = 4; 8 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/state-node/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { 3 | getStatePositionId, 4 | PositionedItemId, 5 | PositionInfo, 6 | } from "../../../transformers/elk"; 7 | import { DrawableFlow, FlowItemIdentifier } from "../../../flow-utils"; 8 | import { TransitionInfo, SvgTransitionsView } from "../transitions-view"; 9 | import { SvgFlowItemList } from "../flow-item-list"; 10 | import { SvgFlowItemIcon } from "../../svg-flow-items"; 11 | import { 12 | cornerRadius, 13 | headingBottomMargin, 14 | headingHeight, 15 | iconSize, 16 | padding, 17 | } from "../sizes"; 18 | import { SizedText } from "../sized-text"; 19 | 20 | export const StateNode = ({ 21 | flow, 22 | stateId, 23 | state, 24 | positions, 25 | isTopLevel, 26 | transitionsByPosId, 27 | }: { 28 | flow: DrawableFlow; 29 | stateId: schema.StateId; 30 | state: NonNullable; 31 | positions: Map; 32 | isTopLevel?: boolean; 33 | transitionsByPosId: Map; 34 | }) => { 35 | const statePosId = getStatePositionId(stateId); 36 | 37 | const pos = positions.get(statePosId); 38 | 39 | if (!pos) { 40 | return null; 41 | } 42 | 43 | const parent = state.parent && flow.states[state.parent]; 44 | const isParallel = parent?.type === "parallel"; 45 | 46 | const childStates = Object.entries(flow.states).filter( 47 | ([_childId, state]) => state!.parent === stateId 48 | ); 49 | 50 | const flowItems = state.entryActions 51 | .map( 52 | (action): FlowItemIdentifier => ({ 53 | flowItemId: action, 54 | flowItemType: "entry-action", 55 | }) 56 | ) 57 | .concat( 58 | state.exitActions.map( 59 | (action): FlowItemIdentifier => ({ 60 | flowItemId: action, 61 | flowItemType: "exit-action", 62 | }) 63 | ) 64 | ) 65 | .concat( 66 | state.assertions.map((assertion) => ({ 67 | flowItemId: assertion, 68 | flowItemType: "assertion", 69 | })) 70 | ); 71 | 72 | const hasContent = childStates.length > 0 || flowItems.length > 0; 73 | 74 | const titleY = hasContent ? pos.y + padding : pos.y + pos.height / 2 - 16 / 2; 75 | 76 | return ( 77 | <> 78 | 89 | 95 | 102 | {state.name} 103 | 104 | {hasContent ? ( 105 | 111 | ) : null} 112 | 120 | {childStates.length > 0 ? ( 121 | 122 | {childStates.map(([childId, childState]) => ( 123 | 131 | ))} 132 | 139 | 140 | ) : null} 141 | 142 | ); 143 | }; 144 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/svg-mask.tsx: -------------------------------------------------------------------------------- 1 | import { PositionInfo } from "../../transformers/elk"; 2 | 3 | export const SvgMask = ({ 4 | maskId, 5 | masks, 6 | }: { 7 | maskId: string; 8 | masks: Array; 9 | }) => { 10 | if (masks.length === 0) { 11 | return null; 12 | } 13 | 14 | const hidden = masks.map((mask, idx) => ( 15 | 23 | )); 24 | 25 | return ( 26 | 27 | 28 | {hidden} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/transition-node/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { 3 | getTransitionPositionId, 4 | PositionedItemId, 5 | } from "../../../transformers/elk"; 6 | import { DrawableFlow, FlowItemIdentifier } from "../../../flow-utils"; 7 | import { SvgFlowItemList } from "../flow-item-list"; 8 | import * as flows from "../../../data/flows"; 9 | import { SvgFlowItemIcon } from "../../svg-flow-items"; 10 | import { 11 | cornerRadius, 12 | headingBottomMargin, 13 | headingHeight, 14 | iconSize, 15 | padding, 16 | } from "../sizes"; 17 | import { SizedText } from "../sized-text"; 18 | 19 | export const SvgTransitionNode = ({ 20 | flow, 21 | sourceStateId, 22 | transitionIdx, 23 | transition, 24 | positions, 25 | }: { 26 | flow: DrawableFlow; 27 | sourceStateId: schema.StateId; 28 | transitionIdx: number; 29 | transition: NonNullable; 30 | positions: Map< 31 | PositionedItemId, 32 | { x: number; y: number; width: number; height: number } 33 | >; 34 | }) => { 35 | const eventName = flows.eventName(flow, transition); 36 | 37 | const transitionPosId = getTransitionPositionId( 38 | sourceStateId, 39 | transitionIdx, 40 | transition.target 41 | ); 42 | 43 | const pos = positions.get(transitionPosId); 44 | 45 | if (!pos) { 46 | return null; 47 | } 48 | 49 | const conditionItems: Array = 50 | typeof transition.condition === "string" 51 | ? [{ flowItemType: "condition", flowItemId: transition.condition }] 52 | : []; 53 | const actionItems = transition.actions.map( 54 | (flowItemId): FlowItemIdentifier => ({ 55 | flowItemType: "action", 56 | flowItemId, 57 | }) 58 | ); 59 | const assertionItems = transition.assertions.map( 60 | (flowItemId): FlowItemIdentifier => ({ 61 | flowItemType: "assertion", 62 | flowItemId, 63 | }) 64 | ); 65 | const items = conditionItems.concat(actionItems).concat(assertionItems); 66 | 67 | const hasContent = items.length > 0; 68 | 69 | const titleY = hasContent ? pos.y + padding : pos.y + pos.height / 2 - 16 / 2; 70 | 71 | return ( 72 | <> 73 | 83 | 89 | 96 | {eventName} 97 | 98 | {hasContent ? ( 99 | 107 | ) : null} 108 | 116 | 117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/transitions-view/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../../schema"; 2 | import { DrawableFlow } from "../../../flow-utils"; 3 | import { PositionedItemId, PositionInfo } from "../../../transformers/elk"; 4 | import { RoutingLayer } from "../routing-layer"; 5 | import { SvgTransitionNode } from "../transition-node"; 6 | 7 | export type Transition = DrawableFlow["transitions"][number]; 8 | export type TransitionInfo = { 9 | transition: Transition; 10 | sourceState: schema.StateId; 11 | transitionIdx: number; 12 | }; 13 | 14 | export const SvgTransitionsView = ({ 15 | containerStateId, 16 | positions, 17 | transitionsByPosId, 18 | flow, 19 | initialState, 20 | }: { 21 | containerStateId: schema.StateId; 22 | positions: Map; 23 | transitionsByPosId: Map; 24 | flow: DrawableFlow; 25 | initialState?: schema.StateId; 26 | }) => { 27 | const transitions = 28 | positions.size > 0 29 | ? Array.from(positions.entries()) 30 | .filter( 31 | ([_posId, pos]) => pos.connector?.container === containerStateId 32 | ) 33 | .map(([_posId, pos]) => { 34 | if (!pos.connector) { 35 | return undefined; 36 | } 37 | const t = transitionsByPosId.get(pos.connector.transitionId); 38 | if (!t) { 39 | return undefined; 40 | } 41 | return { ...t, transitionId: pos.connector.transitionId }; 42 | }) 43 | .filter( 44 | (t): t is TransitionInfo & { transitionId: PositionedItemId } => !!t 45 | ) 46 | .reduce( 47 | ( 48 | [transitions, seen], 49 | transition 50 | ): [Array, Set] => { 51 | if (seen.has(transition.transitionId)) { 52 | return [transitions, seen]; 53 | } 54 | 55 | seen.add(transition.transitionId); 56 | return [transitions.concat([transition]), seen]; 57 | }, 58 | [[], new Set()] as [Array, Set] 59 | )[0] 60 | : []; 61 | 62 | return ( 63 | <> 64 | {transitions.map(({ transition, sourceState, transitionIdx }, idx) => ( 65 | 73 | ))} 74 | 80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/types.ts: -------------------------------------------------------------------------------- 1 | import * as schema from "../../schema"; 2 | import { DrawableFlow, FlowItemIdentifier, FlowItem } from "../../flow-utils"; 3 | 4 | export type Editable = { 5 | getAvailableStates: () => Array<{ id: schema.StateId; name: string }>; 6 | onUpdateState: ( 7 | stateId: schema.StateId, 8 | state: DrawableFlow["states"][schema.StateId] 9 | ) => void; 10 | onUpsertStateItem: (stateId: schema.StateId, item: FlowItem) => void; 11 | onRemoveState: (stateId: schema.StateId) => void; 12 | onDeleteStateItem: ( 13 | stateId: schema.StateId, 14 | itemId: FlowItemIdentifier 15 | ) => void; 16 | onUpdateTransitionTarget: ( 17 | previousTargetId: schema.StateId, 18 | newTargetId: schema.StateId 19 | ) => void; 20 | onAddTransition: (sourceState: schema.StateId | undefined) => void; 21 | onUpdateTransition: ( 22 | sourceState: schema.StateId | undefined, 23 | targetState: schema.StateId | undefined, 24 | event: schema.EventId | undefined, 25 | condition: schema.ConditionId | undefined, 26 | updated: { 27 | event?: { 28 | id: schema.EventId; 29 | name: string; 30 | }; 31 | condition?: { 32 | id: schema.ConditionId; 33 | name: string; 34 | }; 35 | } 36 | ) => void; 37 | onDeleteTransition: ( 38 | sourceState: schema.StateId | undefined, 39 | targetState: schema.StateId | undefined, 40 | event: schema.EventId | undefined, 41 | condition: schema.ConditionId | undefined 42 | ) => void; 43 | onUpsertTransitionItem: ( 44 | sourceState: schema.StateId | undefined, 45 | targetState: schema.StateId | undefined, 46 | event: schema.EventId | undefined, 47 | condition: schema.ConditionId | undefined, 48 | item: FlowItem 49 | ) => void; 50 | onDeleteTransitionItem: ( 51 | sourceState: schema.StateId | undefined, 52 | targetState: schema.StateId | undefined, 53 | event: schema.EventId | undefined, 54 | condition: schema.ConditionId | undefined, 55 | itemId: FlowItemIdentifier 56 | ) => void; 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/svg-flow-graph/use-pan-and-zoom.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useEffect, useReducer, useRef } from "react"; 2 | import { debounceWithLimit } from "../../debounce-utils"; 3 | 4 | type ZoomState = { 5 | zoom: number; 6 | dx: number; 7 | dy: number; 8 | awaitingSizes: boolean; 9 | dragging?: boolean; 10 | containerSize?: Size; 11 | }; 12 | type ZoomAction = 13 | | { type: "update-container-size"; containerSize: Size } 14 | | { 15 | type: "wheel"; 16 | deltaX: number; 17 | deltaY: number; 18 | controlPressed: boolean; 19 | } 20 | | { 21 | type: "drag"; 22 | movementX: number; 23 | movementY: number; 24 | } 25 | | { type: "mouse-down" } 26 | | { type: "mouse-up" } 27 | | { type: "discrete-zoom"; direction: "in" | "out" } 28 | | { type: "reset" } 29 | | { type: "sizes-changed" }; 30 | 31 | type Size = { width: number; height: number }; 32 | 33 | export const usePanAndZoom = (rootPos: Size | undefined) => { 34 | const ref = useRef(null); 35 | 36 | const [zoomState, zoomDispatch] = useReducer( 37 | (state: ZoomState, action: ZoomAction): ZoomState => { 38 | switch (action.type) { 39 | case "update-container-size": 40 | return { 41 | ...state, 42 | containerSize: action.containerSize, 43 | }; 44 | case "reset": 45 | return sizeToFit(rootPos, state.containerSize); 46 | case "discrete-zoom": 47 | return clamp(rootPos, state.containerSize, { 48 | ...state, 49 | zoom: state.zoom + (action.direction === "in" ? 1 : -1) * 0.3, 50 | }); 51 | case "drag": 52 | if (!state.dragging) { 53 | return state; 54 | } 55 | 56 | return clamp(rootPos, state.containerSize, { 57 | ...state, 58 | dx: state.dx + action.movementX / state.zoom, 59 | dy: state.dy + action.movementY / state.zoom, 60 | }); 61 | case "mouse-down": 62 | return { 63 | ...state, 64 | dragging: true, 65 | }; 66 | case "mouse-up": 67 | return { 68 | ...state, 69 | dragging: false, 70 | }; 71 | case "sizes-changed": 72 | return state.awaitingSizes 73 | ? sizeToFit(rootPos, state.containerSize) 74 | : state; 75 | case "wheel": { 76 | if (action.controlPressed) { 77 | const delta = Math.max(-1, Math.min(1, action.deltaY)); 78 | return clamp(rootPos, state.containerSize, { 79 | ...state, 80 | zoom: state.zoom - delta / 20, 81 | }); 82 | } else { 83 | return clamp(rootPos, state.containerSize, { 84 | ...state, 85 | dx: state.dx - action.deltaX, 86 | dy: state.dy - action.deltaY, 87 | }); 88 | } 89 | } 90 | } 91 | return state; 92 | }, 93 | null, 94 | () => sizeToFit(rootPos, undefined) 95 | ); 96 | 97 | // this ensures that our first real render after a sizing rendering will be sized to fit 98 | useEffect(() => zoomDispatch({ type: "sizes-changed" }), [!rootPos]); 99 | 100 | // we need to add the wheel handler directly because React makes it passive and we need to prevent window zooming 101 | useEffect(() => { 102 | const el = ref.current; 103 | if (!el) { 104 | return; 105 | } 106 | 107 | const onWheel = (e: WheelEvent) => { 108 | zoomDispatch({ 109 | type: "wheel", 110 | deltaX: e.deltaX, 111 | deltaY: e.deltaY, 112 | controlPressed: e.getModifierState("Control"), 113 | }); 114 | e.preventDefault(); 115 | }; 116 | 117 | el.addEventListener("wheel", onWheel); 118 | 119 | return () => { 120 | el.removeEventListener("wheel", onWheel); 121 | }; 122 | }, []); 123 | 124 | useEffect(() => { 125 | if (!ref.current) { 126 | return; 127 | } 128 | 129 | // have to debounce/wait because resize observer doesn't always call with the final size 130 | const debouncedUpdateContainerSize = debounceWithLimit(20, 50, () => { 131 | if (!ref.current) { 132 | return; 133 | } 134 | zoomDispatch({ 135 | type: "update-container-size", 136 | containerSize: ref.current.getBoundingClientRect(), 137 | }); 138 | }); 139 | 140 | const resizeObserver = new ResizeObserver(() => { 141 | debouncedUpdateContainerSize(undefined, undefined); 142 | }); 143 | 144 | resizeObserver.observe(ref.current); 145 | 146 | debouncedUpdateContainerSize(undefined, undefined); 147 | 148 | return () => { 149 | resizeObserver.disconnect(); 150 | }; 151 | }, []); 152 | 153 | const { zoom, dx, dy } = zoomState; 154 | 155 | return { 156 | zoom, 157 | dx, 158 | dy, 159 | styles: { 160 | transform: `scale(${zoom}) translate3d(${dx}px, ${dy}px, 0)`, 161 | transformOrigin: "0 0", 162 | "--inverse-zoom": `${1 / zoom}`, 163 | ...(zoomState.dragging ? { transition: "none" } : {}), 164 | }, 165 | ref, 166 | dragStyles: zoomState.dragging ? { cursor: "grabbing" } : {}, 167 | onMouseMove: (e: MouseEvent) => { 168 | if ((e.buttons & 1) !== 1) { 169 | return; 170 | } 171 | zoomDispatch({ 172 | type: "drag", 173 | movementX: e.movementX, 174 | movementY: e.movementY, 175 | }); 176 | }, 177 | onDoubleClick: (e: MouseEvent) => { 178 | zoomDispatch({ type: "discrete-zoom", direction: "in" }); 179 | }, 180 | onMouseDown: (e: MouseEvent) => { 181 | zoomDispatch({ type: "mouse-down" }); 182 | }, 183 | onMouseUp: (e: MouseEvent) => { 184 | zoomDispatch({ type: "mouse-up" }); 185 | }, 186 | onZoomIn: () => { 187 | zoomDispatch({ type: "discrete-zoom", direction: "in" }); 188 | }, 189 | onZoomOut: () => { 190 | zoomDispatch({ type: "discrete-zoom", direction: "out" }); 191 | }, 192 | onReset: () => { 193 | zoomDispatch({ type: "reset" }); 194 | }, 195 | }; 196 | }; 197 | 198 | const clamp = ( 199 | rootPos: Size | undefined, 200 | containerSize: Size | undefined, 201 | proposed: ZoomState 202 | ): ZoomState => { 203 | const maxDy = (containerSize?.height ?? 1000) / proposed.zoom; 204 | const maxDx = (containerSize?.width ?? 750) / proposed.zoom; 205 | 206 | return { 207 | ...proposed, 208 | zoom: clampZoom(proposed.zoom), 209 | dx: Math.max( 210 | Math.min(proposed.dx, maxDx), 211 | -(rootPos?.width ?? 0) / proposed.zoom 212 | ), 213 | dy: Math.max( 214 | Math.min(proposed.dy, maxDy), 215 | -(rootPos?.height ?? 0) / proposed.zoom 216 | ), 217 | }; 218 | }; 219 | 220 | const clampZoom = (zoom: number): number => Math.max(Math.min(zoom, 5), 0.2); 221 | 222 | const sizeToFit = ( 223 | rootPos: Size | undefined, 224 | containerSize: Size | undefined 225 | ): ZoomState => { 226 | if (!rootPos || !containerSize) { 227 | return { zoom: 1.0, dx: 0, dy: 0, awaitingSizes: true, containerSize }; 228 | } 229 | 230 | const paddingFactor = 1.2; 231 | const widthZoom = 232 | (containerSize.width - paddingFactor) / (rootPos.width * paddingFactor); 233 | const heightZoom = 234 | (containerSize.height - paddingFactor) / (rootPos.height * paddingFactor); 235 | 236 | const zoom = clampZoom(Math.min(widthZoom, heightZoom)); 237 | 238 | return clamp(rootPos, containerSize, { 239 | zoom, 240 | dx: (containerSize.width - rootPos.width * zoom) / 2 / zoom, 241 | dy: (containerSize.height - rootPos.height * zoom) / 2 / zoom, 242 | awaitingSizes: false, 243 | containerSize, 244 | }); 245 | }; 246 | -------------------------------------------------------------------------------- /src/components/svg-flow-items/index.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "../../schema"; 2 | 3 | type FlowItemIconProps = IconProps & { 4 | flowItemType: schema.FlowItemType; 5 | }; 6 | 7 | export const SvgFlowItemIcon = ({ 8 | flowItemType, 9 | ...props 10 | }: FlowItemIconProps) => { 11 | const Component = svgFlowItemIconComponentForType(flowItemType); 12 | return ; 13 | }; 14 | 15 | export const svgFlowItemIconComponentForType = ( 16 | flowItemType: schema.FlowItemType 17 | ) => { 18 | switch (flowItemType) { 19 | case "action": 20 | return ActionIcon; 21 | case "entry-action": 22 | return EntryActionIcon; 23 | case "exit-action": 24 | return ExitActionIcon; 25 | case "assertion": 26 | return AssertionIcon; 27 | case "condition": 28 | return ConditionIcon; 29 | case "event": 30 | return EventIcon; 31 | case "state": 32 | return StateIcon; 33 | } 34 | return exhastive(flowItemType); 35 | }; 36 | 37 | type IconProps = { 38 | x: number; 39 | y: number; 40 | size: number; 41 | }; 42 | 43 | function exhastive(x: never): (props: IconProps) => JSX.Element { 44 | console.error("non-exhaustive match", x); 45 | // just make typescript happy... 46 | return () => <>; 47 | } 48 | 49 | export const ActionIcon = ({ x, y, size }: IconProps) => { 50 | return ( 51 | 60 | 61 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | ); 76 | }; 77 | 78 | export const EntryActionIcon = ({ x, y, size }: IconProps) => { 79 | return ( 80 | 89 | 90 | 99 | 100 | 101 | 102 | 109 | 110 | 116 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | ); 130 | }; 131 | 132 | export const ExitActionIcon = ({ x, y, size }: IconProps) => { 133 | return ( 134 | 143 | 144 | 153 | 154 | 155 | 156 | 163 | 164 | 170 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | ); 184 | }; 185 | 186 | export const AssertionIcon = ({ x, y, size }: IconProps) => { 187 | return ( 188 | 197 | 198 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | ); 213 | }; 214 | 215 | export const ConditionIcon = ({ x, y, size }: IconProps) => { 216 | return ( 217 | 226 | 227 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | ); 242 | }; 243 | 244 | export const EventIcon = ({ x, y, size }: IconProps) => { 245 | return ( 246 | 255 | 256 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | ); 271 | }; 272 | 273 | export const StateIcon = ({ x, y, size }: IconProps) => { 274 | return ( 275 | 284 | 285 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | ); 300 | }; 301 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /src/data/flows.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from "uuid"; 2 | import * as schema from "../schema"; 3 | import { DrawableFlow } from "../flow-utils"; 4 | 5 | export const flowItemTypePresentation: Record< 6 | schema.FlowItemType, 7 | { 8 | title: string; 9 | groupTitle: string; 10 | } 11 | > = { 12 | state: { title: "State", groupTitle: "States" }, 13 | event: { title: "Event", groupTitle: "Events" }, 14 | condition: { 15 | title: "Condition", 16 | groupTitle: "Conditions", 17 | }, 18 | action: { 19 | title: "Action", 20 | groupTitle: "Actions", 21 | }, 22 | "entry-action": { 23 | title: "Entry action", 24 | groupTitle: "Entry actions", 25 | }, 26 | "exit-action": { 27 | title: "Exit action", 28 | groupTitle: "Exit actions", 29 | }, 30 | assertion: { 31 | title: "Expectation", 32 | groupTitle: "Expectations", 33 | }, 34 | }; 35 | 36 | export const freshFlowItemId = () => { 37 | return v4(); 38 | }; 39 | 40 | export const eventName = ( 41 | flow: DrawableFlow, 42 | transition: DrawableFlow["transitions"][0] 43 | ): string => { 44 | return ( 45 | (transition.event && flow.metadata.events[transition.event]?.name) ?? 46 | "Immediately" 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/debounce-utils.ts: -------------------------------------------------------------------------------- 1 | export type Timeout = ReturnType; 2 | 3 | export const debounceWithLimit = ( 4 | debounceMs: number, 5 | maxMs: number, 6 | fn: (a: A, b: B) => void 7 | ): ((a: A, b: B) => void) => { 8 | let timeout: Timeout | undefined; 9 | let firstInvoke: number | undefined; 10 | return (a: A, b: B) => { 11 | const now = Date.now(); 12 | const reachedLimit = firstInvoke && now - firstInvoke > maxMs; 13 | if (reachedLimit) { 14 | clearTimeout(timeout); 15 | timeout = undefined; 16 | firstInvoke = undefined; 17 | fn(a, b); 18 | return; 19 | } 20 | 21 | if (!firstInvoke) { 22 | firstInvoke = now; 23 | } 24 | 25 | clearTimeout(timeout); 26 | timeout = setTimeout(() => { 27 | timeout = undefined; 28 | firstInvoke = undefined; 29 | fn(a, b); 30 | }, Math.min(debounceMs, firstInvoke + maxMs - now)); 31 | }; 32 | }; 33 | 34 | export const idempotentDebounce = ( 35 | debounceMs: number, 36 | f: () => void 37 | ): (() => void) => { 38 | let timeout: Timeout | null = null; 39 | return () => { 40 | if (timeout) { 41 | // already scheduled 42 | return; 43 | } 44 | 45 | timeout = setTimeout(() => { 46 | timeout = null; 47 | f(); 48 | }, debounceMs); 49 | }; 50 | }; 51 | 52 | export const invokeAtMostOnceEvery = ( 53 | timePeriodMs: number, 54 | f: () => void 55 | ): (() => void) => { 56 | let lastInvoked = 0; 57 | return () => { 58 | const now = Date.now(); 59 | if (now - lastInvoked > timePeriodMs) { 60 | lastInvoked = now; 61 | f(); 62 | } 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/flow-utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as schema from "../schema"; 2 | import * as z from "zod"; 3 | 4 | export const flowItemSchema = z.discriminatedUnion("flowItemType", [ 5 | z.object({ 6 | flowItemType: z.literal("state"), 7 | flowItemId: schema.stateIdSchema, 8 | flowItemName: z.string(), 9 | }), 10 | z.object({ 11 | flowItemType: z.literal("event"), 12 | flowItemId: schema.eventIdSchema, 13 | flowItemName: z.string(), 14 | }), 15 | z.object({ 16 | flowItemType: z.literal("condition"), 17 | flowItemId: schema.conditionIdSchema, 18 | flowItemName: z.string(), 19 | }), 20 | z.object({ 21 | flowItemType: z.literal("action"), 22 | flowItemId: schema.actionIdSchema, 23 | flowItemName: z.string(), 24 | }), 25 | z.object({ 26 | flowItemType: z.literal("entry-action"), 27 | flowItemId: schema.actionIdSchema, 28 | flowItemName: z.string(), 29 | }), 30 | z.object({ 31 | flowItemType: z.literal("exit-action"), 32 | flowItemId: schema.actionIdSchema, 33 | flowItemName: z.string(), 34 | }), 35 | z.object({ 36 | flowItemType: z.literal("assertion"), 37 | flowItemId: schema.assertionIdSchema, 38 | flowItemName: z.string(), 39 | }), 40 | ]); 41 | 42 | export type FlowItem = z.infer; 43 | 44 | type PickFlowItemIdentifier = T extends { 45 | flowItemId: any; 46 | flowItemType: any; 47 | } 48 | ? Pick 49 | : never; 50 | export type FlowItemIdentifier = PickFlowItemIdentifier; 51 | 52 | export const drawableFlowSchema = schema.flowSchema 53 | .pick({ 54 | id: true, 55 | assertions: true, 56 | entryActions: true, 57 | exitActions: true, 58 | initialState: true, 59 | metadata: true, 60 | states: true, 61 | transitions: true, 62 | }) 63 | .extend({ 64 | name: schema.flowSchema.shape.name.optional(), 65 | }); 66 | 67 | export type DrawableFlow = z.infer; 68 | 69 | export const transitionCount = (flow: DrawableFlow): number => 70 | flow.transitions.length + 71 | Object.values(flow.states).reduce( 72 | (count, state) => count + state!.transitions.length, 73 | 0 74 | ); 75 | 76 | export const relevantToFlowItems = ( 77 | flow: DrawableFlow, 78 | flowItems: Array, 79 | id: FlowItemIdentifier 80 | ): boolean => { 81 | const directlyReferenced = containsFlowItem(flowItems, id); 82 | if (directlyReferenced) { 83 | return true; 84 | } 85 | 86 | if (id.flowItemType === "state") { 87 | const state = flow.states[id.flowItemId]; 88 | if (!state) { 89 | return directlyReferenced; 90 | } 91 | 92 | const stateFlowItemIds: Array = ( 93 | [id] as Array 94 | ) 95 | .concat( 96 | state.assertions.map( 97 | (flowItemId): FlowItemIdentifier => ({ 98 | flowItemType: "assertion", 99 | flowItemId, 100 | }) 101 | ) 102 | ) 103 | .concat( 104 | state.entryActions 105 | .concat(state.exitActions) 106 | .map((flowItemId) => ({ flowItemType: "action", flowItemId })) 107 | ) 108 | .concat( 109 | state.transitions 110 | .map((t): FlowItemIdentifier | null => 111 | t.event ? { flowItemType: "event", flowItemId: t.event } : null 112 | ) 113 | .filter( 114 | (x: FlowItemIdentifier | null): x is FlowItemIdentifier => !!x 115 | ) 116 | ); 117 | 118 | return anyFlowItemIdentifierOverlap(stateFlowItemIds, flowItems); 119 | } 120 | 121 | return directlyReferenced; 122 | }; 123 | 124 | const directlyReferencedFlowItem = ( 125 | _flow: DrawableFlow, 126 | flowItems: Array, 127 | id: FlowItemIdentifier 128 | ): boolean => { 129 | const directlyReferenced = containsFlowItem(flowItems, id); 130 | return directlyReferenced; 131 | }; 132 | 133 | const anyFlowItemIdentifierOverlap = ( 134 | items1: Array, 135 | items2: Array 136 | ) => { 137 | const toStrId = ({ flowItemId, flowItemType }: FlowItemIdentifier) => 138 | `${flowItemType}#${flowItemId}`; 139 | const s1 = new Set(items1.map(toStrId)); 140 | for (const i of items2) { 141 | if (s1.has(toStrId(i))) { 142 | return true; 143 | } 144 | } 145 | 146 | return false; 147 | }; 148 | 149 | export const containsFlowItem = ( 150 | items: Array, 151 | item: FlowItemIdentifier 152 | ): boolean => 153 | items.some( 154 | (i) => 155 | i.flowItemId === item.flowItemId && i.flowItemType === item.flowItemType 156 | ); 157 | 158 | export type Transition = schema.Flow["transitions"][any]; 159 | 160 | export const transitionRelevantToFlowItems = ( 161 | flow: DrawableFlow, 162 | flowItems: Array, 163 | sourceStateId: schema.StateId, 164 | transition: schema.Flow["transitions"][any] 165 | ) => 166 | transition.event 167 | ? directlyReferencedFlowItem(flow, flowItems, { 168 | flowItemType: "event", 169 | flowItemId: transition.event, 170 | }) 171 | : transition.condition 172 | ? directlyReferencedFlowItem(flow, flowItems, { 173 | flowItemType: "condition", 174 | flowItemId: transition.condition, 175 | }) 176 | : transition.target 177 | ? directlyReferencedFlowItem(flow, flowItems, { 178 | flowItemType: "state", 179 | flowItemId: sourceStateId, 180 | }) && 181 | directlyReferencedFlowItem(flow, flowItems, { 182 | flowItemType: "state", 183 | flowItemId: transition.target, 184 | }) 185 | : transition.assertions.length 186 | ? transition.assertions.some((assertion) => 187 | directlyReferencedFlowItem(flow, flowItems, { 188 | flowItemType: "assertion", 189 | flowItemId: assertion, 190 | }) 191 | ) 192 | : transition.actions.some((action) => 193 | directlyReferencedFlowItem(flow, flowItems, { 194 | flowItemType: "action", 195 | flowItemId: action, 196 | }) 197 | ); 198 | 199 | export const getMetadata = ( 200 | flow: DrawableFlow, 201 | flowItem: FlowItemIdentifier 202 | ) => { 203 | switch (flowItem.flowItemType) { 204 | case "action": 205 | case "entry-action": 206 | case "exit-action": 207 | return flow.metadata.actions[flowItem.flowItemId]; 208 | case "assertion": 209 | return flow.metadata.assertions[flowItem.flowItemId]; 210 | case "condition": 211 | return flow.metadata.conditions[flowItem.flowItemId]; 212 | case "event": 213 | return flow.metadata.events[flowItem.flowItemId]; 214 | case "state": 215 | return flow.states[flowItem.flowItemId]; 216 | } 217 | 218 | flowItem satisfies never; 219 | }; 220 | -------------------------------------------------------------------------------- /src/hooks/use-click-away.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, RefObject, useRef, useCallback } from "react"; 2 | 3 | export const useClickAway = ( 4 | ref: RefObject, 5 | onClickAway: () => void 6 | ) => { 7 | const clickAwayRef = useRef(onClickAway); 8 | clickAwayRef.current = onClickAway; 9 | 10 | const ignoreTimestampRef = useRef(Date.now()); 11 | 12 | const doClickAway = () => { 13 | setTimeout(clickAwayRef.current, 10); 14 | }; 15 | 16 | useEffect(() => { 17 | let timeout: ReturnType | null = null; 18 | 19 | const clickHandler = (evt: MouseEvent) => { 20 | const elapsed = Date.now() - ignoreTimestampRef.current; 21 | if (elapsed < 250) { 22 | // ignore this click 23 | return; 24 | } 25 | 26 | const ourEl = ref.current; 27 | if (!ourEl || !(evt.target instanceof Node)) { 28 | doClickAway(); 29 | return; 30 | } 31 | 32 | const isOurClick = ourEl.contains(evt.target); 33 | if (isOurClick) { 34 | return; 35 | } 36 | 37 | const clickTippy = (evt.target as HTMLElement)?.closest( 38 | "[data-tippy-root]" 39 | ); 40 | 41 | const isTippyClick = !!clickTippy; 42 | 43 | if (isTippyClick) { 44 | // ignore clicks on popups unless we're in that popup 45 | const ourTippy = ourEl.closest("[data-tippy-root]"); 46 | if (ourTippy !== clickTippy) { 47 | return; 48 | } 49 | } 50 | 51 | doClickAway(); 52 | }; 53 | 54 | const escHandler = (evt: KeyboardEvent) => { 55 | if (evt.key === "Escape") { 56 | doClickAway(); 57 | } 58 | }; 59 | 60 | timeout = setTimeout(() => { 61 | timeout = null; 62 | document.body.addEventListener("click", clickHandler); 63 | document.body.addEventListener("keydown", escHandler); 64 | }, 100); 65 | 66 | return () => { 67 | if (timeout) { 68 | clearTimeout(timeout); 69 | } 70 | document.body.removeEventListener("click", clickHandler); 71 | document.body.removeEventListener("keydown", escHandler); 72 | }; 73 | }, []); 74 | 75 | const ignoreMomentarily = useCallback(() => { 76 | ignoreTimestampRef.current = Date.now(); 77 | }, []); 78 | 79 | return ignoreMomentarily; 80 | }; 81 | -------------------------------------------------------------------------------- /src/hooks/use-modal.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | export const useModal = (initiallyOpen?: boolean) => { 4 | const [open, setOpen] = useState(initiallyOpen ?? false); 5 | 6 | const onOpen = useCallback(() => setOpen(true), [setOpen]); 7 | const onClose = useCallback(() => setOpen(false), [setOpen]); 8 | const onToggle = useCallback(() => { 9 | setOpen(!open); 10 | }, [open, setOpen]); 11 | 12 | return { 13 | open, 14 | onOpen, 15 | onClose, 16 | onToggle, 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/hooks/use-position.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, Ref, useEffect, useRef } from "react"; 2 | import { idempotentDebounce } from "../debounce-utils"; 3 | 4 | export type Position = { x: number; y: number; width: number; height: number }; 5 | 6 | export const usePosition = < 7 | T extends HTMLElement, 8 | S extends HTMLElement = HTMLElement 9 | >( 10 | updatePosition: (pos: Position) => void, 11 | scrollerRef?: MutableRefObject 12 | ): Ref => { 13 | const ref = useRef(null); 14 | 15 | useEffect(() => { 16 | if (!ref.current) { 17 | return; 18 | } 19 | 20 | const handler = () => { 21 | if (!ref.current) { 22 | return; 23 | } 24 | const rect = ref.current.getBoundingClientRect(); 25 | updatePosition(rect); 26 | }; 27 | 28 | // we want to schedule a final handler run a few ms after we're last invoked 29 | // because we don't always get invoked with the absolute most updated data 30 | const handlerWithDebounceScheduler = () => { 31 | handler(); 32 | debouncedHandler(); 33 | }; 34 | 35 | const debouncedHandler = idempotentDebounce(20, handler); 36 | 37 | scrollerRef?.current?.addEventListener( 38 | "scroll", 39 | handlerWithDebounceScheduler, 40 | { passive: true } 41 | ); 42 | 43 | const resizeObserver = new ResizeObserver(handlerWithDebounceScheduler); 44 | resizeObserver.observe(ref.current); 45 | 46 | handler(); 47 | 48 | return () => { 49 | resizeObserver.disconnect(); 50 | scrollerRef?.current?.removeEventListener( 51 | "scroll", 52 | handlerWithDebounceScheduler 53 | ); 54 | }; 55 | }, [updatePosition]); 56 | 57 | return ref; 58 | }; 59 | -------------------------------------------------------------------------------- /src/hooks/use-reposition-visibly.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect, useRef, useState } from "react"; 2 | 3 | type Adjustments = { xAdjustment: number; yAdjustment: number }; 4 | 5 | export const useRepositionVisibly = ( 6 | ref: MutableRefObject, 7 | idealPadding?: number 8 | ): Adjustments => { 9 | const [adjustments, setAdjustments] = useState({ 10 | xAdjustment: 0, 11 | yAdjustment: 0, 12 | }); 13 | 14 | useEffect(() => { 15 | if (!ref.current) { 16 | return; 17 | } 18 | 19 | const visibilityContainer = getVisibilityContainer(ref.current); 20 | if (!visibilityContainer) { 21 | return; 22 | } 23 | 24 | const rect = ref.current.getBoundingClientRect(); 25 | const visibleRect = visibilityContainer.getBoundingClientRect(); 26 | const inverseZoom = 27 | parseFloat( 28 | (window.getComputedStyle && 29 | window 30 | .getComputedStyle(ref.current) 31 | ?.getPropertyValue("--inverse-zoom")) ?? 32 | 1 33 | ) || 1; 34 | const xAdjustment = 35 | getXAdjustment(rect, visibleRect, idealPadding ?? 0) * inverseZoom; 36 | const yAdjustment = 37 | getYAdjustment(rect, visibleRect, idealPadding ?? 0) * inverseZoom; 38 | 39 | setAdjustments({ 40 | xAdjustment, 41 | yAdjustment, 42 | }); 43 | }, []); 44 | 45 | return adjustments; 46 | }; 47 | 48 | const getXAdjustment = ( 49 | inner: DOMRect, 50 | outer: DOMRect, 51 | idealPadding: number 52 | ): number => { 53 | const tooFarLeft = inner.left < outer.left + idealPadding; 54 | const tooFarRight = inner.right > outer.right - idealPadding; 55 | 56 | if (inner.width > outer.width + 2 * idealPadding) { 57 | // nothing we can do 58 | return 0; 59 | } 60 | 61 | if (tooFarLeft) { 62 | return outer.left - inner.left + idealPadding; 63 | } 64 | 65 | if (tooFarRight) { 66 | return outer.right - inner.right - idealPadding; 67 | } 68 | 69 | return 0; 70 | }; 71 | 72 | const getYAdjustment = ( 73 | inner: DOMRect, 74 | outer: DOMRect, 75 | idealPadding: number 76 | ): number => { 77 | const tooFarUp = inner.top < outer.top + idealPadding; 78 | const tooFarDown = inner.bottom > outer.bottom - idealPadding; 79 | 80 | if (inner.height > outer.height) { 81 | // nothing we can do 82 | return 0; 83 | } 84 | 85 | if (tooFarUp) { 86 | return outer.top - inner.top + idealPadding; 87 | } 88 | 89 | if (tooFarDown) { 90 | return outer.bottom - inner.bottom - idealPadding; 91 | } 92 | 93 | return 0; 94 | }; 95 | 96 | const getVisibilityContainer = (el: HTMLElement): HTMLElement | null => { 97 | const parent = el.parentElement; 98 | if (!parent) { 99 | return null; 100 | } 101 | 102 | if (!window.getComputedStyle) { 103 | return null; 104 | } 105 | 106 | const styles = window.getComputedStyle(parent); 107 | const overflowX = styles?.overflowX; 108 | const overflowY = styles?.overflowY; 109 | 110 | if ( 111 | overflowX === "clip" || 112 | overflowX === "hidden" || 113 | overflowY === "clip" || 114 | overflowY === "hidden" 115 | ) { 116 | return parent; 117 | } 118 | 119 | return getVisibilityContainer(parent); 120 | }; 121 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components/flow-graph"; 2 | export * from "./components/svg-flow-graph"; 3 | export * from "./components/grid-background"; 4 | export * as schema from "./schema"; 5 | export * as xstate from "./transformers/xstate"; 6 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | export const flowIdSchema = z.string().brand("FlowId"); 4 | export type FlowId = z.infer; 5 | 6 | export const stateIdSchema = z.string().brand<"stateId">(); 7 | export type StateId = z.infer; 8 | 9 | export const actionIdSchema = z.string().brand<"actionId">(); 10 | export type ActionId = z.infer; 11 | 12 | export const assertionIdSchema = z.string().brand<"assertionId">(); 13 | export type AssertionId = z.infer; 14 | 15 | export const eventIdSchema = z.string().brand<"eventId">(); 16 | export type EventId = z.infer; 17 | 18 | export const conditionIdSchema = z.string().brand<"conditionId">(); 19 | export type ConditionId = z.infer; 20 | 21 | export const basicFlowItemSchema = z.object({ 22 | name: z.string(), 23 | }); 24 | 25 | const eventSchema = basicFlowItemSchema; 26 | const assertionSchema = basicFlowItemSchema; 27 | const actionSchema = basicFlowItemSchema; 28 | const conditionSchema = basicFlowItemSchema; 29 | 30 | const transitionSchema = z.object({ 31 | // an event-less transition is an "always" transition, taken immediately upon entering the state as long as its guard is satisfied. 32 | event: eventIdSchema.optional(), 33 | target: stateIdSchema.optional(), 34 | condition: conditionIdSchema.optional(), 35 | assertions: z.array(assertionIdSchema).default([]), 36 | actions: z.array(actionIdSchema).default([]), 37 | }); 38 | 39 | const stateSchema = basicFlowItemSchema.extend({ 40 | type: z 41 | .enum(["atomic", "compound", "parallel", "history", "final"]) 42 | .default("atomic"), 43 | initialState: stateIdSchema.optional(), 44 | parent: stateIdSchema.optional(), 45 | entryActions: z.array(actionIdSchema).default([]), 46 | exitActions: z.array(actionIdSchema).default([]), 47 | assertions: z.array(assertionIdSchema).default([]), 48 | transitions: z.array(transitionSchema).default([]), 49 | }); 50 | 51 | export type State = z.infer; 52 | 53 | export const flowSchema = z 54 | .object({ 55 | id: flowIdSchema, 56 | name: z.string().transform((name) => { 57 | if (name.length > 200) { 58 | console.warn("flow name too long", { name }); 59 | return name.slice(0, 197) + "..."; 60 | } 61 | 62 | return name; 63 | }), 64 | states: z.record(stateIdSchema, stateSchema), 65 | metadata: z.object({ 66 | events: z.record(eventIdSchema, eventSchema), 67 | assertions: z.record(assertionIdSchema, assertionSchema), 68 | actions: z.record(actionIdSchema, actionSchema), 69 | conditions: z.record(conditionIdSchema, conditionSchema), 70 | }), 71 | }) 72 | .merge( 73 | stateSchema.pick({ 74 | assertions: true, 75 | initialState: true, 76 | entryActions: true, 77 | exitActions: true, 78 | transitions: true, 79 | }) 80 | ); 81 | 82 | export type Flow = z.infer; 83 | 84 | export const flowItemTypeSchema = z.enum([ 85 | "state", 86 | "event", 87 | "condition", 88 | "action", 89 | "entry-action", 90 | "exit-action", 91 | "assertion", 92 | ]); 93 | export type FlowItemType = z.infer; 94 | 95 | export const flowItemRefSchema = z.discriminatedUnion("type", [ 96 | z.object({ 97 | type: z.literal("state"), 98 | id: stateIdSchema, 99 | }), 100 | z.object({ 101 | type: z.literal("event"), 102 | id: eventIdSchema, 103 | }), 104 | z.object({ 105 | type: z.literal("condition"), 106 | id: conditionIdSchema, 107 | }), 108 | z.object({ 109 | type: z.literal("action"), 110 | id: actionIdSchema, 111 | }), 112 | z.object({ 113 | type: z.literal("entry-action"), 114 | id: actionIdSchema, 115 | }), 116 | z.object({ 117 | type: z.literal("exit-action"), 118 | id: actionIdSchema, 119 | }), 120 | z.object({ 121 | type: z.literal("assertion"), 122 | id: assertionIdSchema, 123 | }), 124 | ]); 125 | export type FlowItemRef = z.infer; 126 | 127 | function testFlowItemRefCoversFlowItemType() { 128 | function receiveFlowItemType(fit: FlowItemType) { 129 | receiveFlowItemRefType(fit); 130 | } 131 | function receiveFlowItemRefType(firt: FlowItemRef["type"]) { 132 | receiveFlowItemType(firt); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/transformers/svg.ts: -------------------------------------------------------------------------------- 1 | // this file is slightly adapted from xstate-viz pathUtils and Edges.tsx 2 | 3 | export const pointsToPath = ( 4 | points: Array<{ x: number; y: number }> 5 | ): SvgPath | undefined => { 6 | if (points.length < 2) { 7 | return; 8 | } 9 | 10 | const startPoint = points[0]; 11 | const endPoint = points[points.length - 1]; 12 | 13 | const path = [ 14 | ["M", startPoint], 15 | ...points.slice(1, -1).map((point) => ["L", point]), 16 | ]; 17 | 18 | const preLastPoint = points[points.length - 2]; 19 | const finalPoint = { 20 | x: endPoint.x, 21 | y: endPoint.y, 22 | }; 23 | path.push(["L", finalPoint]); 24 | 25 | return path as SvgPath; 26 | }; 27 | 28 | interface Point { 29 | x: number; 30 | y: number; 31 | } 32 | 33 | enum Sides { 34 | Top = "top", 35 | Right = "right", 36 | Bottom = "bottom", 37 | Left = "left", 38 | } 39 | 40 | export type LineSegment = [Point, Point]; 41 | 42 | export interface SidePoint extends Point { 43 | side: Sides; 44 | } 45 | 46 | export type SideLineSegment = [SidePoint, SidePoint]; 47 | 48 | export type Path = Point[]; 49 | 50 | export type MPathParam = ["M", Point]; 51 | export type LPathParam = ["L", Point]; 52 | export type ZPathParam = ["Z"]; 53 | export type CPathParam = ["C", Point, Point, Point]; 54 | export type QPathParam = ["Q", Point, Point]; 55 | export type PathParam = 56 | | MPathParam 57 | | LPathParam 58 | | ZPathParam 59 | | CPathParam 60 | | QPathParam; 61 | 62 | export type SvgPath = [MPathParam, ...PathParam[]]; 63 | export type SvgPathPortion = PathParam[]; 64 | 65 | /** 66 | * Removes midpoints from a set of points. 67 | * 68 | * @param points 69 | * @returns Points with midpoints removed 70 | */ 71 | export function simplifyPoints(points: Point[]): Point[] { 72 | const pointHashes = new Set(); 73 | 74 | const result: Point[] = []; 75 | 76 | points.forEach((point, i) => { 77 | const prevPoint = points[i - 1]; 78 | const nextPoint = points[i + 1]; 79 | 80 | if (prevPoint?.x === point.x && point.x === nextPoint?.x) { 81 | return; 82 | } 83 | if (prevPoint?.y === point.y && point.y === nextPoint?.y) { 84 | return; 85 | } 86 | 87 | const hash = `${point.x}|${point.y}`; 88 | 89 | if (pointHashes.has(hash)) { 90 | return; 91 | } 92 | 93 | result.push(point); 94 | }); 95 | 96 | return result; 97 | } 98 | 99 | /** 100 | * Adds midpoints to the center of line segments represented by an array of points. 101 | * 102 | * @param points Points without midpoints 103 | * @returns Points with midpoints 104 | */ 105 | export function withMidpoints(points: Point[]): Point[] { 106 | const pointsWithMid: Point[] = []; 107 | 108 | points.forEach((pt, i) => { 109 | const [ptA, ptB, ptC] = [pt, points[i + 1], points[i + 2]]; 110 | 111 | if (!ptC || !ptB) { 112 | pointsWithMid.push(ptA); 113 | return; 114 | } 115 | 116 | const midpt = { 117 | x: ptA.x + (ptB.x - ptA.x) / 2, 118 | y: ptA.y + (ptB.y - ptA.y) / 2, 119 | color: "green", 120 | label: "midpoint", 121 | }; 122 | 123 | pointsWithMid.push(ptA, midpt); 124 | }); 125 | 126 | return pointsWithMid; 127 | } 128 | 129 | export function getSvgPath( 130 | sourcePoint: Point, 131 | targetPoint: Point, 132 | side: Sides = Sides.Top 133 | ): SvgPath { 134 | const preStartPoint = sourcePoint; 135 | const startPoint = { x: sourcePoint.x + 20, y: sourcePoint.y }; 136 | const endPoint = targetPoint; 137 | const endSide = side; 138 | 139 | const svgPath: SvgPath = [["M", sourcePoint]]; 140 | 141 | const points: Point[] = [preStartPoint, startPoint]; 142 | 143 | const midX = (endPoint.x - startPoint.x) / 2 + startPoint.x; 144 | const midY = (endPoint.y - startPoint.y) / 2 + startPoint.y; 145 | 146 | switch (endSide) { 147 | case "right": 148 | points.push({ x: startPoint.x, y: endPoint.y }); 149 | if (endPoint.y > startPoint.y) { 150 | } 151 | break; 152 | case "left": 153 | points.push({ x: midX, y: startPoint.y }, { x: midX, y: endPoint.y }); 154 | break; 155 | case "top": 156 | if (startPoint.x < endPoint.x) { 157 | points.push({ x: endPoint.x, y: startPoint.y }); 158 | } else { 159 | points.push({ x: startPoint.x, y: midY }, { x: endPoint.x, y: midY }); 160 | } 161 | break; 162 | case "bottom": 163 | if (startPoint.x > endPoint.x) { 164 | points.push({ x: startPoint.x, y: midY }, { x: endPoint.x, y: midY }); 165 | } else { 166 | points.push({ x: endPoint.x, y: startPoint.y }); 167 | } 168 | break; 169 | default: 170 | break; 171 | } 172 | 173 | points.push(endPoint); 174 | 175 | const pointsWithMid = withMidpoints(simplifyPoints(points)); 176 | 177 | pointsWithMid.forEach((pt, i, pts) => { 178 | if ( 179 | i >= 2 && 180 | i <= pts.length - 2 && 181 | isBendable(pts[i - 1], pt, pts[i + 1]) 182 | ) { 183 | const { p1, p2, p } = roundOneCorner(pts[i - 1], pt, pts[i + 1]); 184 | 185 | svgPath.push(["L", p1], ["C", p1, p, p2]); 186 | } else { 187 | svgPath.push(["L", pt]); 188 | } 189 | }); 190 | 191 | return svgPath; 192 | } 193 | 194 | export function isBendable(p1: Point, corner: Point, p2: Point): boolean { 195 | return !( 196 | (p1.x === corner.x && p2.x === corner.x) || 197 | (p1.y === corner.y && p2.y === corner.y) 198 | ); 199 | } 200 | 201 | interface CubicCurve { 202 | p1: Point; 203 | p2: Point; 204 | p: Point; 205 | } 206 | 207 | interface Vector { 208 | type: "vector"; 209 | x: number; 210 | y: number; 211 | } 212 | 213 | const lineToVector = (p1: Point, p2: Point): Vector => { 214 | const vector = { 215 | type: "vector" as const, 216 | x: p2.x - p1.x, 217 | y: p2.y - p1.y, 218 | }; 219 | 220 | return vector; 221 | }; 222 | 223 | const vectorToUnitVector = (v: Vector): Vector => { 224 | let magnitude = v.x * v.x + v.y * v.y; 225 | magnitude = Math.sqrt(magnitude); 226 | const unitVector = { 227 | type: "vector" as const, 228 | x: v.x / magnitude, 229 | y: v.y / magnitude, 230 | }; 231 | return unitVector; 232 | }; 233 | 234 | export const roundOneCorner = ( 235 | p1: Point, 236 | corner: Point, 237 | p2: Point, 238 | radius: number = 20 239 | ): CubicCurve => { 240 | const corner_to_p1 = lineToVector(corner, p1); 241 | const corner_to_p2 = lineToVector(corner, p2); 242 | const p1dist = Math.hypot(corner_to_p1.x, corner_to_p1.y); 243 | const p2dist = Math.hypot(corner_to_p2.x, corner_to_p2.y); 244 | if (p1dist * p2dist === 0) { 245 | return { 246 | p1: corner, 247 | p2: corner, 248 | p: corner, 249 | }; 250 | } 251 | const resolvedRadius = Math.min(radius, p1dist - 0.1, p2dist - 0.1); 252 | const corner_to_p1_unit = vectorToUnitVector(corner_to_p1); 253 | const corner_to_p2_unit = vectorToUnitVector(corner_to_p2); 254 | 255 | const curve_p1 = { 256 | x: corner.x + corner_to_p1_unit.x * resolvedRadius, 257 | y: corner.y + corner_to_p1_unit.y * resolvedRadius, 258 | }; 259 | const curve_p2 = { 260 | x: corner.x + corner_to_p2_unit.x * resolvedRadius, 261 | y: corner.y + corner_to_p2_unit.y * resolvedRadius, 262 | }; 263 | const path = { 264 | p1: curve_p1, 265 | p2: curve_p2, 266 | p: corner, 267 | }; 268 | 269 | return path; 270 | }; 271 | 272 | export function getPath( 273 | sourceRect: ClientRect, 274 | labelRect: ClientRect, 275 | targetRect: ClientRect, 276 | targetPoint?: Point 277 | ): SvgPath | undefined { 278 | // const sourcePoint = r.point('right', 'center'); 279 | const edgeEntryPoint = { 280 | x: labelRect.left, 281 | y: labelRect.top + labelRect.height / 2, 282 | }; 283 | const edgeExitPoint = { 284 | x: labelRect.right, 285 | y: labelRect.top + labelRect.height / 2, 286 | }; 287 | 288 | // self-transition 289 | if (labelRect === targetRect) { 290 | return [ 291 | ["M", edgeExitPoint], 292 | [ 293 | "Q", 294 | { 295 | x: edgeExitPoint.x + 10, 296 | y: edgeExitPoint.y - 10, 297 | }, 298 | { x: edgeExitPoint.x + 20, y: edgeExitPoint.y }, 299 | ], 300 | [ 301 | "Q", 302 | { 303 | x: edgeExitPoint.x + 10, 304 | y: edgeExitPoint.y + 10, 305 | }, 306 | edgeExitPoint, 307 | ], 308 | ]; 309 | } 310 | 311 | const intersections = closestRectIntersections( 312 | [ 313 | edgeExitPoint, 314 | { 315 | x: targetRect.left + targetRect.width / 2, 316 | y: targetRect.top + targetRect.height / 2, 317 | }, 318 | ], 319 | targetRect 320 | ); 321 | 322 | if (!intersections) { 323 | return undefined; 324 | } 325 | 326 | targetPoint = targetPoint ?? intersections[0].point; 327 | 328 | const endPoint = targetPoint; 329 | const endSide = intersections[0].side; 330 | 331 | switch (endSide) { 332 | case Sides.Top: 333 | endPoint.y -= 10; 334 | break; 335 | case Sides.Left: 336 | endPoint.x -= 10; 337 | break; 338 | case Sides.Bottom: 339 | endPoint.y += 10; 340 | break; 341 | case Sides.Right: 342 | endPoint.x += 10; 343 | break; 344 | default: 345 | break; 346 | } 347 | 348 | const preSvgPath = getSvgPath( 349 | { x: sourceRect.right, y: sourceRect.top }, 350 | edgeEntryPoint, 351 | Sides.Left 352 | ); 353 | 354 | const svgPath = getSvgPath(edgeExitPoint, endPoint, endSide); 355 | 356 | // @ts-ignore 357 | // return svgPath; 358 | return preSvgPath.concat(svgPath); 359 | } 360 | 361 | export function pathToD(path: SvgPath): string { 362 | return path 363 | .map(([cmd, ...points]) => 364 | [cmd, ...points.map((point: Point) => `${point.x},${point.y}`)].join(" ") 365 | ) 366 | .join(" "); 367 | } 368 | 369 | type RectIntersection = { 370 | point: Point; 371 | side: Sides; 372 | }; 373 | 374 | function segmentIntersection( 375 | ls1: LineSegment, 376 | ls2: LineSegment 377 | ): Point | false { 378 | const [{ x: x1, y: y1 }, { x: x2, y: y2 }] = ls1; 379 | const [{ x: x3, y: y3 }, { x: x4, y: y4 }] = ls2; 380 | 381 | // Check if none of the lines are of length 0 382 | if ((x1 === x2 && y1 === y2) || (x3 === x4 && y3 === y4)) { 383 | return false; 384 | } 385 | 386 | const denominator = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); 387 | 388 | // Lines are parallel 389 | if (denominator === 0) { 390 | return false; 391 | } 392 | 393 | let ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator; 394 | let ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator; 395 | 396 | // is the intersection along the segments 397 | if (ua < 0 || ua > 1 || ub < 0 || ub > 1) { 398 | return false; 399 | } 400 | 401 | // Return a object with the x and y coordinates of the intersection 402 | let x = x1 + ua * (x2 - x1); 403 | let y = y1 + ua * (y2 - y1); 404 | 405 | return { x, y }; 406 | } 407 | 408 | function rectIntersection( 409 | ls: LineSegment, 410 | rect: ClientRect 411 | ): RectIntersection[] { 412 | const top = { 413 | point: segmentIntersection(ls, [ 414 | { x: rect.left, y: rect.top }, 415 | { x: rect.right, y: rect.top }, 416 | ]), 417 | side: Sides.Top, 418 | }; 419 | const right = { 420 | point: segmentIntersection(ls, [ 421 | { x: rect.right, y: rect.top }, 422 | { x: rect.right, y: rect.bottom }, 423 | ]), 424 | side: Sides.Right, 425 | }; 426 | const bottom = { 427 | point: segmentIntersection(ls, [ 428 | { x: rect.right, y: rect.bottom }, 429 | { x: rect.left, y: rect.bottom }, 430 | ]), 431 | side: Sides.Bottom, 432 | }; 433 | const left = { 434 | point: segmentIntersection(ls, [ 435 | { x: rect.left, y: rect.bottom }, 436 | { x: rect.left, y: rect.top }, 437 | ]), 438 | side: Sides.Left, 439 | }; 440 | 441 | return [top, right, bottom, left].filter( 442 | (ix): ix is RectIntersection => ix.point !== false 443 | ); 444 | } 445 | 446 | export function closestRectIntersections( 447 | ls: LineSegment, 448 | rect: ClientRect 449 | ): RectIntersection[] | false { 450 | const intersections = rectIntersection(ls, rect); 451 | 452 | let minDistance = Infinity; 453 | let rectIntersections: RectIntersection[] = []; 454 | 455 | for (let rectIx of intersections) { 456 | const { side, point: intersection } = rectIx; 457 | // const intersection = intersections[side as Sides]; 458 | 459 | const distance = Math.hypot( 460 | intersection.x - ls[0].x, 461 | intersection.y - ls[0].y 462 | ); 463 | 464 | const rectIntersection = { 465 | point: intersection, 466 | side: side as Sides, 467 | }; 468 | 469 | if (distance < minDistance) { 470 | rectIntersections = [rectIntersection]; 471 | minDistance = distance; 472 | } else if (distance === minDistance) { 473 | rectIntersections.push(rectIntersection); 474 | } 475 | } 476 | 477 | return rectIntersections.length > 0 ? rectIntersections : false; 478 | } 479 | 480 | /** 481 | * Rounds the corners of an SVG path. 482 | * 483 | * @param path The SVG path to round 484 | * @returns A rounded SVG path 485 | */ 486 | export function roundPath(path: SvgPath): SvgPath { 487 | const contiguousLinePaths: SvgPathPortion[] = []; 488 | const bentPath: SvgPathPortion = []; 489 | const current: SvgPathPortion = []; 490 | 491 | for (const svgPoint of path) { 492 | const [cmd] = svgPoint; 493 | if (cmd !== "L") { 494 | if (current.length > 1) { 495 | contiguousLinePaths.push([...current]); 496 | current.length = 0; 497 | } 498 | } 499 | if (cmd === "M") { 500 | current.push(svgPoint); 501 | } else if (cmd === "L") { 502 | current.push(svgPoint); 503 | } 504 | } 505 | if (current.length > 1) { 506 | contiguousLinePaths.push([...current]); 507 | } 508 | 509 | for (const linePath of contiguousLinePaths) { 510 | const points = linePath.map(([, point]) => point as Point); 511 | const pointsWithMid = withMidpoints(simplifyPoints(points)); 512 | const bentPathPortion: SvgPath = [linePath[0] as MPathParam]; 513 | 514 | pointsWithMid.forEach((pt, i, pts) => { 515 | if ( 516 | i >= 2 && 517 | i <= pts.length - 2 && 518 | isBendable(pts[i - 1], pt, pts[i + 1]) 519 | ) { 520 | const { p1, p2, p } = roundOneCorner( 521 | pts[i - 1], 522 | pt, 523 | pts[i + 1], 524 | /* radius = */ 10 525 | ); 526 | 527 | bentPathPortion.push(["L", p1], ["C", p1, p, p2]); 528 | } else { 529 | bentPathPortion.push(["L", pt]); 530 | } 531 | }); 532 | 533 | bentPath.push(...bentPathPortion); 534 | } 535 | 536 | return bentPath as SvgPath; 537 | } 538 | 539 | export const sigmoid = (start: Point, end: Point): string => { 540 | // https://stackoverflow.com/questions/45240401/svg-path-create-a-curvy-line-to-link-two-points 541 | 542 | const xDiff = Math.abs(end.x - start.x); 543 | 544 | const xDir = end.x > start.x ? 1 : -1; 545 | 546 | var bX = start.x + xDir * xDiff * 0.05; 547 | var bY = start.y; 548 | 549 | var cX = start.x + xDiff * 0.33; 550 | var cY = start.y; 551 | var dX = end.x - xDiff * 0.33; 552 | var dY = end.y; 553 | var eX = end.x - xDir * xDiff * 0.05; 554 | var eY = end.y; 555 | 556 | return [ 557 | `M ${start.x},${start.y}`, 558 | `L ${bX},${bY}`, 559 | `C ${cX},${cY} ${dX},${dY} ${eX},${eY}`, 560 | `L ${end.x},${end.y}`, 561 | ].join(" "); 562 | }; 563 | -------------------------------------------------------------------------------- /src/transformers/xstate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionId, 3 | ConditionId, 4 | EventId, 5 | Flow, 6 | FlowId, 7 | State, 8 | StateId, 9 | } from "../schema"; 10 | 11 | export const machineToFlow = (xstate: { 12 | definition: StateNodeDefinition; 13 | }): Flow => machineDefinitionToFlow(xstate.definition); 14 | 15 | const toStateId = (stateId: string) => stateId.replace(/[:.]/g, "_") as StateId; 16 | 17 | export const machineDefinitionToFlow = (xstate: StateNodeDefinition): Flow => { 18 | const states = definitionToFlowState(undefined, xstate); 19 | const machineId = toStateId(xstate.id); 20 | const rootParts = states.find(([id]) => id === machineId); 21 | if (!rootParts) { 22 | return { 23 | assertions: [], 24 | entryActions: [], 25 | exitActions: [], 26 | id: machineId as unknown as FlowId, 27 | metadata: { 28 | actions: {}, 29 | assertions: {}, 30 | conditions: {}, 31 | events: {}, 32 | }, 33 | name: xstate.key, 34 | states: {}, 35 | transitions: [], 36 | }; 37 | } 38 | 39 | const [id, root] = rootParts; 40 | 41 | const flow: Flow = { 42 | id: id as unknown as FlowId, 43 | assertions: [], 44 | name: root.name, 45 | entryActions: root.entryActions, 46 | exitActions: root.exitActions, 47 | states: Object.fromEntries( 48 | states 49 | .filter(([stateId]) => id !== stateId) 50 | .map(([stateId, state]) => [ 51 | stateId, 52 | state.parent === id ? { ...state, parent: undefined } : state, 53 | ]) 54 | ), 55 | transitions: root.transitions, 56 | initialState: root.initialState, 57 | metadata: { 58 | actions: {}, 59 | assertions: {}, 60 | conditions: {}, 61 | events: {}, 62 | }, 63 | }; 64 | 65 | for (const [_, state] of states) { 66 | const actions = state.entryActions 67 | .concat(state.exitActions) 68 | .concat(state.transitions.flatMap((transition) => transition.actions)); 69 | for (const action of actions) { 70 | flow.metadata.actions[action] = { 71 | name: action, 72 | }; 73 | } 74 | 75 | const conditions = state.transitions 76 | .map((transition) => transition.condition) 77 | .filter(Boolean) as Array; 78 | for (const condition of conditions) { 79 | flow.metadata.conditions[condition] = { 80 | name: condition, 81 | }; 82 | } 83 | 84 | const events = state.transitions 85 | .map((transition) => transition.event) 86 | .filter(Boolean) as Array; 87 | for (const event of events) { 88 | flow.metadata.events[event] = { 89 | name: event, 90 | }; 91 | } 92 | } 93 | 94 | return flow; 95 | }; 96 | 97 | export const definitionToFlowState = ( 98 | parent: StateId | undefined, 99 | xstate: StateNodeDefinition 100 | ): Array<[StateId, State]> => { 101 | const stateId = toStateId(xstate.id); 102 | const state: State = { 103 | parent, 104 | name: xstate.key, 105 | type: xstate.type, 106 | initialState: toStateId([xstate.id, xstate.initial].join(".")), 107 | transitions: xstate.transitions 108 | .map((t) => ({ 109 | ...t, 110 | eventType: 111 | typeof t.delay === "number" 112 | ? `After ${t.delay}ms` 113 | : t.delay === "string" 114 | ? `After '${t.delay}'` 115 | : t.eventType.startsWith("done.invoke.") 116 | ? `"${t.eventType.replace(/^done[.]invoke[.]/, "")}" succeeded` 117 | : t.eventType.startsWith("error.platform.") 118 | ? `"${t.eventType.replace(/^error[.]platform[.]/, "")}" failed` 119 | : t.eventType.startsWith("done.state.") 120 | ? `"${t.source.key}" completed` 121 | : t.eventType, 122 | })) 123 | .map((transition) => ({ 124 | actions: transition.actions.map((action) => action.type as ActionId), 125 | assertions: [], 126 | condition: 127 | transition.cond && 128 | ((transition.cond.name ?? transition.cond.type) as ConditionId), 129 | event: (transition.eventType as EventId) || undefined, 130 | target: transition.target?.length 131 | ? toStateId(transition.target[0].id) 132 | : undefined, 133 | })), 134 | entryActions: xstate.entry 135 | .filter( 136 | (action) => 137 | action.type !== "xstate.send" || typeof action.delay === "undefined" 138 | ) 139 | .map((action) => action.type as ActionId) 140 | .concat(xstate.invoke.map((invoke) => invoke.id as ActionId)), 141 | exitActions: xstate.exit 142 | .filter((action) => action.type !== "xstate.cancel") 143 | .map((action) => action.type as ActionId), 144 | assertions: [], 145 | }; 146 | 147 | const children = Object.values(xstate.states).flatMap( 148 | definitionToFlowState.bind(null, stateId) 149 | ); 150 | 151 | return [[stateId, state]].concat(children) as any; 152 | }; 153 | 154 | export type ActionObject = { 155 | type: string; 156 | delay?: number | string; 157 | }; 158 | 159 | export type Guard = { 160 | name?: string; 161 | type: string; 162 | }; 163 | 164 | export type TransitionDefinition = { 165 | source: { key: string }; 166 | target: Array<{ id: string }> | undefined; 167 | actions: Array; 168 | cond?: Guard; 169 | eventType: string; 170 | delay?: number | string; 171 | }; 172 | 173 | export type InvokeDefinition = { 174 | src: string | { type: string }; 175 | id: string; 176 | }; 177 | 178 | export type StateNodeDefinition = { 179 | id: string; 180 | key: string; 181 | type: "atomic" | "compound" | "parallel" | "final" | "history"; 182 | initial: string | number | symbol | undefined; 183 | history: boolean | "shallow" | "deep" | undefined; 184 | states: Record; 185 | transitions: Array; 186 | entry: Array; 187 | exit: Array; 188 | invoke: Array; 189 | description?: string; 190 | tags: string[]; 191 | }; 192 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "react-jsx", 17 | "plugins": [ 18 | { 19 | "name": "typescript-plugin-css-modules" 20 | } 21 | ] 22 | }, 23 | "include": ["src"] 24 | } 25 | --------------------------------------------------------------------------------