Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f27f5ff61 | ||
|
|
d1c289ec68 | ||
|
|
0232756c2a |
@@ -1,24 +1,23 @@
|
|||||||
# Logs
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
logs
|
|
||||||
*.log
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||||
|
|
||||||
|
The page will reload when you make changes.\
|
||||||
|
You may also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `npm test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `npm run eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||||
|
|
||||||
|
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||||
|
|
||||||
|
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
|
|
||||||
|
### Code Splitting
|
||||||
|
|
||||||
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||||
|
|
||||||
|
### Analyzing the Bundle Size
|
||||||
|
|
||||||
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||||
|
|
||||||
|
### Making a Progressive Web App
|
||||||
|
|
||||||
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||||
|
|
||||||
|
### Advanced Configuration
|
||||||
|
|
||||||
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||||
|
|
||||||
|
### `npm run build` fails to minify
|
||||||
|
|
||||||
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||||
|
"# fbd-editor"
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{js,jsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.browser,
|
|
||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>fbd-editor-2</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,29 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "fbd-editor-2",
|
"name": "fbd-editor",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.10.2",
|
"@use-gesture/react": "^10.3.1",
|
||||||
"html-to-image": "^1.11.13",
|
"cra-template": "1.3.0",
|
||||||
"react": "^19.2.6",
|
"d3": "^7.9.0",
|
||||||
"react-dom": "^19.2.6"
|
"react": "^19.2.1",
|
||||||
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
|
"react-dom": "^19.2.1",
|
||||||
|
"react-draggable": "^4.5.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"web-vitals": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"scripts": {
|
||||||
"@eslint/js": "^10.0.1",
|
"start": "react-scripts start",
|
||||||
"@types/react": "^19.2.14",
|
"build": "react-scripts build",
|
||||||
"@types/react-dom": "^19.2.3",
|
"test": "react-scripts test",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"eject": "react-scripts eject"
|
||||||
"eslint": "^10.3.0",
|
},
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslintConfig": {
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"extends": [
|
||||||
"globals": "^17.6.0",
|
"react-app",
|
||||||
"vite": "^8.0.12"
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -1,82 +1,58 @@
|
|||||||
html, body, #root {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #edeef0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App {
|
.App {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
background-color: #edeef0;
|
background-color: #edeef0;
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0px 0px 0px 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
height: calc(100vh - 40px); /* - высота панели управления */
|
|
||||||
min-height: calc(100vh - 65px);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-canvas-container {
|
|
||||||
padding-top: 5px;
|
|
||||||
padding-right: 5px;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-container {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
background-color: #fff;
|
|
||||||
border: 2px solid #e3e8e6;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlPanel {
|
.controlPanel {
|
||||||
width: 100%;
|
width: calc(100% - 20px);
|
||||||
height: 30px;
|
height: 10px;
|
||||||
background-color: white;
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
width: 218px;
|
float: left;
|
||||||
|
width: 204px;
|
||||||
margin: 4.5px 4px 0px 4px;
|
margin: 4.5px 4px 0px 4px;
|
||||||
height: 100%;
|
min-height: calc(100vh - 55px);
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
border: 2px solid #e3e8e6;
|
border: 2px solid #e3e8e6;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
box-sizing: border-box;
|
position: sticky;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarElements {
|
.toolbarElements {
|
||||||
width: 100%;
|
width: 204px;
|
||||||
height: calc(100% - 40px);
|
min-height: calc(100vh - 93px);
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
position: relative;
|
}
|
||||||
|
|
||||||
|
.toolbarBlocksNewSpiszeno {
|
||||||
|
width: 70px;
|
||||||
|
height: 94px;
|
||||||
|
border: 2px solid #ccc;
|
||||||
|
backgroundColor: lightgray;
|
||||||
|
margin: 5px 0;
|
||||||
|
display: flex;
|
||||||
|
alignItems: center;
|
||||||
|
justifyContent: center;
|
||||||
|
flexDirection: column;
|
||||||
|
cursor: grab;
|
||||||
|
fontSize: 12px;
|
||||||
|
fontWeight: bold;
|
||||||
|
borderRadius: 4px;
|
||||||
|
boxShadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabButtons {
|
.tabButtons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap; /* перенос строк */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabButtons [data-row='1'] { width: 51px; }
|
.tabButtons [data-row='1'] { width: 51px; }
|
||||||
.tabButtons [data-row="2"] { width: 68px; border-bottom: 1px solid #ccc; }
|
.tabButtons [data-row="2"] { width: 68px; border-bottom: 1px solid #ccc; }
|
||||||
|
|
||||||
@@ -87,7 +63,8 @@ html, body, #root {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis; /* троеточие */
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabButtons button.activeTab {
|
.tabButtons button.activeTab {
|
||||||
@@ -96,201 +73,203 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbarBlocks {
|
.toolbarBlocks {
|
||||||
width: 100%;
|
width: calc(100% - 21px);
|
||||||
max-height: calc(100% - 58px);
|
height: calc(100% - 58px);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
box-sizing: border-box;
|
position: sticky;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
left: 224px;
|
||||||
|
top: 35px;
|
||||||
|
border: 2px solid #e3e8e6;
|
||||||
|
width: calc(100% - 233px);
|
||||||
|
height: calc(100% - 45px);
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Убедитесь, что блоки стоят выше сетки по событиям */
|
||||||
|
.block-container {
|
||||||
|
pointer-events: all !important;
|
||||||
|
cursor: grab !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сетка НЕ должна перехватывать мышь */
|
||||||
|
.grid-line, #background-layer {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Весь SVG должен пропускать клики до фона, если нет попадания в блок */
|
||||||
|
.canvas svg {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Но элементы внутри (блоки и соединения) — должны ловить */
|
||||||
|
.canvas svg > g {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ✅ Фикс pointer-events */
|
||||||
|
.canvas {
|
||||||
|
position: fixed !important;
|
||||||
|
/*pointer-events: none !important; /* Контейнер НЕ перехватывает события */
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg {
|
||||||
|
pointer-events: all !important; /* SVG получает ВСЕ события */
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg * {
|
||||||
|
pointer-events: auto; /* Все элементы внутри SVG */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Блоки */
|
||||||
|
.block-container {
|
||||||
|
cursor: grab !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-container:active {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сетка и фон - НЕ перехватывают drag */
|
||||||
|
.grid-line {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Фон canvas */
|
||||||
|
#background-layer {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg g.block-container {
|
||||||
|
cursor: grab !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg g.block-container:hover {
|
||||||
|
filter: drop-shadow(0 0 3px rgba(0,0,255,0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.canvas svg g.block-container,
|
||||||
|
.canvas svg .block-container {
|
||||||
|
pointer-events: all !important;
|
||||||
|
cursor: grab !important;
|
||||||
|
z-index: 1000 !important; /* Блоки поверх всего */
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg line,
|
||||||
|
.canvas svg #background-layer * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* SVG блоки ловят события! */
|
||||||
|
.canvas svg g.block-container {
|
||||||
|
pointer-events: all !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg g.block-container * {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Фон/линии НЕ ловят */
|
||||||
|
.canvas svg line,
|
||||||
|
.canvas svg .grid-line {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*ДЛя блоков:
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '70px',
|
||||||
|
height: '94px',
|
||||||
|
border: '2px solid #ccc',
|
||||||
|
backgroundColor: 'lightgray',
|
||||||
|
margin: '5px 0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
cursor: 'grab',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
...getBlockStyle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '5px' }}>{label}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<div style={{ width: '8px', height: '8px', backgroundColor: '#1A1ADB', borderRadius: '50%' }}></div>
|
||||||
|
<div style={{ width: '8px', height: '8px', backgroundColor: '#E00010', borderRadius: '50%' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>*/
|
||||||
|
|
||||||
|
|
||||||
|
.canvas svg {
|
||||||
|
/* Чтобы клики по пустому месту пролетали сквозь SVG к div (для пана),
|
||||||
|
но ловились блоками */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg g.block-container {
|
||||||
|
pointer-events: all !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas svg {
|
||||||
|
pointer-events: all !important; /* SVG должен быть интерактивным */
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-container {
|
||||||
|
pointer-events: all !important;
|
||||||
|
cursor: move !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Линии сетки не должны мешать */
|
||||||
|
.grid-line {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Отключаем выделение текста, которое мешает драгу */
|
||||||
|
.canvas {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Убеждаемся, что слои под блоками не мешают */
|
||||||
|
#background-layer, #connection-layer {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-container:hover {
|
||||||
|
outline: 2px solid red !important;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
from { r: 4; stroke: rgba(255, 215, 0, 0.4); stroke-width: 0; }
|
from { r: 4; stroke: rgba(255, 215, 0, 0.4); stroke-width: 0; }
|
||||||
to { r: 7; stroke: rgba(255, 215, 0, 1); stroke-width: 3; }
|
to { r: 7; stroke: rgba(255, 215, 0, 1); stroke-width: 3; }
|
||||||
}
|
|
||||||
|
|
||||||
.custom-fbd-block {
|
|
||||||
background-color: whitesmoke;
|
|
||||||
border: 1.5px solid black;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
user-select: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-fbd-block .block-title {
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: right;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-right: 7px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-fbd-block .block-inputs-title {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 20px;
|
|
||||||
font-size: 10px;
|
|
||||||
text-align: left;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-fbd-block .block-outputs-title {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 17px;
|
|
||||||
font-size: 10px;
|
|
||||||
text-align: right;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.toolbarLines svg {
|
|
||||||
background-color: white;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
border: 0px solid #e3e8e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarLines svg.activeLine {
|
|
||||||
background-color: #e3e8e6;
|
|
||||||
border: 0px solid #e3e8e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.toolbarLines {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarLines button {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
border: 1px solid #e3e8e6 !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
cursor: pointer;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarLines button.notActiveEdgeTab {
|
|
||||||
background-color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarLines button.activeEdgeTab {
|
|
||||||
background-color: #e3e8e6 !important;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для мини-превью блоков внутри тулбара */
|
|
||||||
.toolbar-block {
|
|
||||||
background-color: whitesmoke;
|
|
||||||
border: 1.4px solid black;
|
|
||||||
box-sizing: border-box;
|
|
||||||
justify-content: flex-start;
|
|
||||||
position: relative;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
user-select: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: move;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-block .preview-title {
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: right;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-right: 7px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-block .preview-inputs-title {
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 10px;
|
|
||||||
text-align: left;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-block .preview-outputs-title {
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 10px;
|
|
||||||
text-align: right;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-port {
|
|
||||||
position: absolute;
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarLines button svg {
|
|
||||||
background: transparent !important;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.control-button {
|
|
||||||
font-size: 13px;
|
|
||||||
border: none;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-export-button {
|
|
||||||
background-color: #1717D8;
|
|
||||||
/*background-color: #E00010;*/
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.svg-export-button:hover {
|
|
||||||
background-color: #1111a8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-button {
|
|
||||||
background-color: #D8000A;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-button:hover {
|
|
||||||
background-color: #a80007;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalDataBack {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalDataMain {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 320px;
|
|
||||||
text-align: left;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import ControlPanel from './components/ControlPanel';
|
||||||
|
import Toolbar from './components/Toolbar';
|
||||||
|
import Canvas from './components/Canvas';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [blocks, setBlocks] = useState([]);
|
||||||
|
const [connections, setConnections] = useState([]);
|
||||||
|
const [activePort, setActivePort] = useState(null);
|
||||||
|
|
||||||
|
const addBlock = useCallback((block, id = Date.now(), x, y) => {
|
||||||
|
setBlocks(currentBlocks => {
|
||||||
|
if (currentBlocks.some(block => block.id === id))
|
||||||
|
return currentBlocks;
|
||||||
|
const newBlock = {...block, id, x, y};
|
||||||
|
return [...currentBlocks, newBlock];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveBlock = useCallback((id, x, y) => {
|
||||||
|
setBlocks(currentBlocks => {
|
||||||
|
const currentBlock = currentBlocks.find(block => block.id === id);
|
||||||
|
if (currentBlock && currentBlock.x === x && currentBlock.y === y)
|
||||||
|
return currentBlocks;
|
||||||
|
return currentBlocks.map(block =>
|
||||||
|
block.id === id ? { ...block, x, y } : block
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeBlock = useCallback((id) => {
|
||||||
|
setBlocks(currentBlocks => {
|
||||||
|
const currentBlock = currentBlocks.find(block => block.id === id);
|
||||||
|
if (!currentBlock)
|
||||||
|
return currentBlocks;
|
||||||
|
return currentBlocks.filter(block =>
|
||||||
|
block.id !== id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const addConnection = useCallback((from, to, type = 'line') => {
|
||||||
|
setConnections(currentConnections => [...currentConnections, { from, to, type }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePortClick = useCallback((blockId, portType, portIndex) => {
|
||||||
|
const used = connections.some(connection => {
|
||||||
|
if (portType === 'input')
|
||||||
|
return(connection.to.blockId === blockId && connection.to.portIndex === portIndex);
|
||||||
|
return(connection.from.blockId === blockId && connection.from.portIndex === portIndex);
|
||||||
|
});
|
||||||
|
const toBlock = blocks.find(block => block.id === blockId);
|
||||||
|
if (used || (portType === 'input' && toBlock.inputCount < portIndex) || (portType === 'output' && toBlock.outputCount < portIndex))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (blockId === activePort?.blockId && portIndex === activePort?.portIndex) {
|
||||||
|
setActivePort(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portType === 'output' && activePort?.portType === 'input') {
|
||||||
|
if (activePort?.blockId !== blockId) {
|
||||||
|
addConnection({blockId, portType, portIndex}, activePort);
|
||||||
|
setActivePort(null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
setActivePort({blockId, portType, portIndex});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portType === 'input' && activePort?.portType === 'output') {
|
||||||
|
if (activePort.blockId !== blockId) {
|
||||||
|
const toPort = {
|
||||||
|
blockId,
|
||||||
|
portType,
|
||||||
|
portIndex
|
||||||
|
}
|
||||||
|
addConnection(activePort, {blockId, portType, portIndex});
|
||||||
|
setActivePort(null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
setActivePort({blockId, portType, portIndex});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivePort({blockId, portType, portIndex});
|
||||||
|
}, [activePort, blocks, connections]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = (data) => {
|
||||||
|
};
|
||||||
|
const handleClear = () => {
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return(
|
||||||
|
<div className = "App">
|
||||||
|
<ControlPanel
|
||||||
|
onExport = {handleExport}
|
||||||
|
onImport = {handleImport}
|
||||||
|
onClear = {handleClear}
|
||||||
|
/>
|
||||||
|
<Toolbar/>
|
||||||
|
<Canvas
|
||||||
|
blocks = {blocks}
|
||||||
|
addBlock = {addBlock}
|
||||||
|
moveBlock = {moveBlock}
|
||||||
|
removeBlock = {removeBlock}
|
||||||
|
connections = {connections}
|
||||||
|
addConnection = {addConnection}
|
||||||
|
activePort = {activePort}
|
||||||
|
onPortClick = {handlePortClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
|
||||||
import { ReactFlow, Controls, Background, useNodesState, useEdgesState, addEdge, BackgroundVariant, getNodesBounds } from '@xyflow/react'
|
|
||||||
import { toSvg, toPng } from 'html-to-image'
|
|
||||||
import '@xyflow/react/dist/style.css'
|
|
||||||
import './App.css'
|
|
||||||
import Toolbar from './components/Toolbar'
|
|
||||||
import ControlPanel from './components/ControlPanel'
|
|
||||||
import CustomBlock from './components/CustomBlock'
|
|
||||||
import ModalData from './components/ModalData'
|
|
||||||
import { convertGraphToXml, parseXmlToGraph } from './utils/xmlUtils'
|
|
||||||
|
|
||||||
const nodeTypes = {
|
|
||||||
fbdBlock: CustomBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_STYLES = {
|
|
||||||
bool: { stroke: '#000', strokeWidth: 2 },
|
|
||||||
int: { stroke: '#040089', strokeWidth: 3 },
|
|
||||||
dint: { stroke: '#771061', strokeWidth: 3 },
|
|
||||||
real: { stroke: '#EB3B13', strokeWidth: 3 },
|
|
||||||
time: { stroke: '#AB7001', strokeWidth: 2.5 },
|
|
||||||
string: { stroke: '#58E9D0', strokeWidth: 2.5 }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
|
||||||
const [flowInstance, setFlowInstance] = useState(null)
|
|
||||||
const [edgeType, setEdgeType] = useState('bool')
|
|
||||||
const [modalData, setModalData] = useState(null)
|
|
||||||
|
|
||||||
function checkEdge(edge, currentNodes) {
|
|
||||||
const sourceNode = currentNodes.find(node => node.id === edge.source)
|
|
||||||
const targetNode = currentNodes.find(node => node.id === edge.target)
|
|
||||||
if (!sourceNode || !targetNode) return null
|
|
||||||
|
|
||||||
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
|
|
||||||
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
|
|
||||||
|
|
||||||
const sourceType = sourceNode.data.outputs[sourceIndex].replace('$/', '') || 'INT'
|
|
||||||
const targetType = targetNode.data.inputs[targetIndex].replace('$/', '') || 'INT'
|
|
||||||
if (edge.source == 1000000006)
|
|
||||||
if (sourceType === targetType && edge.data.type === sourceType.toLowerCase())
|
|
||||||
return null
|
|
||||||
|
|
||||||
let generalType = ''
|
|
||||||
for (let t of sourceType.split('/'))
|
|
||||||
if (targetType.includes(t))
|
|
||||||
generalType += t + '/'
|
|
||||||
if (generalType.endsWith('/'))
|
|
||||||
generalType = generalType.slice(0, -1)
|
|
||||||
if (!generalType)
|
|
||||||
generalType = sourceType.split('/')[0]
|
|
||||||
return generalType
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTwoNode(edge, newType, currentNodes, currentEdges) {
|
|
||||||
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
|
|
||||||
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
|
|
||||||
let change = false
|
|
||||||
|
|
||||||
const nextNodes = currentNodes.map(node => {
|
|
||||||
if (node.id === edge.source) {
|
|
||||||
let newInputs = [...node.data.inputs]
|
|
||||||
let newOutputs = [...node.data.outputs]
|
|
||||||
|
|
||||||
if (node.data.outputs[sourceIndex]?.startsWith('$')) {
|
|
||||||
for (let ind = 0; ind < newInputs.length; ind++)
|
|
||||||
if (newInputs[ind].startsWith('$') && ('$/' + newType) !== newInputs[ind]) {
|
|
||||||
newInputs[ind] = '$/' + newType
|
|
||||||
change = true
|
|
||||||
}
|
|
||||||
for (let ind = 0; ind < newOutputs.length; ind++)
|
|
||||||
if (newOutputs[ind].startsWith('$') && ('$/' + newType) !== newOutputs[ind]) {
|
|
||||||
newOutputs[ind] = '$/' + newType
|
|
||||||
change = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
newOutputs[sourceIndex] = newType
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...node.data,
|
|
||||||
inputs: newInputs,
|
|
||||||
outputs: newOutputs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node.id === edge.target) {
|
|
||||||
let newInputs = [...node.data.inputs]
|
|
||||||
let newOutputs = [...node.data.outputs]
|
|
||||||
|
|
||||||
if (node.data.inputs[targetIndex]?.startsWith('$')) {
|
|
||||||
for (let ind = 0; ind < newInputs.length; ind++)
|
|
||||||
if (newInputs[ind].startsWith('$') && ('$/' + newType) !== newInputs[ind]) {
|
|
||||||
newInputs[ind] = '$/' + newType
|
|
||||||
change = true
|
|
||||||
}
|
|
||||||
for (let ind = 0; ind < newOutputs.length; ind++)
|
|
||||||
if (newOutputs[ind].startsWith('$') && ('$/' + newType) !== newOutputs[ind]) {
|
|
||||||
newOutputs[ind] = '$/' + newType
|
|
||||||
change = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
newInputs[targetIndex] = newType
|
|
||||||
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...node.data,
|
|
||||||
inputs: newInputs,
|
|
||||||
outputs: newOutputs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return node
|
|
||||||
})
|
|
||||||
|
|
||||||
const nextEdges = currentEdges.map(ed => {
|
|
||||||
if (edge.id === ed.id) {
|
|
||||||
const currentStyle = EDGE_STYLES[newType.toLowerCase().split('/')[0]] || EDGE_STYLES['bool']
|
|
||||||
return {
|
|
||||||
...ed,
|
|
||||||
style: currentStyle,
|
|
||||||
data: {type: newType.toLowerCase().split('/')[0]}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ed
|
|
||||||
})
|
|
||||||
|
|
||||||
return {nextNodes, nextEdges, change}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateGraphTypes(currentEdges, currentNodes) {
|
|
||||||
let globalChange = true
|
|
||||||
while (globalChange) {
|
|
||||||
globalChange = false
|
|
||||||
|
|
||||||
for (const edge of currentEdges) {
|
|
||||||
const newType = checkEdge(edge, currentNodes)
|
|
||||||
|
|
||||||
if (newType) {
|
|
||||||
const { nextNodes, nextEdges, change } = updateTwoNode(edge, newType, currentNodes, currentEdges)
|
|
||||||
currentNodes = nextNodes
|
|
||||||
currentEdges = nextEdges
|
|
||||||
globalChange = globalChange || change
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {currentNodes, currentEdges}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onConnect = useCallback((params) => {
|
|
||||||
const sourceNode = nodes.find(node => node.id === params.source)
|
|
||||||
const sourceIndex = parseInt(params.sourceHandle.split('-')[1], 10)
|
|
||||||
const sourceType = sourceNode?.data?.outputs[sourceIndex].replace('$/', '') || 'INT'
|
|
||||||
|
|
||||||
const newEdge = {
|
|
||||||
...params,
|
|
||||||
id: `e_${Date.now()}`,
|
|
||||||
type: 'smoothstep',
|
|
||||||
style: EDGE_STYLES[sourceType.toLowerCase().split('/')[0]] || EDGE_STYLES['bool'],
|
|
||||||
data: { type: sourceType.toLowerCase().split('/')[0] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const allEdges = [...edges, newEdge]
|
|
||||||
const { currentNodes, currentEdges } = updateGraphTypes(allEdges, [...nodes])
|
|
||||||
setNodes(currentNodes)
|
|
||||||
setEdges(currentEdges)
|
|
||||||
}, [nodes, setNodes, setEdges, edgeType])
|
|
||||||
|
|
||||||
const onDragOver = useCallback((event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.dataTransfer.dropEffect = 'move'
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onDrop = useCallback((event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!flowInstance)
|
|
||||||
return
|
|
||||||
|
|
||||||
const dataStr = event.dataTransfer.getData('application/reactflow')
|
|
||||||
if (!dataStr)
|
|
||||||
return
|
|
||||||
|
|
||||||
const {type, label, width, height, inputs = [], outputs = [],
|
|
||||||
inputsLabels = [], outputsLabels = [], offsetX = 0, offsetY = 0} = JSON.parse(dataStr)
|
|
||||||
const position = flowInstance.screenToFlowPosition({
|
|
||||||
x: event.clientX - offsetX,
|
|
||||||
y: event.clientY - offsetY
|
|
||||||
})
|
|
||||||
|
|
||||||
if (['const', 'switch_type', 'input', 'output'].includes(type)) {
|
|
||||||
setModalData({ type, label, width: widthPrew, height: heightPrew, position, inputs, outputs, inputsLabels, outputsLabels })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNode = {
|
|
||||||
id: `${Date.now()}`,
|
|
||||||
type: 'fbdBlock',
|
|
||||||
position,
|
|
||||||
data: { type, label, width, height, inputs, outputs, inputsLabels, outputsLabels }
|
|
||||||
}
|
|
||||||
setNodes((currentNodes) => [...currentNodes, newNode])
|
|
||||||
}, [flowInstance, setNodes])
|
|
||||||
|
|
||||||
const onNodesDelete = useCallback((deletedNodes) => {
|
|
||||||
setNodes((nds) => nds.filter((node) => !deletedNodes.some((dn) => dn.id === node.id)))
|
|
||||||
}, [setNodes])
|
|
||||||
|
|
||||||
const onEdgesDelete = useCallback((deletedEdges) => {
|
|
||||||
setEdges((eds) => eds.filter((edge) => !deletedEdges.some((de) => de.id === edge.id)))
|
|
||||||
}, [setEdges])
|
|
||||||
|
|
||||||
function handleSaveModalData(formData) {
|
|
||||||
if (!modalData)
|
|
||||||
return
|
|
||||||
|
|
||||||
let finalLabel = modalData.label
|
|
||||||
let inputs = []
|
|
||||||
let outputs = []
|
|
||||||
|
|
||||||
if (modalData.type === 'const') {
|
|
||||||
finalLabel = `${formData.value} (${formData.toType})`
|
|
||||||
outputs = [formData.fromType]
|
|
||||||
}
|
|
||||||
else if (modalData.type === 'switch_type') {
|
|
||||||
finalLabel = `${formData.fromType}_TO_${formData.toType}`
|
|
||||||
inputs = [formData.fromType]
|
|
||||||
outputs = [formData.toType]
|
|
||||||
}
|
|
||||||
else if (modalData.type === 'input') {
|
|
||||||
finalLabel = `${formData.value} (${formData.fromType})`
|
|
||||||
outputs = [formData.fromType]
|
|
||||||
}
|
|
||||||
else if (modalData.type === 'output') {
|
|
||||||
finalLabel = `${formData.value} (${formData.fromType})`
|
|
||||||
inputs = [formData.fromType]
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNode = {
|
|
||||||
id: `${Date.now()}`,
|
|
||||||
type: 'fbdBlock',
|
|
||||||
position: modalData.position,
|
|
||||||
data: {
|
|
||||||
type: modalData.type,
|
|
||||||
label: finalLabel,
|
|
||||||
width: modalData.width,
|
|
||||||
height: modalData.height,
|
|
||||||
value: formData.value,
|
|
||||||
inputs,
|
|
||||||
outputs,
|
|
||||||
inputsLabels: modalData.inputsLabels,
|
|
||||||
outputsLabels: modalData.outputsLabels,
|
|
||||||
metaFromType: formData.fromType,
|
|
||||||
metaToType: formData.toType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setNodes((currentNodes) => [...currentNodes, newNode])
|
|
||||||
setModalData(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidConnection = useCallback((connection) => {
|
|
||||||
if (connection.source === connection.target)
|
|
||||||
return false
|
|
||||||
|
|
||||||
function checkIfCycleExists(sourceId, targetId) {
|
|
||||||
const visited = new Set()
|
|
||||||
|
|
||||||
function dfs(currentId) {
|
|
||||||
if (currentId === sourceId)
|
|
||||||
return true
|
|
||||||
if (visited.has(currentId))
|
|
||||||
return false
|
|
||||||
visited.add(currentId)
|
|
||||||
|
|
||||||
const outgoingEdges = edges.filter(edge => edge.source === currentId)
|
|
||||||
for (const edge of outgoingEdges)
|
|
||||||
if (dfs(edge.target))
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return dfs(targetId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkIfCycleExists(connection.source, connection.target))
|
|
||||||
return false
|
|
||||||
|
|
||||||
const sourceNode = nodes.find(node => node.id === connection.source)
|
|
||||||
const targetNode = nodes.find(node => node.id === connection.target)
|
|
||||||
if (!sourceNode || !targetNode)
|
|
||||||
return false
|
|
||||||
|
|
||||||
if (edges.find(edge => edge.target === targetNode))
|
|
||||||
return false
|
|
||||||
|
|
||||||
const sourceIndex = parseInt(connection.sourceHandle.split('-')[1], 10)
|
|
||||||
const targetIndex = parseInt(connection.targetHandle.split('-')[1], 10)
|
|
||||||
|
|
||||||
const sourceType = sourceNode.data.outputs[sourceIndex].replace('$/', '')
|
|
||||||
const targetType = targetNode.data.inputs[targetIndex].replace('$/', '')
|
|
||||||
|
|
||||||
for (let t of sourceType.split('/'))
|
|
||||||
if (targetType.includes(t))
|
|
||||||
return true
|
|
||||||
|
|
||||||
return false
|
|
||||||
}, [nodes, edges])
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if ((nodes.length > 0 || edges.length > 0) && window.confirm("Очистить холст?")) {
|
|
||||||
setNodes([])
|
|
||||||
setEdges([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleExportSvg() {
|
|
||||||
const reactFlowViewport = document.querySelector('.react-flow__viewport')
|
|
||||||
if (!reactFlowViewport || nodes.length === 0) {
|
|
||||||
alert('Не удалось найти холст для экспорта')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const bounds = getNodesBounds(nodes)
|
|
||||||
const padding = 30
|
|
||||||
const widthExportWindow = bounds.width + padding * 2
|
|
||||||
const heightExportWindow = bounds.height + padding * 2
|
|
||||||
|
|
||||||
toSvg(reactFlowViewport, {
|
|
||||||
backgroundColor: 'white',
|
|
||||||
width: widthExportWindow,
|
|
||||||
height: heightExportWindow,
|
|
||||||
style: {
|
|
||||||
width: `${widthExportWindow}px`,
|
|
||||||
height: `${heightExportWindow}px`,
|
|
||||||
transform: `translate(${-bounds.x + padding}px, ${-bounds.y + padding}px) scale(1)`,
|
|
||||||
transformOrigin: 'top left'
|
|
||||||
}
|
|
||||||
}).then(async (dataUrl) => {
|
|
||||||
if ('showSaveFilePicker' in window) {
|
|
||||||
try {
|
|
||||||
const handle = await window.showSaveFilePicker({
|
|
||||||
suggestedName: 'fbd-scheme.svg',
|
|
||||||
types: [{
|
|
||||||
description: 'Векторная диаграмма SVG',
|
|
||||||
accept: {'image/svg+xml': ['.svg']}
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
const writable = await handle.createWritable()
|
|
||||||
const scheme = await fetch(dataUrl)
|
|
||||||
const file = await scheme.blob()
|
|
||||||
await writable.write(file)
|
|
||||||
await writable.close()
|
|
||||||
}
|
|
||||||
catch (err) { console.log('Пользователь отменил сохранение или произошла ошибка: ', err) }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.download = 'fbd-scheme.svg'
|
|
||||||
link.href = dataUrl
|
|
||||||
link.click()
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Ошибка генерации SVG-файла: ', error)
|
|
||||||
alert('Не удалось сгененировать SVG-файл')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleExportPng() {
|
|
||||||
const reactFlowViewport = document.querySelector('.react-flow__viewport')
|
|
||||||
|
|
||||||
if (!reactFlowViewport || nodes.length === 0) {
|
|
||||||
alert('Не удалось найти холст или на нем нет элементов для экспорта')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const bounds = getNodesBounds(nodes)
|
|
||||||
const padding = 7
|
|
||||||
const widthExportWindow = bounds.width + padding * 2
|
|
||||||
const heightExportWindow = bounds.height + padding * 2
|
|
||||||
|
|
||||||
toPng(reactFlowViewport, {
|
|
||||||
backgroundColor: null,
|
|
||||||
width: widthExportWindow,
|
|
||||||
height: heightExportWindow,
|
|
||||||
style: {
|
|
||||||
width: `${widthExportWindow}px`,
|
|
||||||
height: `${heightExportWindow}px`,
|
|
||||||
transform: `translate(${-bounds.x + padding}px, ${-bounds.y + padding}px) scale(1)`,
|
|
||||||
transformOrigin: 'top left'
|
|
||||||
},
|
|
||||||
filter: (node) => {
|
|
||||||
if (node?.classList?.contains('react-flow__background'))
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}).then(async (dataUrl) => {
|
|
||||||
if ('showSaveFilePicker' in window) {
|
|
||||||
try {
|
|
||||||
const handle = await window.showSaveFilePicker({
|
|
||||||
suggestedName: 'fbd-scheme.png',
|
|
||||||
types: [{
|
|
||||||
description: 'Изображение PNG',
|
|
||||||
accept: {'image/png': ['.png']}
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
const writable = await handle.createWritable()
|
|
||||||
const scheme = await fetch(dataUrl)
|
|
||||||
const file = await scheme.blob()
|
|
||||||
await writable.write(file)
|
|
||||||
await writable.close()
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.log('Пользователь отменил сохранение или произошла ошибка: ', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.download = 'fbd-scheme.png'
|
|
||||||
link.href = dataUrl
|
|
||||||
link.click()
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Ошибка генерации PNG-файла: ', error)
|
|
||||||
alert('Не удалось сгенерировать PNG-файл')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleExportXml() {
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
alert('На холсте нет блоков для экспорта')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const xml = convertGraphToXml(nodes, edges)
|
|
||||||
const file = new Blob([xml], {type: 'application/xml'})
|
|
||||||
|
|
||||||
if ('showSaveFilePicker' in window) {
|
|
||||||
try {
|
|
||||||
const handle = await window.showSaveFilePicker({
|
|
||||||
suggestedName: 'fbd-scheme.xml',
|
|
||||||
types: [{
|
|
||||||
description: 'FBD диаграмма в XML',
|
|
||||||
accept: {'application/xml': ['.xml']}
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
const writable = await handle.createWritable()
|
|
||||||
await writable.write(file)
|
|
||||||
await writable.close()
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.log('Сохранение XML отменено или произошла ошибка:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.download = 'fbd-program.xml'
|
|
||||||
link.href = URL.createObjectURL(file)
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(link.href)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImportXml(event) {
|
|
||||||
const file = event.target.files[0]
|
|
||||||
if (!file)
|
|
||||||
return
|
|
||||||
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const xmlText = e.target.result
|
|
||||||
const parser = new DOMParser()
|
|
||||||
const xml = parser.parseFromString(xmlText, 'application/xml')
|
|
||||||
if (xml.querySelector('parsererror')) {
|
|
||||||
alert('Ошибка импорта (невалидный XML-код)')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { finalNodes, finalEdges } = parseXmlToGraph(xml)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { currentNodes, currentEdges } = updateGraphTypes(finalEdges, finalNodes)
|
|
||||||
setNodes(currentNodes)
|
|
||||||
setEdges(currentEdges)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
alert('Произошла ошибка при обработке соединений')
|
|
||||||
setEdges([])
|
|
||||||
setNodes([finalNodes])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
|
||||||
event.target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return(
|
|
||||||
<div className="App">
|
|
||||||
<ControlPanel
|
|
||||||
onClear = {handleClear}
|
|
||||||
onExportSvg = {handleExportSvg}
|
|
||||||
onExportPng = {handleExportPng}
|
|
||||||
onExportXml = {handleExportXml}
|
|
||||||
onImportXml = {handleImportXml}
|
|
||||||
/>
|
|
||||||
<div className = "main-container">
|
|
||||||
<Toolbar/>
|
|
||||||
<div className = "main-canvas-container">
|
|
||||||
<div className="canvas-container">
|
|
||||||
<ReactFlow
|
|
||||||
nodes = {nodes}
|
|
||||||
edges = {edges}
|
|
||||||
onNodesChange = {onNodesChange}
|
|
||||||
onEdgesChange = {onEdgesChange}
|
|
||||||
onConnect = {onConnect}
|
|
||||||
nodeTypes = {nodeTypes}
|
|
||||||
onInit = {setFlowInstance}
|
|
||||||
onDrop = {onDrop}
|
|
||||||
onDragOver = {onDragOver}
|
|
||||||
isValidConnection = {isValidConnection}
|
|
||||||
onNodesDelete = {onNodesDelete}
|
|
||||||
onEdgesDelete = {onEdgesDelete}
|
|
||||||
deleteKeyCode = {["Delete", "Backspace"]}
|
|
||||||
//fitView
|
|
||||||
>
|
|
||||||
<Controls/>
|
|
||||||
<Background variant = {BackgroundVariant.Lines} color = "#eee" gap = {10}/>
|
|
||||||
</ReactFlow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ModalData
|
|
||||||
modalData = {modalData}
|
|
||||||
onClose = {() => setModalData(null)}
|
|
||||||
onSave = {handleSaveModalData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,92 @@
|
|||||||
|
import React, {memo} from 'react';
|
||||||
|
|
||||||
|
const CENTER_X = 4;
|
||||||
|
const CENTER_Y = 0;
|
||||||
|
|
||||||
|
const Block = ({ blockId, type, label, inputCount, outputCount, activePort, onPortClick, connections }) => {
|
||||||
|
|
||||||
|
const blockHeight = 50 + (Math.max(inputCount, outputCount, 2) - 1) * 34;
|
||||||
|
const blockWidth = blockHeight / 1.4;
|
||||||
|
|
||||||
|
const getPortStyle = (portType, portIndex) => {
|
||||||
|
const active = activePort?.portType === portType && activePort?.portIndex === portIndex;
|
||||||
|
const used = connections.some(connection => {
|
||||||
|
if (portType === 'input')
|
||||||
|
return(connection.to.blockId === blockId && connection.to.portIndex === portIndex);
|
||||||
|
return(connection.from.blockId === blockId && connection.from.portIndex === portIndex);
|
||||||
|
});
|
||||||
|
const color = active ? '#999' :
|
||||||
|
portType === 'input' ?
|
||||||
|
used ? '#BABAFF' : '#1717D8' :
|
||||||
|
used ? '#FD90B0' : '#D8000A';
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor: 'pointer',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
r: active ? 6 : 4,
|
||||||
|
fill: color
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCoord = (count) => {
|
||||||
|
const coord = [];
|
||||||
|
if (count === 1)
|
||||||
|
coord.push(blockHeight / 2);
|
||||||
|
else {
|
||||||
|
const step = (blockHeight - 50) / (count - 1);
|
||||||
|
for (let i = 0; i < count; i++)
|
||||||
|
coord.push(25 + i * step);
|
||||||
|
}
|
||||||
|
return coord;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputsCoord = getCoord(inputCount);
|
||||||
|
const outputsCoord = getCoord(outputCount);
|
||||||
|
|
||||||
|
const renderBlock = () => (
|
||||||
|
<svg
|
||||||
|
width={blockWidth + 2 * CENTER_X}
|
||||||
|
height={blockHeight}
|
||||||
|
viewBox={`0 0 ${blockWidth + 2 * CENTER_X} ${blockHeight}`}
|
||||||
|
style={{
|
||||||
|
overflow: 'visible',
|
||||||
|
transform: `translate(${CENTER_X}px, ${CENTER_Y}px)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<rect width = {blockWidth} height = {blockHeight} fill = "lightgray" stroke = "black"/>
|
||||||
|
<text x = {type === 'or' ? 32 : 24} y = "20" textAnchor = "start" fontSize = "14" style = {{pointerEvents: 'none'}}>{label}</text>
|
||||||
|
|
||||||
|
{//
|
||||||
|
|
||||||
|
inputsCoord.map((coord, ind) => (
|
||||||
|
<circle key = {`b-${blockId}-in${ind}`}
|
||||||
|
cx = "0" cy = {coord}
|
||||||
|
onClick = {(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPortClick(blockId, 'input', ind + 1);
|
||||||
|
}}
|
||||||
|
{...getPortStyle('input', ind + 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{
|
||||||
|
outputsCoord.map((coord, ind) => (
|
||||||
|
<circle key = {`b-${blockId}-out${ind}`}
|
||||||
|
cx = {blockWidth} cy = {coord}
|
||||||
|
onClick = {(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPortClick(blockId, 'output', ind + 1);
|
||||||
|
}}
|
||||||
|
{...getPortStyle('output', ind + 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
return renderBlock();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(Block);
|
||||||
|
|
||||||
|
|
||||||
|
/* треугольник
|
||||||
|
<polygon points="60,35 60,49 70,42" fill = "none" stroke="black" />*/
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useRef, useState, useEffect, memo } from 'react';
|
||||||
|
import { useDrop, useDrag } from 'react-dnd';
|
||||||
|
import Connection from './Connection';
|
||||||
|
import DraggableBlock from './DraggableBlock';
|
||||||
|
|
||||||
|
const Canvas = ({ blocks = [], connections = [], activePort = null, addConnection, addBlock, moveBlock, removeBlock, onPortClick }) => {
|
||||||
|
const blocksOnCanvas = blocks || [];
|
||||||
|
const connectionsOnCanvas = connections || [];
|
||||||
|
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [pan, setPan] = useState({x: 0, y: 0});
|
||||||
|
const [panStart, setPanStart] = useState({x: 0, y: 0});
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
|
const [{isOver, monitor}, drop] = useDrop(() => ({
|
||||||
|
accept: ['block', 'newBlock'],
|
||||||
|
hover: (item, monitor) => {
|
||||||
|
const offset = monitor.getClientOffset();
|
||||||
|
if (!offset || !canvasRef.current)
|
||||||
|
return;
|
||||||
|
const svgRect = canvasRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
//координаты мыши
|
||||||
|
const mouseOnCanvasX = offset.x - svgRect.x;
|
||||||
|
const mouseOnCanvasY = offset.y - svgRect.y;
|
||||||
|
|
||||||
|
//координаты на canvas
|
||||||
|
const mouseX = (mouseOnCanvasX - pan.x) / zoom;
|
||||||
|
const mouseY = (mouseOnCanvasY - pan.y) / zoom;
|
||||||
|
|
||||||
|
//смещение
|
||||||
|
const blockWidth = 68;
|
||||||
|
const blockHeight = 84;
|
||||||
|
const x = mouseX - blockWidth / 3;
|
||||||
|
const y = mouseY - blockHeight / 3;
|
||||||
|
|
||||||
|
if (item.id && !item.isNew)
|
||||||
|
moveBlock(item.id, x, y);
|
||||||
|
else {
|
||||||
|
if (!item.tempId) {
|
||||||
|
const newId = Date.now();
|
||||||
|
item.tempId = newId;
|
||||||
|
item.isNew = false;
|
||||||
|
addBlock(item, newId, x, y);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
moveBlock(item.tempId, x, y);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drop: (item) => { item.tempId = null; },
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
monitor: monitor,
|
||||||
|
}),
|
||||||
|
}), [moveBlock, addBlock, pan, zoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
//удаление блока при выходе за пределы холста
|
||||||
|
const draggingItem = monitor?.getItem();
|
||||||
|
if (!isOver && draggingItem?.tempId) {
|
||||||
|
removeBlock(draggingItem.tempId);
|
||||||
|
draggingItem.tempId = null;
|
||||||
|
}
|
||||||
|
}, [isOver, monitor, removeBlock]);
|
||||||
|
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const mouseOnCanvasX = e.clientX - rect.x;
|
||||||
|
const mouseOnCanvasY = e.clientY - rect.y;
|
||||||
|
const mouseX = (mouseOnCanvasX - pan.x) / zoom;
|
||||||
|
const mouseY = (mouseOnCanvasY - pan.y) / zoom;
|
||||||
|
|
||||||
|
const stepZoom = 0.075;
|
||||||
|
const prewZoom = zoom + (e.deltaY > 0 ? -stepZoom : stepZoom);
|
||||||
|
const newZoom = Math.max(0.6, Math.min(1.7, prewZoom));
|
||||||
|
|
||||||
|
const newPanX = mouseOnCanvasX - mouseX * newZoom;
|
||||||
|
const newPanY = mouseOnCanvasY - mouseY * newZoom;
|
||||||
|
|
||||||
|
setZoom(newZoom);
|
||||||
|
setPan({x: newPanX, y: newPanY});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
if (e.target.closest('.block-container') || e.target.closest('circle'))
|
||||||
|
return;
|
||||||
|
if (e.button === 0) {
|
||||||
|
setIsPanning(true);
|
||||||
|
setPanStart({
|
||||||
|
x: e.clientX - pan.x,
|
||||||
|
y: e.clientY - pan.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (isPanning)
|
||||||
|
setPan({
|
||||||
|
x: e.clientX - panStart.x,
|
||||||
|
y: e.clientY - panStart.y
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => { setIsPanning(false); };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalMouseUp = () => setIsPanning(false);
|
||||||
|
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||||
|
return() => window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return(
|
||||||
|
<div
|
||||||
|
ref = {(node) => {
|
||||||
|
if (node) {
|
||||||
|
canvasRef.current = node;
|
||||||
|
drop(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className = "canvas"
|
||||||
|
onWheel = {handleWheel}
|
||||||
|
onMouseDown = {handleMouseDown}
|
||||||
|
onMouseMove = {handleMouseMove}
|
||||||
|
onMouseUp = {handleMouseUp}
|
||||||
|
>
|
||||||
|
<svg width = "100%" height = "100%">
|
||||||
|
|
||||||
|
{/* Сетка */}
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width={8} height={8} patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 8 0 L 0 0 0 8" fill="none" stroke="#eee" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
{//
|
||||||
|
}
|
||||||
|
<pattern
|
||||||
|
id="main-grid"
|
||||||
|
width={80} height={80}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
patternTransform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}
|
||||||
|
>
|
||||||
|
<rect width={80} height={80} fill="url(#grid)" />
|
||||||
|
<path d="M 80 0 L 0 0 0 80" fill="none" stroke="#ddd" strokeWidth="1.25" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
id = "background-layer"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
fill="url(#main-grid)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Блоки и соединения */}
|
||||||
|
<g style = {{
|
||||||
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
pointerEvents: 'all'
|
||||||
|
}}>
|
||||||
|
<g id = "connection-layer">
|
||||||
|
{connectionsOnCanvas.map((connection, index) => (
|
||||||
|
<Connection key = {index} connection = {connection} blocks = {blocksOnCanvas} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{//
|
||||||
|
}
|
||||||
|
<g id = "block-layer">
|
||||||
|
{blocksOnCanvas.map((block) => (
|
||||||
|
<DraggableBlock
|
||||||
|
key = {block.id}
|
||||||
|
block = {block}
|
||||||
|
addConnection = {addConnection}
|
||||||
|
onPortClick = {onPortClick}
|
||||||
|
activePort = {activePort?.blockId === block.id ? activePort : null}
|
||||||
|
connections = {connectionsOnCanvas}
|
||||||
|
isNew = {false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(Canvas);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Connection = ({connection, blocks}) => {
|
||||||
|
const fromBlock = blocks.find(block => block.id === connection.from.blockId);
|
||||||
|
const toBlock = blocks.find(block => block.id === connection.to.blockId);
|
||||||
|
if (!fromBlock || !toBlock) return null;
|
||||||
|
|
||||||
|
|
||||||
|
const fromBlockHeight = 50 + (Math.max(fromBlock.inputCount, fromBlock.outputCount, 2) - 1) * 34;
|
||||||
|
const toBlockHeight = 50 + (Math.max(toBlock.inputCount, toBlock.outputCount, 2) - 1) * 34;
|
||||||
|
const fromBlockWidth = fromBlockHeight / 1.4 + 3;
|
||||||
|
const stepFrom = fromBlock.outputCount === 1 ? (fromBlockHeight - 50) / 2 : (fromBlockHeight - 50) / (fromBlock.outputCount - 1);
|
||||||
|
const stepTo = toBlock.inputCount === 1 ? (toBlockHeight - 50) / 2 : (toBlockHeight - 50) / (toBlock.inputCount - 1);
|
||||||
|
|
||||||
|
const xFrom = fromBlock.x + fromBlockWidth;
|
||||||
|
const yFrom = fromBlock.y + 25 + (fromBlock.outputCount === 1 ? stepFrom : (stepFrom * (connection.from.portIndex - 1)));
|
||||||
|
const xTo = toBlock.x + 2;
|
||||||
|
const yTo = toBlock.y + 25 + (toBlock.inputCount === 1 ? stepTo : (stepTo * (connection.to.portIndex - 1)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g data-connection-from={connection.from} data-connection-to={connection.to}>
|
||||||
|
<line x1 = {xFrom} y1 = {yFrom} x2 = {xFrom + 15} y2 = {yFrom} stroke = "black" strokeWidth = "2" />
|
||||||
|
<line x1 = {xFrom + 15} y1 = {yFrom} x2 = {xTo - 15} y2 = {yTo} stroke = "black" strokeWidth = "2" />
|
||||||
|
<line x1 = {xTo} y1 = {yTo} x2 = {xTo - 15} y2 = {yTo} stroke = "black" strokeWidth = "2" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Connection;
|
||||||
|
|
||||||
|
// последние 2 элемента ещё сырые и их надо будет дорабатывать.
|
||||||
|
// Connection.js:
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ControlPanel = ({ onExport, onImport, onClear }) => {
|
||||||
|
// Обработчик импорта файла (читает JSON и передаёт в App)
|
||||||
|
const handleImport = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.target.result); // Парсим JSON
|
||||||
|
onImport(data); // Вызываем функцию из App
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка при чтении файла: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="controlPanel" style={{ padding: '10px', borderBottom: '1px solid #ccc', backgroundColor: '#f0f0f0', display: 'flex', gap: '10px' }}>
|
||||||
|
<button onClick={onExport}>Экспорт в JSON</button>
|
||||||
|
<input type="file" accept=".json" onChange={handleImport} /> {/* Поле для выбора файла импорта */}
|
||||||
|
<button onClick={onClear}>Очистить холст</button>
|
||||||
|
{/* Можно добавить: <button>Зум +</button> <button>Зум -</button> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ControlPanel;
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import React, {useRef} from 'react';
|
|
||||||
|
|
||||||
function ControlPanel({ onClear, onExportSvg, onExportPng, onExportXml, onImportXml }) {
|
|
||||||
const fileInputRef = useRef(null)
|
|
||||||
|
|
||||||
const handleImportButtonClick = () => {
|
|
||||||
if (fileInputRef.current)
|
|
||||||
fileInputRef.current.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className = "controlPanel">
|
|
||||||
<button
|
|
||||||
onClick = {onExportSvg}
|
|
||||||
className = "control-button svg-export-button"
|
|
||||||
>
|
|
||||||
Экспорт в SVG
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick = {onExportPng}
|
|
||||||
className = "control-button svg-export-button"
|
|
||||||
>
|
|
||||||
Экспорт в PNG
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick = {onExportXml}
|
|
||||||
className = "control-button svg-export-button"
|
|
||||||
>
|
|
||||||
Экспорт в XML
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick = {handleImportButtonClick}
|
|
||||||
className = "control-button svg-export-button"
|
|
||||||
>
|
|
||||||
Импорт XML
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type = "file"
|
|
||||||
ref = {fileInputRef}
|
|
||||||
onChange = {onImportXml}
|
|
||||||
accept = ".xml"
|
|
||||||
style = {{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={onClear}
|
|
||||||
className = 'control-button clear-button'
|
|
||||||
>
|
|
||||||
Очистить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ControlPanel;
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import React, { memo } from 'react'
|
|
||||||
import { Handle, Position, useEdges } from '@xyflow/react'
|
|
||||||
|
|
||||||
function CustomBlock({ id: blockId, data }) {
|
|
||||||
const {type, label, inputs = [], outputs = [], inputsLabels = [], outputsLabels = [], metaFromType, metaToType } = data
|
|
||||||
const edges = useEdges()
|
|
||||||
|
|
||||||
const inputCount = inputs.length
|
|
||||||
const outputCount = outputs.length
|
|
||||||
let blockWidth = data.width || 60
|
|
||||||
let blockHeight = data.height
|
|
||||||
let countForHeight = Math.max(inputCount, outputCount, 2)
|
|
||||||
|
|
||||||
if (!blockHeight)
|
|
||||||
blockHeight = 50 + (countForHeight - 1) * 34
|
|
||||||
if (['input', 'output', 'const'].includes(type)) {
|
|
||||||
countForHeight = Math.max(inputCount, outputCount)
|
|
||||||
blockHeight = data.height || 40 + (countForHeight - 1) * 34
|
|
||||||
blockWidth = data.width || blockHeight * 2.1
|
|
||||||
}
|
|
||||||
if (type === 'switch_type') {
|
|
||||||
countForHeight = Math.max(inputCount, outputCount)
|
|
||||||
blockHeight = data.height || 40 + (countForHeight - 1) * 34
|
|
||||||
blockWidth = data.width || blockHeight * 2.5
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPortStyle(portType, portIndex) {
|
|
||||||
const used = edges.some(edge => {
|
|
||||||
if (portType === 'input')
|
|
||||||
return edge.target === blockId && edge.targetHandle === `in-${portIndex}`
|
|
||||||
return edge.source === blockId && edge.sourceHandle === `out-${portIndex}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const color = portType === 'input' ?
|
|
||||||
(used ? '#BABAFF' : '#1717D8') :
|
|
||||||
(used ? '#FD90B0' : '#D8000A')
|
|
||||||
|
|
||||||
return {
|
|
||||||
background: color,
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transform: portType === 'input' ? 'translate(-50%, -50%)' : 'translate(50%, -50%)',
|
|
||||||
transition: 'all 0.2s ease'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCoords(count) {
|
|
||||||
const coords = []
|
|
||||||
if (count === 1)
|
|
||||||
coords.push(50)
|
|
||||||
else if (count < countForHeight) {
|
|
||||||
const step = blockHeight / (count + 1)
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const y = (i + 1) * step
|
|
||||||
coords.push((y / blockHeight) * 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const step = (blockHeight - 50) / (count - 1)
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const y = 25 + i * step
|
|
||||||
coords.push((y / blockHeight) * 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return coords
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputsCoords = getCoords(inputCount)
|
|
||||||
const outputsCoords = getCoords(outputCount)
|
|
||||||
|
|
||||||
function getAddElements() {
|
|
||||||
if (!['and', 'or', 'not', 'xor', 'add', 'sub', 'mul', 'div', 'gt', 'lt', 'eq', 'move', 'input', 'output', 'const', 'switch_type'].includes(type))
|
|
||||||
return(
|
|
||||||
<>
|
|
||||||
{inputsLabels.map((inLab, ind) => {
|
|
||||||
return (
|
|
||||||
<div className = "block-inputs-title" style = {{
|
|
||||||
top: `${inputsCoords[ind]}%`,
|
|
||||||
left: 3,
|
|
||||||
textAlign: 'left'
|
|
||||||
}}>{inLab}</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{outputsLabels.map((outLab, ind) => {
|
|
||||||
return (
|
|
||||||
<div className = "block-outputs-title" style = {{
|
|
||||||
top: `${outputsCoords[ind]}%`,
|
|
||||||
left: `${blockWidth - 23}px`,
|
|
||||||
right: '0',
|
|
||||||
textAlign: 'right'
|
|
||||||
}}>{outLab}</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return(
|
|
||||||
<div
|
|
||||||
className = "custom-fbd-block"
|
|
||||||
style = {{
|
|
||||||
width: `${blockWidth}px`,
|
|
||||||
height: `${blockHeight}px`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className = "block-title">{label}</div>
|
|
||||||
{getAddElements()}
|
|
||||||
|
|
||||||
{inputsCoords.map((coord, ind) => (
|
|
||||||
<Handle
|
|
||||||
key = {`in-${ind}`}
|
|
||||||
type = "target"
|
|
||||||
position = {Position.Left}
|
|
||||||
id = {`in-${ind}`}
|
|
||||||
style = {{
|
|
||||||
...getPortStyle('input', ind),
|
|
||||||
top: `${coord}%`,
|
|
||||||
left: 0
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{outputsCoords.map((coord, ind) => (
|
|
||||||
<Handle
|
|
||||||
key = {`out-${ind}`}
|
|
||||||
type = "source"
|
|
||||||
position = {Position.Right}
|
|
||||||
id = {`out-${ind}`}
|
|
||||||
style = {{
|
|
||||||
...getPortStyle('output', ind),
|
|
||||||
top: `${coord}%`,
|
|
||||||
right: 0
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(CustomBlock)
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React, {memo} from 'react';
|
||||||
|
import { useDrag } from 'react-dnd';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||||
|
import Block from './Block';
|
||||||
|
import ToolbarBlock from './ToolbarBlock';
|
||||||
|
|
||||||
|
const DraggableBlock = ({ block, connections, addConnection, activePort, onPortClick, isNew = true }) => {
|
||||||
|
const blockId = block.id;
|
||||||
|
const blockType = block.type;
|
||||||
|
const blockLabel = block.label;
|
||||||
|
const blockInputCount = block.inputCount;
|
||||||
|
const blockOutputCount = block.outputCount;
|
||||||
|
|
||||||
|
const [{isDragging}, drag, preview] = useDrag(() => ({
|
||||||
|
type: isNew ? 'newBlock' : 'block',
|
||||||
|
item: isNew
|
||||||
|
? { type: blockType, label: blockLabel, inputCount: blockInputCount, outputCount: blockOutputCount }
|
||||||
|
: { id: blockId, type: blockType, label: blockLabel, inputCount: blockInputCount, outputCount: blockOutputCount },
|
||||||
|
collect: (monitor) => ({ isDragging: monitor.isDragging() })
|
||||||
|
}), [blockType, blockLabel, blockId, blockInputCount, blockOutputCount, isNew]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
preview(getEmptyImage(), {captureDraggingState: true});
|
||||||
|
}, [preview]);
|
||||||
|
|
||||||
|
const draggingStyle = {
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
cursor: 'move',
|
||||||
|
pointerEvents: 'all'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
return(
|
||||||
|
<div
|
||||||
|
ref = {drag}
|
||||||
|
style = {{
|
||||||
|
...draggingStyle,
|
||||||
|
width: `68px`,
|
||||||
|
height: `84px`,
|
||||||
|
margin: '5px auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{// <ToolbarBlock type={block.type} label={block.label} />
|
||||||
|
}
|
||||||
|
<ToolbarBlock
|
||||||
|
type = {block.type}
|
||||||
|
label = {block.label}
|
||||||
|
inputCount = {block.inputCount}
|
||||||
|
outputCount = {block.outputCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
//
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return(
|
||||||
|
<foreignObject
|
||||||
|
className = "block-container"
|
||||||
|
x = {block.x || 0}
|
||||||
|
y = {block.y || 0}
|
||||||
|
width = "68"
|
||||||
|
height = "84"
|
||||||
|
style = {{overflow: 'visible'}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref = {drag}
|
||||||
|
style = {{
|
||||||
|
cursor: 'move',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
opacity: isDragging ? 0.7 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Block
|
||||||
|
key = {block.id}
|
||||||
|
blockId = {block.id}
|
||||||
|
type = {block.type}
|
||||||
|
label = {block.label}
|
||||||
|
inputCount = {block.inputCount}
|
||||||
|
outputCount = {block.outputCount}
|
||||||
|
onPortClick = {onPortClick}
|
||||||
|
activePort = {activePort}
|
||||||
|
connections = {connections}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(DraggableBlock);
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
function ModalData({ modalData, onClose, onSave }) {
|
|
||||||
const [inputValue, setInputValue] = useState('')
|
|
||||||
const [fromType, setFromType] = useState('INT')
|
|
||||||
const [toType, setToType] = useState('REAL')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setInputValue('')
|
|
||||||
setFromType('INT')
|
|
||||||
setToType('REAL')
|
|
||||||
}, [modalData])
|
|
||||||
|
|
||||||
if (!modalData)
|
|
||||||
return null
|
|
||||||
|
|
||||||
function getTypeValue() {
|
|
||||||
const value = inputValue.toLowerCase().trim()
|
|
||||||
const timeRegex = /^(t|time)#(-)?\d+(d|h|m|s|ms)(\d+(d|h|m|s|ms))*/
|
|
||||||
const realRegex = /^-?\d+\.\d+$/
|
|
||||||
const intRegex = /^-?\d+$/
|
|
||||||
|
|
||||||
if (value === 'true' || value === 'false')
|
|
||||||
return 'BOOL'
|
|
||||||
if (timeRegex.test(value))
|
|
||||||
return 'TIME'
|
|
||||||
if (value.startsWith("'") && value.endsWith("'"))
|
|
||||||
return 'STRING'
|
|
||||||
if (realRegex.test(value))
|
|
||||||
return 'REAL'
|
|
||||||
if (intRegex.test(value)) {
|
|
||||||
const num = Number(value)
|
|
||||||
if (num >= -32768 && num <= 32767)
|
|
||||||
return 'INT'
|
|
||||||
if (num >= -2147483648 && num <= 2147483647)
|
|
||||||
return 'DINT'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
const trimmed = inputValue.trim()
|
|
||||||
|
|
||||||
if (modalData.type === 'const') {
|
|
||||||
if (trimmed === '') {
|
|
||||||
alert('Переменная пуста')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (trimmed === '' && (modalData.type === 'input' || modalData.type === 'output')) {
|
|
||||||
alert('Не заданно имя переменной')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalData.type === 'const') {
|
|
||||||
const getedType = getTypeValue()
|
|
||||||
if (!getedType) {
|
|
||||||
alert('Не удалось получить тип переменной (не корректный ввод)')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onSave({
|
|
||||||
value: trimmed,
|
|
||||||
fromType: getedType,
|
|
||||||
toType: getedType
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onSave({
|
|
||||||
value: trimmed,
|
|
||||||
fromType,
|
|
||||||
toType
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWindowWithType() {
|
|
||||||
const types = ["BOOL", "INT", "DINT", "REAL", "STRING", "TIME"]
|
|
||||||
switch(modalData.type) {
|
|
||||||
case 'switch_type':
|
|
||||||
return(
|
|
||||||
<div style = {{ marginBottom: '15px' }}>
|
|
||||||
<label style = {{ fontSize: '12px', display: 'block', marginBottom: '5px' }}>Входной тип данных:</label>
|
|
||||||
<select
|
|
||||||
value = {fromType}
|
|
||||||
onChange={(e) => setFromType(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '6px', marginBottom: '10px' }}
|
|
||||||
>
|
|
||||||
{types.map((type) => (
|
|
||||||
<option key = {`from-${type}`} value = {type}>{type}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label style = {{ fontSize: '12px', display: 'block', marginBottom: '5px' }}>Выходной тип данных:</label>
|
|
||||||
<select
|
|
||||||
value = {toType}
|
|
||||||
onChange={(e) => setToType(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '6px' }}
|
|
||||||
>
|
|
||||||
{types.map((type) => (
|
|
||||||
<option key = {`from-${type}`} value = {type}>{type}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case 'const':
|
|
||||||
return(
|
|
||||||
<div style = {{ marginBottom: '15px' }}>
|
|
||||||
<label style = {{ fontSize: '12px', display: 'block', marginBottom: '5px' }}>Значение константы:</label>
|
|
||||||
<input
|
|
||||||
type = "text"
|
|
||||||
value = {inputValue}
|
|
||||||
onChange = {(e) => setInputValue(e.target.value)}
|
|
||||||
style = {{ width: '100%', padding: '6px', boxSizing: 'border-box', marginBottom: '12px' }}
|
|
||||||
placeholder = {12.5}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case 'input':
|
|
||||||
case 'output':
|
|
||||||
return(
|
|
||||||
<div style = {{ marginBottom: '15px' }}>
|
|
||||||
<label style = {{ fontSize: '12px', display: 'block', marginBottom: '5px' }}>Имя переменной/тега:</label>
|
|
||||||
<input
|
|
||||||
type = "text"
|
|
||||||
value = {inputValue}
|
|
||||||
onChange = {(e) => setInputValue(e.target.value)}
|
|
||||||
style = {{ width: '100%', padding: '6px', boxSizing: 'border-box', marginBottom: '12px' }}
|
|
||||||
placeholder = {'Name'}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<label style = {{ fontSize: '12px', display: 'block', marginBottom: '5px' }}>Тип данных:</label>
|
|
||||||
<select
|
|
||||||
value = {fromType}
|
|
||||||
onChange = {(e) => setFromType(e.target.value)}
|
|
||||||
style = {{ width: '100%', padding: '6px' }}
|
|
||||||
>
|
|
||||||
{types.map((type) => (
|
|
||||||
<option key={`from-${type}`} value = {type}>{type}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return(
|
|
||||||
<div className = "modalDataBack">
|
|
||||||
<div className = "modalDataMain">
|
|
||||||
<h4 style = {{ marginTop: 0, marginBottom: '15px'}}>Настройка: {modalData.label}</h4>
|
|
||||||
{getWindowWithType()}
|
|
||||||
<div style = {{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
|
||||||
<button
|
|
||||||
onClick = {onClose}
|
|
||||||
style = {{ padding: '6px 12px', cursor: 'pointer'}}
|
|
||||||
>Отмена</button>
|
|
||||||
<button
|
|
||||||
onClick = {handleConfirm}
|
|
||||||
style = {{
|
|
||||||
padding: '6px 12px',
|
|
||||||
background: '#1717D8',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>ОК</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ModalData
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import DraggableBlock from './DraggableBlock';
|
||||||
|
|
||||||
|
const NAME_CATEGORIES = {
|
||||||
|
logical: 'Логические',
|
||||||
|
arithmetic: 'Арифметические',
|
||||||
|
timers: 'Таймеры',
|
||||||
|
counters: 'Счётчики',
|
||||||
|
comparisons: 'Сравнения',
|
||||||
|
other: 'Другие',
|
||||||
|
customized: 'Пользовательские'
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCK_CATEGORIES = {
|
||||||
|
logical: [
|
||||||
|
{ type: 'and', label: 'AND', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'or', label: 'OR', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'not', label: 'NOT', inputCount: 1, outputCount: 1 },
|
||||||
|
{ type: 'xor', label: 'XOR', inputCount: 2, outputCount: 1 }],
|
||||||
|
arithmetic: [
|
||||||
|
{ type: 'add', label: 'ADD', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'sub', label: 'SUB', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'mul', label: 'MUL', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'div', label: 'DIV', inputCount: 2, outputCount: 1 }],
|
||||||
|
timers: [
|
||||||
|
{ type: 'ton', label: 'TON', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'tof', label: 'TOF', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'tp', label: 'TP', inputCount: 2, outputCount: 1 }],
|
||||||
|
counters: [
|
||||||
|
{ type: 'ctu', label: 'CTU', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'ctd', label: 'CTD', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'ctud', label: 'CTUD',inputCount: 2, outputCount: 1 }],
|
||||||
|
comparisons: [
|
||||||
|
{ type: 'gt', label: 'GT', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'lt', label: 'LT', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'eq', label: 'EQ', inputCount: 2, outputCount: 1 }],
|
||||||
|
other: [
|
||||||
|
{ type: 'sr', label: 'SR', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'rs', label: 'RS', inputCount: 2, outputCount: 1 },
|
||||||
|
{ type: 'move', label: 'MOVE', inputCount: 2, outputCount: 1 }],
|
||||||
|
customized: []
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const Toolbar = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState('logical');
|
||||||
|
const tabs = useMemo(() => Object.keys(BLOCK_CATEGORIES), []);
|
||||||
|
const activeBlocks = BLOCK_CATEGORIES[activeTab] || [];
|
||||||
|
|
||||||
|
return(
|
||||||
|
<div className = "toolbar">
|
||||||
|
<h4 style = {{ marginTop: '3px', marginBottom: '15px' }}>Блоки</h4>
|
||||||
|
<div className = "toolbarElements">
|
||||||
|
{ //Вкладки
|
||||||
|
}
|
||||||
|
<div className = "tabButtons">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key = {tab}
|
||||||
|
className = { activeTab === tab ? 'activeTab' : 'notActiveTab' }
|
||||||
|
data-row = { tab === 'customized' || tab === 'other' || tab === 'comparisons' ? '2' : '1' }
|
||||||
|
onClick = {() => setActiveTab(tab)}
|
||||||
|
title = {NAME_CATEGORIES[tab]}
|
||||||
|
> { NAME_CATEGORIES[tab] } </button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ /*Блоки*/ }
|
||||||
|
<div className = "toolbarBlocks">
|
||||||
|
{activeBlocks.map((block) => (
|
||||||
|
<DraggableBlock
|
||||||
|
key = {block.type}
|
||||||
|
block = {block}
|
||||||
|
isNew = {true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toolbar;
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import React, { useState, useMemo, useLayoutEffect, useRef } from 'react'
|
|
||||||
import {ToolbarBlock} from './ToolbarElements'
|
|
||||||
|
|
||||||
const NAME_CATEGORIES = {
|
|
||||||
logical: 'Логические',
|
|
||||||
arithmetic: 'Арифметические',
|
|
||||||
timers: 'Таймеры',
|
|
||||||
counters: 'Счётчики',
|
|
||||||
comparisons: 'Сравнения',
|
|
||||||
other: 'Другие',
|
|
||||||
data: 'Данные',
|
|
||||||
customized: 'Пользовательские'
|
|
||||||
};
|
|
||||||
|
|
||||||
const BLOCK_CATEGORIES = {
|
|
||||||
logical: [
|
|
||||||
{ type: 'and', label: 'AND', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'or', label: 'OR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'not', label: 'NOT', inputs: ['BOOL'], outputs: ['BOOL'], inputsLabels: ['IN'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'xor', label: 'XOR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] }],
|
|
||||||
arithmetic: [
|
|
||||||
{ type: 'add', label: 'ADD', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'sub', label: 'SUB', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'mul', label: 'MUL', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'div', label: 'DIV', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] }],
|
|
||||||
timers: [
|
|
||||||
{ type: 'ton', label: 'TON', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
|
|
||||||
{ type: 'tof', label: 'TOF', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
|
|
||||||
{ type: 'tp', label: 'TP', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] }],
|
|
||||||
counters: [
|
|
||||||
{ type: 'ctu', label: 'CTU', inputs: ['BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', '$/INT/DINT'], inputsLabels: ['CU', 'R', 'PV'], outputsLabels: ['Q', 'CV'] },
|
|
||||||
{ type: 'ctd', label: 'CTD', inputs: ['BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', '$/INT/DINT'], inputsLabels: ['CD', 'LD', 'PV'], outputsLabels: ['Q', 'CV'] },
|
|
||||||
{ type: 'ctud', label: 'CTUD', inputs: ['BOOL', 'BOOL', 'BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', 'BOOL', '$/INT/DINT'], inputsLabels: ['CU', 'CD', 'R', 'LD', 'PV'], outputsLabels: ['QU', 'QD', 'CV'] }],
|
|
||||||
comparisons: [
|
|
||||||
{ type: 'gt', label: '>', inputs: ['$/INT/DINT/REAL/TIME', '$/INT/DINT/REAL/TIME'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'lt', label: '<', inputs: ['$/INT/DINT/REAL/TIME', '$/INT/DINT/REAL/TIME'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'eq', label: '=', inputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING', '$/BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] }],
|
|
||||||
other: [
|
|
||||||
{ type: 'sr', label: 'SR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['S1', 'R'], outputsLabels: ['Q1'] },
|
|
||||||
{ type: 'rs', label: 'RS', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['S', 'R1'], outputsLabels: ['Q'] },
|
|
||||||
{ type: 'move', label: 'MOVE', inputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING', 'BOOL'], inputsLabels: ['IN'], outputsLabels: ['OUT', 'EN'] }],
|
|
||||||
data: [
|
|
||||||
{ type: 'input', label: 'input', inputs: [], outputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], inputsLabels: [], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'output', label: 'output', inputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], outputs: [], inputsLabels: ['IN'], outputsLabels: [] },
|
|
||||||
{ type: 'const', label: 'const', inputs: [], outputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], inputsLabels: [], outputsLabels: ['OUT'] },
|
|
||||||
{ type: 'switch_type', label: '*_TO_**', inputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], inputsLabels: ['IN'], outputsLabels: ['OUT'] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLOCK_SCALE = 1
|
|
||||||
|
|
||||||
function Toolbar() {
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('logical')
|
|
||||||
const containerRef = useRef(null)
|
|
||||||
const [caseWidth, setCaseWidth] = useState(0)
|
|
||||||
|
|
||||||
const tabs = useMemo(() => Object.keys(BLOCK_CATEGORIES), [])
|
|
||||||
const activeBlocks = BLOCK_CATEGORIES[activeTab] || []
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!containerRef.current)
|
|
||||||
return
|
|
||||||
|
|
||||||
function updateSize() {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect()
|
|
||||||
setCaseWidth(rect.width / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSize()
|
|
||||||
const observer = new ResizeObserver(() => updateSize())
|
|
||||||
observer.observe(containerRef.current)
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function onDragStart(event, block) {
|
|
||||||
const offsetX = event.nativeEvent.offsetX * BLOCK_SCALE
|
|
||||||
const offsetY = event.nativeEvent.offsetY * BLOCK_SCALE
|
|
||||||
|
|
||||||
const inputCount = block.inputs.length
|
|
||||||
const outputCount = block.outputs.length
|
|
||||||
let countForHeight = Math.max(inputCount, outputCount, 2)
|
|
||||||
let blockHeight = 50 + (countForHeight - 1) * 34
|
|
||||||
let blockWidth = 60
|
|
||||||
if (['input', 'output', 'const', 'switch_type'].includes(block.type)) {
|
|
||||||
countForHeight = Math.max(inputCount, outputCount)
|
|
||||||
blockHeight = 40 + (countForHeight - 1) * 34
|
|
||||||
blockWidth = blockHeight * 2
|
|
||||||
}
|
|
||||||
|
|
||||||
event.dataTransfer.setData('application/reactflow', JSON.stringify({
|
|
||||||
type: block.type,
|
|
||||||
label: block.label,
|
|
||||||
widthPrew: blockWidth,
|
|
||||||
heightPrew: blockHeight,
|
|
||||||
inputs: block.inputs,
|
|
||||||
outputs: block.outputs,
|
|
||||||
inputsLabels: block.inputsLabels,
|
|
||||||
outputsLabels: block.outputsLabels,
|
|
||||||
offsetX,
|
|
||||||
offsetY
|
|
||||||
}))
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
|
||||||
}
|
|
||||||
|
|
||||||
return(
|
|
||||||
<div className = "toolbar">
|
|
||||||
<h4 style = {{ marginTop: '3px', marginBottom: '15px', textAlign: 'center' }}>Блоки</h4>
|
|
||||||
<div className = "toolbarElements">
|
|
||||||
{ /*Вкладки*/ }
|
|
||||||
<div className = "tabButtons">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key = {tab}
|
|
||||||
className = { activeTab === tab ? 'activeTab' : 'notActiveTab' }
|
|
||||||
data-row = {
|
|
||||||
['logical', 'arithmetic', 'timers', 'counters'].includes(tab) ? '1' :
|
|
||||||
['comparisons', 'other', 'data'].includes(tab) ? '2' :
|
|
||||||
'3'
|
|
||||||
}
|
|
||||||
onClick = {() => setActiveTab(tab)}
|
|
||||||
title = {NAME_CATEGORIES[tab]}
|
|
||||||
> { NAME_CATEGORIES[tab] } </button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ /*Блоки*/ }
|
|
||||||
<div className = "toolbarBlocks">
|
|
||||||
{activeBlocks.map((block) => (
|
|
||||||
<ToolbarBlock
|
|
||||||
key = {block.type}
|
|
||||||
block = {block}
|
|
||||||
onDragStart = {onDragStart}
|
|
||||||
scale = {BLOCK_SCALE}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Toolbar
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React, {memo} from 'react';
|
||||||
|
|
||||||
|
const CENTER_X = 4;
|
||||||
|
const CENTER_Y = 0;
|
||||||
|
|
||||||
|
const ToolbarBlock = ({ type, label, inputCount, outputCount }) => {
|
||||||
|
|
||||||
|
const blockHeight = 50 + (Math.max(inputCount, outputCount, 2) - 1) * 34;
|
||||||
|
const blockWidth = blockHeight / 1.4;
|
||||||
|
|
||||||
|
const renderBlock = () => {
|
||||||
|
const textX = type === 'or' ? 32 : 24;
|
||||||
|
const outputsCoord = [];
|
||||||
|
const inputsCoord = [];
|
||||||
|
|
||||||
|
if (inputCount === 1)
|
||||||
|
inputsCoord.push(blockHeight / 2);
|
||||||
|
else {
|
||||||
|
const stepInput = (blockHeight - 50) / (inputCount - 1);
|
||||||
|
for (let i = 0; i < inputCount; i++)
|
||||||
|
inputsCoord.push(25 + i * stepInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputCount === 1)
|
||||||
|
outputsCoord.push(blockHeight / 2);
|
||||||
|
else {
|
||||||
|
const stepOutput = (blockHeight - 50) / (outputCount - 1);
|
||||||
|
for (let i = 0; i < outputCount; i++)
|
||||||
|
outputsCoord.push(25 + i * stepOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
return(
|
||||||
|
<svg
|
||||||
|
width={blockWidth + 2 * CENTER_X}
|
||||||
|
height={blockHeight}
|
||||||
|
viewBox={`0 0 ${blockWidth + 2 * CENTER_X} ${blockHeight}`}
|
||||||
|
style={{
|
||||||
|
overflow: 'visible',
|
||||||
|
transform: `translate(${CENTER_X}px, ${CENTER_Y}px)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<rect width = {blockWidth} height = {blockHeight} fill = "lightgray" stroke = "black"/>
|
||||||
|
<text x = {textX} y = "20" textAnchor = "start" fontSize = "14" style = {{pointerEvents: 'none'}}>{label}</text>
|
||||||
|
|
||||||
|
{//
|
||||||
|
inputsCoord.map((coord, ind) => (
|
||||||
|
<circle key = {`in-${ind + 1}`} cx = "0" cy = {coord} r = "4" fill = '#1717D8'/>
|
||||||
|
))}
|
||||||
|
{outputsCoord.map((coord, ind) => (
|
||||||
|
<circle key = {`out-${ind + 1}`} cx = {blockWidth} cy = {coord} r = "4" fill = '#D8000A'/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return(renderBlock());
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(ToolbarBlock);
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import React, {memo} from 'react';
|
|
||||||
|
|
||||||
export const ToolbarBlock = memo(({ block, onDragStart, scale = 1 }) => {
|
|
||||||
const inputCount = block.inputs.length
|
|
||||||
const outputCount = block.outputs.length
|
|
||||||
let countForHeight = Math.max(inputCount, outputCount, 2)
|
|
||||||
let blockHeight = (50 + (countForHeight - 1) * 34) / scale
|
|
||||||
let blockWidth = 60 / scale
|
|
||||||
if (['input', 'output', 'const', 'switch_type'].includes(block.type)) {
|
|
||||||
countForHeight = Math.max(inputCount, outputCount)
|
|
||||||
blockHeight = (40 + (countForHeight - 1) * 34) / scale
|
|
||||||
blockWidth = blockHeight * 2
|
|
||||||
}
|
|
||||||
block.widthPrew = blockWidth
|
|
||||||
block.heightPrew = blockHeight
|
|
||||||
|
|
||||||
function getCoords(count) {
|
|
||||||
const coords = []
|
|
||||||
if (count === 1)
|
|
||||||
coords.push(50)
|
|
||||||
else if (count < countForHeight) {
|
|
||||||
const step = blockHeight / (count + 1)
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const y = (i + 1) * step
|
|
||||||
coords.push((y / blockHeight) * 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const step = (blockHeight - (50 / scale)) / (count - 1)
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const y = 25 / scale + i * step
|
|
||||||
coords.push((y / blockHeight) * 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return coords
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputsCoords = getCoords(inputCount)
|
|
||||||
const outputsCoords = getCoords(outputCount)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className = "toolbar-block"
|
|
||||||
draggable
|
|
||||||
onDragStart = {(e) => onDragStart(e, block)}
|
|
||||||
style = {{
|
|
||||||
width: `${blockWidth}px`,
|
|
||||||
height: `${blockHeight}px`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className = "preview-title" style = {{ fontSize: `${13 / scale}px` }}>{block.label}</div>
|
|
||||||
{inputsCoords.map((coord, ind) => (
|
|
||||||
<div key = {`t-in-${ind}`}
|
|
||||||
className = "preview-port"
|
|
||||||
style = {{
|
|
||||||
top: `${coord}%`,
|
|
||||||
left: 0,
|
|
||||||
backgroundColor: '#1717D8',
|
|
||||||
transform: 'translate(-50%, -50%)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{outputsCoords.map((coord, ind) => (
|
|
||||||
<div key = {`t-out-${ind}`}
|
|
||||||
className = "preview-port"
|
|
||||||
style = {{
|
|
||||||
top: `${coord}%`,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: '#D8000A',
|
|
||||||
transform: 'translate(50%, -50%)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { DndProvider } from 'react-dnd';
|
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<App />
|
||||||
|
</DndProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,9 +0,0 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import App from './App.jsx'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const reportWebVitals = onPerfEntry => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
export function convertGraphToXml(nodes, edges) {
|
|
||||||
let xml = `<?xml version="1.0" encoding="utf-8"?>\n`
|
|
||||||
xml += `<project xmlns="http://www.plcopen.org/xml/tc6_0201" `
|
|
||||||
xml += `xmlns:xsd="http://www.w3.org/2001/XMLSchema-instance" `
|
|
||||||
xml += `xmlns:ns1="http://www.plcopen.org/xml/tc6_0201" `
|
|
||||||
xml += `xmlns:xhtml="http://www.w3.org/1999/xhtml\">\n`
|
|
||||||
|
|
||||||
xml += ` <fileHeader companyName="University" productName="FBD Editor" productVersion="1" creationDateTime="${new Date().toISOString().split('.')[0] + 'Z'}"/>\n`
|
|
||||||
xml += ` <contentHeader name="diagram" modificationDateTime="${new Date().toISOString().split('.')[0] + 'Z'}">\n`
|
|
||||||
xml += ` <coordinateInfo>\n`
|
|
||||||
xml += ` <fbd><scaling x="1" y="1"/></fbd>\n`
|
|
||||||
xml += ` <ld><scaling x="1" y="1"/></ld>\n`
|
|
||||||
xml += ` <sfc><scaling x="1" y="1"/></sfc>\n`
|
|
||||||
xml += ` </coordinateInfo>\n`
|
|
||||||
xml += ` </contentHeader>\n`
|
|
||||||
xml += ` <types>\n`
|
|
||||||
xml += ` <dataTypes/>\n`
|
|
||||||
xml += ` <pous>\n`
|
|
||||||
xml += ` <pou name="main" pouType="program">\n`
|
|
||||||
|
|
||||||
function getPortType(type) {
|
|
||||||
const newType = type.toUpperCase().replace('$/', '')
|
|
||||||
|
|
||||||
if (!newType.includes('/'))
|
|
||||||
return newType
|
|
||||||
|
|
||||||
const types = newType.split('/')
|
|
||||||
const hasAll = (...neededTypes) => neededTypes.every(t => types.includes(t))
|
|
||||||
|
|
||||||
if (hasAll('BOOL', 'INT', 'DINT', 'REAL', 'TIME', 'STRING'))
|
|
||||||
return 'ANY'
|
|
||||||
if (hasAll('INT', 'DINT', 'REAL', 'TIME'))
|
|
||||||
return 'ANY_MAGNITUDE'
|
|
||||||
if (hasAll('INT', 'DINT', 'REAL'))
|
|
||||||
return 'ANY_NUM'
|
|
||||||
if (hasAll('INT', 'DINT'))
|
|
||||||
return 'ANY_INT'
|
|
||||||
return 'ANY'
|
|
||||||
}
|
|
||||||
|
|
||||||
let dopInd = 1
|
|
||||||
const nodesIndexes = new Map()
|
|
||||||
nodes.forEach(node => {
|
|
||||||
let counts = 0
|
|
||||||
if ((['input', 'const'].includes(node.data.type)) && node.data.value) {
|
|
||||||
edges.forEach(edge => {
|
|
||||||
if (edge.source === node.id)
|
|
||||||
counts += 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else if (node.data.type === 'output' && node.data.value) {
|
|
||||||
edges.forEach(edge => {
|
|
||||||
if (edge.target === node.id)
|
|
||||||
counts += 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else
|
|
||||||
counts += 1
|
|
||||||
|
|
||||||
if (counts !== 0) {
|
|
||||||
nodesIndexes.set(node.id, [1000000000 + dopInd])
|
|
||||||
dopInd += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let ind = 1; ind < counts; ind++, dopInd++)
|
|
||||||
nodesIndexes.get(node.id).push(1000000000 + dopInd)
|
|
||||||
})
|
|
||||||
|
|
||||||
function getNewBlockId(id) {
|
|
||||||
const newInd = nodesIndexes.get(id)
|
|
||||||
if (newInd.length > 1) {
|
|
||||||
const t = nodesIndexes.get(id).splice(0, 1)[0]
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
else if (newInd)
|
|
||||||
return newInd[0]
|
|
||||||
|
|
||||||
dopInd += 1
|
|
||||||
return 1000000000 + dopInd
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += ` <interface>\n`
|
|
||||||
|
|
||||||
const valuesIn = new Map()
|
|
||||||
const valuesOut = new Map()
|
|
||||||
nodes.forEach(node => {
|
|
||||||
if (node.data.type === 'input' && node.data.value)
|
|
||||||
valuesIn.set(node.data.value, node.data.metaFromType || 'ANY')
|
|
||||||
else if (node.data.type === 'output' && node.data.value)
|
|
||||||
valuesOut.set(node.data.value, node.data.metaFromType || 'ANY')
|
|
||||||
})
|
|
||||||
|
|
||||||
if (valuesIn) {
|
|
||||||
xml += ` <inputVars>\n`
|
|
||||||
valuesIn.forEach((type, value) => {
|
|
||||||
xml += ` <variable name="${value}">\n`
|
|
||||||
xml += ` <type><${getPortType(type)}/></type>\n`
|
|
||||||
xml += ` </variable>\n`
|
|
||||||
})
|
|
||||||
xml += ` </inputVars>\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valuesOut) {
|
|
||||||
xml += ` <outputVars>\n`
|
|
||||||
valuesOut.forEach((type, value) => {
|
|
||||||
xml += ` <variable name="${value}">\n`
|
|
||||||
xml += ` <type><${getPortType(type)}/></type>\n`
|
|
||||||
xml += ` </variable>\n`
|
|
||||||
})
|
|
||||||
xml += ` </outputVars>\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += ` </interface>\n`
|
|
||||||
|
|
||||||
xml += ` <body>\n`
|
|
||||||
xml += ` <FBD>\n\n`
|
|
||||||
|
|
||||||
nodes.forEach(node => {
|
|
||||||
const newInd = nodesIndexes.get(node.id)
|
|
||||||
if (node && node.data.type === 'input' || node.data.type === 'const') {
|
|
||||||
for (let i = 0; i < newInd?.length; i++) {
|
|
||||||
xml += ` <inVariable localId="${newInd[i]}" width="${node.data.width}" height="${node.data.height}">\n`
|
|
||||||
xml += ` <position x="${Math.round(node.position.x)}" y="${Math.round(node.position.y)}"/>\n`
|
|
||||||
xml += ` <connectionPointOut/>\n`
|
|
||||||
xml += ` <expression>${node.data.value}</expression>\n`
|
|
||||||
xml += ` </inVariable>\n\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
nodes.forEach(node => {
|
|
||||||
const newInd = nodesIndexes.get(node.id)
|
|
||||||
if (node.data.type === 'output') {
|
|
||||||
for (let i = 0; i < newInd?.length; i++) {
|
|
||||||
xml += ` <outVariable localId="${newInd[i]}" width="${node.data.width}" height="${node.data.height}">\n`
|
|
||||||
xml += ` <position x="${Math.round(node.position.x)}" y="${Math.round(node.position.y)}"/>\n`
|
|
||||||
xml += ` <connectionPointIn>\n`
|
|
||||||
|
|
||||||
edges.forEach(edge => {
|
|
||||||
if (edge.target === node.id) {
|
|
||||||
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
|
|
||||||
const targetLabel = node.data.inputsLabels[targetIndex]
|
|
||||||
|
|
||||||
if (targetLabel === 'IN') {
|
|
||||||
const sourceNode = nodes.find(node => node.id === edge.source)
|
|
||||||
if (['const', 'input'].includes(sourceNode.data.type))
|
|
||||||
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}"/>\n`
|
|
||||||
else {
|
|
||||||
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
|
|
||||||
const sourceLabel = sourceNode.data.outputsLabels[sourceIndex]
|
|
||||||
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}" formalParameter="${sourceLabel}"/>\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
xml += ` </connectionPointIn>\n`
|
|
||||||
xml += ` <expression>${node.data.value}</expression>\n`
|
|
||||||
xml += ` </outVariable>\n\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
nodes.forEach(node => {
|
|
||||||
if (!['output', 'input', 'const'].includes(node.data.type)) {
|
|
||||||
xml += ` <block localId="${getNewBlockId(node.id)}" typeName="${node.data.type === 'switch_type' ? node.data.label.toUpperCase() : node.data.type.toUpperCase()}" width="${node.data.width}" height="${node.data.height}">\n`
|
|
||||||
xml += ` <position x="${Math.round(node.position.x)}" y="${Math.round(node.position.y)}"/>\n`
|
|
||||||
xml += ` <inputVariables>\n`
|
|
||||||
for (const portType of node.data.inputsLabels) {
|
|
||||||
xml += ` <variable formalParameter="${portType}">\n`
|
|
||||||
xml += ` <connectionPointIn>\n`
|
|
||||||
edges.forEach(edge => {
|
|
||||||
if (edge.target === node.id) {
|
|
||||||
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
|
|
||||||
const targetLabel = node.data.inputsLabels[targetIndex]
|
|
||||||
|
|
||||||
if (targetLabel === portType) {
|
|
||||||
const sourceNode = nodes.find(node => node.id === edge.source)
|
|
||||||
if (['const', 'input'].includes(sourceNode.data.type))
|
|
||||||
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}"/>\n`
|
|
||||||
else {
|
|
||||||
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
|
|
||||||
const sourceLabel = sourceNode.data.outputsLabels[sourceIndex]
|
|
||||||
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}" formalParameter="${sourceLabel}"/>\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
xml += ` </connectionPointIn>\n`
|
|
||||||
xml += ` </variable>\n`
|
|
||||||
}
|
|
||||||
xml += ` </inputVariables>\n`
|
|
||||||
xml += ` <inOutVariables/>\n`
|
|
||||||
xml += ` <outputVariables>\n`
|
|
||||||
for (const portType of node.data.outputsLabels) {
|
|
||||||
xml += ` <variable formalParameter="${portType}">\n`
|
|
||||||
xml += ` <connectionPointOut/>\n`
|
|
||||||
xml += ` </variable>\n`
|
|
||||||
}
|
|
||||||
xml += ` </outputVariables>\n`
|
|
||||||
xml += ` </block>\n\n`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
xml += ` </FBD>\n`
|
|
||||||
xml += ` </body>\n`
|
|
||||||
xml += ` </pou>\n`
|
|
||||||
xml += ` </pous>\n`
|
|
||||||
xml += ` </types>\n`
|
|
||||||
xml += ` <instances>\n`
|
|
||||||
xml += ` <configurations/>\n`
|
|
||||||
xml += ` </instances>\n`
|
|
||||||
xml += `</project>`
|
|
||||||
return xml
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseXmlToGraph(xml) {
|
|
||||||
const importedNodes = []
|
|
||||||
const importedEdges = []
|
|
||||||
const xmlIdToReactId = new Map()
|
|
||||||
const interfaceVars = new Map() // name: { dataType, initialValue, varGroup }
|
|
||||||
|
|
||||||
const xmlVariables = xml.querySelectorAll('interface * > variable')
|
|
||||||
xmlVariables.forEach(v => {
|
|
||||||
const name = v.getAttribute('name')
|
|
||||||
const varType = v.parentElement.tagName.toLowerCase()
|
|
||||||
const type = v.querySelector('type *')
|
|
||||||
const value = v.querySelector('initialValue simpleValue')?.getAttribute('value') || ''
|
|
||||||
if (name && type)
|
|
||||||
interfaceVars.set(name, {
|
|
||||||
dataType: type.tagName.toUpperCase(),
|
|
||||||
value,
|
|
||||||
varType
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function restoreType(recivedType) {
|
|
||||||
const type = recivedType.toUpperCase()
|
|
||||||
if (type === 'ANY') return 'BOOl/INT/DINT/REAL/TIME/STRING'
|
|
||||||
if (type === 'ANY_MAGNITUDE') return 'INT/DINT/REAL/TIME'
|
|
||||||
if (type === 'ANY_NUM') return 'INT/DINT/REAL'
|
|
||||||
if (type === 'ANY_INT') return 'INT/DINT'
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlInVariables = xml.querySelectorAll('inVariable')
|
|
||||||
xmlInVariables.forEach(inVar => {
|
|
||||||
const xmlId = inVar.getAttribute('localId')
|
|
||||||
const width = parseFloat(inVar.getAttribute('width') || '80')
|
|
||||||
const height = parseFloat(inVar.getAttribute('height') || '40')
|
|
||||||
const pos = inVar.querySelector('position')
|
|
||||||
const x = parseFloat(pos?.getAttribute('x') || '0')
|
|
||||||
const y = parseFloat(pos?.getAttribute('y') || '0')
|
|
||||||
const value = inVar.querySelector('expression')?.textContent || ''
|
|
||||||
|
|
||||||
function isItConst(recivedValue) {
|
|
||||||
const value = recivedValue.toLowerCase().trim()
|
|
||||||
const timeRegex = /^(t|time)#(-)?\d+(d|h|m|s|ms)(\d+(d|h|m|s|ms))*/
|
|
||||||
const realRegex = /^-?\d+\.\d+$/
|
|
||||||
const intRegex = /^-?\d+$/
|
|
||||||
|
|
||||||
if (value === 'true' || value === 'false')
|
|
||||||
return 'BOOL'
|
|
||||||
if (timeRegex.test(value))
|
|
||||||
return 'TIME'
|
|
||||||
if (value.startsWith("'") && value.endsWith("'"))
|
|
||||||
return 'STRING'
|
|
||||||
if (realRegex.test(value))
|
|
||||||
return 'REAL'
|
|
||||||
if (intRegex.test(value)) {
|
|
||||||
const num = Number(value)
|
|
||||||
if (num >= -32768 && num <= 32767)
|
|
||||||
return 'INT'
|
|
||||||
if (num >= -2147483648 && num <= 2147483647)
|
|
||||||
return 'DINT'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let isConst = isItConst(value) ? true : false
|
|
||||||
let varInfo = interfaceVars.get(value)
|
|
||||||
const outputPortType = varInfo?.dataType || isItConst(value) || 'ANY'
|
|
||||||
|
|
||||||
if (varInfo?.varType === 'tempvars')
|
|
||||||
isConst = true
|
|
||||||
|
|
||||||
const finalType = isConst ? 'const' : 'input'
|
|
||||||
let finalValue = value
|
|
||||||
if (varInfo?.varType === 'tempvars' && varInfo.value)
|
|
||||||
finalValue = varInfo.value
|
|
||||||
|
|
||||||
let existingNode = importedNodes.find(node => node.position.x === x && node.position.y === y && node.data.value === finalValue)
|
|
||||||
if (!existingNode) {
|
|
||||||
const newId = `${xmlId}`
|
|
||||||
const label = finalType === 'const' ? `${finalValue} (${outputPortType})` : `${finalValue} (${outputPortType})`
|
|
||||||
existingNode = {
|
|
||||||
id: newId,
|
|
||||||
type: 'fbdBlock',
|
|
||||||
position: {x, y},
|
|
||||||
data: {
|
|
||||||
type: finalType, label,
|
|
||||||
value: finalValue,
|
|
||||||
metaFromType: outputPortType,
|
|
||||||
width, height,
|
|
||||||
inputs: [],
|
|
||||||
outputs: [restoreType(outputPortType)],
|
|
||||||
inputsLabels: [],
|
|
||||||
outputsLabels: ['OUT']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
importedNodes.push(existingNode)
|
|
||||||
}
|
|
||||||
xmlIdToReactId.set(xmlId, {reactId: existingNode.id, portIndex: 0, handleType: 'out'})
|
|
||||||
})
|
|
||||||
|
|
||||||
const xmlOutVariables = xml.querySelectorAll('outVariable')
|
|
||||||
xmlOutVariables.forEach(outVar => {
|
|
||||||
const xmlId = outVar.getAttribute('localId')
|
|
||||||
const width = parseFloat(outVar.getAttribute('width') || '80')
|
|
||||||
const height = parseFloat(outVar.getAttribute('height') || '40')
|
|
||||||
const pos = outVar.querySelector('position')
|
|
||||||
const x = parseFloat(pos?.getAttribute('x') || '0')
|
|
||||||
const y = parseFloat(pos?.getAttribute('y') || '0')
|
|
||||||
const value = outVar.querySelector('expression')?.textContent || ''
|
|
||||||
let varInfo = interfaceVars.get(value)
|
|
||||||
|
|
||||||
const dataType = varInfo?.dataType || 'ANY'
|
|
||||||
let existingNode = importedNodes.find(node => node.position.x === x && node.position.y === y && node.data.value === value)
|
|
||||||
if (!existingNode) {
|
|
||||||
existingNode = {
|
|
||||||
id: xmlId,
|
|
||||||
type: 'fbdBlock',
|
|
||||||
position: {x, y},
|
|
||||||
data: {
|
|
||||||
type: 'output',
|
|
||||||
label: `${value} (${dataType})`,
|
|
||||||
value,
|
|
||||||
metaFromType: dataType,
|
|
||||||
width, height,
|
|
||||||
inputs: [restoreType(dataType)],
|
|
||||||
outputs: [],
|
|
||||||
inputsLabels: ['IN'],
|
|
||||||
outputsLabels: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
importedNodes.push(existingNode)
|
|
||||||
}
|
|
||||||
xmlIdToReactId.set(xmlId, {reactId: existingNode.id, portIndex: 0, handleType: 'in'})
|
|
||||||
|
|
||||||
const connection = outVar.querySelector('connectionPointIn connection')
|
|
||||||
if (connection) {
|
|
||||||
const sourceNode = connection.getAttribute('refLocalId')
|
|
||||||
const sourcePortLabel = connection.getAttribute('formalParameter')
|
|
||||||
importedEdges.push({
|
|
||||||
xmlSourceNode: sourceNode,
|
|
||||||
xmlSourcePortLabel: sourcePortLabel,
|
|
||||||
reactTargetId: existingNode.id,
|
|
||||||
reactTargetPort: 'in-0'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const libraryBlocks = {
|
|
||||||
and: { label: 'AND', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
or: { label: 'OR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
not: { label: 'NOT', inputs: ['BOOL'], outputs: ['BOOL'], inputsLabels: ['IN'], outputsLabels: ['OUT'] },
|
|
||||||
xor: { label: 'XOR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
add: { label: 'ADD', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
sub: { label: 'SUB', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
mul: { label: 'MUL', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
div: { label: 'DIV', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
ton: { label: 'TON', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
|
|
||||||
tof: { label: 'TOF', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
|
|
||||||
tp: { label: 'TP', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
|
|
||||||
ctu: { label: 'CTU', inputs: ['BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', '$/INT/DINT'], inputsLabels: ['CU', 'R', 'PV'], outputsLabels: ['Q', 'CV'] },
|
|
||||||
ctd: { label: 'CTD', inputs: ['BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', '$/INT/DINT'], inputsLabels: ['CD', 'LD', 'PV'], outputsLabels: ['Q', 'CV'] },
|
|
||||||
ctud: { label: 'CTUD', inputs: ['BOOL', 'BOOL', 'BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', 'BOOL', '$/INT/DINT'], inputsLabels: ['CU', 'CD', 'R', 'LD', 'PV'], outputsLabels: ['QU', 'QD', 'CV'] },
|
|
||||||
gt: { label: '>', inputs: ['$/INT/DINT/REAL/TIME', '$/INT/DINT/REAL/TIME'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
lt: { label: '<', inputs: ['$/INT/DINT/REAL/TIME', '$/INT/DINT/REAL/TIME'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
eq: { label: '=', inputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING', '$/BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
|
|
||||||
sr: { label: 'SR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['S1', 'R'], outputsLabels: ['Q1'] },
|
|
||||||
rs: { label: 'RS', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['S', 'R1'], outputsLabels: ['Q'] },
|
|
||||||
move: { label: 'MOVE', inputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING'], inputsLabels: ['IN'], outputsLabels: ['OUT'] },
|
|
||||||
switch_type: { label: '*_TO_**', inputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], inputsLabels: ['IN'], outputsLabels: ['OUT'] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlBlocks = xml.querySelectorAll('FBD > block')
|
|
||||||
xmlBlocks.forEach(block => {
|
|
||||||
const xmlId = block.getAttribute('localId')
|
|
||||||
const xmlType = block.getAttribute('typeName')
|
|
||||||
const pos = block.querySelector('position')
|
|
||||||
const x = parseFloat(pos?.getAttribute('x') || '0')
|
|
||||||
const y = parseFloat(pos?.getAttribute('y') || '0')
|
|
||||||
const label = block.querySelector('content')?.textContent || libraryBlocks[xmlType.toLowerCase()]?.label || xmlType
|
|
||||||
const value = block.querySelector('additionalData')?.getAttribute('value') || ''
|
|
||||||
let inputsLabels = Array.from(block.querySelectorAll('inputVariables variable')).map(v => v.getAttribute('formalParameter'))
|
|
||||||
let inputs = Array.from(block.querySelectorAll('inputVariables variable')).map(v => restoreType(v.getAttribute('dataType') || 'ANY'))
|
|
||||||
let outputsLabels = Array.from(block.querySelectorAll('outputVariables variable')).map(v => v.getAttribute('formalParameter'))
|
|
||||||
let outputs = Array.from(block.querySelectorAll('outputVariables variable')).map(v => restoreType(v.getAttribute('dataType') || 'ANY'))
|
|
||||||
|
|
||||||
if (inputsLabels.length === libraryBlocks[xmlType.toLowerCase()]?.inputsLabels.length)
|
|
||||||
inputsLabels = libraryBlocks[xmlType.toLowerCase()].inputsLabels
|
|
||||||
if (outputsLabels.length === libraryBlocks[xmlType.toLowerCase()]?.outputsLabels.length)
|
|
||||||
outputsLabels = libraryBlocks[xmlType.toLowerCase()].outputsLabels
|
|
||||||
if (inputs.length === libraryBlocks[xmlType.toLowerCase()]?.inputs.length)
|
|
||||||
inputs = libraryBlocks[xmlType.toLowerCase()].inputs
|
|
||||||
if (outputs.length === libraryBlocks[xmlType.toLowerCase()]?.outputs.length)
|
|
||||||
outputs = libraryBlocks[xmlType.toLowerCase()].outputs
|
|
||||||
|
|
||||||
const isSwitchType = xmlType.includes('_TO_')
|
|
||||||
const finalType = isSwitchType ? 'switch_type' : xmlType.toLowerCase()
|
|
||||||
const width = parseFloat(block.getAttribute('width') || (isSwitchType ? '80' : 50 + (Math.max(inputs.length, outputs.length, 2) - 1) * 34))
|
|
||||||
const height = parseFloat(block.getAttribute('height') || (isSwitchType ? '40' : '60'))
|
|
||||||
const newNode = {
|
|
||||||
id: xmlId,
|
|
||||||
type: 'fbdBlock',
|
|
||||||
position: {x, y},
|
|
||||||
data: { type: finalType, label, value, width, height, inputs, outputs, inputsLabels, outputsLabels}
|
|
||||||
}
|
|
||||||
importedNodes.push(newNode)
|
|
||||||
xmlIdToReactId.set(xmlId, { reactId: xmlId, inputsLabels, outputsLabels })
|
|
||||||
|
|
||||||
const variable = block.querySelectorAll('inputVariables variable')
|
|
||||||
variable.forEach((v, inputIndex) => {
|
|
||||||
const connection = v.querySelector('connectionPointIn connection')
|
|
||||||
if (connection) {
|
|
||||||
const sourceNode = connection.getAttribute('refLocalId')
|
|
||||||
const sourcePortLabel = connection.getAttribute('formalParameter')
|
|
||||||
importedEdges.push({
|
|
||||||
xmlSourceNode: sourceNode,
|
|
||||||
xmlSourcePortLabel: sourcePortLabel,
|
|
||||||
reactTargetId: xmlId,
|
|
||||||
reactTargetPort: `in-${inputIndex}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const finalEdges = importedEdges.map((edgeData, index) => {
|
|
||||||
const sourceMeta = xmlIdToReactId.get(edgeData.xmlSourceNode)
|
|
||||||
if (!sourceMeta)
|
|
||||||
return null
|
|
||||||
let sourceHandle = 'out-0'
|
|
||||||
if (sourceMeta.outputsLabels) {
|
|
||||||
const outIndex = sourceMeta.outputsLabels.indexOf(edgeData.xmlSourcePortLabel)
|
|
||||||
sourceHandle = `out-${outIndex >= 0 ? outIndex : 0}`
|
|
||||||
}
|
|
||||||
const sourceNode = importedNodes.find(node => node.id === sourceMeta.reactId)
|
|
||||||
const sourceIndex = parseInt(sourceHandle.split('-')[1], 10)
|
|
||||||
const sourceType = sourceNode?.data?.outputs[sourceIndex] || 'ANY'
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `e_${Date.now()}_${index}`,
|
|
||||||
source: sourceMeta.reactId,
|
|
||||||
target: edgeData.reactTargetId,
|
|
||||||
sourceHandle: sourceHandle,
|
|
||||||
targetHandle: edgeData.reactTargetPort,
|
|
||||||
type: 'smoothstep',
|
|
||||||
style: EDGE_STYLES[sourceType.toLowerCase().split('/')[0]] || EDGE_STYLES['bool'],
|
|
||||||
data: {type: sourceType.toLowerCase().split('/')[0]}
|
|
||||||
}
|
|
||||||
}).filter(Boolean)
|
|
||||||
|
|
||||||
const finalNodes = importedNodes.map(node => {
|
|
||||||
if (['add', 'sub', 'mul', 'div', 'move'].includes(node.data.type))
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...node.data,
|
|
||||||
inputs: node.data.inputs.map(i => '$/' + i),
|
|
||||||
outputs: node.data.outputs.map(i => '$/' + i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (['ctu', 'ctd', 'ctud'].includes(node.data.type))
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...node.data,
|
|
||||||
inputs: node.data.inputs.map((i, ind) => {
|
|
||||||
if (node.data.inputsLabels.toLowerCase() === 'pv')
|
|
||||||
return '$/' + i
|
|
||||||
else return i
|
|
||||||
}),
|
|
||||||
outputs: node.data.outputs.map((i, ind) => {
|
|
||||||
if (node.data.inputsLabels.toLowerCase() === 'cv')
|
|
||||||
return '$/' + i
|
|
||||||
else return i
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (['gt', 'lt', 'eq'].includes(node.data.type))
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...node.data,
|
|
||||||
inputs: node.data.inputs.map(i => '$/' + i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else return node
|
|
||||||
})
|
|
||||||
return {finalNodes, finalEdges}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
})
|
|
||||||