From 2d62496f9fd03b2b44f5edcb14386b156dfb5bcb Mon Sep 17 00:00:00 2001
From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com>
Date: Tue, 30 Dec 2025 00:03:30 +0900
Subject: [PATCH] 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
---
app/api/chat/route.ts | 6 +-
lib/system-prompts.ts | 6 +-
lib/utils.ts | 77 +++++++++++++++----
packages/mcp-server/package.json | 2 +-
packages/mcp-server/src/diagram-operations.ts | 77 +++++++++++++++----
5 files changed, 129 insertions(+), 39 deletions(-)
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index 1d1ad1e..96f448e 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -605,7 +605,7 @@ Notes:
Operations:
- 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.
-- 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.
@@ -614,8 +614,8 @@ For update/add, new_xml must be a complete mxCell element including mxGeometry.
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]}
-Example - Delete a cell:
-{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
+Example - Delete container (children & edges auto-deleted):
+{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
inputSchema: z.object({
operations: z
.array(
diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts
index 5016ae6..0400907 100644
--- a/lib/system-prompts.ts
+++ b/lib/system-prompts.ts
@@ -276,7 +276,7 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
**Operations:**
- **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.
-- **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:**
\`\`\`json
@@ -301,9 +301,9 @@ Add new shape:
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "\\n \\n"}]}
\`\`\`
-Delete cell:
+Delete container (children & edges auto-deleted):
\`\`\`json
-{"operations": [{"operation": "delete", "cell_id": "5"}]}
+{"operations": [{"operation": "delete", "cell_id": "2"}]}
\`\`\`
**Error Recovery:**
diff --git a/lib/utils.ts b/lib/utils.ts
index 74e7038..89ba67a 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -633,32 +633,77 @@ export function applyDiagramOperations(
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") {
- const existingCell = cellMap.get(op.cell_id)
- if (!existingCell) {
+ // Protect root cells from deletion
+ if (op.cell_id === "0" || op.cell_id === "1") {
errors.push({
type: "delete",
cellId: op.cell_id,
- message: `Cell with id="${op.cell_id}" not found`,
+ message: `Cannot delete root cell "${op.cell_id}"`,
})
continue
}
- // Check for edges referencing this cell (warning only, still delete)
- const referencingEdges = root.querySelectorAll(
- `mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
- )
- if (referencingEdges.length > 0) {
- const edgeIds = Array.from(referencingEdges)
- .map((e) => e.getAttribute("id"))
- .join(", ")
- console.warn(
- `[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
+ const existingCell = cellMap.get(op.cell_id)
+ if (!existingCell) {
+ // 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()
+
+ // 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}"]`,
+ )
+ children.forEach((child) => {
+ const childId = child.getAttribute("id")
+ if (childId && childId !== "0" && childId !== "1") {
+ collectDescendants(childId)
+ }
+ })
+ }
+
+ // 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
- existingCell.parentNode?.removeChild(existingCell)
- cellMap.delete(op.cell_id)
+ // Delete all collected cells
+ for (const cellId of cellsToDelete) {
+ const cell = cellMap.get(cellId)
+ if (cell) {
+ cell.parentNode?.removeChild(cell)
+ cellMap.delete(cellId)
+ }
+ }
}
}
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index bc3c3da..c0d5b81 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/mcp-server/src/diagram-operations.ts b/packages/mcp-server/src/diagram-operations.ts
index 5976e87..93225fc 100644
--- a/packages/mcp-server/src/diagram-operations.ts
+++ b/packages/mcp-server/src/diagram-operations.ts
@@ -182,32 +182,77 @@ export function applyDiagramOperations(
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") {
- const existingCell = cellMap.get(op.cell_id)
- if (!existingCell) {
+ // Protect root cells from deletion
+ if (op.cell_id === "0" || op.cell_id === "1") {
errors.push({
type: "delete",
cellId: op.cell_id,
- message: `Cell with id="${op.cell_id}" not found`,
+ message: `Cannot delete root cell "${op.cell_id}"`,
})
continue
}
- // Check for edges referencing this cell (warning only, still delete)
- const referencingEdges = root.querySelectorAll(
- `mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
- )
- if (referencingEdges.length > 0) {
- const edgeIds = Array.from(referencingEdges)
- .map((e) => e.getAttribute("id"))
- .join(", ")
- console.warn(
- `[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
+ const existingCell = cellMap.get(op.cell_id)
+ if (!existingCell) {
+ // 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()
+
+ // 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}"]`,
+ )
+ children.forEach((child) => {
+ const childId = child.getAttribute("id")
+ if (childId && childId !== "0" && childId !== "1") {
+ collectDescendants(childId)
+ }
+ })
+ }
+
+ // 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
- existingCell.parentNode?.removeChild(existingCell)
- cellMap.delete(op.cell_id)
+ // Delete all collected cells
+ for (const cellId of cellsToDelete) {
+ const cell = cellMap.get(cellId)
+ if (cell) {
+ cell.parentNode?.removeChild(cell)
+ cellMap.delete(cellId)
+ }
+ }
}
}