From b246a2d45fb376d2c737f321548bcac7687e84d8 Mon Sep 17 00:00:00 2001 From: doouding Date: Mon, 23 Dec 2024 10:33:56 +0000 Subject: [PATCH] fix: drag mind map root node should layout in real time (#9252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes [BS-2062](https://linear.app/affine-design/issue/BS-2062/拖拽整个-mind-map-还是希望和之前一样,整个思维导图跟手) --- .../affine/block-surface/src/view/mindmap.ts | 12 +- .../model/src/elements/mindmap/mindmap.ts | 9 + .../edgeless/edgeless-root-block.ts | 5 +- .../mind-map-ext/mind-map-ext.ts | 128 +++---- .../tests-legacy/edgeless/mindmap.spec.ts | 327 +++++++++++++++--- .../tests-legacy/utils/actions/edgeless.ts | 31 +- blocksuite/tests-legacy/utils/mindmap.ts | 130 +++++++ 7 files changed, 533 insertions(+), 109 deletions(-) create mode 100644 blocksuite/tests-legacy/utils/mindmap.ts diff --git a/blocksuite/affine/block-surface/src/view/mindmap.ts b/blocksuite/affine/block-surface/src/view/mindmap.ts index be6cff91c5..453571bdd9 100644 --- a/blocksuite/affine/block-surface/src/view/mindmap.ts +++ b/blocksuite/affine/block-surface/src/view/mindmap.ts @@ -69,7 +69,10 @@ export class MindMapView extends GfxElementModelView { if (payload.props['xywh']) { updateButtons(); } - if (payload.props['hidden'] !== undefined) { + if ( + payload.props['hidden'] !== undefined || + payload.props['opacity'] !== undefined + ) { this._updateButtonVisibility(payload.id); } } @@ -169,6 +172,7 @@ export class MindMapView extends GfxElementModelView { if (!latestNode) { buttonModel.opacity = 0; + buttonModel.hidden = true; return; } @@ -186,9 +190,9 @@ export class MindMapView extends GfxElementModelView { buttonModel.hidden = latestNode.element.hidden; buttonModel.opacity = - hasChildren && notHidden && (collapsed || isNodeSelected || hovered) + (hasChildren && notHidden && (collapsed || isNodeSelected || hovered) ? 1 - : 0; + : 0) * (latestNode.element.opacity ?? 1); } private _updateCollapseButton(node: MindmapNode) { @@ -216,7 +220,7 @@ export class MindMapView extends GfxElementModelView { const buttonStyle = collapsed ? style.expandButton : style.collapseButton; Object.entries(buttonStyle).forEach(([key, value]) => { - // @ts-expect-error FIXME: ts error + // @ts-expect-error key is string collapseButton[key as unknown] = value; }); diff --git a/blocksuite/affine/model/src/elements/mindmap/mindmap.ts b/blocksuite/affine/model/src/elements/mindmap/mindmap.ts index 9be393e3c5..628215e298 100644 --- a/blocksuite/affine/model/src/elements/mindmap/mindmap.ts +++ b/blocksuite/affine/model/src/elements/mindmap/mindmap.ts @@ -624,6 +624,15 @@ export class MindmapElementModel extends GfxGroupLikeElementModel void; + clear: () => void; + /** + * Whether the dragged node is the root node of the mind map + */ + isRoot: boolean; originalMindMapBound: Bound; startPoint: PointerEventState; }; @@ -94,75 +97,73 @@ export class MindMapExt extends DefaultToolExt { node: hoveredNode, }; - // 1. not hovered on any mind map or - // 2. hovered on the other mind map but not on any node - // then consider user is trying to detach the node + // hovered on the currently dragged mind map but + // 1. not hovered on any node or + // 2. hovered on the node that is itself or its children (which is not allowed) + // then consider user is trying to drop the node to its original position if ( - !hoveredMindMap || - (hoveredMindMap !== dragMindMapCtx.mindmap && !hoveredNode) - ) { - hoveredCtx.detach = true; - - const reset = (hoveredCtx.abort = MindmapUtils.hideNodeConnector( - dragMindMapCtx.mindmap, + hoveredNode && + hoveredMindMap && + !MindmapUtils.containsNode( + hoveredMindMap, + hoveredNode, dragMindMapCtx.node - )); + ) + ) { + const operation = MindmapUtils.tryMoveNode( + hoveredMindMap, + hoveredNode, + dragMindMapCtx.mindmap, + dragMindMapCtx.node, + [x, y], + options => this._drawIndicator(options) + ); - hoveredCtx.abort = () => { - reset?.(); + if (operation) { + hoveredCtx.abort = operation.abort; + hoveredCtx.merge = operation.merge; + } + } else if (dragMindMapCtx.isRoot) { + dragMindMapCtx.mindmap.layout(); + hoveredCtx.merge = () => { + dragMindMapCtx.mindmap.layout(); }; } else { - // hovered on the currently dragging mind map but - // 1. not hovered on any node or - // 2. hovered on the node that is itself or its children (which is not allowed) - // then consider user is trying to drop the node to its original position - if ( - !hoveredNode || - MindmapUtils.containsNode( - hoveredMindMap, - hoveredNode, - dragMindMapCtx.node - ) - ) { - const { mindmap, node } = dragMindMapCtx; - - // if the node is the root node, then do nothing - if (node === mindmap.tree) { - return; - } - - const nodeBound = node.element.elementBound; + // if `hoveredMindMap` is not null + // either the node is hovered on the dragged node's children + // or the there is no hovered node at all + // then consider user is trying to place the node to its original position + if (hoveredMindMap) { + const { node: draggedNode, mindmap } = dragMindMapCtx; + const nodeBound = draggedNode.element.elementBound; hoveredCtx.abort = this._drawIndicator({ targetMindMap: mindmap, - target: node, + target: draggedNode, sourceMindMap: mindmap, - source: node, - newParent: node.parent!, + source: draggedNode, + newParent: draggedNode.parent!, insertPosition: { type: 'sibling', - layoutDir: mindmap.getLayoutDir(node) as Exclude< + layoutDir: mindmap.getLayoutDir(draggedNode) as Exclude< LayoutType, LayoutType.BALANCE >, position: y > nodeBound.y + nodeBound.h / 2 ? 'next' : 'prev', }, - path: mindmap.getPath(node), + path: mindmap.getPath(draggedNode), }); } else { - const operation = MindmapUtils.tryMoveNode( - hoveredMindMap, - hoveredNode, - dragMindMapCtx.mindmap, - dragMindMapCtx.node, - [x, y], - options => this._drawIndicator(options) - ); + hoveredCtx.detach = true; - if (operation) { - hoveredCtx.abort = operation.abort; - hoveredCtx.merge = operation.merge; - } + const reset = (hoveredCtx.abort = MindmapUtils.hideNodeConnector( + dragMindMapCtx.mindmap, + dragMindMapCtx.node + )); + + hoveredCtx.abort = () => { + reset?.(); + }; } } }, @@ -204,7 +205,7 @@ export class MindMapExt extends DefaultToolExt { } hoveredCtx = null; - dragMindMapCtx.clear?.(); + dragMindMapCtx.clear(); this._responseAreaUpdated.clear(); }, }; @@ -394,8 +395,7 @@ export class MindMapExt extends DefaultToolExt { const mindmap = dragState.movedElements[0].group as MindmapElementModel; const mindmapNode = mindmap.getNode(dragState.movedElements[0].id)!; const mindmapBound = mindmap.elementBound; - - dragState.movedElements.splice(0, 1); + const isRoot = mindmapNode === mindmap.tree; mindmapBound.x -= NODE_HORIZONTAL_SPACING; mindmapBound.y -= NODE_VERTICAL_SPACING * 2; @@ -404,19 +404,25 @@ export class MindMapExt extends DefaultToolExt { this._calcDragResponseArea(mindmap); - const clearDragImage = this._setupDragNodeImage( - mindmapNode, - dragState.event - ); + const clearDragStatus = isRoot + ? mindmap.stashTree(mindmapNode) + : this._setupDragNodeImage(mindmapNode, dragState.event); const clearOpacity = this._updateNodeOpacity(mindmap, mindmapNode); + if (!isRoot) { + dragState.movedElements.splice(0, 1); + } + const mindMapDragCtx: DragMindMapCtx = { mindmap, node: mindmapNode, + isRoot, clear: () => { clearOpacity(); - clearDragImage?.(); - dragState.movedElements.push(mindmapNode.element); + clearDragStatus?.(); + if (!isRoot) { + dragState.movedElements.push(mindmapNode.element); + } }, originalMindMapBound: mindmapBound, startPoint: dragState.event, diff --git a/blocksuite/tests-legacy/edgeless/mindmap.spec.ts b/blocksuite/tests-legacy/edgeless/mindmap.spec.ts index 7b1a10c4a4..83d4d4ae17 100644 --- a/blocksuite/tests-legacy/edgeless/mindmap.spec.ts +++ b/blocksuite/tests-legacy/edgeless/mindmap.spec.ts @@ -1,4 +1,3 @@ -import type { MindmapElementModel } from '@blocksuite/affine-model'; import { expect } from '@playwright/test'; import { clickView } from 'utils/actions/click.js'; import { dragBetweenCoords } from 'utils/actions/drag.js'; @@ -8,11 +7,16 @@ import { edgelessCommonSetup, getSelectedBound, getSelectedBoundCount, + selectElementInEdgeless, + waitFontsLoaded, zoomResetByKeyboard, } from 'utils/actions/edgeless.js'; import { pressBackspace, + pressEnter, + pressTab, selectAllByKeyboard, + type, undoByKeyboard, } from 'utils/actions/keyboard.js'; import { waitNextFrame } from 'utils/actions/misc.js'; @@ -20,6 +24,11 @@ import { assertEdgelessSelectedRect, assertSelectedBound, } from 'utils/asserts.js'; +import { + addMindmapNodes, + createMindMap, + getMindMapNode, +} from 'utils/mindmap.js'; import { test } from '../utils/playwright.js'; @@ -67,60 +76,296 @@ test('drag mind map node to reorder the node', async ({ page }) => { await edgelessCommonSetup(page); await zoomResetByKeyboard(page); - await page.keyboard.press('m'); - await clickView(page, [0, 0]); - await autoFit(page); + const mindmapId = await createMindMap(page, [0, 0]); + const { id: nodeId, rect: nodeRect } = await getMindMapNode( + page, + mindmapId, + [0, 0] + ); + const { rect: targetRect } = await getMindMapNode(page, mindmapId, [0, 1]); + const { rect: lastRect } = await getMindMapNode(page, mindmapId, [0, 2]); - const { mindmapId, nodeId, nodeRect } = await page.evaluate(() => { - const edgelessBlock = document.querySelector('affine-edgeless-root'); - if (!edgelessBlock) { - throw new Error('edgeless block not found'); + await selectElementInEdgeless(page, [nodeId]); + await dragBetweenCoords( + page, + { x: nodeRect.x + nodeRect.w / 2, y: nodeRect.y + nodeRect.h / 2 }, + { x: targetRect.x + targetRect.w / 2, y: targetRect.y + targetRect.h + 40 }, + { + steps: 50, } - const mindmap = edgelessBlock.gfx.gfxElements.filter( - el => 'type' in el && el.type === 'mindmap' - )[0] as MindmapElementModel; - const node = mindmap.tree.children[0].element; - const rect = edgelessBlock.gfx.viewport.toViewBound(node.elementBound); + ); + expect((await getMindMapNode(page, mindmapId, [0, 1])).id).toEqual(nodeId); - edgelessBlock.gfx.selection.set({ elements: [node.id] }); - - return { - mindmapId: mindmap.id, - nodeId: node.id, - nodeRect: { - x: rect.x, - y: rect.y, - w: rect.w, - h: rect.h, - }, - }; - }); - - await waitNextFrame(page, 100); + await dragBetweenCoords( + page, + { x: targetRect.x + targetRect.w / 2, y: targetRect.y + targetRect.h / 2 }, + { x: nodeRect.x - 20, y: nodeRect.y - 40 }, + { + steps: 50, + } + ); + expect((await getMindMapNode(page, mindmapId, [0, 0])).id).toEqual(nodeId); await dragBetweenCoords( page, { x: nodeRect.x + nodeRect.w / 2, y: nodeRect.y + nodeRect.h / 2 }, - { x: nodeRect.x + nodeRect.w / 2, y: nodeRect.y + nodeRect.h / 2 + 120 }, + { x: lastRect.x - 20, y: lastRect.y + lastRect.h + 40 }, + { + steps: 50, + } + ); + expect((await getMindMapNode(page, mindmapId, [0, 2])).id).toEqual(nodeId); +}); + +test('drag mind map node to make it a child node', async ({ page }) => { + await edgelessCommonSetup(page); + await zoomResetByKeyboard(page); + + const mindmapId = await createMindMap(page, [0, 0]); + + { + const { id: nodeId, rect: nodeRect } = await getMindMapNode( + page, + mindmapId, + [0, 0] + ); + const { rect: targetRect } = await getMindMapNode(page, mindmapId, [0, 1]); + + await selectElementInEdgeless(page, [nodeId]); + await dragBetweenCoords( + page, + { x: nodeRect.x + nodeRect.w / 2, y: nodeRect.y + nodeRect.h / 2 }, + { + x: targetRect.x + targetRect.w / 2, + y: targetRect.y + targetRect.h / 2, + }, + { + steps: 50, + } + ); + expect((await getMindMapNode(page, mindmapId, [0, 0, 0])).id).toEqual( + nodeId + ); + } + + { + const { id: childId } = await getMindMapNode(page, mindmapId, [0, 0, 0]); + const { rect: firstRect } = await getMindMapNode(page, mindmapId, [0, 0]); + const { rect: secondRect } = await getMindMapNode(page, mindmapId, [0, 1]); + + await dragBetweenCoords( + page, + { x: firstRect.x + firstRect.w / 2, y: firstRect.y + firstRect.h / 2 }, + { + x: secondRect.x + secondRect.w + 10, + y: secondRect.y + secondRect.h / 2, + }, + { + steps: 50, + } + ); + expect((await getMindMapNode(page, mindmapId, [0, 0, 0, 0])).id).toEqual( + childId + ); + } +}); + +test('cannot drag mind map node to itself or its descendants', async ({ + page, +}) => { + await edgelessCommonSetup(page); + await zoomResetByKeyboard(page); + + const mindmapId = await createMindMap(page, [0, 1]); + await addMindmapNodes(page, mindmapId, [0, 1], { + text: 'child node 1', + children: [ + { + text: 'child node 2', + }, + { + text: 'child node 3', + }, + ], + }); + + const { id: node, rect } = await getMindMapNode(page, mindmapId, [0, 1]); + const { id: childNode3, rect: childRect3 } = await getMindMapNode( + page, + mindmapId, + [0, 1, 0, 1] + ); + await dragBetweenCoords( + page, + { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }, + { x: childRect3.x + childRect3.w + 10, y: childRect3.y + childRect3.h / 2 }, { steps: 50, } ); - const secondNodeId = await page.evaluate( - ({ mindmapId }) => { - const edgelessBlock = document.querySelector('affine-edgeless-root'); - if (!edgelessBlock) { - throw new Error('edgeless block not found'); - } - const mindmap = edgelessBlock.gfx.getElementById( - mindmapId - ) as MindmapElementModel; + expect((await getMindMapNode(page, mindmapId, [0, 1])).id).toEqual(node); + expect((await getMindMapNode(page, mindmapId, [0, 1, 0, 1])).id).toEqual( + childNode3 + ); +}); - return mindmap.tree.children[1].id; +test('drag root node should layout in real time', async ({ page }) => { + await edgelessCommonSetup(page); + await zoomResetByKeyboard(page); + + // wait for the font to be loaded + await waitFontsLoaded(page); + + const mindmapId = await createMindMap(page, [0, 0]); + const { rect: rootRect } = await getMindMapNode(page, mindmapId, [0]); + const { rect: firstRect } = await getMindMapNode(page, mindmapId, [0, 0]); + const { rect: secondRect } = await getMindMapNode(page, mindmapId, [0, 1]); + const { rect: thirdRect } = await getMindMapNode(page, mindmapId, [0, 2]); + + const assertMindMapNodesPosition = async (deltaX: number, deltaY: number) => { + await expect((await getMindMapNode(page, mindmapId, [0, 0])).rect).toEqual({ + ...firstRect, + x: firstRect.x + deltaX, + y: firstRect.y + deltaY, + }); + await expect((await getMindMapNode(page, mindmapId, [0, 1])).rect).toEqual({ + ...secondRect, + x: secondRect.x + deltaX, + y: secondRect.y + deltaY, + }); + await expect((await getMindMapNode(page, mindmapId, [0, 2])).rect).toEqual({ + ...thirdRect, + x: thirdRect.x + deltaX, + y: thirdRect.y + deltaY, + }); + }; + + await dragBetweenCoords( + page, + { x: rootRect.x + rootRect.w / 2, y: rootRect.y + rootRect.h / 2 }, + { + x: rootRect.x + rootRect.w / 2 + 10, + y: rootRect.y + rootRect.h / 2 + 10, }, - { mindmapId, nodeId } + { + steps: 50, + } ); - expect(secondNodeId).toEqual(nodeId); + await assertMindMapNodesPosition(10, 10); + + await page.mouse.move( + rootRect.x + rootRect.w / 2 + 10, + rootRect.y + rootRect.h / 2 + 10 + ); + await page.mouse.down(); + await page.mouse.move( + rootRect.x + rootRect.w / 2 + 10 + 4, + rootRect.y + rootRect.h / 2 + 10 + 4 + ); + await page.mouse.move( + rootRect.x + rootRect.w / 2 + 10 + 44, + rootRect.y + rootRect.h / 2 + 10 + 44, + { steps: 10 } + ); + + // assert when dragging is in progress + await waitNextFrame(page, 500); + await assertMindMapNodesPosition(50, 50); + + await page.mouse.up(); +}); + +test('drag node out of mind map should detach the node and create a new mind map', async ({ + page, +}) => { + await edgelessCommonSetup(page); + await zoomResetByKeyboard(page); + + const mindmapId = await createMindMap(page, [0, 1]); + await addMindmapNodes(page, mindmapId, [0, 1], { + text: 'child node 1', + children: [ + { + text: 'child node 2', + }, + { + text: 'child node 3', + }, + ], + }); + + const { rect } = await getMindMapNode(page, mindmapId, [0, 1]); + await dragBetweenCoords( + page, + { + x: rect.x + rect.w / 2, + y: rect.y + rect.h / 2, + }, + { + x: rect.x + rect.w / 2, + y: rect.y + rect.h / 2 + 300, + }, + { + steps: 50, + } + ); + + const { count, mindmap: lastMindmapId } = await page.evaluate(() => { + const edgelessBlock = document.querySelector('affine-edgeless-root'); + if (!edgelessBlock) { + throw new Error('edgeless block not found'); + } + const mindmaps = edgelessBlock.gfx.gfxElements.filter( + el => 'type' in el && el.type === 'mindmap' + ); + + return { + count: mindmaps.length, + mindmap: mindmaps[mindmaps.length - 1].id, + }; + }); + + expect(count).toBe(2); + expect((await getMindMapNode(page, lastMindmapId, [0, 0])).text).toBe( + 'child node 1' + ); + expect((await getMindMapNode(page, lastMindmapId, [0, 0, 0])).text).toBe( + 'child node 2' + ); + expect((await getMindMapNode(page, lastMindmapId, [0, 0, 1])).text).toBe( + 'child node 3' + ); +}); + +test('allow to type content directly when node has been selected', async ({ + page, +}) => { + await edgelessCommonSetup(page); + await zoomResetByKeyboard(page); + + const mindmapId = await createMindMap(page, [0, 0]); + const { id: nodeId } = await getMindMapNode(page, mindmapId, [0, 1]); + + await clickView(page, [0, 0]); + await selectElementInEdgeless(page, [nodeId]); + await type(page, 'parent node'); + await pressEnter(page); + await pressTab(page); + await type(page, 'child node 1'); + await pressEnter(page); + await pressEnter(page); + await type(page, 'child node 2'); + await pressEnter(page); + + await expect((await getMindMapNode(page, mindmapId, [0, 1])).text).toBe( + 'parent node' + ); + await expect((await getMindMapNode(page, mindmapId, [0, 1, 0])).text).toBe( + 'child node 1' + ); + await expect((await getMindMapNode(page, mindmapId, [0, 1, 1])).text).toBe( + 'child node 2' + ); }); diff --git a/blocksuite/tests-legacy/utils/actions/edgeless.ts b/blocksuite/tests-legacy/utils/actions/edgeless.ts index 633baf4286..51a318a955 100644 --- a/blocksuite/tests-legacy/utils/actions/edgeless.ts +++ b/blocksuite/tests-legacy/utils/actions/edgeless.ts @@ -1154,8 +1154,8 @@ export async function triggerComponentToolbarAction( await button.click(); break; } - case 'changeShapeStrokeStyles': - case 'changeShapeStrokeColor': { + case 'changeShapeStrokeColor': + case 'changeShapeStrokeStyles': { const button = locatorComponentToolbar(page) .locator('edgeless-change-shape-button') .getByRole('button', { name: 'Border style' }); @@ -1916,3 +1916,30 @@ export function toIdCountMap(ids: string[]) { export function getFrameTitle(page: Page, frame: string) { return page.locator(`affine-frame-title[data-id="${frame}"]`); } + +export async function selectElementInEdgeless(page: Page, elements: string[]) { + await page.evaluate( + ({ elements }) => { + const edgelessBlock = document.querySelector('affine-edgeless-root'); + if (!edgelessBlock) { + throw new Error('edgeless block not found'); + } + + edgelessBlock.gfx.selection.set({ + elements, + }); + }, + { elements } + ); +} + +export async function waitFontsLoaded(page: Page) { + await page.evaluate(() => { + const edgelessBlock = document.querySelector('affine-edgeless-root'); + if (!edgelessBlock) { + throw new Error('edgeless block not found'); + } + + return edgelessBlock.fontLoader?.ready; + }); +} diff --git a/blocksuite/tests-legacy/utils/mindmap.ts b/blocksuite/tests-legacy/utils/mindmap.ts new file mode 100644 index 0000000000..7633cad09f --- /dev/null +++ b/blocksuite/tests-legacy/utils/mindmap.ts @@ -0,0 +1,130 @@ +import type { + MindmapElementModel, + MindmapNode, + ShapeElementModel, +} from '@blocksuite/affine-model'; +import type { Page } from '@playwright/test'; + +import { clickView } from './actions/click.js'; + +export async function createMindMap(page: Page, coords: [number, number]) { + await page.keyboard.press('m'); + await clickView(page, coords); + + const id = await page.evaluate(() => { + const edgelessBlock = document.querySelector('affine-edgeless-root'); + if (!edgelessBlock) { + throw new Error('edgeless block not found'); + } + const mindmaps = edgelessBlock.gfx.gfxElements.filter( + el => 'type' in el && el.type === 'mindmap' + ); + + return mindmaps[mindmaps.length - 1].id; + }); + + return id; +} + +export async function getMindMapNode( + page: Page, + mindmapId: string, + pathOrId: number[] | string +) { + return page.evaluate( + ({ mindmapId, pathOrId }) => { + const edgelessBlock = document.querySelector('affine-edgeless-root'); + if (!edgelessBlock) { + throw new Error('edgeless block not found'); + } + + const mindmap = edgelessBlock.gfx.getElementById( + mindmapId + ) as MindmapElementModel; + if (!mindmap) { + throw new Error(`Mindmap not found: ${mindmapId}`); + } + + const node = Array.isArray(pathOrId) + ? mindmap.getNodeByPath(pathOrId) + : mindmap.getNode(pathOrId); + if (!node) { + throw new Error(`Mindmap node not found at: ${pathOrId}`); + } + + const rect = edgelessBlock.gfx.viewport.toViewBound( + node.element.elementBound + ); + + return { + path: mindmap.getPath(node), + id: node.id, + text: (node.element as ShapeElementModel).text?.toString() ?? '', + rect: { + x: rect.x, + y: rect.y, + w: rect.w, + h: rect.h, + }, + }; + }, + { + mindmapId, + pathOrId, + } + ); +} + +type NewNodeInfo = { + text: string; + children?: NewNodeInfo[]; +}; + +export async function addMindmapNodes( + page: Page, + mindmapId: string, + path: number[], + newNode: NewNodeInfo +) { + return page.evaluate( + ({ mindmapId, path, newNode }) => { + const edgelessBlock = document.querySelector('affine-edgeless-root'); + if (!edgelessBlock) { + throw new Error('edgeless block not found'); + } + + const mindmap = edgelessBlock.gfx.getElementById( + mindmapId + ) as MindmapElementModel; + if (!mindmap) { + throw new Error(`Mindmap not found: ${mindmapId}`); + } + + const parent = mindmap.getNodeByPath(path); + if (!parent) { + throw new Error(`Mindmap node not found at: ${path}`); + } + + const addNode = ( + mindmap: MindmapElementModel, + node: NewNodeInfo, + parent: MindmapNode + ) => { + const newNodeId = mindmap.addNode(parent, undefined, undefined, { + text: node.text, + }); + + if (node.children) { + node.children.forEach(child => { + addNode(mindmap, child, mindmap.getNode(newNodeId)!); + }); + } + + return newNodeId; + }; + + return addNode(mindmap, newNode, parent); + }, + { mindmapId, path, newNode } + ); +}