@ichigo_san/graphing
Version:
A lightweight UML-style diagram editor built with React Flow and Tailwind CSS
1,328 lines (1,294 loc) • 69.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _react = _interopRequireWildcard(require("react"));
var _htmlToImage = require("html-to-image");
var _lucideReact = require("lucide-react");
var _reactflow = _interopRequireWildcard(require("reactflow"));
require("reactflow/dist/style.css");
var _DiamondNode = _interopRequireDefault(require("../nodes/DiamondNode"));
var _CircleNode = _interopRequireDefault(require("../nodes/CircleNode"));
var _HexagonNode = _interopRequireDefault(require("../nodes/HexagonNode"));
var _TriangleNode = _interopRequireDefault(require("../nodes/TriangleNode"));
var _ContainerNode = _interopRequireDefault(require("../nodes/ContainerNode"));
var _ComponentNode = _interopRequireDefault(require("../nodes/ComponentNode"));
var _edges = require("../edges");
var _PromptModal = _interopRequireDefault(require("../modals/PromptModal"));
var _ConfirmModal = _interopRequireDefault(require("../modals/ConfirmModal"));
var _ContainerSelectorModal = _interopRequireDefault(require("../modals/ContainerSelectorModal"));
var _ShapeSelectorModal = _interopRequireDefault(require("../modals/ShapeSelectorModal"));
var _JsonPasteModal = _interopRequireDefault(require("../modals/JsonPasteModal"));
var _JsonValidatorModal = _interopRequireDefault(require("../modals/JsonValidatorModal"));
var _TailwindPropertyEditor = _interopRequireDefault(require("./TailwindPropertyEditor"));
var _EnhancedMenuBar = _interopRequireDefault(require("./EnhancedMenuBar"));
var _ajv = _interopRequireDefault(require("ajv"));
var _autoLayout = require("../utils/autoLayout");
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } // Import node components
// Import modal components
// Import editor components
const diagramSchema = {
type: 'object',
properties: {
containers: {
type: 'array',
items: {
type: 'object',
required: ['id', 'label', 'position', 'size'],
properties: {
id: {
type: 'string'
},
label: {
type: 'string'
},
position: {
type: 'object',
required: ['x', 'y'],
properties: {
x: {
type: 'number'
},
y: {
type: 'number'
}
}
},
size: {
type: 'object',
required: ['width', 'height'],
properties: {
width: {
type: 'number'
},
height: {
type: 'number'
}
}
}
}
}
},
nodes: {
type: 'array',
items: {
type: 'object',
required: ['id', 'label', 'position'],
properties: {
id: {
type: 'string'
},
label: {
type: 'string'
},
type: {
type: 'string'
},
position: {
type: 'object',
required: ['x', 'y'],
properties: {
x: {
type: 'number'
},
y: {
type: 'number'
}
}
}
}
}
},
connections: {
type: 'array',
items: {
type: 'object',
required: ['id', 'source', 'target'],
properties: {
id: {
type: 'string'
},
source: {
type: 'string'
},
target: {
type: 'string'
}
}
}
}
},
required: ['containers', 'nodes', 'connections']
};
const ajv = new _ajv.default();
const validateDiagram = ajv.compile(diagramSchema);
const ArchitectureDiagramEditorContent = _ref => {
let {
initialDiagram,
onToggleTheme,
showThemeToggle,
onToggleFullscreen,
isFullscreen,
onToggleMini,
showMiniToggle
} = _ref;
// State for nodes and edges
const [nodes, setNodes] = (0, _react.useState)([]);
const [edges, setEdges] = (0, _react.useState)([]);
const [selectedElements, setSelectedElements] = (0, _react.useState)({
nodes: [],
edges: []
});
const [history, setHistory] = (0, _react.useState)({
past: [],
present: {
nodes: [],
edges: []
},
future: []
});
const [isInitialized, setIsInitialized] = (0, _react.useState)(false);
const [clipboardData, setClipboardData] = (0, _react.useState)(null);
const [propertyPanelOpen, setPropertyPanelOpen] = (0, _react.useState)(true);
const [propertyPanelMinimized, setPropertyPanelMinimized] = (0, _react.useState)(false);
const [statsPanelOpen, setStatsPanelOpen] = (0, _react.useState)(true);
const [panMode, setPanMode] = (0, _react.useState)(false);
const getDiagramBounds = (0, _react.useCallback)(() => {
if (nodes.length === 0) return {
x: 0,
y: 0,
width: 0,
height: 0
};
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
nodes.forEach(n => {
var _n$__rf, _n$style, _n$__rf2, _n$style2;
const width = ((_n$__rf = n.__rf) === null || _n$__rf === void 0 ? void 0 : _n$__rf.width) || ((_n$style = n.style) === null || _n$style === void 0 ? void 0 : _n$style.width) || 150;
const height = ((_n$__rf2 = n.__rf) === null || _n$__rf2 === void 0 ? void 0 : _n$__rf2.height) || ((_n$style2 = n.style) === null || _n$style2 === void 0 ? void 0 : _n$style2.height) || 80;
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + width);
maxY = Math.max(maxY, n.position.y + height);
});
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}, [nodes]);
const updateNodeInternals = (0, _reactflow.useUpdateNodeInternals)();
// State for modals
const [promptModal, setPromptModal] = (0, _react.useState)({
isOpen: false,
title: '',
message: '',
defaultValue: '',
onConfirm: null
});
const [confirmModal, setConfirmModal] = (0, _react.useState)({
isOpen: false,
title: '',
message: '',
onConfirm: null
});
const [containerSelectorModal, setContainerSelectorModal] = (0, _react.useState)({
isOpen: false,
title: '',
message: '',
containers: [],
onSelect: null
});
const [shapeSelectorModal, setShapeSelectorModal] = (0, _react.useState)({
isOpen: false
});
const [jsonPasteModal, setJsonPasteModal] = (0, _react.useState)({
isOpen: false,
onConfirm: null
});
const [jsonValidatorModal, setJsonValidatorModal] = (0, _react.useState)({
isOpen: false
});
// Refs
const reactFlowWrapper = (0, _react.useRef)(null);
const saveTimeoutRef = (0, _react.useRef)(null);
const togglePropertyPanel = (0, _react.useCallback)(() => {
setPropertyPanelOpen(prev => {
const next = !prev;
if (next) setPropertyPanelMinimized(false);
return next;
});
}, []);
const togglePropertyPanelMinimized = (0, _react.useCallback)(() => {
setPropertyPanelMinimized(prev => !prev);
}, []);
const toggleStatsPanel = (0, _react.useCallback)(() => {
setStatsPanelOpen(prev => !prev);
}, []);
const togglePanMode = (0, _react.useCallback)(() => {
setPanMode(prev => !prev);
}, []);
const handleContextMenu = (0, _react.useCallback)(event => {
if (panMode) {
event.preventDefault();
}
}, [panMode]);
// Custom node types
const nodeTypes = (0, _react.useMemo)(() => ({
diamond: _DiamondNode.default,
circle: _CircleNode.default,
hexagon: _HexagonNode.default,
triangle: _TriangleNode.default,
container: _ContainerNode.default,
component: _ComponentNode.default
}), []);
const edgeTypes = (0, _react.useMemo)(() => ({
adjustable: _edges.AdjustableEdge
}), []);
// Stable node label change handler
const handleNodeLabelChange = (0, _react.useCallback)((nodeId, label) => {
setNodes(nds => nds.map(node => {
if (node.id === nodeId) {
return _objectSpread(_objectSpread({}, node), {}, {
data: _objectSpread(_objectSpread({}, node.data), {}, {
label
})
});
}
return node;
}));
// Debounced history save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
setHistory(prev => ({
past: [...prev.past, prev.present],
present: {
nodes,
edges
},
future: []
}));
}, 500);
}, [nodes, edges]);
// Convert JSON configuration to React Flow format
const jsonToReactFlow = (0, _react.useCallback)(config => {
var _config$containers, _config$nodes, _config$connections;
const flowNodes = [];
const flowEdges = [];
// Add container nodes first
(_config$containers = config.containers) === null || _config$containers === void 0 || _config$containers.forEach(container => {
var _container$size, _container$size2;
flowNodes.push({
id: container.id,
type: 'container',
position: container.position,
data: {
label: container.label,
color: container.color || '#f5f5f5',
bgColor: container.bgColor || '#f5f5f5',
borderColor: container.borderColor || '#ddd',
icon: container.icon,
description: container.description,
onLabelChange: handleNodeLabelChange
},
style: {
width: ((_container$size = container.size) === null || _container$size === void 0 ? void 0 : _container$size.width) || 400,
height: ((_container$size2 = container.size) === null || _container$size2 === void 0 ? void 0 : _container$size2.height) || 300,
zIndex: container.zIndex || 1
},
draggable: true,
selectable: true,
zIndex: container.zIndex || 1
});
});
// Add component nodes
(_config$nodes = config.nodes) === null || _config$nodes === void 0 || _config$nodes.forEach(node => {
var _node$size, _node$size2;
flowNodes.push({
id: node.id,
type: 'component',
position: node.position,
parentNode: node.parentContainer,
data: {
label: node.label,
color: node.color || '#E3F2FD',
borderColor: node.borderColor || '#90CAF9',
icon: node.icon,
description: node.description,
onLabelChange: handleNodeLabelChange
},
style: {
width: ((_node$size = node.size) === null || _node$size === void 0 ? void 0 : _node$size.width) || 150,
height: ((_node$size2 = node.size) === null || _node$size2 === void 0 ? void 0 : _node$size2.height) || 80,
zIndex: node.zIndex || 10
},
draggable: true,
selectable: true,
zIndex: node.zIndex || 10
});
});
// Add connections/edges
(_config$connections = config.connections) === null || _config$connections === void 0 || _config$connections.forEach(connection => {
flowEdges.push({
id: connection.id,
source: connection.source,
target: connection.target,
label: connection.label,
type: connection.type || 'floating',
animated: connection.animated || false,
style: {
strokeWidth: 2,
zIndex: connection.zIndex || 5
},
zIndex: connection.zIndex || 5,
markerStart: connection.markerStart,
markerEnd: connection.markerEnd || {
type: 'arrow'
},
data: {
label: connection.label,
description: connection.description || '',
control: connection.control,
intersection: connection.intersection || 'none'
}
});
});
return {
nodes: flowNodes,
edges: flowEdges
};
}, [handleNodeLabelChange]);
// Initialize diagram from the provided configuration
(0, _react.useEffect)(() => {
if (!isInitialized) {
const config = initialDiagram || {
containers: [],
nodes: [],
connections: []
};
const {
nodes: initialNodes,
edges: initialEdges
} = jsonToReactFlow(config);
const laidOut = (0, _autoLayout.autoLayoutNodes)(initialNodes);
setNodes(laidOut);
setEdges(initialEdges);
setHistory({
past: [],
present: {
nodes: laidOut,
edges: initialEdges
},
future: []
});
laidOut.forEach(n => updateNodeInternals(n.id));
setIsInitialized(true);
}
}, [isInitialized, jsonToReactFlow, initialDiagram, updateNodeInternals]);
// Optimized save to history function
const saveToHistory = (0, _react.useCallback)(() => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
setHistory(prev => ({
past: [...prev.past, prev.present],
present: {
nodes,
edges
},
future: []
}));
}, 300);
}, [nodes, edges]);
// Helper functions for modals
const showPromptModal = (0, _react.useCallback)((title, message, defaultValue, onConfirm) => {
setPromptModal({
isOpen: true,
title,
message,
defaultValue,
onConfirm: value => {
onConfirm(value);
setPromptModal(prev => _objectSpread(_objectSpread({}, prev), {}, {
isOpen: false
}));
}
});
}, []);
const showConfirmModal = (0, _react.useCallback)((title, message, onConfirm) => {
setConfirmModal({
isOpen: true,
title,
message,
onConfirm: () => {
onConfirm();
setConfirmModal(prev => _objectSpread(_objectSpread({}, prev), {}, {
isOpen: false
}));
}
});
}, []);
const showContainerSelectorModal = (0, _react.useCallback)((title, message, containers, onSelect) => {
setContainerSelectorModal({
isOpen: true,
title,
message,
containers,
onSelect: containerId => {
onSelect(containerId);
setContainerSelectorModal(prev => _objectSpread(_objectSpread({}, prev), {}, {
isOpen: false
}));
}
});
}, []);
const showShapeSelectorModal = (0, _react.useCallback)(() => {
setShapeSelectorModal({
isOpen: true
});
}, []);
const showJsonPasteModal = (0, _react.useCallback)(onConfirm => {
setJsonPasteModal({
isOpen: true,
onConfirm
});
}, []);
const showJsonValidatorModal = (0, _react.useCallback)(() => {
setJsonValidatorModal({
isOpen: true
});
}, []);
// Optimized change handlers
const handleNodesChange = (0, _react.useCallback)(changes => {
setNodes(nds => (0, _reactflow.applyNodeChanges)(changes, nds));
}, []);
const onEdgesChange = (0, _react.useCallback)(changes => {
setEdges(eds => (0, _reactflow.applyEdgeChanges)(changes, eds));
}, []);
const onNodeDragStop = (0, _react.useCallback)(() => {
saveToHistory();
}, [saveToHistory]);
const onEdgeUpdateStart = (0, _react.useCallback)(() => {}, []);
const onEdgeUpdateEnd = (0, _react.useCallback)(() => {
saveToHistory();
}, [saveToHistory]);
const onEdgeUpdate = (0, _react.useCallback)((oldEdge, newConnection) => {
setEdges(eds => (0, _reactflow.reconnectEdge)(oldEdge, newConnection, eds));
}, []);
const onConnectStart = (0, _react.useCallback)(() => {}, []);
const onConnectEnd = (0, _react.useCallback)(() => {}, []);
const defaultEdgeOptions = (0, _react.useMemo)(() => ({
type: 'adjustable',
animated: true,
style: {
strokeWidth: 2,
stroke: '#2563eb',
strokeDasharray: '5 5'
},
data: {
intersection: 'none'
}
}), []);
// Handle new connections
const onConnect = (0, _react.useCallback)(params => {
const newEdge = _objectSpread(_objectSpread({}, params), {}, {
id: "edge-".concat(Date.now()),
type: 'adjustable',
animated: true,
style: {
strokeWidth: 2,
stroke: '#2563eb',
strokeDasharray: '5 5',
zIndex: 5
},
zIndex: 5,
markerEnd: {
type: 'arrow'
},
data: {
label: '',
description: '',
intersection: 'none',
// Default to no intersection
control: null // Will be auto-calculated
}
});
setEdges(eds => (0, _reactflow.addEdge)(newEdge, eds));
saveToHistory();
}, [saveToHistory]);
// Handle selection changes - now includes edges
const onSelectionChange = (0, _react.useCallback)(_ref2 => {
let {
nodes: selectedNodes,
edges: selectedEdges
} = _ref2;
setSelectedElements({
nodes: selectedNodes || [],
edges: selectedEdges || []
});
}, []);
// Handle edge click - now uses property panel instead of modal
const onEdgeClick = (0, _react.useCallback)((event, edge) => {
event.stopPropagation();
setSelectedElements({
nodes: [],
edges: [edge]
});
}, []);
// Handle property changes for both nodes and edges
const handleElementPropertyChange = (0, _react.useCallback)((elementType, property, value) => {
if (elementType === 'node' && selectedElements.nodes.length === 1) {
const nodeId = selectedElements.nodes[0].id;
setNodes(nds => nds.map(node => {
if (node.id === nodeId) {
if (property === 'label') {
return _objectSpread(_objectSpread({}, node), {}, {
data: _objectSpread(_objectSpread({}, node.data), {}, {
label: value
})
});
} else if (property === 'zIndex') {
return _objectSpread(_objectSpread({}, node), {}, {
style: _objectSpread(_objectSpread({}, node.style), {}, {
zIndex: parseInt(value)
}),
zIndex: parseInt(value)
});
} else {
return _objectSpread(_objectSpread({}, node), {}, {
data: _objectSpread(_objectSpread({}, node.data), {}, {
[property]: value
})
});
}
}
return node;
}));
} else if (elementType === 'edge' && selectedElements.edges.length === 1) {
const edgeId = selectedElements.edges[0].id;
setEdges(eds => eds.map(edge => {
if (edge.id === edgeId) {
if (property === 'label') {
return _objectSpread(_objectSpread({}, edge), {}, {
label: value,
data: _objectSpread(_objectSpread({}, edge.data), {}, {
label: value
})
});
} else if (property === 'zIndex') {
return _objectSpread(_objectSpread({}, edge), {}, {
style: _objectSpread(_objectSpread({}, edge.style), {}, {
zIndex: parseInt(value)
}),
zIndex: parseInt(value)
});
} else if (property === 'type' || property === 'animated') {
return _objectSpread(_objectSpread({}, edge), {}, {
[property]: value
});
} else if (property === 'markerStart' || property === 'markerEnd') {
return _objectSpread(_objectSpread({}, edge), {}, {
[property]: value
});
} else if (property.startsWith('style.')) {
const styleProp = property.replace('style.', '');
return _objectSpread(_objectSpread({}, edge), {}, {
style: _objectSpread(_objectSpread({}, edge.style), {}, {
[styleProp]: value
})
});
} else {
return _objectSpread(_objectSpread({}, edge), {}, {
data: _objectSpread(_objectSpread({}, edge.data), {}, {
[property]: value
})
});
}
}
return edge;
}));
}
saveToHistory();
}, [selectedElements, saveToHistory]);
const applyAutoLayout = (0, _react.useCallback)(currentNodes => {
const laidOut = (0, _autoLayout.autoLayoutNodes)(currentNodes);
setNodes(laidOut);
laidOut.forEach(n => updateNodeInternals(n.id));
}, [updateNodeInternals]);
// Add new container node
const addContainerNode = (0, _react.useCallback)(() => {
showPromptModal('Add Container', 'Enter container name:', 'New Container', name => {
const newNode = {
id: "container-".concat(Date.now()),
type: 'container',
position: {
x: Math.random() * 300 + 100,
y: Math.random() * 200 + 100
},
data: {
label: name,
icon: '📦',
color: '#f5f5f5',
borderColor: '#ddd',
description: '',
onLabelChange: handleNodeLabelChange
},
style: {
width: 400,
height: 300,
zIndex: 1
},
draggable: true,
selectable: true,
zIndex: 1
};
setNodes(nds => [...nds, newNode]);
saveToHistory();
});
}, [nodes, handleNodeLabelChange, saveToHistory, showPromptModal]);
// Add new component node
const addComponentNode = (0, _react.useCallback)(() => {
const containerNodes = nodes.filter(node => node.type === 'container');
if (containerNodes.length > 0) {
showContainerSelectorModal('Select Container', 'Choose a container for this component:', containerNodes, containerId => {
showPromptModal('Add Component', 'Enter component name:', 'New Component', name => {
const container = nodes.find(node => node.id === containerId);
let position = {
x: 50,
y: 50
};
if (container) {
const componentsInContainer = nodes.filter(node => node.parentNode === containerId).length;
position = {
x: 50 + componentsInContainer % 2 * 170,
y: 50 + Math.floor(componentsInContainer / 2) * 100
};
}
const newNode = {
id: "component-".concat(Date.now()),
type: 'component',
position,
parentNode: containerId,
data: {
label: name,
icon: '🔹',
color: '#E3F2FD',
borderColor: '#90CAF9',
description: '',
onLabelChange: handleNodeLabelChange
},
style: {
width: 150,
height: 80,
zIndex: 10
},
draggable: true,
selectable: true,
zIndex: 10
};
setNodes(nds => [...nds, newNode]);
saveToHistory();
});
});
} else {
showPromptModal('Add Component', 'Enter component name:', 'New Component', name => {
const newNode = {
id: "component-".concat(Date.now()),
type: 'component',
position: {
x: Math.random() * 300 + 100,
y: Math.random() * 200 + 100
},
data: {
label: name,
icon: '🔹',
color: '#E3F2FD',
borderColor: '#90CAF9',
description: '',
onLabelChange: handleNodeLabelChange
},
style: {
width: 150,
height: 80,
zIndex: 10
},
draggable: true,
selectable: true,
zIndex: 10
};
setNodes(nds => [...nds, newNode]);
saveToHistory();
});
}
}, [nodes, handleNodeLabelChange, saveToHistory, showContainerSelectorModal, showPromptModal]);
// Add new shape node
const addShapeNode = (0, _react.useCallback)(shapeType => {
showPromptModal('Add Shape', 'Enter shape name:', 'New Shape', name => {
const newNode = {
id: "".concat(shapeType, "-").concat(Date.now()),
type: shapeType,
position: {
x: Math.random() * 300 + 100,
y: Math.random() * 200 + 100
},
data: {
label: name,
icon: shapeType === 'diamond' ? '♦️' : shapeType === 'circle' ? '⭕' : shapeType === 'triangle' ? '🔺' : '⬢',
color: shapeType === 'diamond' ? '#81D4FA' : shapeType === 'circle' ? '#C5E1A5' : shapeType === 'triangle' ? '#FFD54F' : '#FFCC80',
borderColor: '#ddd',
description: '',
onLabelChange: handleNodeLabelChange
},
style: {
width: 100,
height: 100,
zIndex: 15
},
draggable: true,
selectable: true,
zIndex: 15
};
setNodes(nds => [...nds, newNode]);
saveToHistory();
});
}, [nodes, handleNodeLabelChange, saveToHistory, showPromptModal]);
// Copy selected elements
const copySelected = (0, _react.useCallback)(() => {
if (selectedElements.nodes.length > 0 || selectedElements.edges.length > 0) {
setClipboardData({
nodes: selectedElements.nodes,
edges: selectedElements.edges
});
}
}, [selectedElements]);
// Paste elements
const pasteElements = (0, _react.useCallback)(() => {
if (clipboardData) {
const newNodes = clipboardData.nodes.map(node => _objectSpread(_objectSpread({}, node), {}, {
id: "".concat(node.type, "-").concat(Date.now(), "-").concat(Math.random().toString(36).substr(2, 9)),
position: {
x: node.position.x + 50,
y: node.position.y + 50
},
selected: false
}));
const newEdges = clipboardData.edges.map(edge => _objectSpread(_objectSpread({}, edge), {}, {
id: "edge-".concat(Date.now(), "-").concat(Math.random().toString(36).substr(2, 9)),
selected: false
}));
setNodes(nds => [...nds, ...newNodes]);
setEdges(eds => [...eds, ...newEdges]);
saveToHistory();
}
}, [nodes, clipboardData, saveToHistory]);
// Delete selected elements
const deleteSelected = (0, _react.useCallback)(() => {
if (selectedElements.nodes.length === 0 && selectedElements.edges.length === 0) return;
const selectedNodeIds = selectedElements.nodes.map(node => node.id);
const edgesToKeep = edges.filter(edge => !selectedNodeIds.includes(edge.source) && !selectedNodeIds.includes(edge.target) && !selectedElements.edges.map(e => e.id).includes(edge.id));
const nodesToKeep = nodes.filter(node => {
return !selectedNodeIds.includes(node.id) && (!node.parentNode || !selectedNodeIds.includes(node.parentNode));
});
setNodes(nodesToKeep);
setEdges(edgesToKeep);
setSelectedElements({
nodes: [],
edges: []
});
saveToHistory();
}, [selectedElements, nodes, edges, saveToHistory]);
// Undo/Redo functions
const undo = (0, _react.useCallback)(() => {
setHistory(prev => {
if (prev.past.length === 0) return prev;
const newPresent = prev.past[prev.past.length - 1];
const newPast = prev.past.slice(0, prev.past.length - 1);
setNodes(newPresent.nodes);
setEdges(newPresent.edges);
return {
past: newPast,
present: newPresent,
future: [prev.present, ...prev.future]
};
});
}, []);
const redo = (0, _react.useCallback)(() => {
setHistory(prev => {
if (prev.future.length === 0) return prev;
const newPresent = prev.future[0];
const newFuture = prev.future.slice(1);
setNodes(newPresent.nodes);
setEdges(newPresent.edges);
return {
past: [...prev.past, prev.present],
present: newPresent,
future: newFuture
};
});
}, []);
(0, _react.useEffect)(() => {
const handleKeyDown = event => {
if (event.target.closest('input, textarea, [contenteditable="true"]')) {
return; // allow default behavior inside inputs and editable areas
}
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'c':
event.preventDefault();
copySelected();
break;
case 'v':
event.preventDefault();
pasteElements();
break;
case 'z':
event.preventDefault();
if (event.shiftKey) {
redo();
} else {
undo();
}
break;
default:
break;
}
} else if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
deleteSelected();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [copySelected, pasteElements, deleteSelected, undo, redo]);
// Export and import functions (same as before but with z-index)
const exportJSON = (0, _react.useCallback)(() => {
const diagramData = {
metadata: {
name: 'Architecture Diagram',
description: 'Exported architecture diagram',
version: '1.0',
exportDate: new Date().toISOString()
},
containers: nodes.filter(node => node.type === 'container').map(container => {
var _container$style, _container$style2;
return {
id: container.id,
label: container.data.label,
position: container.position,
size: {
width: ((_container$style = container.style) === null || _container$style === void 0 ? void 0 : _container$style.width) || 400,
height: ((_container$style2 = container.style) === null || _container$style2 === void 0 ? void 0 : _container$style2.height) || 300
},
color: container.data.color,
bgColor: container.data.bgColor,
borderColor: container.data.borderColor,
icon: container.data.icon,
description: container.data.description,
zIndex: container.zIndex || 1
};
}),
nodes: nodes.filter(node => node.type !== 'container').map(node => {
var _node$style, _node$style2;
return {
id: node.id,
label: node.data.label,
type: node.type,
position: node.position,
parentContainer: node.parentNode,
size: {
width: ((_node$style = node.style) === null || _node$style === void 0 ? void 0 : _node$style.width) || 150,
height: ((_node$style2 = node.style) === null || _node$style2 === void 0 ? void 0 : _node$style2.height) || 80
},
color: node.data.color,
borderColor: node.data.borderColor,
icon: node.data.icon,
description: node.data.description,
zIndex: node.zIndex || 10
};
}),
connections: edges.map(edge => {
var _edge$data, _edge$data2, _edge$data3, _edge$data4, _edge$style, _edge$style2;
return {
id: edge.id,
source: edge.source,
target: edge.target,
label: (_edge$data = edge.data) === null || _edge$data === void 0 ? void 0 : _edge$data.label,
type: edge.type,
animated: edge.animated,
description: (_edge$data2 = edge.data) === null || _edge$data2 === void 0 ? void 0 : _edge$data2.description,
control: (_edge$data3 = edge.data) === null || _edge$data3 === void 0 ? void 0 : _edge$data3.control,
markerStart: edge.markerStart,
markerEnd: edge.markerEnd,
intersection: (_edge$data4 = edge.data) === null || _edge$data4 === void 0 ? void 0 : _edge$data4.intersection,
style: {
strokeWidth: ((_edge$style = edge.style) === null || _edge$style === void 0 ? void 0 : _edge$style.strokeWidth) || 2,
strokeDasharray: (_edge$style2 = edge.style) === null || _edge$style2 === void 0 ? void 0 : _edge$style2.strokeDasharray
},
zIndex: edge.zIndex || 5
};
})
};
const dataStr = JSON.stringify(diagramData, null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
const exportFileDefaultName = 'architecture-diagram.json';
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
}, [nodes, edges]);
// Import from draw.io XML
const importFromDrawioXML = (0, _react.useCallback)(() => {
const inputElement = document.createElement('input');
inputElement.type = 'file';
inputElement.accept = '.drawio,.xml';
inputElement.onchange = event => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = e => {
try {
const xmlContent = e.target.result;
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
const cells = xmlDoc.querySelectorAll('mxCell');
const tempNodes = [];
const tempEdges = [];
cells.forEach(cell => {
const id = cell.getAttribute('id');
if (id === '0' || id === '1') return; // skip root
const value = cell.getAttribute('value') || '';
const style = cell.getAttribute('style') || '';
const vertex = cell.getAttribute('vertex');
const edge = cell.getAttribute('edge');
const geometry = cell.querySelector('mxGeometry');
if (!geometry) return;
const x = parseFloat(geometry.getAttribute('x')) || 0;
const y = parseFloat(geometry.getAttribute('y')) || 0;
const width = parseFloat(geometry.getAttribute('width')) || 120;
const height = parseFloat(geometry.getAttribute('height')) || 80;
if (vertex) {
tempNodes.push({
oldId: id,
value,
style,
x,
y,
width,
height,
parent: cell.getAttribute('parent')
});
} else if (edge) {
tempEdges.push({
oldId: id,
value,
style,
source: cell.getAttribute('source'),
target: cell.getAttribute('target')
});
}
});
const cellIdMap = new Map();
const importedNodes = [];
tempNodes.forEach(n => {
const newId = "imported-".concat(Date.now(), "-").concat(Math.random().toString(36).substr(2, 9));
cellIdMap.set(n.oldId, newId);
let nodeType = 'component';
if (n.style.includes('swimlane')) nodeType = 'container';else if (n.style.includes('rhombus')) nodeType = 'diamond';else if (n.style.includes('ellipse')) nodeType = 'circle';else if (n.style.includes('hexagon')) nodeType = 'hexagon';else if (n.style.includes('triangle')) nodeType = 'triangle';
const fillColorMatch = n.style.match(/fillColor=([^;]+)/);
const strokeColorMatch = n.style.match(/strokeColor=([^;]+)/);
const fillColor = fillColorMatch ? fillColorMatch[1] : '#ffffff';
const strokeColor = strokeColorMatch ? strokeColorMatch[1] : '#000000';
const [firstLine, ...restLines] = n.value.split('\n');
importedNodes.push({
id: newId,
type: nodeType,
position: {
x: n.x,
y: n.y
},
data: {
label: firstLine,
color: fillColor,
borderColor: strokeColor,
icon: nodeType === 'container' ? '📦' : '🔹',
description: restLines.join('\n'),
onLabelChange: handleNodeLabelChange
},
style: {
width: n.width,
height: n.height,
zIndex: nodeType === 'container' ? 1 : 10
},
draggable: true,
selectable: true,
zIndex: nodeType === 'container' ? 1 : 10,
__parentOldId: n.parent
});
});
importedNodes.forEach(node => {
if (node.__parentOldId && cellIdMap.has(node.__parentOldId)) {
node.parentNode = cellIdMap.get(node.__parentOldId);
}
delete node.__parentOldId;
});
const importedEdges = tempEdges.map(e => {
const strokeWidthMatch = e.style.match(/strokeWidth=([^;]+)/);
const strokeColorMatch = e.style.match(/strokeColor=([^;]+)/);
const strokeWidth = strokeWidthMatch ? parseInt(strokeWidthMatch[1]) : 2;
const strokeColor = strokeColorMatch ? strokeColorMatch[1] : '#000000';
const isDashed = e.style.includes('dashed=1');
return {
id: "imported-edge-".concat(Date.now(), "-").concat(Math.random().toString(36).substr(2, 9)),
source: cellIdMap.get(e.source) || e.source,
target: cellIdMap.get(e.target) || e.target,
label: e.value,
type: 'smoothstep',
animated: false,
style: {
strokeWidth,
stroke: strokeColor,
strokeDasharray: isDashed ? '5,5' : undefined,
zIndex: 5
},
zIndex: 5,
data: {
label: e.value,
description: ''
}
};
});
const nodeMap = Object.fromEntries(importedNodes.map(n => [n.id, n]));
const filteredEdges = [];
importedEdges.forEach(edge => {
const s = nodeMap[edge.source];
const t = nodeMap[edge.target];
if (s && t && s.type === 'container' && t.type === 'container') {
t.parentNode = s.id;
t.position = {
x: t.position.x - s.position.x,
y: t.position.y - s.position.y
};
} else {
filteredEdges.push(edge);
}
});
setNodes(importedNodes);
setEdges(filteredEdges);
saveToHistory();
} catch (error) {
console.error('Error importing draw.io XML:', error);
alert('Error importing draw.io file. Please check the file format.');
}
};
reader.readAsText(file);
}
};
inputElement.click();
}, [handleNodeLabelChange, saveToHistory]);
// Convert to draw.io XML format
const exportToDrawioXML = (0, _react.useCallback)(() => {
// Map node types to draw.io shapes
const getDrawioShape = nodeType => {
switch (nodeType) {
case 'container':
return 'swimlane;';
case 'component':
return 'rounded=1;whiteSpace=wrap;html=1;';
case 'diamond':
return 'rhombus;whiteSpace=wrap;html=1;';
case 'circle':
return 'ellipse;whiteSpace=wrap;html=1;';
case 'hexagon':
return 'hexagon;whiteSpace=wrap;html=1;';
case 'triangle':
return 'triangle;whiteSpace=wrap;html=1;';
default:
return 'rounded=1;whiteSpace=wrap;html=1;';
}
};
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mxfile host=\"app.diagrams.net\" modified=\"".concat(new Date().toISOString(), "\" agent=\"Architecture Diagram Editor\" version=\"1.0\">\n <diagram name=\"Architecture Diagram\" id=\"diagram1\">\n <mxGraphModel dx=\"1422\" dy=\"794\" grid=\"1\" gridSize=\"10\" guides=\"1\" tooltips=\"1\" connect=\"1\" arrows=\"1\" fold=\"1\" page=\"1\" pageScale=\"1\" pageWidth=\"827\" pageHeight=\"1169\" math=\"0\" shadow=\"0\">\n <root>\n <mxCell id=\"0\"/>\n <mxCell id=\"1\" parent=\"0\"/>");
// Add nodes
nodes.forEach(node => {
var _node$style3, _node$style4;
const shape = getDrawioShape(node.type);
const valueText = [node.data.label || '', node.data.description || ''].filter(Boolean).join('\n');
xml += "\n <mxCell id=\"".concat(node.id, "\" value=\"").concat(valueText, "\" style=\"").concat(shape, "fillColor=").concat(node.data.color || '#ffffff', ";strokeColor=").concat(node.data.borderColor || '#000000', ";strokeWidth=2;\" vertex=\"1\" parent=\"").concat(node.parentNode || '1', "\">\n <mxGeometry x=\"").concat(node.position.x, "\" y=\"").concat(node.position.y, "\" width=\"").concat(((_node$style3 = node.style) === null || _node$style3 === void 0 ? void 0 : _node$style3.width) || 120, "\" height=\"").concat(((_node$style4 = node.style) === null || _node$style4 === void 0 ? void 0 : _node$style4.height) || 80, "\" as=\"geometry\"/>\n </mxCell>");
});
const allEdges = edges;
// Add edges
allEdges.forEach(edge => {
var _edge$style3, _edge$style4, _edge$style5, _edge$data5;
const strokeWidth = ((_edge$style3 = edge.style) === null || _edge$style3 === void 0 ? void 0 : _edge$style3.strokeWidth) || 2;
const strokeColor = ((_edge$style4 = edge.style) === null || _edge$style4 === void 0 ? void 0 : _edge$style4.stroke) || '#000000';
const strokeDash = (_edge$style5 = edge.style) !== null && _edge$style5 !== void 0 && _edge$style5.strokeDasharray ? 'dashed=1;' : '';
xml += "\n <mxCell id=\"".concat(edge.id, "\" value=\"").concat(((_edge$data5 = edge.data) === null || _edge$data5 === void 0 ? void 0 : _edge$data5.label) || '', "\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=").concat(strokeWidth, ";strokeColor=").concat(strokeColor, ";").concat(strokeDash, "\" edge=\"1\" parent=\"1\" source=\"").concat(edge.source, "\" target=\"").concat(edge.target, "\">\n <mxGeometry relative=\"1\" as=\"geometry\"/>\n </mxCell>");
});
xml += "\n </root>\n </mxGraphModel>\n </diagram>\n</mxfile>";
const dataUri = 'data:application/xml;charset=utf-8,' + encodeURIComponent(xml);
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', 'architecture-diagram.drawio');
linkElement.click();
}, [nodes, edges]);
const importDiagram = (0, _react.useCallback)(() => {
const inputElement = document.createElement('input');
inputElement.type = 'file';
inputElement.accept = '.json';
inputElement.onchange = event => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = e => {
try {
const importedData = JSON.parse(e.target.result);
const {
nodes: importedNodes,
edges: importedEdges
} = jsonToReactFlow(importedData);
applyAutoLayout(importedNodes);
setEdges(importedEdges);
saveToHistory();
} catch (error) {
console.error('Error importing diagram:', error);
alert('Error importing diagram. Please check the file format.');
}
};
reader.readAsText(file);
}
};
inputElement.click();
}, [jsonToReactFlow, applyAutoLayout, saveToHistory]);
const importDiagramObject = (0, _react.useCallback)(data => {
if (!validateDiagram(data)) {
alert('Invalid diagram JSON');
return;
}
const {
nodes: importedNodes,
edges: importedEdges
} = jsonToReactFlow(data);
applyAutoLayout(importedNodes);
setEdges(importedEdges);
saveToHistory();
}, [jsonToReactFlow, applyAutoLayout, saveToHistory]);
const handleJsonPasteImport = (0, _react.useCallback)(data => {
importDiagramObject(data);
setJsonPasteModal({
isOpen: false,
onConfirm: null
});
}, [importDiagramObject]);
const validateJson = (0, _react.useCallback)(data => {
const valid = validateDiagram(data);
const errors = valid ? [] : (validateDiagram.errors || []).map(e => "".concat(e.instancePath, " ").concat(e.message));
return {
valid,
errors
};
}, []);
// Clean up timeout on unmount
(0, _react.useEffect)(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
const newDiagram = (0, _react.useCallback)(() => {
showConfirmModal('New Diagram', 'Are you sure you want to create a new diagram? All unsaved changes will be lost.', () => {
applyAutoLayout([]);
setEdges([]);
setSelectedElements({
nodes: [],
edges: []
});
saveToHistory();
});
}, [applyAutoLayout, saveToHistory, showConfirmModal]);
const saveDiagram = (0, _react.useCallback)(() => {
exportJSON();
}, [exportJSON]);
const saveAsDiagram = (0, _react.useCallback)(() => {
exportJSON();
}, [exportJSON]);
const openDiagram = (0, _react.useCallback)(() => {
importDiagram();
}, [importDiagram]);
// Image export helpers
const exportImage = (0, _react.useCallback)(async type => {
if (!reactFlowWrapper.current) return;
const renderer = reactFlowWrapper.current.querySelector('.react-flow__renderer') || reactFlowWrapper.current;
const isDark = !!reactFlowWrapper.current.closest('.dark');
const bounds = getDiagramBounds();
const margin = 20;
const width = bounds.width + margin * 2;
const height = bounds.height + margin * 2;
const common = {
cacheBust: true,
backgroundColor: isDark ? '#111111' : '#ffffff',
width,
height,
style: {
width: "".concat(width, "px"),
height: "".concat(height, "px"),
transform: "translate(".concat(-bounds.x + margin, "px, ").concat(-bounds.y + margin, "px)")
}
};
let dataUrl;
if (type === 'png') dataUrl = await (0, _htmlToImage.toPng)(renderer, common);
if (type === 'jpg') dataUrl = await (0, _htmlToImage.toJpeg)(renderer, _objectSpread(_objectSpread({}, common), {}, {
quality: 0.95
}));
if (type === 'svg') dataUrl = await (0, _htmlToImage.toSvg)(renderer, common);
const link = document.createElement('a');
link.download = "diagram.".concat(type);
link.href = dataUrl;
link.click();
}, [getDiagramBounds]);
const exportAsPNG = (0, _react.useCallback)(() => exportImage("png"), [exportImage]);
const exportAsJPG = (0, _react.useCallback)(() => exportImage('jpg'), [exportImage]);
const exportAsSVG = (0, _react.useCallback)(() => exportImage('svg'), [exportImage]);
const autoLayout = (0, _react.useCallback)(() => {
applyAutoLayout(nodes);
saveToHistory();
}, [nodes, saveToHistory, applyAutoLayout]);
// Enhanced selection operations
const selectAllElements = (0, _react.useCallback)(() => {
setSelectedElements({
nodes: nodes,
edges: edges
});
}, [nodes, edges]);
const deselectAllElements = (0, _react.useCallback)(() => {
setSelectedElements({
nodes: [],
edges: []
});
}, []);
const cutSelected = (0, _react.useCallback)(() => {
if (selectedElements.nodes.length > 0 || selectedElements.edges.length > 0) {
copySelected();
deleteSelected();
}
}, [selectedElements, copySelected, deleteSelected]);
const linkSelectedNodes = (0, _react.useCallback)(() => {
if (selectedElements.nodes.length < 2) {
return;
}
const containers = selectedElements.nodes.filter(n => n.type === 'container');
let parent = containers[0] || selectedElements.nodes[0];
const children = selectedElements.nodes.filter(n => n.id !== parent.id);
setN