├── .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 | , 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 | 19 | 25 | 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 | 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 | , 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 |