├── .gitattributes
├── jsconfig.json
├── public
├── favicon.ico
├── robots.txt
├── manifest.json
└── index.html
├── .prettierrc
├── src
├── helpers
│ ├── findStart.js
│ ├── generateId.js
│ ├── createImage.js
│ ├── labelPosition.js
│ ├── machineToConfig.js
│ ├── configToMachine.js
│ ├── autoLayout.js
│ └── paths.js
├── styles
│ ├── blocks
│ │ ├── _command.scss
│ │ ├── _toasts.scss
│ │ ├── _sidebar.scss
│ │ ├── _controls.scss
│ │ ├── _modal.scss
│ │ ├── _switch.scss
│ │ ├── _canvas.scss
│ │ └── _tooltip.scss
│ ├── _tokens.scss
│ └── index.scss
├── index.js
├── hooks
│ ├── useTheme.js
│ ├── useOrientation.js
│ ├── useAutoOpen.js
│ ├── useFsm.js
│ ├── useOutsideClick.js
│ ├── useStore.js
│ ├── useModalTransition.js
│ └── useCreateImage.js
├── components
│ ├── controls
│ │ ├── ControlItem.js
│ │ └── Controls.js
│ ├── Switch.js
│ ├── canvas
│ │ ├── StateNode.js
│ │ ├── ConnectionLine.js
│ │ ├── TransitionEdge.js
│ │ └── Canvas.js
│ ├── command
│ │ ├── Command.js
│ │ ├── commands.js
│ │ └── CommandPalette.js
│ ├── Toast.js
│ ├── sidebar
│ │ ├── ActionList.js
│ │ └── Sidebar.js
│ ├── Modal.js
│ └── header
│ │ ├── Header.js
│ │ ├── SettingsModal.js
│ │ ├── HelpModal.js
│ │ └── CodeModal.js
└── App.js
├── netlify.toml
├── README.md
├── .gitignore
├── .eslintrc
└── package.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.json merge=union
2 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kpnnkmp/fsm-editor/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "printWidth": 80,
4 | "bracketSpacing": true,
5 | "arrowParens": "always",
6 | "jsxBracketSameLine": true
7 | }
--------------------------------------------------------------------------------
/src/helpers/findStart.js:
--------------------------------------------------------------------------------
1 | export default function findStart(nodes, edges) {
2 | const targets = edges.map((e) => e.target);
3 | return nodes.find((n) => !targets.includes(n.id)) || nodes[0];
4 | }
5 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 |
2 | [build]
3 | command = "yarn build" # the command you run to build this file
4 | publish = "build" # create-react-app builds to this folder, Netlify should serve all these files statically
--------------------------------------------------------------------------------
/src/styles/blocks/_command.scss:
--------------------------------------------------------------------------------
1 | .command-palette {
2 | border-top: 1px solid var(--color-gray-500);
3 | max-height: 350px;
4 | overflow-y: auto;
5 |
6 | span {
7 | padding-top: 2px;
8 | padding-bottom: 2px;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/helpers/generateId.js:
--------------------------------------------------------------------------------
1 | export default function generateId() {
2 | return 'xxyxx'.replace(/[xy]/g, function (c) {
3 | var r = (Math.random() * 16) | 0,
4 | v = c === 'x' ? r : (r & 0x3) | 0x8;
5 | return v.toString(16);
6 | });
7 | }
8 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './styles/index.scss';
4 | import App from 'App';
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById('root')
11 | );
12 |
--------------------------------------------------------------------------------
/src/styles/blocks/_toasts.scss:
--------------------------------------------------------------------------------
1 | #toast {
2 | position: fixed;
3 | top: 2rem;
4 | left: 50%;
5 | transform: translateX(-50%);
6 | z-index: 999;
7 |
8 | .toast {
9 | transition: all 300ms ease-in-out;
10 | }
11 |
12 | .toast[data-state='notvisible'] {
13 | transform: translateY(-20rem) scale(1);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/styles/blocks/_sidebar.scss:
--------------------------------------------------------------------------------
1 | .sidebar {
2 | overflow-y: auto;
3 | z-index: 100;
4 | width: var(--break-1);
5 | transition: var(--transition);
6 | overflow-y: auto;
7 | }
8 |
9 | .sidebar[data-opened='false'] {
10 | width: 0px;
11 | padding: 0px;
12 | }
13 |
14 | .sidebar button[data-type='delete'] {
15 | margin-top: auto;
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/useTheme.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import useAppStore from './useStore';
3 |
4 | export default function useTheme() {
5 | const theme = useAppStore('theme');
6 |
7 | useEffect(() => {
8 | if (localStorage.getItem('theme') !== theme)
9 | localStorage.setItem('theme', theme);
10 | }, [theme]);
11 |
12 | return theme;
13 | }
14 |
--------------------------------------------------------------------------------
/src/hooks/useOrientation.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import useAppStore from './useStore';
3 |
4 | export default function useOrientation() {
5 | const theme = useAppStore('orientation');
6 |
7 | useEffect(() => {
8 | if (localStorage.getItem('orientation') !== theme)
9 | localStorage.setItem('orientation', theme);
10 | }, [theme]);
11 |
12 | return theme;
13 | }
14 |
--------------------------------------------------------------------------------
/src/helpers/createImage.js:
--------------------------------------------------------------------------------
1 | import { toPng } from 'html-to-image';
2 | import download from 'downloadjs';
3 | export default async function createImage(
4 | id,
5 | name = 'my-finite-state-machine'
6 | ) {
7 | var node = document.getElementById(id);
8 | try {
9 | const dataUrl = await toPng(node);
10 | download(dataUrl, `${name}.png`);
11 | } catch (e) {
12 | throw e;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Finite state machine editor",
3 | "name": "Finite state machine editor",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/controls/ControlItem.js:
--------------------------------------------------------------------------------
1 | export default function ControlItem({ onClick, children, ...props }) {
2 | const Tag = onClick ? 'button' : 'div';
3 |
4 | return (
5 |
10 | {children}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/hooks/useAutoOpen.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export default function useAutoOpen(cond) {
4 | const [state, setOpen] = useState(false);
5 |
6 | // Auto show/hide sidebar
7 | useEffect(() => {
8 | if (cond && !state) setOpen(true);
9 | if (!cond && state) setOpen(false);
10 | }, [cond, state]);
11 |
12 | function close() {
13 | setOpen(false);
14 | }
15 |
16 | return [state, close];
17 | }
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FSM Editor
2 |
3 | A simple finite state machine editor. It can be found [here](https://fsm-editor.netlify.app/). It uses:
4 |
5 | - [React Flow](https://reactflow.dev/)
6 | - [FSM](https://github.com/crinklesio/fsm)
7 | - [Feo CSS](https://github.com/crinklesio/feo-css)
8 | - [DIGL - layout engine](https://github.com/crinklesio/digl)
9 |
10 | _This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app)._
11 |
--------------------------------------------------------------------------------
/src/hooks/useFsm.js:
--------------------------------------------------------------------------------
1 | import { fsm } from '@crinkles/fsm';
2 | import { useLayoutEffect, useReducer, useRef } from 'react';
3 |
4 | // Define the hook, with query for computed parameters
5 | export default function useFsm(initial, config) {
6 | const [, rerender] = useReducer((c) => c + 1, 0);
7 | const value = useRef(fsm(initial, config));
8 |
9 | useLayoutEffect(() => {
10 | value.current.listen(rerender);
11 | }, []); //eslint-disable-line
12 |
13 | return value.current;
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .eslintcache
26 | TEST-results.trx
27 | .idea
28 | .vscode/settings.json
29 |
--------------------------------------------------------------------------------
/src/styles/blocks/_controls.scss:
--------------------------------------------------------------------------------
1 | .controls {
2 | position: absolute;
3 | top: 50%;
4 | left: var(--size-1);
5 | transform: translateY(-50%);
6 | z-index: 100;
7 |
8 | > * + * {
9 | border-top: 1px solid var(--color-blue-dark);
10 | }
11 |
12 | & > *:first-child {
13 | border-top-left-radius: var(--size-000);
14 | border-top-right-radius: var(--size-000);
15 | }
16 |
17 | & > *:last-child {
18 | border-bottom-left-radius: var(--size-000);
19 | border-bottom-right-radius: var(--size-000);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/Switch.js:
--------------------------------------------------------------------------------
1 | import { FiCheck, FiX } from 'react-icons/fi';
2 |
3 | export default function Switch({ className = '', checked, onClick, label }) {
4 | return (
5 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/hooks/useOutsideClick.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export default function useOutsideClick(ref, show, onClose) {
4 | useEffect(() => {
5 | const listener = (event) => {
6 | if (!ref.current || ref.current.contains(event.target) || !show) return;
7 | onClose();
8 | };
9 |
10 | document.addEventListener('mousedown', listener);
11 | document.addEventListener('touchstart', listener);
12 |
13 | return () => {
14 | document.removeEventListener('mousedown', listener);
15 | document.removeEventListener('touchstart', listener);
16 | };
17 | }, [ref, onClose, show]);
18 | }
19 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": [
4 | "react-app",
5 | "react-app/jest",
6 | "eslint-config-prettier",
7 | "plugin:jsx-a11y/recommended"
8 | ],
9 | "globals": {
10 | "i18n": false
11 | },
12 | "plugins": ["prettier", "jsx-a11y", "react-hooks", "unused-imports"],
13 | "env": {
14 | "browser": true,
15 | "node": true,
16 | "jest": true
17 | },
18 | "rules": {
19 | "prettier/prettier": "error",
20 | "react-hooks/rules-of-hooks": "error",
21 | "react-hooks/exhaustive-deps": "warn",
22 | "no-unused-vars": "off",
23 | "unused-imports/no-unused-imports": "error"
24 | },
25 | "settings": {
26 | "import/resolver": {
27 | "node": {
28 | "moduleDirectory": ["node_modules", "src/"]
29 | }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/src/hooks/useStore.js:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef, useLayoutEffect } from 'react';
2 | import { proxy } from '@crinkles/pubbel';
3 |
4 | const initTheme = localStorage.getItem('theme') || 'light';
5 | const initOrientation = localStorage.getItem('orientation') || 'vertical';
6 | export const store = proxy(() => ({
7 | theme: initTheme,
8 | orientation: initOrientation,
9 | }));
10 |
11 | export default function useAppStore(key) {
12 | const [, rerender] = useReducer((c) => c + 1, 0);
13 | const value = useRef(store[key]);
14 |
15 | useLayoutEffect(() => {
16 | function updateCachedValue(s) {
17 | value.current = s;
18 | rerender();
19 | }
20 |
21 | store.on(key, updateCachedValue);
22 | return () => store.off(key, updateCachedValue);
23 | }, []); //eslint-disable-line
24 |
25 | return value.current;
26 | }
27 |
--------------------------------------------------------------------------------
/src/styles/blocks/_modal.scss:
--------------------------------------------------------------------------------
1 | .modal-screen {
2 | position: fixed;
3 | top: 0;
4 | left: 0;
5 | z-index: 998;
6 | display: flex;
7 | align-items: flex-start;
8 | justify-content: center;
9 | width: 100%;
10 | height: 100%;
11 | overflow-x: hidden;
12 | overflow-y: auto;
13 | background-color: rgba(#000, 0.25);
14 | outline: 0;
15 | }
16 |
17 | .modal-dialog {
18 | position: relative;
19 | margin-top: calc(var(--size-3) + 1rem);
20 | width: var(--break-3);
21 | max-height: 100%;
22 | background-clip: padding-box;
23 | outline: 0;
24 | pointer-events: auto;
25 | transition: all 300ms ease-in-out;
26 | opacity: 1;
27 | }
28 |
29 | .modal-dialog[data-state='appearing'],
30 | .modal-dialog[data-state='dissapearing'] {
31 | opacity: 0;
32 | transform: translateY(-30rem);
33 | }
34 |
35 | .modal-body {
36 | flex: 1 1 auto;
37 | overflow-y: auto;
38 | }
39 |
--------------------------------------------------------------------------------
/src/helpers/labelPosition.js:
--------------------------------------------------------------------------------
1 | import { getCorner, getMiddle } from './paths';
2 |
3 | const leftRight = ['left', 'right'];
4 | const topBottom = ['bottom', 'top'];
5 |
6 | export default function getLabelPosition(s, t, offset = 14.5) {
7 | let x, y;
8 |
9 | if (s.id === t.id) {
10 | [x, y] = getCorner(s, t);
11 | y -= offset;
12 | } else if (s.pos === t.pos) {
13 | [x, y] = getMiddle(s, t, topBottom.includes(s.pos) ? 60 : 75);
14 | y -= offset;
15 | } else if (
16 | (leftRight.includes(s.pos) && leftRight.includes(t.pos)) ||
17 | (topBottom.includes(s.pos) && topBottom.includes(t.pos))
18 | ) {
19 | x = s.x + (t.x - s.x) / 2;
20 | y = s.y + (t.y - s.y) / 2 - offset;
21 | } else if (topBottom.includes(s.pos)) {
22 | x = s.x;
23 | y = t.y - offset;
24 | } else {
25 | x = t.x;
26 | y = s.y - offset;
27 | }
28 |
29 | return [x, y];
30 | }
31 |
--------------------------------------------------------------------------------
/src/styles/blocks/_switch.scss:
--------------------------------------------------------------------------------
1 | button[role='switch'] {
2 | height: calc(1.4rem + 4px);
3 | padding: 0;
4 | background-color: var(--color-gray-300);
5 | border: 2px solid var(--color-gray-300);
6 | border-radius: 1rem;
7 | transition: all 400ms ease-in-out;
8 | }
9 |
10 | button[role='switch'][aria-checked='true'] {
11 | background-color: var(--color-blue);
12 | border-color: var(--color-blue);
13 | }
14 |
15 | button[role='switch'] span {
16 | display: inline-block;
17 | font-size: 1.4rem;
18 | line-height: 1.4rem;
19 | height: 1.4rem;
20 | min-width: 1.8rem;
21 | color: var(--color-gray-100);
22 | border-radius: 2rem;
23 | pointer-events: none;
24 | transition: all 400ms ease-in-out;
25 | }
26 |
27 | button[role='switch'][aria-checked='true'] > :last-child,
28 | button[role='switch'][aria-checked='false'] > :first-child {
29 | background: var(--color-gray-100);
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/canvas/StateNode.js:
--------------------------------------------------------------------------------
1 | import { Handle } from 'react-flow-renderer';
2 |
3 | export default function StateNode({ data, selected, id, ...props }) {
4 | console.log({ data });
5 | return (
6 |
9 |
{data.label}
10 |
16 |
17 |
23 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/hooks/useModalTransition.js:
--------------------------------------------------------------------------------
1 | import { send } from '@crinkles/fsm';
2 | import useFsm from './useFsm';
3 |
4 | const config = {
5 | appearing: {
6 | FINISHED: 'visible',
7 | _entry: [() => send('FINISHED', null, 50)],
8 | },
9 | visible: {
10 | CLOSE: 'dissapearing',
11 | },
12 | dissapearing: {
13 | FINISHED: 'removed',
14 | _entry: [() => send('FINISHED', null, 500)],
15 | },
16 | removed: {
17 | OPEN: 'appearing',
18 | },
19 | };
20 |
21 | export default function useModalTransition() {
22 | const state = useFsm('removed', config);
23 |
24 | const visible = state.current !== 'removed';
25 |
26 | function close() {
27 | state.send('CLOSE');
28 | }
29 |
30 | function open() {
31 | state.send('OPEN');
32 | }
33 |
34 | function change() {
35 | if (state.current !== 'removed') state.send('CLOSE');
36 | else state.send('OPEN');
37 | }
38 |
39 | return { visible, open, close, state: state.current, change };
40 | }
41 |
--------------------------------------------------------------------------------
/src/styles/blocks/_canvas.scss:
--------------------------------------------------------------------------------
1 | .node {
2 | position: relative;
3 | background-color: var(--color-blue);
4 | border: none;
5 | box-shadow: var(--elevation-2);
6 | text-align: center;
7 | width: 150px;
8 | word-wrap: break-word;
9 |
10 | > .react-flow__handle {
11 | visibility: hidden;
12 | background-color: var(--color-blue);
13 | }
14 | }
15 |
16 | .node[data-selected='true'] {
17 | background-color: var(--color-blue-dark);
18 |
19 | > .react-flow__handle {
20 | background-color: var(--color-blue-dark);
21 | }
22 | }
23 |
24 | .node:hover > .react-flow__handle {
25 | visibility: visible;
26 | }
27 |
28 | .edge {
29 | text-align: center;
30 |
31 | > .label {
32 | line-height: 1.8;
33 | white-space: nowrap;
34 | }
35 |
36 | > .guard {
37 | line-height: 1.2;
38 | }
39 | }
40 |
41 | .edge[data-selected='true'] {
42 | > .label {
43 | background-color: var(--color-blue);
44 | }
45 |
46 | > .guard {
47 | color: var(--color-blue);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/canvas/ConnectionLine.js:
--------------------------------------------------------------------------------
1 | import { getSmoothStepPath } from 'react-flow-renderer';
2 |
3 | const opposites = {
4 | left: 'top',
5 | right: 'left',
6 | bottom: 'top',
7 | top: 'bottom',
8 | };
9 |
10 | export default function ConnectionLine({
11 | sourceX,
12 | sourceY,
13 | sourcePosition,
14 | targetX,
15 | targetY,
16 | connectionLineType,
17 | connectionLineStyle,
18 | }) {
19 | const edgePath = getSmoothStepPath({
20 | sourceX,
21 | sourceY,
22 | sourcePosition,
23 | targetX,
24 | targetY,
25 | targetPosition: opposites[sourcePosition],
26 | });
27 |
28 | return (
29 |
30 |
37 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/command/Command.js:
--------------------------------------------------------------------------------
1 | export default function Command({ execute, command, search }) {
2 | let tokens;
3 | if (!search.length) tokens = [command.hint];
4 | else tokens = command.hint.split(new RegExp(search, 'gi'));
5 |
6 | return (
7 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/styles/_tokens.scss:
--------------------------------------------------------------------------------
1 | $feo-colors: (
2 | 'gray-100': #fafafa,
3 | 'gray-200': #e5eaed,
4 | 'gray-300': #a7b1bc,
5 | 'gray-400': #29333a,
6 | 'gray-500': #0e141e,
7 | 'blue': #5467ff,
8 | 'blue-dark': #2e43eb,
9 | 'red': #ff0056,
10 | 'red-dark': #d00046,
11 | );
12 |
13 | $feo-fluid: false;
14 |
15 | $light-theme: (
16 | 'front': 'gray-500',
17 | 'back': 'gray-100',
18 | 'back-secondary': 'gray-200',
19 | 'transition': 'gray-400',
20 | );
21 |
22 | $dark-theme: (
23 | 'front': 'gray-100',
24 | 'back': 'gray-500',
25 | 'back-secondary': 'gray-400',
26 | 'transition': 'gray-300',
27 | );
28 |
29 | $feo-themes: (
30 | 'dark': $dark-theme,
31 | 'light': $light-theme,
32 | );
33 |
34 | // Size system
35 | $feo-breakpoints: (
36 | '00': 6rem,
37 | '0': 15rem,
38 | '1': 20rem,
39 | '2': 30rem,
40 | '3': 40rem,
41 | '4': 52.5rem,
42 | '5': 75rem,
43 | );
44 |
45 | :root {
46 | // box-shadow
47 | --elevation-1: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
48 | --elevation-2: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
49 | 0 2px 4px -1px rgba(0, 0, 0, 0.06);
50 | --elevation-3: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
51 | 0 4px 6px -2px rgba(0, 0, 0, 0.05);
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Toast.js:
--------------------------------------------------------------------------------
1 | import { send } from '@crinkles/fsm';
2 | import useFsm from 'hooks/useFsm';
3 | import React, { useState } from 'react';
4 | import { createPortal } from 'react-dom';
5 |
6 | const states = {
7 | visible: {
8 | REMOVED: 'notvisible',
9 | CREATED: 'visible',
10 | _entry: [() => send('REMOVED', 6000)],
11 | },
12 | notvisible: {
13 | CREATED: 'visible',
14 | },
15 | };
16 |
17 | export const ToastContext = React.createContext();
18 |
19 | export function ToastProvider({ children }) {
20 | const [label, setLabel] = useState('');
21 | const state = useFsm('notvisible', states);
22 |
23 | function add(v) {
24 | setLabel(v);
25 | state.send('CREATED');
26 | }
27 |
28 | return (
29 |
30 | {children}
31 | {createPortal(
32 |
35 | {label}
36 |
,
37 | document.querySelector('#toast')
38 | )}
39 |
40 | );
41 | }
42 |
43 | export default function useToastManager() {
44 | return React.useContext(ToastContext);
45 | }
46 |
--------------------------------------------------------------------------------
/src/styles/blocks/_tooltip.scss:
--------------------------------------------------------------------------------
1 | [data-tooltip] {
2 | position: relative;
3 | }
4 |
5 | [data-tooltip]:hover::before {
6 | content: attr(data-tooltip);
7 | position: absolute;
8 | line-height: 1.3;
9 |
10 | border-radius: var(--size-0);
11 | padding: var(--size-000);
12 | background-color: var(--color-gray-200);
13 | box-shadow: var(--elevation-3);
14 | color: var(--color-gray-500);
15 |
16 | bottom: 115%;
17 | left: 50%;
18 | width: 200px;
19 | transform: translate(-50%, 0);
20 |
21 | text-align: center;
22 | font-size: var(--size-00);
23 | }
24 |
25 | [data-tooltip]:hover::after {
26 | content: '';
27 | position: absolute;
28 | top: -15%;
29 | left: 50%;
30 | transform: translate(-50%, 0);
31 | border-width: 5px;
32 | border-style: solid;
33 | border-color: var(--color-gray-200) transparent transparent transparent;
34 | }
35 |
36 | [data-tooltip-position='left']:hover::before {
37 | left: calc(100% - 6px);
38 | top: 50%;
39 | bottom: auto;
40 | transform: translate(0, -50%);
41 | }
42 |
43 | [data-tooltip-position='left']:hover::after {
44 | top: 50%;
45 | left: calc(100% - 15px);
46 | transform: translate(0, -50%);
47 | border-color: transparent var(--color-gray-300) transparent transparent;
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/sidebar/ActionList.js:
--------------------------------------------------------------------------------
1 | import { FiPlusCircle, FiTrash2 } from 'react-icons/fi';
2 |
3 | const OPTIONS = ['send', 'assign', 'custom'];
4 |
5 | export default function ActionList({
6 | actions = [],
7 | onChange,
8 | onAdd,
9 | className = '',
10 | onRemove,
11 | title,
12 | }) {
13 | return (
14 |
15 |
18 | {title} *
19 |
20 | {actions.map((a, i) => (
21 |
22 | onChange(i, v.target.value)}
27 | className="px-00 py-000 radius-000 border-gray-400 focus:border-blue border-w-1 no-outline flex-grow"
28 | />
29 |
32 |
33 | ))}
34 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/helpers/machineToConfig.js:
--------------------------------------------------------------------------------
1 | // machine to a config object that has replaceable parts
2 | function machineToConfig(nodes, edges) {
3 | // Initial convertion
4 | const config = {};
5 | nodes.forEach((n) => {
6 | config[n.data.label] = {};
7 | if (n.data?._entry?.length)
8 | config[n.data.label]._entry = n.data._entry.map((a) => `{{${a}}}`);
9 | if (n.data?._exit?.length)
10 | config[n.data.label]._exit = n.data._exit.map((a) => `{{${a}}}`);
11 | });
12 |
13 | edges.forEach((e) => {
14 | const { data: source } = nodes.find((n) => n.id === e.source);
15 | const { data: target } = nodes.find((n) => n.id === e.target);
16 |
17 | if (e.data.guard || e.data?.actions?.length)
18 | config[source.label][e.data.label] = { target: target.label };
19 | else config[source.label][e.data.label] = target.label;
20 |
21 | if (e.data.guard)
22 | config[source.label][e.data.label].guard = `{{${e.data.guard}}}`;
23 | if (e.data?.actions?.length)
24 | config[source.label][e.data.label].actions = e.data.actions.map(
25 | (a) => `{{${a}}}`
26 | );
27 | });
28 |
29 | return config;
30 | }
31 |
32 | // prepare for copy to clipboard
33 | export function stringifyMachine(nodes, edges) {
34 | return JSON.stringify(machineToConfig(nodes, edges), null, 2)
35 | .replaceAll('"{{', '')
36 | .replaceAll('}}"', '');
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/Modal.js:
--------------------------------------------------------------------------------
1 | import useOutsideClick from 'hooks/useOutsideClick';
2 | import useAppStore from 'hooks/useStore';
3 | import React, { useRef } from 'react';
4 | import { createPortal } from 'react-dom';
5 | import { FaRegTimesCircle } from 'react-icons/fa';
6 |
7 | export default function Modal({ children, title, onClose, visible, state }) {
8 | const ref = useRef();
9 | const theme = useAppStore('theme');
10 | useOutsideClick(ref, visible, onClose);
11 |
12 | if (!visible) return null;
13 |
14 | return createPortal(
15 |
22 |
26 | {title && (
27 |
28 | {title}
29 |
35 |
36 | )}
37 |
{children}
38 |
39 |
,
40 | document.querySelector('#modal')
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fsm-editor",
3 | "version": "0.10.1",
4 | "private": true,
5 | "dependencies": {
6 | "@crinkles/digl": "^0.4.0",
7 | "@crinkles/feo": "^2.2.4",
8 | "@crinkles/fsm": "^0.10.3",
9 | "@crinkles/pubbel": "^2.1.0",
10 | "@testing-library/jest-dom": "^5.11.4",
11 | "@testing-library/react": "^11.1.0",
12 | "@testing-library/user-event": "^12.1.10",
13 | "downloadjs": "^1.4.7",
14 | "html-to-image": "^1.6.2",
15 | "react": "^17.0.2",
16 | "react-dom": "^17.0.2",
17 | "react-flow-renderer": "^9.5.4",
18 | "react-icons": "^4.2.0",
19 | "react-scripts": "4.0.3"
20 | },
21 | "devDependencies": {
22 | "babel-eslint": "^10.1.0",
23 | "eslint-config-prettier": "^7.0.0",
24 | "eslint-config-react-app": "^6.0.0",
25 | "eslint-plugin-jsx-a11y": "^6.4.1",
26 | "eslint-plugin-prettier": "^3.2.0",
27 | "eslint-plugin-react-hooks": "^4.2.0",
28 | "eslint-plugin-unused-imports": "^1.1.1",
29 | "postcss-custom-properties": "^10.0.0",
30 | "prettier": "^2.2.1",
31 | "sass": "^1.35.2"
32 | },
33 | "scripts": {
34 | "start": "react-scripts start",
35 | "build": "react-scripts build",
36 | "test": "react-scripts test",
37 | "eject": "react-scripts eject"
38 | },
39 | "browserslist": {
40 | "production": [
41 | ">0.2%",
42 | "not dead",
43 | "not op_mini all"
44 | ],
45 | "development": [
46 | "last 1 chrome version",
47 | "last 1 firefox version",
48 | "last 1 safari version"
49 | ]
50 | },
51 | "resolutions": {
52 | "react-scripts/postcss-preset-env/postcss-custom-properties": "^10.0.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/helpers/configToMachine.js:
--------------------------------------------------------------------------------
1 | import autoLayout from './autoLayout';
2 |
3 | const layoutConfig = { width: 100, height: 60, orientation: 'horizontal' };
4 |
5 | function getStates(config) {
6 | const states = [];
7 |
8 | for (const key in config) {
9 | const state = { id: key, type: 'state', data: { label: key } };
10 | if (config[key]._entry) state.data._entry = config[key]._entry;
11 | if (config[key]._exit) state.data._exit = config[key]._exit;
12 | states.push(state);
13 | }
14 |
15 | return states;
16 | }
17 |
18 | export function getTransitions(config) {
19 | const edges = [];
20 |
21 | for (const source in config) {
22 | const { _entry, _exit, ...transitions } = config[source];
23 |
24 | for (const name in transitions) {
25 | const t = transitions[name];
26 | const target = t?.target || t;
27 |
28 | const edge = {
29 | id: `${source}_${target}_${name}`,
30 | source,
31 | target,
32 | type: 'transition',
33 | data: { label: name },
34 | };
35 |
36 | if (t?.guard) edge.data.guard = t.guard;
37 | if (t?.actions) edge.data.actions = t.actions;
38 | edges.push(edge);
39 | }
40 | }
41 |
42 | return edges;
43 | }
44 |
45 | export function configToMachine(start, orientation, config) {
46 | const string = config
47 | .replaceAll(/\((.*?),*\n/gm, '"($1",\n')
48 | .replaceAll(/,\n\s*]/gm, '\n]')
49 | .replaceAll(/,\n\s*}/gm, '\n}');
50 |
51 | try {
52 | const newConfig = JSON.parse(string);
53 | const nodes = getStates(newConfig);
54 | const edges = getTransitions(newConfig);
55 |
56 | return autoLayout(start, nodes, edges, orientation);
57 | } catch (e) {
58 | console.log(e);
59 | return;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | Finite state machine editor
25 |
26 |
27 |
28 |
29 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/components/header/Header.js:
--------------------------------------------------------------------------------
1 | import HelpModal from './HelpModal';
2 | import CodeModal from './CodeModal';
3 | import SettingsModal from './SettingsModal';
4 | import useCreateImage from 'hooks/useCreateImage';
5 | import { FiImage } from 'react-icons/fi';
6 | import CommandBar from 'components/command/CommandPalette';
7 |
8 | export default function Header() {
9 | const downloadImage = useCreateImage('my-canvas');
10 | return (
11 |
12 |
26 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/header/SettingsModal.js:
--------------------------------------------------------------------------------
1 | import Switch from 'components/Switch';
2 | import { store } from 'hooks/useStore';
3 | import Modal from '../Modal';
4 | import { FiSettings } from 'react-icons/fi';
5 | import useTheme from 'hooks/useTheme';
6 | import useOrientation from 'hooks/useOrientation';
7 | import useModalTransition from 'hooks/useModalTransition';
8 |
9 | export default function SettingsModal() {
10 | const theme = useTheme();
11 | const { visible, close, open, state } = useModalTransition();
12 | const orientation = useOrientation();
13 |
14 | function handleChangeTheme() {
15 | store.theme = theme === 'dark' ? 'light' : 'dark';
16 | }
17 |
18 | function handleChangeOrienttation() {
19 | store.orientation =
20 | orientation === 'horizontal' ? 'vertical' : 'horizontal';
21 | }
22 |
23 | return (
24 | <>
25 |
30 |
31 |
32 |
33 |
34 |
35 | Prefer horizontal orientation{' '}
36 |
37 |
42 |
43 |
44 | Turn on dark mode *
45 |
50 |
51 |
52 | * Dark mode will also add a dark background to the exported images
53 |
54 |
55 |
56 | >
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/canvas/TransitionEdge.js:
--------------------------------------------------------------------------------
1 | import getLabelPosition from 'helpers/labelPosition';
2 | import { createPath } from 'helpers/paths';
3 |
4 | const hPair = ['left', 'right'];
5 | const vPair = ['bottom', 'top'];
6 |
7 | export default function TransitionEdge({
8 | id,
9 | sourceX,
10 | sourceY,
11 | targetX,
12 | targetY,
13 | sourcePosition,
14 | targetPosition,
15 | data,
16 | selected,
17 | source,
18 | target,
19 | }) {
20 | const s = { pos: sourcePosition, x: sourceX, y: sourceY, id: source };
21 | const t = { pos: targetPosition, x: targetX, y: targetY, id: target };
22 |
23 | const edgePath = createPath(s, t);
24 | const [x, y] = getLabelPosition(s, t);
25 |
26 | const color = 'var(--color-transition)';
27 |
28 | return (
29 | <>
30 |
37 |
43 |
44 |
45 | {data.label}
46 |
47 | {data.guard && (
48 |
49 | [{data.guard}]
50 |
51 | )}
52 |
53 |
54 |
55 |
61 |
68 |
69 |
70 | >
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/styles/index.scss:
--------------------------------------------------------------------------------
1 | @import './tokens';
2 | @import '~@crinkles/feo/feo';
3 |
4 | /*******
5 | * Generic styling
6 | *******/
7 | html,
8 | body,
9 | .grail {
10 | min-width: var(--break-1);
11 | color: var(--color-gray-100);
12 | font-family: var(--sans-serif);
13 | background-color: var(--color-gray-500);
14 | line-height: 1.4;
15 | background-color: var(--color-back);
16 | }
17 |
18 | /*******
19 | * Custom styles
20 | *******/
21 | @import './blocks/sidebar';
22 | @import './blocks/canvas';
23 | @import './blocks/toasts';
24 | @import './blocks/tooltip';
25 | @import './blocks/modal';
26 | @import './blocks/switch';
27 | @import './blocks/controls';
28 | @import './blocks/command';
29 |
30 | .no-outline:focus {
31 | outline: none;
32 | }
33 |
34 | .full-width {
35 | width: 100%;
36 | }
37 |
38 | .shadow {
39 | box-shadow: var(--elevation-2);
40 | }
41 |
42 | main {
43 | position: relative;
44 | overflow-y: hidden !important;
45 | }
46 |
47 | header {
48 | z-index: 100;
49 |
50 | > .logo {
51 | margin-right: auto;
52 | }
53 |
54 | > button {
55 | line-height: 1.1;
56 | }
57 | }
58 |
59 | textarea {
60 | font-family: var(--monospace);
61 | }
62 |
63 | footer {
64 | position: absolute;
65 | bottom: var(--size-0);
66 | left: var(--size-0);
67 | z-index: 100;
68 | }
69 |
70 | a,
71 | a:link,
72 | a:visited,
73 | a:active {
74 | color: var(--color-front);
75 | text-decoration-color: var(--color-blue);
76 | text-decoration-thickness: 1px;
77 | transition: var(--transition);
78 | }
79 |
80 | a:hover {
81 | color: var(--color-blue);
82 | }
83 |
84 | /* width */
85 | ::-webkit-scrollbar {
86 | width: 1rem;
87 | }
88 |
89 | /* text-area resizer */
90 | ::-webkit-resizer {
91 | background-color: var(--color-gray-400);
92 | }
93 |
94 | /* Track */
95 | ::-webkit-scrollbar-track {
96 | background-color: var(--color-gray-400);
97 | border-radius: 1rem;
98 | }
99 |
100 | /* Handle */
101 | ::-webkit-scrollbar-thumb {
102 | background: var(--color-gray-300);
103 | border-radius: 1rem;
104 | }
105 |
106 | /* Handle on hover */
107 | ::-webkit-scrollbar-thumb:hover {
108 | background: var(--color-blue);
109 | }
110 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import Canvas from 'components/canvas/Canvas';
2 | import { ToastProvider } from 'components/Toast';
3 | import { createContext, useEffect, useRef, useState } from 'react';
4 | import { ReactFlowProvider } from 'react-flow-renderer';
5 | import packageJson from '../package.json';
6 | import Sidebar from 'components/sidebar/Sidebar';
7 | import Controls from 'components/controls/Controls';
8 | import useAppStore from 'hooks/useStore';
9 | import Header from 'components/header/Header';
10 |
11 | const initial = JSON.parse(localStorage.getItem('elements')) || [];
12 |
13 | export const AppContext = createContext();
14 |
15 | export default function App({ children }) {
16 | const theme = useAppStore('theme');
17 | const [instance, setInstance] = useState(null);
18 | const reactFlowWrapper = useRef(null);
19 | const [elements, setElements] = useState(initial);
20 |
21 | function updateElement(field, id) {
22 | return function (value) {
23 | setElements((es) =>
24 | es.map((e) => {
25 | if (e.id !== id) return e;
26 | e.data[field] = value;
27 |
28 | return e;
29 | })
30 | );
31 | };
32 | }
33 |
34 | useEffect(() => {
35 | if (!instance) return;
36 | localStorage.setItem(
37 | 'elements',
38 | JSON.stringify(instance.toObject().elements)
39 | );
40 | }, [elements, instance]);
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 |
49 |
56 |
57 |
58 |
59 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/hooks/useCreateImage.js:
--------------------------------------------------------------------------------
1 | import useToastManager from 'components/Toast';
2 | import createImage from 'helpers/createImage';
3 | import { useZoomPanHelper } from 'react-flow-renderer';
4 |
5 | async function delay(ms = 0) {
6 | return new Promise((resolve) => {
7 | setTimeout(resolve, ms);
8 | });
9 | }
10 | function getScale(node) {
11 | return parseFloat(
12 | node.style.transform.split(') ')[1].replace('scale(', '').replace(')', '')
13 | );
14 | }
15 |
16 | function getDimensions(nodes) {
17 | let hStart = 0,
18 | hEnd = 0,
19 | wStart = 0,
20 | wEnd = 0;
21 |
22 | for (let i = 0; i < nodes.children.length; i++) {
23 | const y = parseInt(
24 | nodes.children[i].style.transform.split(' ')[1].replace('px)', '')
25 | );
26 | const x = parseInt(
27 | nodes.children[i].style.transform
28 | .split(' ')[0]
29 | .replace(',', '')
30 | .replace('translate(', '')
31 | );
32 |
33 | if (y < hStart) hStart = y;
34 | if (y > hEnd) hEnd = y;
35 | if (x < wStart) wStart = x;
36 | if (x > wEnd) wEnd = x;
37 | }
38 |
39 | let extraWidth = 0;
40 | let extraHeight = 0;
41 |
42 | let width = wEnd - wStart + 150;
43 | let height = hEnd - hStart + 60;
44 |
45 | if (width > height) height += 170;
46 | else width += 220;
47 |
48 | return [width, height];
49 | }
50 |
51 | export default function useCreateImage(id) {
52 | const { fitView } = useZoomPanHelper();
53 | const { add } = useToastManager();
54 |
55 | async function handler() {
56 | add('Preparing... you will see some jumping around');
57 | try {
58 | fitView();
59 | await delay(100);
60 | const nodes = document.getElementsByClassName('react-flow__nodes')[0];
61 | const [width, height] = getDimensions(nodes);
62 | const scale = getScale(nodes);
63 |
64 | const canvas = document.getElementById(id);
65 |
66 | if (width > height) canvas.style.height = `${height + 2 * 75 * scale}px`;
67 | else canvas.style.width = `${width + 2 * 75 * scale}px`;
68 |
69 | await delay(100);
70 |
71 | fitView();
72 | await delay(100);
73 | await createImage(id);
74 | canvas.style.height = `100%`;
75 | canvas.style.width = `100%`;
76 | await delay(100);
77 | fitView();
78 | add('Image created! Select your location to store it.');
79 | } catch (e) {
80 | add('Something went wrong');
81 | }
82 | }
83 |
84 | return handler;
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/controls/Controls.js:
--------------------------------------------------------------------------------
1 | import useToastManager from 'components/Toast';
2 | import {
3 | useStoreActions,
4 | useStoreState,
5 | useZoomPanHelper,
6 | } from 'react-flow-renderer';
7 | import ControlItem from './ControlItem';
8 | import { FiMaximize, FiPlusCircle, FiShare2 } from 'react-icons/fi';
9 | import { AiOutlineClear } from 'react-icons/ai';
10 | import { useContext } from 'react';
11 | import { AppContext } from 'App';
12 | import findStart from 'helpers/findStart';
13 | import { stringifyMachine } from 'helpers/machineToConfig';
14 | import { configToMachine } from 'helpers/configToMachine';
15 | import useAppStore from 'hooks/useStore';
16 |
17 | export default function Controls() {
18 | const { add } = useToastManager();
19 | const { setElements } = useContext(AppContext);
20 | const { fitView } = useZoomPanHelper();
21 | const nodes = useStoreState((store) => store.nodes);
22 | const edges = useStoreState((store) => store.edges);
23 | const setSelected = useStoreActions((actions) => actions.setSelectedElements);
24 | const orientation = useAppStore('orientation');
25 |
26 | function clear() {
27 | setElements([]);
28 | setSelected([]);
29 | add('Canvas cleared!');
30 | }
31 |
32 | function onDragStart(event, nodeType) {
33 | event.dataTransfer.setData('application/reactflow', nodeType);
34 | event.dataTransfer.effectAllowed = 'move';
35 | }
36 |
37 | function layout() {
38 | const start = findStart(nodes, edges)?.data?.label;
39 | if (!start) return;
40 | const config = stringifyMachine(nodes, edges);
41 | const machine = configToMachine(start, orientation, config);
42 |
43 | if (!machine) return;
44 | setSelected([]);
45 | setElements(machine);
46 | add('Your machine magically placed itself somewhere else');
47 |
48 | setTimeout(() => fitView(), 250);
49 | }
50 |
51 | return (
52 |
53 |
onDragStart(event, 'state')}
56 | draggable>
57 |
58 |
59 |
60 |
61 |
62 |
fitView()}>
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/header/HelpModal.js:
--------------------------------------------------------------------------------
1 | import Modal from '../Modal';
2 | import { FiHelpCircle } from 'react-icons/fi';
3 | import useModalTransition from 'hooks/useModalTransition';
4 |
5 | export default function HelpModal() {
6 | const { visible, close, open, state } = useModalTransition();
7 |
8 | return (
9 | <>
10 |
15 |
16 |
17 |
18 | The editor allows minimal features around creating state machines:
19 |
20 |
21 | -
22 | Add states by 'drag-n-drop' the '+' icon in the bottom toolbar on
23 | the canvas
24 |
25 | -
26 | Add transitions by 'click-n-hold' on the handles of a state and move
27 | to another state
28 |
29 | -
30 | Configure automated transitions by setting the 'entry' and 'delay'
31 | settings on a state (dotted transitions)
32 |
33 | -
34 | Add guard conditions to transition (based on a
.ctx{' '}
35 | object)
36 |
37 | - Export the canvas as a .PNG
38 | -
39 | Magically create a layout based on the{' '}
40 | DIGL engine
41 |
42 | -
43 | Set a preferred orientation in the settings for the layout engine
44 |
45 | -
46 | Light and dark-mode (note: dark-mode impacts the background color of
47 | the .PNG export)
48 |
49 | -
50 | Export the finite state machine configuration, useable for{' '}
51 | this fsm library,
52 | or xstate *).{' '}
53 |
54 | -
55 | Import said configuration and the editor will create a machine for
56 | you (with the ability to alter the configuration before importing)
57 |
58 | -
59 | Open/cose the command palette via
ctrl + p
60 |
61 |
62 |
63 |
64 | * The 'entry' and 'delay' settings do not comply with all libraries.
65 |
66 |
67 | >
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/helpers/autoLayout.js:
--------------------------------------------------------------------------------
1 | import { digl } from '@crinkles/digl';
2 |
3 | const layoutConfig = {
4 | width: 165,
5 | height: 75,
6 | orientation: 'horizontal',
7 | shortestPath: true,
8 | addEmptySpots: true,
9 | };
10 |
11 | function findRankPosition(ranks, edge) {
12 | let p1, p2;
13 |
14 | for (let i = 0; i < ranks.length; i++) {
15 | for (let j = 0; j < ranks[i].length; j++) {
16 | if (ranks[i][j] === edge.source) p1 = { x: i, y: j };
17 | if (ranks[i][j] === edge.target) p2 = { x: i, y: j };
18 | }
19 | }
20 |
21 | return [p1, p2];
22 | }
23 |
24 | export default function autoLayout(start, nodes, edges, orientation) {
25 | const layout = digl({ ...layoutConfig, orientation });
26 | const positions = layout.positions(start, nodes, edges);
27 | const ranks = layout.ranks(start, nodes, edges);
28 |
29 | const positionedNodes = nodes.map((n) => {
30 | const pos = positions.find((r) => r.id === n.id);
31 | return { ...n, position: { x: pos.x, y: pos.y } };
32 | });
33 |
34 | const horizontal = orientation === 'horizontal';
35 |
36 | // array of all handled edges to identify duplicates;
37 | const handled = [];
38 |
39 | const positionedEdges = edges.map((e) => {
40 | const newEdge = { ...e };
41 |
42 | const pos = findRankPosition(ranks, e);
43 | // Source === target
44 | if (e.source === e.target) {
45 | newEdge.sourceHandle = horizontal ? 'right' : 'bottom';
46 | newEdge.targetHandle = horizontal ? 'bottom' : 'left';
47 | // rank(source) === rank(target)
48 | } else if (pos[0].x === pos[1].x) {
49 | newEdge[pos[0].y < pos[1].y ? 'sourceHandle' : 'targetHandle'] =
50 | horizontal ? 'bottom' : 'right';
51 | newEdge[pos[0].y < pos[1].y ? 'targetHandle' : 'sourceHandle'] =
52 | horizontal ? 'top' : 'left';
53 | // Back to prev. rank
54 | } else if (pos[0].x > pos[1].x) {
55 | newEdge.sourceHandle = horizontal ? 'bottom' : 'left';
56 | newEdge.targetHandle = horizontal ? 'bottom' : 'left';
57 | // Non-first edge between source & target
58 | } else if (handled.includes(`${e.source}-${e.target}`)) {
59 | newEdge.sourceHandle = horizontal ? 'top' : 'right';
60 | newEdge.targetHandle = horizontal ? 'top' : 'right';
61 | } else {
62 | newEdge.sourceHandle = horizontal ? 'right' : 'bottom';
63 | newEdge.targetHandle = horizontal ? 'left' : 'top';
64 | }
65 |
66 | const source = nodes.find((n) => n.id === e.source);
67 | if (source.data.entry === e.data.label) newEdge.animated = true;
68 |
69 | handled.push(`${e.source}-${e.target}`);
70 | return newEdge;
71 | });
72 |
73 | return [...positionedNodes, ...positionedEdges];
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/canvas/Canvas.js:
--------------------------------------------------------------------------------
1 | import ReactFlow, {
2 | addEdge,
3 | updateEdge,
4 | useStoreActions,
5 | useZoomPanHelper,
6 | } from 'react-flow-renderer';
7 |
8 | import ConnectionLine from './ConnectionLine';
9 | import StateNode from './StateNode';
10 | import TransitionEdge from './TransitionEdge';
11 | import gId from '../../helpers/generateId';
12 |
13 | export default function Canvas({
14 | wrapper,
15 | elements,
16 | setElements,
17 | onLoad,
18 | instance,
19 | }) {
20 | const { fitView } = useZoomPanHelper();
21 | const setSelected = useStoreActions((actions) => actions.setSelectedElements);
22 |
23 | const onConnect = (p) => {
24 | // disallow connecting to the self handle
25 | if (p.source === p.target && p.sourceHandle === p.targetHandle) return;
26 |
27 | // select on add
28 | const element = setElements((els) => {
29 | const newEl = { ...p, type: 'transition', data: { label: 'event' } };
30 | const newEls = addEdge(newEl, els);
31 |
32 | setSelected([newEls[newEls.length - 1]]);
33 | return newEls;
34 | });
35 | };
36 |
37 | const onEdgeUpdate = (old, con) => {
38 | setElements((els) => {
39 | // Select on update
40 | const newEls = updateEdge(old, con, els);
41 | const el = newEls.find(
42 | (e) => e.source === con.source && e.target === con.target
43 | );
44 | setSelected([el]);
45 | return newEls;
46 | });
47 | };
48 |
49 | const onDrop = (event) => {
50 | event.preventDefault();
51 |
52 | const reactFlowBounds = wrapper.current.getBoundingClientRect();
53 | const type = event.dataTransfer.getData('application/reactflow');
54 | const position = instance.project({
55 | x: event.clientX - reactFlowBounds.left,
56 | y: event.clientY - reactFlowBounds.top,
57 | });
58 | const newNode = { id: gId(), type, position, data: { label: `state` } };
59 | setElements((es) => es.concat(newNode));
60 | setSelected([newNode]);
61 | };
62 |
63 | const onDragOver = (event) => {
64 | event.preventDefault();
65 | event.dataTransfer.dropEffect = 'move';
66 | };
67 |
68 | function handleLoad(instance) {
69 | onLoad(instance);
70 | fitView({ padding: 1.2 });
71 | }
72 |
73 | return (
74 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/command/commands.js:
--------------------------------------------------------------------------------
1 | import { configToMachine } from 'helpers/configToMachine';
2 | import createImage from 'helpers/createImage';
3 | import findStart from 'helpers/findStart';
4 | import gId from 'helpers/generateId';
5 | import { stringifyMachine } from 'helpers/machineToConfig';
6 | import { store } from 'hooks/useStore';
7 |
8 | const commands = [
9 | {
10 | key: 'add',
11 | group: 'machine',
12 | hint: 'Add a state on the [0, 0] position',
13 | execute: (_n, _e, set, instance) => {
14 | const position = { x: 0, y: 0 };
15 | const type = 'state';
16 | const node = { id: gId(), type, position, data: { label: 'state' } };
17 | set((es) => es.concat(node));
18 | setTimeout(() => instance.fitView(), 100);
19 | },
20 | },
21 | {
22 | key: 'clear',
23 | group: 'canvas',
24 | hint: 'Clear the canvas by removing everything',
25 | execute: (_n, _e, set) => set([]),
26 | },
27 | {
28 | key: 'zoom',
29 | group: 'canvas',
30 | hint: 'Zoom and center the machine',
31 | execute: (_n, _e, _s, instance) => instance.fitView(),
32 | },
33 | {
34 | key: 'layout',
35 | group: 'canvas',
36 | hint: 'Magically layout the machine',
37 | execute: (n, e, set, instance) => {
38 | const start = findStart(n, e)?.data?.label;
39 | if (!start) return;
40 | const config = stringifyMachine(n, e);
41 | const machine = configToMachine(start, store.orientation, config);
42 | if (!machine) return;
43 | set(machine);
44 | setTimeout(() => instance.fitView(), 100);
45 | },
46 | },
47 | {
48 | key: 'clipboard',
49 | group: 'export',
50 | hint: 'Copy configuration to clipboard',
51 | execute: async (n, e) => {
52 | const config = stringifyMachine(n, e);
53 | await navigator.clipboard.writeText(config);
54 | },
55 | },
56 | {
57 | key: 'png',
58 | group: 'export',
59 | hint: 'Export canvas to a .png image',
60 | execute: () => createImage('my-canvas'),
61 | },
62 | {
63 | key: 'dark theme',
64 | group: 'settings',
65 | hint: 'Enable the dark theme',
66 | execute: (v) => store.update('theme', () => 'dark'),
67 | },
68 | {
69 | key: 'light theme',
70 | group: 'settings',
71 | hint: 'Enable the light theme',
72 | execute: (v) => store.update('theme', () => 'light'),
73 | },
74 | {
75 | key: 'vertical',
76 | group: 'settings',
77 | hint: 'Set preferred layout orientation to vertical',
78 | execute: (v) => store.update('orientation', () => 'vertical'),
79 | },
80 | {
81 | key: 'horizontal',
82 | group: 'settings',
83 | hint: 'Set preferred layout orientation to horizontal',
84 | execute: (v) => store.update('orientation', () => 'horizontal'),
85 | },
86 | ];
87 |
88 | export default commands;
89 |
--------------------------------------------------------------------------------
/src/components/command/CommandPalette.js:
--------------------------------------------------------------------------------
1 | import { AppContext } from 'App';
2 | import useToastManager from 'components/Toast';
3 | import useModalTransition from 'hooks/useModalTransition';
4 | import useOutsideClick from 'hooks/useOutsideClick';
5 | import { Fragment, useContext, useEffect, useRef, useState } from 'react';
6 | import { createPortal } from 'react-dom';
7 | import { useStoreState } from 'react-flow-renderer';
8 | import { FiTerminal } from 'react-icons/fi';
9 | import Command from './Command';
10 | import commands from './commands';
11 |
12 | export default function CommandPalette() {
13 | const ref = useRef();
14 | const innerRef = useRef();
15 | const { visible, close, open, state, change } = useModalTransition();
16 | const [command, setCommand] = useState('');
17 | const { add } = useToastManager();
18 | const { setElements, instance } = useContext(AppContext);
19 | const nodes = useStoreState((store) => store.nodes);
20 | const edges = useStoreState((store) => store.edges);
21 |
22 | // Add global listener to open the command paletttet
23 | useEffect(() => {
24 | const list = window.addEventListener('keydown', (e) => {
25 | if (e.ctrlKey && e.key === 'p') change();
26 | });
27 | }, []); //eslint-disable-line
28 |
29 | // On open, set focus on the input field
30 | useEffect(() => {
31 | if (visible) innerRef.current.focus();
32 | }, [visible]);
33 |
34 | // Ability to close the window with an outside click
35 | useOutsideClick(ref, visible, close);
36 |
37 | // Filter all commands based on typed command
38 | const filtered = commands.filter((c) =>
39 | c.hint
40 | .toLocaleLowerCase()
41 | .includes(command.split(' ')[0].toLocaleLowerCase())
42 | );
43 |
44 | function handleClose() {
45 | close();
46 | setCommand('');
47 | }
48 |
49 | async function executeOnEnter(e) {
50 | if (!command || command === '') return;
51 | if (e.keyCode === 13) executeCommand(filtered[0]);
52 | }
53 |
54 | async function executeCommand(c) {
55 | if (!c) return;
56 | await c.execute(nodes, edges, setElements, instance);
57 | add('Command executed!');
58 | close();
59 | }
60 |
61 | return (
62 | <>
63 |
68 | {visible &&
69 | createPortal(
70 |
76 |
80 |
81 |
setCommand(e.target.value)}
88 | />
89 |
90 | {filtered.map((v) => (
91 |
97 | ))}
98 |
99 |
100 |
101 |
,
102 | document.querySelector('#modal')
103 | )}
104 | >
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/helpers/paths.js:
--------------------------------------------------------------------------------
1 | const { getSmoothStepPath } = require('react-flow-renderer');
2 |
3 | const leftRight = ['left', 'right'];
4 | const topBottom = ['bottom', 'top'];
5 |
6 | const lt = (x, y, size = 5) => `L ${x},${y + size}Q ${x},${y} ${x + size},${y}`;
7 | const rt = (x, y, size = 5) => `L ${x},${y + size}Q ${x},${y} ${x - size},${y}`;
8 | const lb = (x, y, size = 5) => `L ${x},${y - size}Q ${x},${y} ${x + size},${y}`;
9 | const rb = (x, y, size = 5) => `L ${x},${y - size}Q ${x},${y} ${x - size},${y}`;
10 | const tl = (x, y, size = 5) => `L ${x + size},${y}Q ${x},${y} ${x},${y + size}`;
11 | const tr = (x, y, size = 5) => `L ${x - size},${y}Q ${x},${y} ${x},${y + size}`;
12 | const bl = (x, y, size = 5) => `L ${x + size},${y}Q ${x},${y} ${x},${y - size}`;
13 | const br = (x, y, size = 5) => `L ${x - size},${y}Q ${x},${y} ${x},${y - size}`;
14 |
15 | export function getCorner(s, t, offset = 30) {
16 | let x, y;
17 | if (topBottom.includes(s.pos)) y = s.y <= t.y ? s.y - offset : s.y + offset;
18 | else y = s.y <= t.y ? t.y + offset : t.y - offset;
19 |
20 | if (leftRight.includes(s.pos)) x = s.x <= t.x ? s.x - offset : s.x + offset;
21 | else x = s.x <= t.x ? t.x + offset : t.x - offset;
22 |
23 | return [x, y];
24 | }
25 |
26 | function getLoopPath(s, t, offset = 30) {
27 | let p1, p2, p3;
28 | const [x, y] = getCorner(s, t, offset);
29 |
30 | if (s.pos === 'top') {
31 | p1 = t.pos === 'right' ? lt(s.x, y) : rt(s.x, y);
32 | p2 = t.pos === 'right' ? tr(x, y) : tl(x, y);
33 | p3 = t.pos === 'right' ? rb(x, t.y) : lb(x, t.y);
34 | } else if (s.pos === 'bottom') {
35 | p1 = t.pos === 'right' ? lb(s.x, y) : rb(s.x, y);
36 | p2 = t.pos === 'right' ? br(x, y) : bl(x, y);
37 | p3 = t.pos === 'right' ? rt(x, t.y) : lt(x, t.y);
38 | } else if (s.pos === 'left') {
39 | p1 = t.pos === 'top' ? bl(x, s.y) : tl(x, s.y);
40 | p2 = t.pos === 'top' ? lt(x, y) : lb(x, y);
41 | p3 = t.pos === 'top' ? tr(t.x, y) : br(t.x, y);
42 | } else if (s.pos === 'right') {
43 | p1 = t.pos === 'top' ? br(x, s.y) : tr(x, s.y);
44 | p2 = t.pos === 'top' ? rt(x, y) : rb(x, y);
45 | p3 = t.pos === 'top' ? tl(t.x, y) : bl(t.x, y);
46 | }
47 |
48 | return `M ${s.x},${s.y} ${p1} ${p2} ${p3} L ${t.x},${t.y}`;
49 | }
50 |
51 | export function getMiddle(s, t, offset = 75) {
52 | let x, y;
53 |
54 | if (topBottom.includes(s.pos)) {
55 | x = (s.x + t.x) / 2;
56 | y =
57 | s.pos === 'top'
58 | ? Math.min(s.y, t.y) - offset
59 | : Math.max(s.y, s.y) + offset;
60 | } else {
61 | x =
62 | s.pos === 'left'
63 | ? Math.min(s.x, t.x) - offset
64 | : Math.max(s.x, t.x) + offset;
65 | y = (s.y + t.y) / 2;
66 | }
67 |
68 | return [x, y];
69 | }
70 |
71 | function getUPath(s, t, offset = 75) {
72 | let p1, p2;
73 |
74 | const [x, y] = getMiddle(s, t, offset);
75 |
76 | if (s.pos === 'top') {
77 | p1 = s.x < t.x ? lt(s.x, y) : rt(s.x, y);
78 | p2 = s.x < t.x ? tr(t.x, y) : tl(t.x, y);
79 | } else if (s.pos === 'bottom') {
80 | p1 = s.x < t.x ? lb(s.x, y) : rb(s.x, y);
81 | p2 = s.x < t.x ? br(t.x, y) : bl(t.x, y);
82 | } else if (s.pos === 'left') {
83 | p1 = s.y < t.y ? tl(x, s.y) : bl(x, s.y);
84 | p2 = s.y < t.y ? lb(x, t.y) : lt(x, t.y);
85 | } else if (s.pos === 'right') {
86 | p1 = s.y < t.y ? tr(x, s.y) : br(x, s.y);
87 | p2 = s.y < t.y ? rb(x, t.y) : rt(x, t.y);
88 | }
89 |
90 | return `M ${s.x},${s.y} ${p1} ${p2} L ${t.x},${t.y}`;
91 | }
92 |
93 | export function createPath(s, t) {
94 | if (s.id === t.id) return getLoopPath(s, t);
95 | if (s.pos === t.pos)
96 | return getUPath(s, t, topBottom.includes(s.pos) ? 60 : 75);
97 |
98 | return getSmoothStepPath({
99 | sourceX: s.x,
100 | sourceY: s.y,
101 | sourcePosition: s.pos,
102 | targetX: t.x,
103 | targetY: t.y,
104 | targetPosition: t.pos,
105 | });
106 | }
107 |
--------------------------------------------------------------------------------
/src/components/header/CodeModal.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useContext, useEffect, useState } from 'react';
2 | import Modal from '../Modal';
3 | import useToastManager from '../Toast';
4 | import useAppStore from 'hooks/useStore';
5 | import { configToMachine } from 'helpers/configToMachine';
6 | import { AppContext } from 'App';
7 | import { useStoreState, useZoomPanHelper } from 'react-flow-renderer';
8 | import { BiCodeCurly } from 'react-icons/bi';
9 | import { FiClipboard, FiDownload } from 'react-icons/fi';
10 | import { stringifyMachine } from 'helpers/machineToConfig';
11 | import findStart from 'helpers/findStart';
12 | import useModalTransition from 'hooks/useModalTransition';
13 |
14 | export default function ImportModal() {
15 | const { visible, close, open, state } = useModalTransition();
16 | const { setElements } = useContext(AppContext);
17 | const orientation = useAppStore('orientation');
18 | const theme = useAppStore('theme');
19 |
20 | const { add } = useToastManager();
21 | const { fitView } = useZoomPanHelper();
22 | const nodes = useStoreState((store) => store.nodes);
23 | const edges = useStoreState((store) => store.edges);
24 | const [config, setConfig] = useState('');
25 | const [start, setStart] = useState('');
26 |
27 | const resetModal = useCallback(() => {
28 | if (nodes.length && edges.length) {
29 | setConfig(stringifyMachine(nodes, edges));
30 | setStart(findStart(nodes, edges)?.data?.label || '');
31 | }
32 | }, [nodes, edges]);
33 |
34 | useEffect(() => {
35 | if (visible) resetModal();
36 | }, [visible, resetModal]);
37 |
38 | async function handleCopy() {
39 | await navigator.clipboard.writeText(config);
40 | add('Configuration copied to your clipboard!');
41 | }
42 |
43 | function handleSubmit() {
44 | const machine = configToMachine(start, orientation, config);
45 |
46 | if (!machine) {
47 | add('Not a valid configuration');
48 | return;
49 | }
50 |
51 | setElements(machine);
52 | close();
53 | add('Configuration imported!');
54 | setTimeout(() => fitView(), 250);
55 | }
56 |
57 | const codeColor = theme === 'dark' ? 'bg-gray-500' : 'bg-gray-400';
58 |
59 | return (
60 | <>
61 |
66 |
71 |
74 | setStart(e.target.value)}
79 | className="px-00 py-000 radius-000 border-gray-400 focus:border-blue border-w-2 no-outline full-width mb-0"
80 | />
81 |
82 | You can edit the below content!
83 |
106 | >
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/components/sidebar/Sidebar.js:
--------------------------------------------------------------------------------
1 | import {
2 | removeElements,
3 | useStoreActions,
4 | useStoreState,
5 | } from 'react-flow-renderer';
6 | import { useContext, useEffect, useRef } from 'react';
7 | import { FiTrash2 } from 'react-icons/fi';
8 | import { AppContext } from 'App';
9 | import useAutoOpen from 'hooks/useAutoOpen';
10 | import ActionList from './ActionList';
11 |
12 | export default function Sidebar() {
13 | const { updateElement, setElements } = useContext(AppContext);
14 | const setSelected = useStoreActions((actions) => actions.setSelectedElements);
15 | // State management
16 | const inputRef = useRef();
17 | const selected = useStoreState((store) => {
18 | const el = store.selectedElements?.[0];
19 | if (el?.source) return store.edges.find((e) => e.id === el.id);
20 | return el;
21 | });
22 | const edges = useStoreState((store) => {
23 | const el = store.selectedElements?.[0];
24 | return store.edges.filter((e) => e.source === el?.id);
25 | });
26 | const [state, close] = useAutoOpen(selected);
27 |
28 | // auto focus
29 | useEffect(() => {
30 | if (selected?.id) inputRef.current.focus();
31 | }, [selected?.id]);
32 |
33 | // Conditionals
34 | const isEdge = selected?.source ? true : false;
35 |
36 | // handlers
37 | function handleDelete() {
38 | setElements((els) => removeElements([selected], els));
39 | setSelected([]);
40 | close();
41 | }
42 |
43 | // Add action to list
44 | function addAction(type) {
45 | updateElement(type, selected?.id)([...(selected?.data?.[type] || []), '']);
46 | }
47 |
48 | // Remove action from a list based on index
49 | function removeAction(index, type) {
50 | const _actions = selected?.data?.[type] || [];
51 | _actions.splice(index, 1);
52 | updateElement(type, selected?.id)(_actions);
53 | }
54 |
55 | // update action based on index & input
56 | function updateAction(index, value, type) {
57 | const _actions = selected?.data?.[type] || [];
58 | _actions[index] = value;
59 | updateElement(type, selected?.id)(_actions);
60 | }
61 |
62 | return (
63 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------