import { applyNodeStyle, LayoutType, type MindmapElementModel, type MindmapNode, type MindmapRoot, type MindmapStyle, type NodeDetail, type NodeType, type ShapeElementModel, } from '@blocksuite/affine-model'; import { generateKeyBetween, type SurfaceBlockModel, } from '@blocksuite/block-std/gfx'; import type { IVec } from '@blocksuite/global/gfx'; import { assertType } from '@blocksuite/global/utils'; import isEqual from 'lodash-es/isEqual'; import last from 'lodash-es/last'; import * as Y from 'yjs'; import { fitContent } from '../../renderer/elements/shape/utils.js'; import { layout } from './layout.js'; export function getHoveredArea( target: ShapeElementModel, position: [number, number], layoutDir: LayoutType ): 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' { const { x, y, w, h } = target; const center = layoutDir === LayoutType.BALANCE ? [x + w / 2, y + h / 2] : layoutDir === LayoutType.LEFT ? [x + (w / 3) * 1, y + h / 2] : [x + (w / 3) * 2, y + h / 2]; return `${position[1] - center[1] > 0 ? 'bottom' : 'top'}-${position[0] - center[0] > 0 ? 'right' : 'left'}`; } /** * Hide the connector between the target node and its parent */ export function hideNodeConnector( mindmap: MindmapElementModel, /** * The mind map node which's connector will be hide */ target: MindmapNode ) { const parent = mindmap.getParentNode(target.id); if (!parent) { return; } const connectorId = `#${parent.id}-${target.id}`; const connector = mindmap.connectors.get(connectorId); if (!connector) { return; } connector.opacity = 0; return () => { connector.opacity = 1; }; } /** * Move the node to the new parent within the same mind map * @param mindmap * @param node the node should already exist in the mind map * @param parent * @param targetIndex * @param layout * @returns */ function moveNodePosition( mindmap: MindmapElementModel, node: MindmapNode, parent: string | MindmapNode, targetIndex: number, layout?: LayoutType ) { parent = mindmap.nodeMap.get( typeof parent === 'string' ? parent : parent.id )!; if (!parent || !mindmap.nodeMap.has(node.id)) { return; } assertType(parent); if (layout === LayoutType.BALANCE || parent !== mindmap.tree) { layout = undefined; } const siblings = parent.children.filter(child => child !== node); targetIndex = Math.min(targetIndex, parent.children.length); siblings.splice(targetIndex, 0, node); // calculate the index // the sibling node may be the same node, so we need to filter it out const preSibling = siblings[targetIndex - 1]; const afterSibling = siblings[targetIndex + 1]; const index = preSibling || afterSibling ? generateKeyBetween( preSibling?.detail.index ?? null, afterSibling?.detail.index ?? null ) : (node.detail.index ?? undefined); mindmap.surface.doc.transact(() => { const val: NodeDetail = { ...node.detail, index, parent: parent.id, }; mindmap.children.set(node.id, val); }); if (parent.detail.collapsed) { mindmap.toggleCollapse(parent); } mindmap.layout(); return mindmap.nodeMap.get(node.id); } export function applyStyle( mindmap: MindmapElementModel, shouldFitContent: boolean = false ) { mindmap.surface.doc.transact(() => { const style = mindmap.styleGetter; if (!style) return; applyNodeStyle(mindmap.tree, style.root); if (shouldFitContent) { fitContent(mindmap.tree.element as ShapeElementModel); } const walk = (node: MindmapNode, path: number[]) => { node.children.forEach((child, idx) => { const currentPath = [...path, idx]; const nodeStyle = style.getNodeStyle(child, currentPath); applyNodeStyle(child, nodeStyle.node); if (shouldFitContent) { fitContent(child.element as ShapeElementModel); } walk(child, currentPath); }); }; walk(mindmap.tree, [0]); }); } /** * * @param mindmap the mind map to add the node to * @param parent the parent node or the parent node id * @param node the node must be an detached node * @param targetIndex the index to insert the node at * @returns */ export function addNode( mindmap: MindmapElementModel, parent: string | MindmapNode, node: MindmapNode, targetIndex?: number ) { const parentNode = mindmap.getNode( typeof parent === 'string' ? parent : parent.id ); if (!parentNode) { return; } const children = parentNode.children.slice(); targetIndex = targetIndex !== undefined ? Math.min(targetIndex, children.length) : children.length; children.splice(targetIndex, 0, node); const before = children[targetIndex - 1] ?? null; const after = children[targetIndex + 1] ?? null; const index = before || after ? generateKeyBetween( before?.detail.index ?? null, after?.detail.index ?? null ) : node.detail.index; mindmap.surface.doc.transact(() => { mindmap.children.set(node.id, { ...node.detail, index, parent: parentNode.id, }); const recursiveAddChild = (node: MindmapNode) => { node.children?.forEach(child => { mindmap.children.set(child.id, { ...child.detail, parent: node.id, }); recursiveAddChild(child); }); }; recursiveAddChild(node); }); if (parentNode.detail.collapsed) { mindmap.toggleCollapse(parentNode); } mindmap.layout(); } export function addTree( mindmap: MindmapElementModel, parent: string | MindmapNode, tree: NodeType | MindmapNode, /** * `sibling` indicates where to insert a subtree among peer elements. * If it's a string, it represents a peer element's ID; * if it's a number, it represents its index. * The subtree will be inserted before the sibling element. */ sibling?: string | number ) { parent = typeof parent === 'string' ? parent : parent.id; if (!mindmap.nodeMap.has(parent) || !parent) { return null; } assertType(parent); const traverse = ( node: NodeType | MindmapNode, parent: string, sibling?: string | number ) => { let nodeId: string; if ('text' in node) { nodeId = mindmap.addNode(parent, sibling, 'before', { text: node.text, }); } else { mindmap.children.set(node.id, { ...node.detail, parent, }); nodeId = node.id; } node.children?.forEach(child => traverse(child, nodeId)); return nodeId; }; if (!('text' in tree)) { // Modify the children ymap directly hence need transaction mindmap.surface.doc.transact(() => { traverse(tree, parent, sibling); }); applyStyle(mindmap); mindmap.layout(); return mindmap.nodeMap.get(tree.id); } else { const nodeId = traverse(tree, parent, sibling); mindmap.layout(); return mindmap.nodeMap.get(nodeId); } } /** * Detach a mindmap node or subtree. It is similar to `removeChild` but * it does not delete the node. * * So the node can be used to create a new mind map or merge into other mind map * @param mindmap the mind map that the subtree belongs to * @param subtree the subtree to detach */ export function detachMindmap( mindmap: MindmapElementModel, subtree: string | MindmapNode ) { subtree = typeof subtree === 'string' ? mindmap.nodeMap.get(subtree)! : subtree; assertType(subtree); if (!subtree) return; const traverse = (subtree: MindmapNode) => { mindmap.children.delete(subtree.id); // cut the reference inside the ymap subtree.detail = { ...subtree.detail, }; subtree.children.forEach(child => traverse(child)); }; mindmap.surface.doc.transact(() => { traverse(subtree); }); mindmap.layout(); delete subtree.detail.parent; return subtree; } export function handleLayout( mindmap: MindmapElementModel, tree?: MindmapNode | MindmapRoot, shouldApplyStyle = true, layoutType?: LayoutType ) { if (!tree || !tree.element) return; if (shouldApplyStyle) { applyStyle(mindmap, true); } mindmap.surface.doc.transact(() => { const path = mindmap.getPath(tree.id); layout(tree, mindmap, layoutType ?? mindmap.getLayoutDir(tree.id), path); }); } export function createFromTree( tree: MindmapNode, style: MindmapStyle, layoutType: LayoutType, surface: SurfaceBlockModel ) { const children = new Y.Map(); const traverse = (subtree: MindmapNode, parent?: string) => { const value: NodeDetail = { ...subtree.detail, parent, }; if (!parent) { delete value.parent; } children.set(subtree.id, value); subtree.children.forEach(child => traverse(child, subtree.id)); }; traverse(tree); const mindmapId = surface.addElement({ type: 'mindmap', children, layoutType, style, }); const mindmap = surface.getElementById(mindmapId) as MindmapElementModel; handleLayout(mindmap, mindmap.tree, true, mindmap.layoutType); return mindmap; } /** * Move a subtree from one mind map to another * @param from the mind map that the `subtree` belongs to * @param subtree the subtree to move * @param to the mind map to move the `subtree` to * @param parent the new parent node to attach the `subtree` to * @param index the index to insert the `subtree` at */ export function moveNode( from: MindmapElementModel, subtree: MindmapNode, to: MindmapElementModel, parent: MindmapNode | string, index: number ) { if (from === to) { return moveNodePosition(from, subtree, parent, index); } if (!detachMindmap(from, subtree)) return; return addNode(to, parent, subtree, index); } export function findTargetNode( mindmap: MindmapElementModel, position: IVec ): MindmapNode | null { const find = (node: MindmapNode): MindmapNode | null => { if (!node.responseArea) { return null; } const layoutDir = mindmap.getLayoutDir(node); if ( layoutDir === LayoutType.BALANCE || (layoutDir === LayoutType.RIGHT && position[0] > node.element.x + node.element.w) || (layoutDir === LayoutType.LEFT && position[0] < node.element.x) ) { for (const child of node.children) { const result = find(child); if (result) { return result; } } } return node.responseArea.containsPoint(position) ? node : null; }; return find(mindmap.tree); } function determineInsertPosition( mindmap: MindmapElementModel, mindmapNode: MindmapNode, position: IVec ): | { type: 'child'; layoutDir: LayoutType.LEFT | LayoutType.RIGHT; } | { type: 'sibling'; layoutDir: LayoutType.LEFT | LayoutType.RIGHT; position: 'prev' | 'next'; } | null { if ( !mindmapNode.responseArea || !mindmapNode.responseArea.containsPoint(position) ) { return null; } const layoutDir = mindmap.getLayoutDir(mindmapNode); const elementBound = mindmapNode.element.elementBound; const targetLayout: LayoutType.LEFT | LayoutType.RIGHT = layoutDir === LayoutType.BALANCE ? position[0] > elementBound.x + elementBound.w / 2 ? LayoutType.RIGHT : LayoutType.LEFT : layoutDir; if ( elementBound.containsPoint(position) || (layoutDir === LayoutType.RIGHT ? position[0] > elementBound.x + elementBound.w : position[0] < elementBound.x) ) { return { type: 'child', layoutDir: targetLayout, }; } if ( mindmap.layoutType === LayoutType.BALANCE && mindmap.getPath(mindmapNode.id).length === 2 ) { return { type: 'sibling', layoutDir: targetLayout, position: targetLayout === LayoutType.LEFT ? position[1] > elementBound.y + elementBound.h / 2 ? 'prev' : 'next' : position[1] > elementBound.y + elementBound.h / 2 ? 'next' : 'prev', }; } return { type: 'sibling', layoutDir: targetLayout, position: position[1] > elementBound.y + elementBound.h / 2 ? 'next' : 'prev', }; } function showMergeIndicator( targetMindMap: MindmapElementModel, target: MindmapNode, sourceMindMap: MindmapElementModel, source: MindmapNode, insertPosition: | { type: 'sibling'; layoutDir: Exclude; position: 'prev' | 'next'; } | { type: 'child'; layoutDir: Exclude }, callback: (option: { targetMindMap: MindmapElementModel; target: MindmapNode; sourceMindMap: MindmapElementModel; source: MindmapNode; newParent: MindmapNode; insertPosition: | { type: 'sibling'; layoutDir: Exclude; position: 'prev' | 'next'; } | { type: 'child'; layoutDir: Exclude }; path: number[]; }) => () => void ) { const newParent = insertPosition.type === 'child' ? target : targetMindMap.getParentNode(target.id)!; if (!newParent) { return null; } const path = targetMindMap.getPath(newParent); const curPath = sourceMindMap.getPath(source.id); if (insertPosition.type === 'sibling') { const curPath = targetMindMap.getPath(target.id); const parent = targetMindMap.getParentNode(target.id); if (!parent) { return null; } const idx = parent.children .filter(child => child.id !== source.id) .indexOf(target); path.push( idx === -1 ? Math.max( 0, last(curPath)! + (insertPosition.position === 'next' ? 1 : 0) ) : Math.max(0, idx + (insertPosition.position === 'next' ? 1 : 0)) ); } else { path.push(target.children.length); } // hide original connector const abortPreview = callback({ targetMindMap, target: target, sourceMindMap, source, newParent, insertPosition, path, }); const abort = () => { abortPreview?.(); }; const merge = () => { abort(); if (targetMindMap === sourceMindMap && isEqual(path, curPath)) { return; } moveNode(sourceMindMap, source, targetMindMap, newParent, last(path)!); }; return { abort, merge, }; } /** * Try to move a node to another mind map. * It will show a merge indicator if the node can be merged to the target mind map. * @param targetMindMap * @param target * @param sourceMindMap * @param source * @param position * @return return two functions, `abort` and `merge`. `abort` will cancel the operation and `merge` will merge the node to the target mind map. */ export function tryMoveNode( targetMindMap: MindmapElementModel, target: MindmapNode, sourceMindMap: MindmapElementModel, source: MindmapNode, position: IVec, callback: (option: { targetMindMap: MindmapElementModel; target: MindmapNode; sourceMindMap: MindmapElementModel; source: MindmapNode; newParent: MindmapNode; insertPosition: | { type: 'sibling'; layoutDir: Exclude; position: 'prev' | 'next'; } | { type: 'child'; layoutDir: Exclude }; path: number[]; }) => () => void ) { const insertInfo = determineInsertPosition(targetMindMap, target, position); if (!insertInfo) { return null; } return showMergeIndicator( targetMindMap, target, sourceMindMap, source, insertInfo, callback ); } /** * Check if the mind map contains the target node. * @param mindmap Mind map to check * @param targetNode Node to check * @param searchParent If provided, check if the node is a descendant of the parent node. Otherwise, check the whole mind map. * @returns */ export function containsNode( mindmap: MindmapElementModel, targetNode: MindmapNode, searchParent?: MindmapNode ) { searchParent = searchParent ?? mindmap.tree; const find = (checkAgainstNode: MindmapNode) => { if (checkAgainstNode.id === targetNode.id) { return true; } for (const child of checkAgainstNode.children) { if (find(child)) { return true; } } return false; }; return find(searchParent); }