Compare commits

..
9 Commits
39 changed files with 3346 additions and 17117 deletions
+21 -20
View File
@@ -1,23 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Logs
logs
# dependencies *.log
/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?
-70
View File
@@ -1,70 +0,0 @@
# 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)
+21
View File
@@ -0,0 +1,21 @@
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 } },
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!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>
+1338 -16521
View File
File diff suppressed because it is too large Load Diff
+22 -34
View File
@@ -1,41 +1,29 @@
{ {
"name": "fbd-editor", "name": "fbd-editor-2",
"version": "0.1.0",
"private": true, "private": true,
"dependencies": { "version": "0.0.0",
"@use-gesture/react": "^10.3.1", "type": "module",
"cra-template": "1.3.0",
"d3": "^7.9.0",
"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"
},
"scripts": { "scripts": {
"start": "react-scripts start", "dev": "vite",
"build": "react-scripts build", "build": "vite build",
"test": "react-scripts test", "lint": "eslint .",
"eject": "react-scripts eject" "preview": "vite preview"
}, },
"eslintConfig": { "dependencies": {
"extends": [ "@xyflow/react": "^12.10.2",
"react-app", "html-to-image": "^1.11.13",
"react-app/jest" "react": "^19.2.6",
] "react-dom": "^19.2.6"
}, },
"browserslist": { "devDependencies": {
"production": [ "@eslint/js": "^10.0.1",
">0.2%", "@types/react": "^19.2.14",
"not dead", "@types/react-dom": "^19.2.3",
"not op_mini all" "@vitejs/plugin-react": "^6.0.1",
], "eslint": "^10.3.0",
"development": [ "eslint-plugin-react-hooks": "^7.1.1",
"last 1 chrome version", "eslint-plugin-react-refresh": "^0.5.2",
"last 1 firefox version", "globals": "^17.6.0",
"last 1 safari version" "vite": "^8.0.12"
]
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<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>

After

Width:  |  Height:  |  Size: 4.9 KiB

-43
View File
@@ -1,43 +0,0 @@
<!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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

-25
View File
@@ -1,25 +0,0 @@
{
"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"
}
-3
View File
@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+240 -27
View File
@@ -1,41 +1,82 @@
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: calc(100% - 20px); width: 100%;
height: 10px; height: 30px;
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 {
float: left; width: 218px;
width: 204px;
margin: 4.5px 4px 0px 4px; margin: 4.5px 4px 0px 4px;
min-height: calc(100vh - 55px); height: 100%;
padding: 5px; padding: 5px;
border: 2px solid #e3e8e6; border: 2px solid #e3e8e6;
background-color: white; background-color: white;
position: sticky; box-sizing: border-box;
} }
.toolbarElements { .toolbarElements {
width: 204px; width: 100%;
min-height: calc(100vh - 93px); height: calc(100% - 40px);
border: 1px solid #ccc; border: 1px solid #ccc;
position: relative;
} }
.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; }
@@ -46,8 +87,7 @@
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 {
@@ -56,28 +96,201 @@
} }
.toolbarBlocks { .toolbarBlocks {
width: calc(100% - 21px); width: 100%;
height: calc(100% - 58px); max-height: calc(100% - 58px);
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 12px;
padding: 10px; padding: 10px;
position: sticky; box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
} }
@keyframes pulse {
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; }
}
.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;
}
.canvas { .toolbarLines svg {
left: 224px; background-color: white;
top: 35px; transition: background-color 0.2s ease;
border: 2px solid #e3e8e6; border: 0px solid #e3e8e6;
width: calc(100% - 233px); }
height: calc(100% - 45px);
.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; position: fixed;
overflow: hidden; 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;
} }
.dragging { .modalDataMain {
opacity: 0.7; 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);
} }
-25
View File
@@ -1,25 +0,0 @@
import React, { useState } from 'react';
import ControlPanel from './components/ControlPanel';
import Canvas from './components/Canvas';
import Toolbar from './components/Toolbar';
import './App.css';
function App() {
const [blocks, setBlocks] = useState([]);
const addBlock = (block, x = 100, y = 100) => { //добавить блок на холст
const newBlock = {...block, id: Date.now(), x, y};
setBlocks(prevBlocks => [...prevBlocks, newBlock]);
};
return (
<div className="App">
<ControlPanel />
<Toolbar onAddBlock={addBlock} />
<Canvas blocks={blocks} setBlocks={setBlocks} addBlock = {addBlock} />
</div>
);
};
export default App;
+548
View File
@@ -0,0 +1,548 @@
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
-8
View File
@@ -1,8 +0,0 @@
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();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

