Compare commits

..

No commits in common. "b89c963cdcef560049422e8c2d9536e0ed38811c" and "4f27f5ff61955cb3d89bff219a0aa4f1983ae43a" have entirely different histories.

10 changed files with 852 additions and 258 deletions

View File

@ -68,3 +68,4 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/d
### `npm run build` fails to minify ### `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) 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"

View File

@ -31,6 +31,23 @@
border: 1px solid #ccc; border: 1px solid #ccc;
} }
.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; /* перенос строк */
@ -81,3 +98,178 @@
.dragging { .dragging {
opacity: 0.7; 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 {
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; }
}

View File

@ -1,23 +1,134 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import ControlPanel from './components/ControlPanel'; import ControlPanel from './components/ControlPanel';
import Canvas from './components/Canvas';
import Toolbar from './components/Toolbar'; import Toolbar from './components/Toolbar';
import Canvas from './components/Canvas';
import './App.css'; import './App.css';
function App() { function App() {
const [blocks, setBlocks] = useState([]); const [blocks, setBlocks] = useState([]);
const [connections, setConnections] = useState([]);
const [activePort, setActivePort] = useState(null);
const addBlock = (block, x = 100, y = 100) => { //добавить блок на холст const addBlock = useCallback((block, id = Date.now(), x, y) => {
const newBlock = {...block, id: Date.now(), x, y}; setBlocks(currentBlocks => {
setBlocks(prevBlocks => [...prevBlocks, newBlock]); 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 />
<Toolbar onAddBlock={addBlock} />
<Canvas blocks={blocks} setBlocks={setBlocks} addBlock = {addBlock} />
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> </div>
); );
}; };

View File

@ -1,62 +1,92 @@
import React from 'react'; import React, {memo} from 'react';
const Block = ({ type, label, blockId }) => { const CENTER_X = 4;
const CENTER_Y = 0;
const renderBlock = () => { const Block = ({ blockId, type, label, inputCount, outputCount, activePort, onPortClick, connections }) => {
const centerX = 4;
const centerY = 0;
switch (type) { const blockHeight = 50 + (Math.max(inputCount, outputCount, 2) - 1) * 34;
case 'NOT': const blockWidth = blockHeight / 1.4;
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>
{/* вход */} const getPortStyle = (portType, portIndex) => {
<circle cx = "0" cy = "42" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} /> const active = activePort?.portType === portType && activePort?.portIndex === portIndex;
{/* выход */} const used = connections.some(connection => {
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" style = {{ cursor: 'pointer' }} /> if (portType === 'input')
</g> return(connection.to.blockId === blockId && connection.to.portIndex === portIndex);
</svg> return(connection.from.blockId === blockId && connection.from.portIndex === portIndex);
); });
case 'OR': const color = active ? '#999' :
return ( portType === 'input' ?
<svg width = "68" height = "84" alight = "center"> used ? '#BABAFF' : '#1717D8' :
<g transform={`translate(${centerX}, ${centerY})`}> used ? '#FD90B0' : '#D8000A';
<rect width="60" height = "100%" fill = "lightgray" stroke = "black" />
<text x = "32" y = "20" textAnchor = "left" fontSize = "14" style={{ pointerEvents: 'none' }}>{label}</text>
{/* вход */} return {
<circle cx = "0" cy = "25" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} /> cursor: 'pointer',
{/* вход 2*/} pointerEvents: 'auto',
<circle cx = "0" cy = "59" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} /> r: active ? 6 : 4,
{/* выход */} fill: color
<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>
{/* вход */} const getCoord = (count) => {
<circle cx = "0" cy = "25" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} /> const coord = [];
{/* вход 2*/} if (count === 1)
<circle cx = "0" cy = "59" r = "4" fill = "#1A1ADB" style = {{ cursor: 'pointer' }} /> coord.push(blockHeight / 2);
{/* выход */} else {
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" style = {{ cursor: 'pointer' }} /> const step = (blockHeight - 50) / (count - 1);
</g> for (let i = 0; i < count; i++)
</svg> 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(); return renderBlock();
}; };
export default Block; export default memo(Block);
/* треугольник
<polygon points="60,35 60,49 70,42" fill = "none" stroke="black" />*/

View File

@ -1,105 +1,188 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect, memo } from 'react';
import { useDrop } from 'react-dnd'; import { useDrop, useDrag } from 'react-dnd';
import { useDrag } from '@use-gesture/react'; import Connection from './Connection';
import Block from './Block'; import DraggableBlock from './DraggableBlock';
const Canvas = ({ blocks, setBlocks, addBlock }) => { const Canvas = ({ blocks = [], connections = [], activePort = null, addConnection, addBlock, moveBlock, removeBlock, onPortClick }) => {
const safeBlocks = blocks || []; const blocksOnCanvas = blocks || [];
const connectionsOnCanvas = connections || [];
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 500 }); const [zoom, setZoom] = useState(1);
const canvasRef = useRef (null); 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);
useEffect(() => { const [{isOver, monitor}, drop] = useDrop(() => ({
const updateSize = () => { accept: ['block', 'newBlock'],
if (canvasRef.current) { hover: (item, monitor) => {
const rect = canvasRef.current.getBoundingClientRect(); const offset = monitor.getClientOffset();
setCanvasSize({ width: rect.width, height: rect.height }); if (!offset || !canvasRef.current)
} return;
}; const svgRect = canvasRef.current.getBoundingClientRect();
updateSize(); //первичное обновление
window.addEventListener('resize', updateSize); //обновление при изменении размера окна
return () => window.removeEventListener('resize', updateSize); //очистка
}, []);
const [{ isOver }, drop] = useDrop(() => ({ //координаты мыши
accept: 'block', const mouseOnCanvasX = offset.x - svgRect.x;
drop: (item, monitor) => { const mouseOnCanvasY = offset.y - svgRect.y;
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) { //координаты на canvas
console.log('moveBlock'); 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); moveBlock(item.id, x, y);
} else { else {
console.log('addBlock'); if (!item.tempId) {
//добавление нового const newId = Date.now();
addBlock(item, x, y); item.tempId = newId;
item.isNew = false;
addBlock(item, newId, x, y);
} }
else
moveBlock(item.tempId, x, y);
} }
}, },
drop: (item) => { item.tempId = null; },
collect: (monitor) => ({ collect: (monitor) => ({
isOver: monitor.isOver(), //курсор с блоком над canvas isOver: monitor.isOver(),
monitor: monitor,
}), }),
})); }), [moveBlock, addBlock, pan, zoom]);
const moveBlock = (id, x, y) => { useEffect(() => {
setBlocks(safeBlocks.map(block => block.id === id ? { ...block, x, y} : block)); //удаление блока при выходе за пределы холста
const draggingItem = monitor?.getItem();
if (!isOver && draggingItem?.tempId) {
removeBlock(draggingItem.tempId);
draggingItem.tempId = null;
} }
}, [isOver, monitor, removeBlock]);
//сетка const handleWheel = (e) => {
const renderGrid = () => { e.preventDefault();
const gridSize = 7; const rect = canvasRef.current.getBoundingClientRect();
const numLinesX = Math.ceil(canvasSize.width / gridSize) + 10; const mouseOnCanvasX = e.clientX - rect.x;
const numLinesY = Math.ceil(canvasSize.height / gridSize) + 10; const mouseOnCanvasY = e.clientY - rect.y;
const mouseX = (mouseOnCanvasX - pan.x) / zoom;
const mouseY = (mouseOnCanvasY - pan.y) / zoom;
const lines = []; const stepZoom = 0.075;
const prewZoom = zoom + (e.deltaY > 0 ? -stepZoom : stepZoom);
const newZoom = Math.max(0.6, Math.min(1.7, prewZoom));
for (let i = 0; i < numLinesX; i++) { const newPanX = mouseOnCanvasX - mouseX * newZoom;
lines.push(<line key={`v${i}`} x1={i * gridSize} y1="0" x2={i * gridSize} y2={canvasSize.height} stroke="#eee" strokeWidth="1" />); const newPanY = mouseOnCanvasY - mouseY * newZoom;
}
for (let i = 0; i < numLinesY; i++) { setZoom(newZoom);
lines.push(<line key={`h${i}`} x1="0" y1={i * gridSize} x2={canvasSize.width} y2={i * gridSize} stroke="#eee" strokeWidth="1" />); setPan({x: newPanX, y: newPanY});
} //`
return lines;
}; };
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
});
}
};
return ( const handleMouseMove = (e) => {
<div ref = {drop} className="canvas" > if (isPanning)
<svg width = '100vw' height = '100vh' ref = {canvasRef} > setPan({
{renderGrid()} x: e.clientX - panStart.x,
{safeBlocks.map((block) => ( y: e.clientY - panStart.y
<DraggableBlock key = {block.id} block = {block} moveBlock = {moveBlock} /> });
};
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> </svg>
</div> </div>
); );
}; };
const DraggableBlock = ({ block, moveBlock }) => { export default memo(Canvas);
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;

View File

@ -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:

View File

@ -1,11 +1,29 @@
import React from 'react'; import React from 'react';
const ControlPanel = () => { 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 ( return (
<div className="controlPanel" style={{ padding: '10px', borderBottom: '1px solid #ccc', backgroundColor: '#f0f0f0', display: 'flex', gap: '10px' }}> <div className="controlPanel" style={{ padding: '10px', borderBottom: '1px solid #ccc', backgroundColor: '#f0f0f0', display: 'flex', gap: '10px' }}>
<button>Экспорт в JSON</button> <button onClick={onExport}>Экспорт в JSON</button>
<button>Очистить холст</button> <input type="file" accept=".json" onChange={handleImport} /> {/* Поле для выбора файла импорта */}
<button onClick={onClear}>Очистить холст</button>
{/* Можно добавить: <button>Зум +</button> <button>Зум -</button> */}
</div> </div>
); );
}; };

