├── .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 · [](https://github.com/statebacked/react-statechart/blob/main/LICENSE) [](https://www.npmjs.com/package/@statebacked/react-statechart) [](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 | 
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 | {
188 | if (id === newStateId) {
189 | editable.onUpdateState(stateId, {
190 | ...state,
191 | name: "New State",
192 | });
193 | return;
194 | }
195 |
196 | editable.onUpdateTransitionTarget(
197 | stateId,
198 | id as schema.StateId
199 | );
200 | }}
201 | >
202 | {(item) => {item.name}
}
203 |
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 |
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 | {
95 | if (typeof ref === "function") {
96 | ref(r);
97 | } else if (ref) {
98 | ref.current = r;
99 | }
100 |
101 | clickAwayRef.current = r;
102 | }}
103 | className={`${styles.container} ${containerClassName ?? ""}`}
104 | >
105 |
114 | {children}
115 |
116 |
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 |
--------------------------------------------------------------------------------