-62
View File
@@ -1,62 +0,0 @@
import React from 'react';
const Block = ({ type, label, blockId }) => {
const renderBlock = () => {
const centerX = 4;
const centerY = 0;
switch (type) {
case 'NOT':
return (
<svg width = "68" height = "84" alight = "center">
<g transform={`translate(${centerX}, ${centerY})`}>
<rect width="60" height = "100%" fill = "lightgray" stroke = "black" />
<text x = "24" y = "20" textAnchor = "left" fontSize = "14" style={{ pointerEvents: 'none' }}>{label}</text>
{/* вход */}
<circle cx = "0" cy = "42" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} />
{/* выход */}
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" style = {{ cursor: 'pointer' }} />
</g>
</svg>
);
case 'OR':
return (
<svg width = "68" height = "84" alight = "center">
<g transform={`translate(${centerX}, ${centerY})`}>
<rect width="60" height = "100%" fill = "lightgray" stroke = "black" />
<text x = "32" y = "20" textAnchor = "left" fontSize = "14" style={{ pointerEvents: 'none' }}>{label}</text>
{/* вход */}
<circle cx = "0" cy = "25" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} />
{/* вход 2*/}
<circle cx = "0" cy = "59" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} />
{/* выход */}
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" style = {{ cursor: 'pointer' }} />
</g>
</svg>
);
default:
return (
<svg width = "68" height = "84" alight = "center">
<g transform={`translate(${centerX}, ${centerY})`}>
<rect width="60" height = "100%" fill = "lightgray" stroke = "black" />
<text x = "24" y = "20" textAnchor = "left" fontSize = "14" style={{ pointerEvents: 'none' }}>{label}</text>
{/* вход */}
<circle cx = "0" cy = "25" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} />
{/* вход 2*/}
<circle cx = "0" cy = "59" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} />
{/* выход */}
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" style = {{ cursor: 'pointer' }} />
</g>
</svg>
);
}
}
return renderBlock();
};
export default Block;
-105
View File
@@ -1,105 +0,0 @@
import React, { useRef, useState, useEffect } from 'react';
import { useDrop } from 'react-dnd';
import { useDrag } from '@use-gesture/react';
import Block from './Block';
const Canvas = ({ blocks, setBlocks, addBlock }) => {
const safeBlocks = blocks || [];
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 500 });
const canvasRef = useRef (null);
useEffect(() => {
const updateSize = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
}
};
updateSize(); //первичное обновление
window.addEventListener('resize', updateSize); //обновление при изменении размера окна
return () => window.removeEventListener('resize', updateSize); //очистка
}, []);
const [{ isOver }, drop] = useDrop(() => ({
accept: 'block',
drop: (item, monitor) => {
const offset = monitor.getClientOffset(); //позиция курсора
//console.log('Offset: ', offset);
if (offset && canvasRef.current) {
const canvasRect = canvasRef.current.getBoundingClientRect(); // границы svg
//console.log('CanvasRect: ', canvasRect);
const x = offset.x - canvasRect.left;
const y = offset.y - canvasRect.top;
//console.log('Calculated x/y: ', x, y);
if (item.id) {
console.log('moveBlock');
//перемещение
moveBlock(item.id, x, y);
} else {
console.log('addBlock');
//добавление нового
addBlock(item, x, y);
}
}
},
collect: (monitor) => ({
isOver: monitor.isOver(), //курсор с блоком над canvas
}),
}));
const moveBlock = (id, x, y) => {
setBlocks(safeBlocks.map(block => block.id === id ? { ...block, x, y} : block));
}
//сетка
const renderGrid = () => {
const gridSize = 7;
const numLinesX = Math.ceil(canvasSize.width / gridSize) + 10;
const numLinesY = Math.ceil(canvasSize.height / gridSize) + 10;
const lines = [];
for (let i = 0; i < numLinesX; i++) {
lines.push(<line key={`v${i}`} x1={i * gridSize} y1="0" x2={i * gridSize} y2={canvasSize.height} stroke="#eee" strokeWidth="1" />);
}
for (let i = 0; i < numLinesY; i++) {
lines.push(<line key={`h${i}`} x1="0" y1={i * gridSize} x2={canvasSize.width} y2={i * gridSize} stroke="#eee" strokeWidth="1" />);
} //`
return lines;
};
return (
<div ref = {drop} className="canvas" >
<svg width = '100vw' height = '100vh' ref = {canvasRef} >
{renderGrid()}
{safeBlocks.map((block) => (
<DraggableBlock key = {block.id} block = {block} moveBlock = {moveBlock} />
))}
</svg>
</div>
);
};
const DraggableBlock = ({ block, moveBlock }) => {
const [isDragging, setIsDragging] = useState(false);
const bind = useDrag(({ active, movement: [mx, my] }) => {
setIsDragging(active);
if (!active) {
const newX = block.x + mx;
const newY = block.y + my;
moveBlock(block.id, newX, newY);
}
});
return (
<g {...bind()} transform={`translate(${block.x}, ${block.y})`} className={isDragging ? 'dragging' : ''} style={{ cursor: 'move' }}>
<Block type={block.type} label={block.label} blockId={block.id} />
</g>
);
};
export default Canvas;
-13
View File
@@ -1,13 +0,0 @@
import React from 'react';
const ControlPanel = () => {
return (
<div className="controlPanel" style={{ padding: '10px', borderBottom: '1px solid #ccc', backgroundColor: '#f0f0f0', display: 'flex', gap: '10px' }}>
<button>Экспорт в JSON</button>
<button>Очистить холст</button>
</div>
);
};
export default ControlPanel;
+54
View File
@@ -0,0 +1,54 @@
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;
+144
View File
@@ -0,0 +1,144 @@
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)
+176
View File
@@ -0,0 +1,176 @@
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
-105
View File
@@ -1,105 +0,0 @@
import React, { useState } from 'react';
import { useDrag } from 'react-dnd';
import Block from './Block';
const Toolbar = ({ onAddBlock }) => {
//состояние для активной вкладки
const [activeTab, setActiveTab] = useState('logical');
const blockCategories = {
logical: [
{ type: 'AND', label: 'AND' },
{ type: 'OR', label: 'OR' },
{ type: 'NOT', label: 'NOT' },
{ type: 'XOR', label: 'XOR' } ],
arithmetic: [
{ type: 'ADD', label: 'ADD' },
{ type: 'SUB', label: 'SUB' },
{ type: 'MUL', label: 'MUL' },
{ type: 'DIV', label: 'DIV' } ],
timers: [
{ type: 'TON', label: 'TON' },
{ type: 'TOF', label: 'TOF' },
{ type: 'TP', label: 'TP' } ],
counters: [
{ type: 'CTU', label: 'CTU' },
{ type: 'CTD', label: 'CTD' },
{ type: 'CTUD', label: 'CTUD' } ],
comparisons: [
{ type: 'GT', label: 'GT' },
{ type: 'LT', label: 'LT' },
{ type: 'EQ', label: 'EQ' } ],
other: [
{ type: 'SR', label: 'SR' },
{ type: 'RS', label: 'RS' },
{ type: 'MOVE', label: 'MOVE' } ],
customized: []
};
const activeBlocks = blockCategories[activeTab] || [];
return (
<div className = "toolbar">
<h4 style = {{ marginTop: '3px', marginBottom: '15px' }}>Блоки</h4>
<div className="toolbarElements">
{/*//Вкладки*/}
<div className = "tabButtons">
{Object.keys (blockCategories).map((tab) => (
<button
key = {tab}
className = {activeTab === tab ? 'activeTab' : ''}
data-row = { tab === 'customized' || tab === 'other' || tab === 'comparisons' ? '2' : '1' }
onClick = { () => setActiveTab(tab) }
title = {
tab === 'logical' ? 'Логические' :
tab === 'arithmetic' ? 'Арифметические' :
tab === 'timers' ? 'Таймеры' :
tab === 'counters' ? 'Счётчики' :
tab === 'comparisons' ? 'Сравнения' :
tab === 'other' ? 'Другие' :
'Пользовательские'
}
> {
tab === 'logical' ? 'Логические' :
tab === 'arithmetic' ? 'Арифметические' :
tab === 'timers' ? 'Таймеры' :
tab === 'counters' ? 'Счётчики' :
tab === 'comparisons' ? 'Сравнения' :
tab === 'other' ? 'Другие' :
'Пользовательские'
} </button>
))}
</div>
{/*Блоки*/}
<div className = "toolbarBlocks">
{activeBlocks.map((block) => (
<DraggableBlock key = {block.type} block = {block} onAddBlock = {onAddBlock} />
))}
</div>
</div>
</div>
); {/*//Блоки*/}
};
const DraggableBlock = ({ block, onAddBlock }) => {
const [{ isDragging }, drag] = useDrag(() => ({
type: 'block',
item: block,
end: (item, monitor) => {
if (monitor.didDrop()) {}
},
collect: (monitor) => ({ //состояние
isDragging: monitor.isDragging(),
}),
}));
return (
<div ref={drag} style={{ opacity: isDragging ? 0.7 : 1 }}>
<Block type={block.type} label={block.label} />
</div>
);
};
export default Toolbar;
+143
View File
@@ -0,0 +1,143 @@
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
+77
View File
@@ -0,0 +1,77 @@
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>
);
})
-13
View File
@@ -1,13 +0,0 @@
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;
}
-21
View File
@@ -1,21 +0,0 @@
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();
-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.6 KiB

+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
-13
View File
@@ -1,13 +0,0 @@
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;
-5
View File
@@ -1,5 +0,0 @@
// 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';
View File
+503
View File
@@ -0,0 +1,503 @@
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}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})