fix(edit_diagram): implement cascade delete for children and edges (#451)

* fix(edit_diagram): implement cascade delete for children and edges

- Add automatic cascade deletion when deleting a cell
- Recursively delete all child cells (parent attribute references)
- Delete all edges referencing deleted cells (source/target)
- Skip silently if cell already deleted (handles AI redundant ops)
- Update prompts to inform AI about cascade behavior

Fixes #450

* fix: add root cell protection and sync MCP server cascade delete

- Add protection for root cells '0' and '1' to prevent full diagram wipe
- Sync MCP server with main app's cascade delete logic
- Both lib/utils.ts and packages/mcp-server now have identical delete behavior

* chore(mcp): bump version to 0.1.9

* fix(cascade-delete): recursively collect edge children (labels)

- Change from cellsToDelete.add(edgeId) to collectDescendants(edgeId)
- Fixes orphaned edge labels causing draw.io to crash/clear canvas
- Edge labels (parent=edgeId) are now deleted with their parent edge
This commit is contained in:
Dayuan Jiang
2025-12-30 00:03:30 +09:00
committed by GitHub
parent c2aa7f49be
commit 2d62496f9f
5 changed files with 129 additions and 39 deletions

View File

@@ -605,7 +605,7 @@ Notes:
Operations: Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml. - update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml. - add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell by its id. Only cell_id is needed. - delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
For update/add, new_xml must be a complete mxCell element including mxGeometry. For update/add, new_xml must be a complete mxCell element including mxGeometry.
@@ -614,8 +614,8 @@ For update/add, new_xml must be a complete mxCell element including mxGeometry.
Example - Add a rectangle: Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]} {"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
Example - Delete a cell: Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`, {"operations": [{"operation": "delete", "cell_id": "2"}]}`,
inputSchema: z.object({ inputSchema: z.object({
operations: z operations: z
.array( .array(

View File

@@ -276,7 +276,7 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
**Operations:** **Operations:**
- **update**: Replace an existing cell. Provide cell_id and new_xml. - **update**: Replace an existing cell. Provide cell_id and new_xml.
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml. - **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
- **delete**: Remove a cell. Only cell_id is needed. - **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
**Input Format:** **Input Format:**
\`\`\`json \`\`\`json
@@ -301,9 +301,9 @@ Add new shape:
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\` \`\`\`
Delete cell: Delete container (children & edges auto-deleted):
\`\`\`json \`\`\`json
{"operations": [{"operation": "delete", "cell_id": "5"}]} {"operations": [{"operation": "delete", "cell_id": "2"}]}
\`\`\` \`\`\`
**Error Recovery:** **Error Recovery:**

View File

@@ -633,32 +633,77 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.operation === "delete") {
const existingCell = cellMap.get(op.cell_id) // Protect root cells from deletion
if (!existingCell) { if (op.cell_id === "0" || op.cell_id === "1") {
errors.push({ errors.push({
type: "delete", type: "delete",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cannot delete root cell "${op.cell_id}"`,
}) })
continue continue
} }
// Check for edges referencing this cell (warning only, still delete) const existingCell = cellMap.get(op.cell_id)
const referencingEdges = root.querySelectorAll( if (!existingCell) {
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`, // Cell not found - might have been cascade-deleted by a previous operation
// Skip silently instead of erroring (AI may redundantly list children/edges)
continue
}
// Cascade delete: collect all cells to delete (children + edges + self)
const cellsToDelete = new Set<string>()
// Recursive function to find all descendants
const collectDescendants = (cellId: string) => {
if (cellsToDelete.has(cellId)) return
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
const children = root.querySelectorAll(
`mxCell[parent="${cellId}"]`,
) )
if (referencingEdges.length > 0) { children.forEach((child) => {
const edgeIds = Array.from(referencingEdges) const childId = child.getAttribute("id")
.map((e) => e.getAttribute("id")) if (childId && childId !== "0" && childId !== "1") {
.join(", ") collectDescendants(childId)
console.warn( }
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`, })
}
// Collect the target cell and all its descendants
collectDescendants(op.cell_id)
// Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) {
const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
// Recurse to collect edge's children (like labels)
collectDescendants(edgeId)
}
})
}
// Log what will be deleted
if (cellsToDelete.size > 1) {
console.log(
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
) )
} }
// Remove the node // Delete all collected cells
existingCell.parentNode?.removeChild(existingCell) for (const cellId of cellsToDelete) {
cellMap.delete(op.cell_id) const cell = cellMap.get(cellId)
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
}
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.8", "version": "0.1.10",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -182,32 +182,77 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.operation === "delete") {
const existingCell = cellMap.get(op.cell_id) // Protect root cells from deletion
if (!existingCell) { if (op.cell_id === "0" || op.cell_id === "1") {
errors.push({ errors.push({
type: "delete", type: "delete",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cannot delete root cell "${op.cell_id}"`,
}) })
continue continue
} }
// Check for edges referencing this cell (warning only, still delete) const existingCell = cellMap.get(op.cell_id)
const referencingEdges = root.querySelectorAll( if (!existingCell) {
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`, // Cell not found - might have been cascade-deleted by a previous operation
// Skip silently instead of erroring (AI may redundantly list children/edges)
continue
}
// Cascade delete: collect all cells to delete (children + edges + self)
const cellsToDelete = new Set<string>()
// Recursive function to find all descendants
const collectDescendants = (cellId: string) => {
if (cellsToDelete.has(cellId)) return
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
const children = root.querySelectorAll(
`mxCell[parent="${cellId}"]`,
) )
if (referencingEdges.length > 0) { children.forEach((child) => {
const edgeIds = Array.from(referencingEdges) const childId = child.getAttribute("id")
.map((e) => e.getAttribute("id")) if (childId && childId !== "0" && childId !== "1") {
.join(", ") collectDescendants(childId)
console.warn( }
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`, })
}
// Collect the target cell and all its descendants
collectDescendants(op.cell_id)
// Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) {
const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
// Recurse to collect edge's children (like labels)
collectDescendants(edgeId)
}
})
}
// Log what will be deleted
if (cellsToDelete.size > 1) {
console.log(
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
) )
} }
// Remove the node // Delete all collected cells
existingCell.parentNode?.removeChild(existingCell) for (const cellId of cellsToDelete) {
cellMap.delete(op.cell_id) const cell = cellMap.get(cellId)
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
}
} }
} }