View File

@ -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);

View File

@ -1,103 +1,81 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { useDrag } from 'react-dnd'; import DraggableBlock from './DraggableBlock';
import Block from './Block';
const Toolbar = ({ onAddBlock }) => { const NAME_CATEGORIES = {
//состояние для активной вкладки logical: 'Логические',
const [activeTab, setActiveTab] = useState('logical'); arithmetic: 'Арифметические',
timers: 'Таймеры',
const blockCategories = { counters: 'Счётчики',
logical: [ comparisons: 'Сравнения',
{ type: 'AND', label: 'AND' }, other: 'Другие',
{ type: 'OR', label: 'OR' }, customized: 'Пользовательские'
{ 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 BLOCK_CATEGORIES = {
const [{ isDragging }, drag] = useDrag(() => ({ logical: [
type: 'block', { type: 'and', label: 'AND', inputCount: 2, outputCount: 1 },
item: block, { type: 'or', label: 'OR', inputCount: 2, outputCount: 1 },
end: (item, monitor) => { { type: 'not', label: 'NOT', inputCount: 1, outputCount: 1 },
if (monitor.didDrop()) {} { type: 'xor', label: 'XOR', inputCount: 2, outputCount: 1 }],
}, arithmetic: [
collect: (monitor) => ({ //состояние { type: 'add', label: 'ADD', inputCount: 2, outputCount: 1 },
isDragging: monitor.isDragging(), { 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: []
};
return (
<div ref={drag} style={{ opacity: isDragging ? 0.7 : 1 }}> const Toolbar = () => {
<Block type={block.type} label={block.label} /> 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> </div>
); );
}; };

View File

@ -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);