feat(network): draw.io XML export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand } from 'lucide-react'
|
||||
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type InteractionMode = 'select' | 'pan'
|
||||
@@ -18,6 +18,7 @@ interface DiagramHeaderProps {
|
||||
onExportSvg: () => void
|
||||
onExportPdf: () => void
|
||||
onExportJson: () => void
|
||||
onExportDrawio: () => void
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
canUndo: boolean
|
||||
@@ -39,6 +40,7 @@ export function DiagramHeader({
|
||||
onExportSvg,
|
||||
onExportPdf,
|
||||
onExportJson,
|
||||
onExportDrawio,
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
@@ -233,6 +235,12 @@ export function DiagramHeader({
|
||||
<FileJson size={12} /> Export JSON
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Share2 size={12} /> Export draw.io
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
99
frontend/src/lib/drawio-export.ts
Normal file
99
frontend/src/lib/drawio-export.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||
import type { GroupNodeData } from '@/types/network-diagram'
|
||||
|
||||
// Maps our device slugs to draw.io Cisco stencil shape styles
|
||||
const SLUG_TO_DRAWIO_STYLE: Record<string, string> = {
|
||||
'router': 'shape=mxgraph.cisco.routers.router;',
|
||||
'switch': 'shape=mxgraph.cisco.switches.layer_3_switch;',
|
||||
'access-point': 'shape=mxgraph.cisco.misc.access_point;',
|
||||
'load-balancer': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'firewall': 'shape=mxgraph.cisco.firewalls.firewall;',
|
||||
'badge-reader': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'server': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||
'vm': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||
'container': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||
'nas': 'shape=mxgraph.cisco.storage.tape_storage_library;',
|
||||
'san': 'shape=mxgraph.cisco.storage.tape_storage_library;',
|
||||
'cloud-storage': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'cloud': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'aws': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'azure': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'gcp': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'isp': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'workstation': 'shape=mxgraph.cisco.computers_and_peripherals.pc;',
|
||||
'laptop': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
|
||||
'tablet': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
|
||||
'phone': 'shape=mxgraph.cisco.computers_and_peripherals.ip_phone;',
|
||||
'printer': 'shape=mxgraph.cisco.computers_and_peripherals.printer;',
|
||||
'ups': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'pdu': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'rack': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'patch-panel': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'camera': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'nvr': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'iot': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
}
|
||||
|
||||
const BASE_NODE_STYLE =
|
||||
'sketch=0;html=1;pointerEvents=1;dashed=0;fillColor=#036897;strokeColor=#ffffff;strokeWidth=2;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;'
|
||||
const GROUP_STYLE =
|
||||
'swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;collapsible=0;marginBottom=0;swimlaneHead=0;fillColor=none;'
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function exportToDrawio(nodes: Node[], edges: Edge[]): string {
|
||||
const cells: string[] = [
|
||||
'<mxCell id="0"/>',
|
||||
'<mxCell id="1" parent="0"/>',
|
||||
]
|
||||
|
||||
for (const node of nodes) {
|
||||
const w = typeof node.style?.width === 'number' ? node.style.width : (node.measured?.width ?? 120)
|
||||
const h = typeof node.style?.height === 'number' ? node.style.height : (node.measured?.height ?? 120)
|
||||
const x = node.position.x
|
||||
const y = node.position.y
|
||||
const parentId = node.parentId ?? '1'
|
||||
|
||||
if (node.type === 'group') {
|
||||
const gd = node.data as GroupNodeData
|
||||
cells.push(
|
||||
`<mxCell id="${esc(node.id)}" value="${esc(gd.label ?? '')}" style="${GROUP_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
} else {
|
||||
const dd = node.data as DeviceNodeData
|
||||
const slug = dd.deviceType ?? 'server'
|
||||
const shapeStyle = SLUG_TO_DRAWIO_STYLE[slug] ?? 'rounded=1;whiteSpace=wrap;html=1;'
|
||||
cells.push(
|
||||
`<mxCell id="${esc(node.id)}" value="${esc(dd.label ?? '')}" style="${shapeStyle}${BASE_NODE_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
const label = typeof edge.label === 'string' ? edge.label : ''
|
||||
cells.push(
|
||||
`<mxCell id="${esc(edge.id)}" value="${esc(label)}" style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" source="${esc(edge.source)}" target="${esc(edge.target)}" parent="1">` +
|
||||
`<mxGeometry relative="1" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
}
|
||||
|
||||
const xml =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<mxGraphModel><root>\n` +
|
||||
cells.join('\n') +
|
||||
`\n</root></mxGraphModel>`
|
||||
|
||||
return xml
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
||||
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
||||
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { exportToDrawio } from '@/lib/drawio-export'
|
||||
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
||||
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||
|
||||
@@ -729,6 +730,21 @@ function DiagramEditorInner() {
|
||||
}
|
||||
}, [diagramId, name])
|
||||
|
||||
const handleExportDrawio = useCallback(() => {
|
||||
if (nodes.length === 0) {
|
||||
toast.warning('Add some devices to the diagram before exporting')
|
||||
return
|
||||
}
|
||||
const xml = exportToDrawio(getNodes(), edges)
|
||||
const blob = new Blob([xml], { type: 'application/xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.drawio`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [nodes, edges, getNodes, name])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -752,6 +768,7 @@ function DiagramEditorInner() {
|
||||
onExportSvg={handleExportSvg}
|
||||
onExportPdf={handleExportPdf}
|
||||
onExportJson={handleExportJson}
|
||||
onExportDrawio={handleExportDrawio}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
canUndo={canUndo}
|
||||
|
||||
Reference in New Issue
Block a user