From cc9a4d1b507f492f6f95f02ff8b96709b6d18614 Mon Sep 17 00:00:00 2001 From: longdayi <13477510+longdayilongdayi@user.noreply.gitee.com> Date: Wed, 5 Feb 2025 09:32:54 +0800 Subject: [PATCH 1/4] 02050932 --- apps/web/package.json | 2 +- .../common/editor/graph/edges/FloatEdge.tsx | 47 - .../common/editor/graph/edges/GraphEdge.tsx | 63 +- .../common/editor/graph/layout/BaseLayout.ts | 26 + .../editor/graph/layout/MindMapLayout.ts | 87 + .../editor/graph/layout/SingleMapLayout.ts | 127 + .../common/editor/graph/layout/TreeLayout.ts | 68 + .../common/editor/graph/layout/index.ts | 142 +- .../common/editor/graph/layout/types.ts | 23 + .../common/editor/graph/nodes/GraphNode.tsx | 221 +- .../common/editor/graph/nodes/style.ts | 49 + .../components/common/editor/graph/store.ts | 18 +- .../src/components/presentation/mind-map.tsx | 20 - packages/mind-elixir-core | 1 + packages/mindmap/README.md | 3 - .../mindmap/bin/createPluginsTypeFiles.js | 21 - packages/mindmap/bin/wsServer.mjs | 152 - packages/mindmap/example/exampleData.js | 941 ------- packages/mindmap/example/exportFullData.json | 73 - packages/mindmap/package.json | 53 - packages/mindmap/scripts/walkJsFiles.js | 46 - packages/mindmap/src/constants/constant.ts | 238 -- .../mindmap/src/constants/defaultOptions.ts | 486 ---- packages/mindmap/src/core/command/Command.ts | 326 --- .../mindmap/src/core/command/KeyCommand.ts | 334 --- packages/mindmap/src/core/command/keyMap.ts | 102 - packages/mindmap/src/core/event/Event.ts | 284 -- packages/mindmap/src/core/render/Render.js | 2134 -------------- packages/mindmap/src/core/render/TextEdit.js | 502 ---- .../src/core/render/node/MindMapNode.js | 1412 ---------- .../mindmap/src/core/render/node/Shape.ts | 294 -- .../mindmap/src/core/render/node/Style.ts | 376 --- .../src/core/render/node/nodeCommandWraps.js | 68 - .../src/core/render/node/nodeCooperate.js | 120 - .../core/render/node/nodeCreateContents.js | 573 ---- .../src/core/render/node/nodeExpandBtn.js | 187 -- .../node/nodeExpandBtnPlaceholderRect.js | 67 - .../core/render/node/nodeGeneralization.js | 235 -- .../src/core/render/node/nodeModifyWidth.js | 153 - packages/mindmap/src/core/view/View.ts | 506 ---- packages/mindmap/src/full.js | 56 - packages/mindmap/src/index.ts | 722 ----- packages/mindmap/src/layouts/Base.js | 646 ----- .../src/layouts/CatalogOrganization.js | 368 --- packages/mindmap/src/layouts/Fishbone.js | 411 --- .../mindmap/src/layouts/FishboneBottom.js | 370 --- packages/mindmap/src/layouts/FishboneTop.js | 351 --- .../mindmap/src/layouts/LogicalStructure.js | 363 --- packages/mindmap/src/layouts/MindMap.js | 417 --- .../src/layouts/OrganizationStructure.js | 323 --- packages/mindmap/src/layouts/Timeline.js | 363 --- .../mindmap/src/layouts/VerticalTimeline.js | 424 --- packages/mindmap/src/layouts/fishboneUtils.js | 246 -- packages/mindmap/src/parse/markdown.ts | 7 - packages/mindmap/src/parse/markdownTo.ts | 116 - packages/mindmap/src/parse/toMarkdown.ts | 54 - packages/mindmap/src/parse/toTxt.ts | 37 - packages/mindmap/src/parse/xmind.js | 335 --- .../mindmap/src/plugins/AssociativeLine.js | 756 ----- packages/mindmap/src/plugins/Cooperate.js | 284 -- packages/mindmap/src/plugins/Demonstrate.js | 430 --- packages/mindmap/src/plugins/Drag.js | 1210 -------- packages/mindmap/src/plugins/Export.js | 391 --- packages/mindmap/src/plugins/ExportPDF.js | 71 - packages/mindmap/src/plugins/ExportXMind.js | 24 - packages/mindmap/src/plugins/Formula.ts | 232 -- packages/mindmap/src/plugins/FormulaStyle.js | 1091 -------- .../mindmap/src/plugins/KeyboardNavigation.js | 287 -- .../mindmap/src/plugins/MindMapLayoutPro.js | 117 - packages/mindmap/src/plugins/MiniMap.js | 224 -- packages/mindmap/src/plugins/NodeImgAdjust.js | 337 --- packages/mindmap/src/plugins/OuterFrame.js | 406 --- packages/mindmap/src/plugins/Painter.js | 87 - packages/mindmap/src/plugins/RainbowLines.js | 92 - packages/mindmap/src/plugins/RichText.js | 826 ------ packages/mindmap/src/plugins/Scrollbar.js | 281 -- packages/mindmap/src/plugins/Search.js | 321 --- packages/mindmap/src/plugins/Select.js | 239 -- packages/mindmap/src/plugins/TouchEvent.js | 193 -- packages/mindmap/src/plugins/Watermark.ts | 210 -- .../associativeLineControls.js | 294 -- .../associativeLine/associativeLineText.js | 200 -- .../associativeLine/associativeLineUtils.js | 336 --- packages/mindmap/src/svg/btns.ts | 18 - packages/mindmap/src/svg/icons.ts | 322 --- packages/mindmap/src/theme/default.ts | 301 -- packages/mindmap/src/theme/index.ts | 1 - packages/mindmap/src/types.ts | 73 - packages/mindmap/src/utils/AutoMove.ts | 111 - packages/mindmap/src/utils/BatchExecution.ts | 103 - packages/mindmap/src/utils/Dom.ts | 243 -- packages/mindmap/src/utils/File.ts | 23 - packages/mindmap/src/utils/Image.ts | 172 -- packages/mindmap/src/utils/Lru.ts | 110 - packages/mindmap/src/utils/NodeTools.ts | 279 -- packages/mindmap/src/utils/Nodes.ts | 217 -- packages/mindmap/src/utils/Number.ts | 98 - packages/mindmap/src/utils/Object.ts | 168 -- packages/mindmap/src/utils/Platform.ts | 17 - packages/mindmap/src/utils/Security.ts | 48 - packages/mindmap/src/utils/String.ts | 87 - packages/mindmap/src/utils/Task.ts | 93 - packages/mindmap/src/utils/Theme.ts | 128 - packages/mindmap/src/utils/Tools.ts | 160 -- packages/mindmap/src/utils/Tree.ts | 142 - packages/mindmap/src/utils/Version.ts | 22 - packages/mindmap/src/utils/index.ts | 20 - packages/mindmap/src/utils/mersenneTwister.ts | 123 - .../utils/simulateCSSBackgroundInCanvas.ts | 351 --- packages/mindmap/src/utils/xmind.js | 244 -- packages/mindmap/tsconfig.json | 43 - packages/mindmap/tsup.config.ts | 12 - pnpm-lock.yaml | 2480 ++++++++++++----- 113 files changed, 2401 insertions(+), 28936 deletions(-) delete mode 100644 apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx create mode 100644 apps/web/src/components/common/editor/graph/layout/BaseLayout.ts create mode 100644 apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts create mode 100644 apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts create mode 100644 apps/web/src/components/common/editor/graph/layout/TreeLayout.ts create mode 100644 apps/web/src/components/common/editor/graph/layout/types.ts create mode 100644 apps/web/src/components/common/editor/graph/nodes/style.ts create mode 160000 packages/mind-elixir-core delete mode 100644 packages/mindmap/README.md delete mode 100644 packages/mindmap/bin/createPluginsTypeFiles.js delete mode 100644 packages/mindmap/bin/wsServer.mjs delete mode 100644 packages/mindmap/example/exampleData.js delete mode 100644 packages/mindmap/example/exportFullData.json delete mode 100644 packages/mindmap/package.json delete mode 100644 packages/mindmap/scripts/walkJsFiles.js delete mode 100644 packages/mindmap/src/constants/constant.ts delete mode 100644 packages/mindmap/src/constants/defaultOptions.ts delete mode 100644 packages/mindmap/src/core/command/Command.ts delete mode 100644 packages/mindmap/src/core/command/KeyCommand.ts delete mode 100644 packages/mindmap/src/core/command/keyMap.ts delete mode 100644 packages/mindmap/src/core/event/Event.ts delete mode 100644 packages/mindmap/src/core/render/Render.js delete mode 100644 packages/mindmap/src/core/render/TextEdit.js delete mode 100644 packages/mindmap/src/core/render/node/MindMapNode.js delete mode 100644 packages/mindmap/src/core/render/node/Shape.ts delete mode 100644 packages/mindmap/src/core/render/node/Style.ts delete mode 100644 packages/mindmap/src/core/render/node/nodeCommandWraps.js delete mode 100644 packages/mindmap/src/core/render/node/nodeCooperate.js delete mode 100644 packages/mindmap/src/core/render/node/nodeCreateContents.js delete mode 100644 packages/mindmap/src/core/render/node/nodeExpandBtn.js delete mode 100644 packages/mindmap/src/core/render/node/nodeExpandBtnPlaceholderRect.js delete mode 100644 packages/mindmap/src/core/render/node/nodeGeneralization.js delete mode 100644 packages/mindmap/src/core/render/node/nodeModifyWidth.js delete mode 100644 packages/mindmap/src/core/view/View.ts delete mode 100644 packages/mindmap/src/full.js delete mode 100644 packages/mindmap/src/index.ts delete mode 100644 packages/mindmap/src/layouts/Base.js delete mode 100644 packages/mindmap/src/layouts/CatalogOrganization.js delete mode 100644 packages/mindmap/src/layouts/Fishbone.js delete mode 100644 packages/mindmap/src/layouts/FishboneBottom.js delete mode 100644 packages/mindmap/src/layouts/FishboneTop.js delete mode 100644 packages/mindmap/src/layouts/LogicalStructure.js delete mode 100644 packages/mindmap/src/layouts/MindMap.js delete mode 100644 packages/mindmap/src/layouts/OrganizationStructure.js delete mode 100644 packages/mindmap/src/layouts/Timeline.js delete mode 100644 packages/mindmap/src/layouts/VerticalTimeline.js delete mode 100644 packages/mindmap/src/layouts/fishboneUtils.js delete mode 100644 packages/mindmap/src/parse/markdown.ts delete mode 100644 packages/mindmap/src/parse/markdownTo.ts delete mode 100644 packages/mindmap/src/parse/toMarkdown.ts delete mode 100644 packages/mindmap/src/parse/toTxt.ts delete mode 100644 packages/mindmap/src/parse/xmind.js delete mode 100644 packages/mindmap/src/plugins/AssociativeLine.js delete mode 100644 packages/mindmap/src/plugins/Cooperate.js delete mode 100644 packages/mindmap/src/plugins/Demonstrate.js delete mode 100644 packages/mindmap/src/plugins/Drag.js delete mode 100644 packages/mindmap/src/plugins/Export.js delete mode 100644 packages/mindmap/src/plugins/ExportPDF.js delete mode 100644 packages/mindmap/src/plugins/ExportXMind.js delete mode 100644 packages/mindmap/src/plugins/Formula.ts delete mode 100644 packages/mindmap/src/plugins/FormulaStyle.js delete mode 100644 packages/mindmap/src/plugins/KeyboardNavigation.js delete mode 100644 packages/mindmap/src/plugins/MindMapLayoutPro.js delete mode 100644 packages/mindmap/src/plugins/MiniMap.js delete mode 100644 packages/mindmap/src/plugins/NodeImgAdjust.js delete mode 100644 packages/mindmap/src/plugins/OuterFrame.js delete mode 100644 packages/mindmap/src/plugins/Painter.js delete mode 100644 packages/mindmap/src/plugins/RainbowLines.js delete mode 100644 packages/mindmap/src/plugins/RichText.js delete mode 100644 packages/mindmap/src/plugins/Scrollbar.js delete mode 100644 packages/mindmap/src/plugins/Search.js delete mode 100644 packages/mindmap/src/plugins/Select.js delete mode 100644 packages/mindmap/src/plugins/TouchEvent.js delete mode 100644 packages/mindmap/src/plugins/Watermark.ts delete mode 100644 packages/mindmap/src/plugins/associativeLine/associativeLineControls.js delete mode 100644 packages/mindmap/src/plugins/associativeLine/associativeLineText.js delete mode 100644 packages/mindmap/src/plugins/associativeLine/associativeLineUtils.js delete mode 100644 packages/mindmap/src/svg/btns.ts delete mode 100644 packages/mindmap/src/svg/icons.ts delete mode 100644 packages/mindmap/src/theme/default.ts delete mode 100644 packages/mindmap/src/theme/index.ts delete mode 100644 packages/mindmap/src/types.ts delete mode 100644 packages/mindmap/src/utils/AutoMove.ts delete mode 100644 packages/mindmap/src/utils/BatchExecution.ts delete mode 100644 packages/mindmap/src/utils/Dom.ts delete mode 100644 packages/mindmap/src/utils/File.ts delete mode 100644 packages/mindmap/src/utils/Image.ts delete mode 100644 packages/mindmap/src/utils/Lru.ts delete mode 100644 packages/mindmap/src/utils/NodeTools.ts delete mode 100644 packages/mindmap/src/utils/Nodes.ts delete mode 100644 packages/mindmap/src/utils/Number.ts delete mode 100644 packages/mindmap/src/utils/Object.ts delete mode 100644 packages/mindmap/src/utils/Platform.ts delete mode 100644 packages/mindmap/src/utils/Security.ts delete mode 100644 packages/mindmap/src/utils/String.ts delete mode 100644 packages/mindmap/src/utils/Task.ts delete mode 100644 packages/mindmap/src/utils/Theme.ts delete mode 100644 packages/mindmap/src/utils/Tools.ts delete mode 100644 packages/mindmap/src/utils/Tree.ts delete mode 100644 packages/mindmap/src/utils/Version.ts delete mode 100644 packages/mindmap/src/utils/index.ts delete mode 100644 packages/mindmap/src/utils/mersenneTwister.ts delete mode 100644 packages/mindmap/src/utils/simulateCSSBackgroundInCanvas.ts delete mode 100644 packages/mindmap/src/utils/xmind.js delete mode 100644 packages/mindmap/tsconfig.json delete mode 100644 packages/mindmap/tsup.config.ts diff --git a/apps/web/package.json b/apps/web/package.json index a7fa5de..5534079 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,7 +33,7 @@ "@nice/client": "workspace:^", "@nice/common": "workspace:^", "@nice/iconer": "workspace:^", - "@nice/mindmap": "workspace:^", + "mind-elixir": "workspace:^", "@nice/ui": "workspace:^", "@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/react-query": "^5.51.21", diff --git a/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx b/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx deleted file mode 100644 index 7224da2..0000000 --- a/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { EdgeProps, getBezierPath, useInternalNode } from '@xyflow/react'; -import { getEdgeParams } from '../utils'; - -/** - * FloatingEdge 组件用于渲染图中的浮动边。 - * 该组件通过计算源节点和目标节点的位置,生成贝塞尔曲线路径,并渲染为SVG路径元素。 - * 适用于需要自定义边样式的图结构可视化场景。 - */ -function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) { - // 使用 useInternalNode 钩子获取源节点和目标节点的内部节点信息 - const sourceNode = useInternalNode(source); - const targetNode = useInternalNode(target); - - // 如果源节点或目标节点不存在,则不渲染任何内容 - if (!sourceNode || !targetNode) { - return null; - } - - // 获取边的参数,包括源节点和目标节点的坐标及位置信息 - const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( - sourceNode, - targetNode, - ); - - // 使用 getBezierPath 函数生成贝塞尔曲线路径 - const [edgePath] = getBezierPath({ - sourceX: sx, - sourceY: sy, - sourcePosition: sourcePos, - targetPosition: targetPos, - targetX: tx, - targetY: ty, - }); - - // 返回 SVG 路径元素,表示图中的边 - return ( - - ); -} - -export default FloatingEdge; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx b/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx index f7da3d2..15e2224 100644 --- a/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx +++ b/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx @@ -1,17 +1,58 @@ -import { BaseEdge, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, useReactFlow } from '@xyflow/react'; +import { BaseEdge, Node, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, Position, useReactFlow, useInternalNode, InternalNode } from '@xyflow/react'; export type GraphEdge = Edge<{ text: string }, 'graph-edge'>; -export const GraphEdge = ({ id, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps) => { - const { setEdges } = useReactFlow(); - // 使用贝塞尔曲线代替直线,让连线更流畅 - const [edgePath, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - targetX, - targetY, - }); +function getEdgeParams(sourceNode: InternalNode, targetNode: InternalNode) { + console.log(sourceNode) + const sourceCenter = { + x: sourceNode.position.x + sourceNode.width / 2, + y: sourceNode.position.y + sourceNode.height / 2, + }; + const targetCenter = { + x: targetNode.position.x + targetNode.width / 2, + y: targetNode.position.y + targetNode.height / 2, + }; + const dx = targetCenter.x - sourceCenter.x; + + // 简化连接逻辑:只基于x轴方向判断 + let sourcePos: Position; + let targetPos: Position; + + // 如果目标在源节点右边,源节点用右侧连接点,目标节点用左侧连接点 + if (dx > 0) { + sourcePos = Position.Right; + targetPos = Position.Left; + } else { + // 如果目标在源节点左边,源节点用左侧连接点,目标节点用右侧连接点 + sourcePos = Position.Left; + targetPos = Position.Right; + } + + // 使用节点中心的y坐标 + return { + sourcePos, + targetPos, + sx: sourceCenter.x + sourceNode.measured.width / 2, + sy: sourceCenter.y, + tx: targetCenter.x - targetNode.measured.width / 2, + ty: targetCenter.y, + }; +} +export const GraphEdge = ({ id, source, target, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + const { sx, sy, tx, ty, targetPos, sourcePos } = getEdgeParams(sourceNode, targetNode) + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX: sx, + sourceY: sy, + targetX: tx, + targetY: ty, + sourcePosition: sourcePos, + targetPosition: targetPos, + curvature: 0.3, + + }); return ( <> - {/* 添加边的标签渲染器 */} {data?.text && (
{ + const nodeMap = new Map(); + nodes.forEach(node => { + nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 }); + }); + return nodeMap; + } + + protected buildTreeStructure(nodeMap: Map, edges: Edge[]): NodeWithLayout | undefined { + edges.forEach(edge => { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (source && target) { + source.children?.push(target); + target.parent = source; + } + }); + return Array.from(nodeMap.values()).find(node => !node.parent); + } + + abstract layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] }; +} diff --git a/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts b/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts new file mode 100644 index 0000000..42779ca --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts @@ -0,0 +1,87 @@ +import { Edge,Node } from "@xyflow/react"; +import { BaseLayout } from "./BaseLayout"; +import { LayoutOptions, NodeWithLayout } from "./types"; + +// 思维导图布局实现 +export class MindMapLayout extends BaseLayout { + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } { + const { + nodes, + edges, + levelSeparation = 200, + nodeSeparation = 60 + } = options; + + const nodeMap = this.buildNodeMap(nodes); + const rootNode = this.buildTreeStructure(nodeMap, edges); + if (!rootNode) return { nodes, edges }; + + this.assignSides(rootNode); + this.calculateSubtreeHeight(rootNode, nodeSeparation); + this.calculateLayout(rootNode, 0, 0, levelSeparation, nodeSeparation); + + const layoutedNodes = Array.from(nodeMap.values()).map(node => ({ + ...node, + position: node.position, + })); + + return { nodes: layoutedNodes, edges }; + } + + private assignSides(node: NodeWithLayout, isRight: boolean = true): void { + if (!node.children?.length) return; + + const len = node.children.length; + const midIndex = Math.floor(len / 2); + + if (!node.parent) { + for (let i = 0; i < len; i++) { + const child = node.children[i]; + this.assignSides(child, i < midIndex); + child.isRight = i < midIndex; + } + } else { + node.children.forEach(child => { + this.assignSides(child, isRight); + child.isRight = isRight; + }); + } + } + + private calculateSubtreeHeight(node: NodeWithLayout, nodeSeparation: number): number { + if (!node.children?.length) { + node.subtreeHeight = node.height || 40; + return node.subtreeHeight; + } + + const childrenHeight = node.children.reduce((sum, child) => { + return sum + this.calculateSubtreeHeight(child, nodeSeparation); + }, 0); + + const totalGaps = (node.children.length - 1) * nodeSeparation; + node.subtreeHeight = Math.max(node.height || 40, childrenHeight + totalGaps); + return node.subtreeHeight; + } + + private calculateLayout( + node: NodeWithLayout, + x: number, + y: number, + levelSeparation: number, + nodeSeparation: number + ): void { + node.position = { x, y }; + if (!node.children?.length) return; + + let currentY = y - (node.subtreeHeight || 0) / 2; + + node.children.forEach(child => { + const direction = child.isRight ? 1 : -1; + const childX = x + (levelSeparation * direction); + const childY = currentY + (child.subtreeHeight || 0) / 2; + + this.calculateLayout(child, childX, childY, levelSeparation, nodeSeparation); + currentY += (child.subtreeHeight || 0) + nodeSeparation; + }); + } +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts b/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts new file mode 100644 index 0000000..2d7d68c --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts @@ -0,0 +1,127 @@ +import { Edge, Node } from "@xyflow/react"; +import { BaseLayout } from "./BaseLayout"; +import { LayoutOptions, NodeWithLayout } from "./types"; + +/** + * SingleMapLayout 类继承自 BaseLayout,用于实现单图布局。 + * 该类主要负责将节点和边按照一定的规则进行布局,使得节点在视觉上呈现出层次分明、结构清晰的效果。 + */ +export class SingleMapLayout extends BaseLayout { + /** + * 布局方法,根据提供的选项对节点和边进行布局。 + * @param options 布局选项,包含节点、边、层级间距和节点间距等信息。 + * @returns 返回布局后的节点和边。 + */ + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } { + const { nodes, edges, levelSeparation = 100, nodeSeparation = 30 } = options; + const nodeMap = this.buildNodeMap(nodes); + const root = this.buildTreeStructure(nodeMap, edges); + + if (!root) { + return { nodes: [], edges: [] }; + } + + // 计算子树的尺寸 + this.calculateSubtreeDimensions(root); + + // 第一遍:分配垂直位置 + this.assignInitialVerticalPositions(root, 0); + + // 第二遍:使用平衡布局定位节点 + this.positionNodes(root, 0, 0, levelSeparation, nodeSeparation, 'right'); + + return { + nodes: Array.from(nodeMap.values()), + edges + }; + } + + /** + * 计算子树的尺寸,包括高度和宽度。 + * @param node 当前节点。 + */ + private calculateSubtreeDimensions(node: NodeWithLayout): void { + + node.subtreeHeight = node.height || 40; + node.subtreeWidth = node.width || 150; + + if (node.children && node.children.length > 0) { + // 首先计算所有子节点的尺寸 + node.children.forEach(child => this.calculateSubtreeDimensions(child)); + + // 计算子节点所需的总高度,包括间距 + const totalChildrenHeight = this.calculateTotalChildrenHeight(node.children, 30); + + // 更新节点的子树尺寸 + node.subtreeHeight = Math.max(node.subtreeHeight, totalChildrenHeight); + node.subtreeWidth += Math.max(...node.children.map(child => child.subtreeWidth || 0)); + } + } + + /** + * 计算子节点的总高度。 + * @param children 子节点数组。 + * @param spacing 子节点之间的间距。 + * @returns 返回子节点的总高度。 + */ + private calculateTotalChildrenHeight(children: NodeWithLayout[], spacing: number): number { + if (!children.length) return 0; + const totalHeight = children.reduce((sum, child) => sum + (child.subtreeHeight || 0), 0); + return totalHeight + (spacing * (children.length - 1)); + } + + /** + * 分配初始垂直位置。 + * @param node 当前节点。 + * @param level 当前层级。 + */ + private assignInitialVerticalPositions(node: NodeWithLayout, level: number): void { + if (!node.children?.length) return; + const totalHeight = this.calculateTotalChildrenHeight(node.children, 30); + let currentY = -(totalHeight / 2); + node.children.forEach(child => { + const childHeight = child.subtreeHeight || 0; + child.verticalLevel = level + 1; + child.relativeY = currentY + (childHeight / 2); + this.assignInitialVerticalPositions(child, level + 1); + currentY += childHeight + 30; // 30 是垂直间距 + }); + } + + /** + * 定位节点。 + * @param node 当前节点。 + * @param x 当前节点的水平位置。 + * @param y 当前节点的垂直位置。 + * @param levelSeparation 层级间距。 + * @param nodeSeparation 节点间距。 + * @param direction 布局方向,'left' 或 'right'。 + */ + private positionNodes( + node: NodeWithLayout, + x: number, + y: number, + levelSeparation: number, + nodeSeparation: number, + direction: 'left' | 'right' + ): void { + node.position = { x, y }; + if (!node.children?.length) return; + // 计算子节点的水平位置 + const nextX = direction === 'right' + ? x + (node.width || 0) + levelSeparation + : x - (node.width || 0) - levelSeparation; + // 定位每个子节点 + node.children.forEach(child => { + const childY = y + (child.relativeY || 0); + this.positionNodes( + child, + nextX, + childY, + levelSeparation, + nodeSeparation, + direction + ); + }); + } +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts b/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts new file mode 100644 index 0000000..31fe186 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts @@ -0,0 +1,68 @@ +import { BaseLayout } from "./BaseLayout"; +import { LayoutOptions, LayoutStrategy, NodeWithLayout } from "./types"; +import { Edge, Node } from "@xyflow/react"; +export class TreeLayout extends BaseLayout { + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } { + const { + nodes, + edges, + levelSeparation = 100, // 层级间垂直距离 + nodeSeparation = 50 // 节点间水平距离 + } = options; + + const nodeMap = this.buildNodeMap(nodes); + const rootNode = this.buildTreeStructure(nodeMap, edges); + if (!rootNode) return { nodes, edges }; + // 计算每个节点的子树宽度 + this.calculateSubtreeWidth(rootNode, nodeSeparation); + // 计算布局位置 + this.calculateTreeLayout(rootNode, 0, 0, levelSeparation); + const layoutedNodes = Array.from(nodeMap.values()).map(node => ({ + ...node, + position: node.position, + })); + return { nodes: layoutedNodes, edges }; + } + + private calculateSubtreeWidth(node: NodeWithLayout, nodeSeparation: number): number { + if (!node.children?.length) { + node.subtreeWidth = node.width || 150; + return node.subtreeWidth; + } + + const childrenWidth = node.children.reduce((sum, child) => { + return sum + this.calculateSubtreeWidth(child, nodeSeparation); + }, 0); + + const totalGaps = (node.children.length - 1) * nodeSeparation; + node.subtreeWidth = Math.max(node.width || 150, childrenWidth + totalGaps); + return node.subtreeWidth; + } + + private calculateTreeLayout( + node: NodeWithLayout, + x: number, + y: number, + levelSeparation: number + ): void { + node.position = { x, y }; + + if (!node.children?.length) return; + + const totalChildrenWidth = node.children.reduce((sum, child) => + sum + (child.subtreeWidth || 0), 0); + const totalGaps = (node.children.length - 1) * (node.width || 150); + + // 计算最左侧子节点的起始x坐标 + let startX = x - (totalChildrenWidth + totalGaps) / 2; + + node.children.forEach(child => { + const childX = startX + (child.subtreeWidth || 0) / 2; + const childY = y + levelSeparation; + + this.calculateTreeLayout(child, childX, childY, levelSeparation); + + startX += (child.subtreeWidth || 0) + (node.width || 150); + }); + } +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/index.ts b/apps/web/src/components/common/editor/graph/layout/index.ts index 631dcda..45022e5 100644 --- a/apps/web/src/components/common/editor/graph/layout/index.ts +++ b/apps/web/src/components/common/editor/graph/layout/index.ts @@ -1,124 +1,34 @@ import { Node, Edge } from "@xyflow/react"; +import { LayoutOptions, LayoutStrategy } from "./types"; +import { TreeLayout } from "./TreeLayout"; +import { MindMapLayout } from "./MindMapLayout"; +import { SingleMapLayout } from "./SingleMapLayout"; -interface LayoutOptions { - nodes: Node[]; - edges: Edge[]; - levelSeparation?: number; - nodeSeparation?: number; +// 布局工厂类 +class LayoutFactory { + static createLayout(type: 'mindmap' | 'tree' | 'force' | 'single'): LayoutStrategy { + switch (type) { + case 'mindmap': + return new MindMapLayout(); + case 'tree': + return new TreeLayout(); + case 'single': + return new SingleMapLayout() + case 'force': + // return new ForceLayout(); // 待实现 + default: + return new MindMapLayout(); + } + } } -interface NodeWithLayout extends Node { - width?: number; - height?: number; - children?: NodeWithLayout[]; - parent?: NodeWithLayout; - subtreeHeight?: number; - isRight?: boolean; +// 导出布局函数 +export function getLayout(type: 'mindmap' | 'tree' | 'force' | 'single', options: LayoutOptions) { + const layoutStrategy = LayoutFactory.createLayout(type); + return layoutStrategy.layout(options); } +// 为了保持向后兼容,保留原有的导出 export function getMindMapLayout(options: LayoutOptions) { - const { - nodes, - edges, - levelSeparation = 200, - nodeSeparation = 60 - } = options; - - // 构建树形结构 - const nodeMap = new Map(); - nodes.forEach(node => { - nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 }); - }); - - let rootNode: NodeWithLayout | undefined; - edges.forEach(edge => { - const source = nodeMap.get(edge.source); - const target = nodeMap.get(edge.target); - if (source && target) { - source.children?.push(target); - target.parent = source; - } - }); - - // 找到根节点 - rootNode = Array.from(nodeMap.values()).find(node => !node.parent); - if (!rootNode) return { nodes, edges }; - - // 分配节点到左右两侧 - function assignSides(node: NodeWithLayout, isRight: boolean = true) { - if (!node.children?.length) return; - - const len = node.children.length; - const midIndex = Math.floor(len / 2); - - // 如果是根节点,将子节点分为左右两部分 - if (!node.parent) { - for (let i = 0; i < len; i++) { - const child = node.children[i]; - assignSides(child, i < midIndex); - child.isRight = i < midIndex; - } - } - // 如果不是根节点,所有子节点继承父节点的方向 - else { - node.children.forEach(child => { - assignSides(child, isRight); - child.isRight = isRight; - }); - } - } - - // 计算子树高度 - function calculateSubtreeHeight(node: NodeWithLayout): number { - if (!node.children?.length) { - node.subtreeHeight = node.height || 40; - return node.subtreeHeight; - } - - const childrenHeight = node.children.reduce((sum, child) => { - return sum + calculateSubtreeHeight(child); - }, 0); - - const totalGaps = (node.children.length - 1) * nodeSeparation; - node.subtreeHeight = Math.max(node.height || 40, childrenHeight + totalGaps); - return node.subtreeHeight; - } - - // 布局计算 - function calculateLayout(node: NodeWithLayout, x: number, y: number) { - node.position = { x, y }; - if (!node.children?.length) return; - - let currentY = y - (node.subtreeHeight || 0) / 2; - - node.children.forEach(child => { - const direction = child.isRight ? 1 : -1; - const childX = x + (levelSeparation * direction); - const childY = currentY + (child.subtreeHeight || 0) / 2; - - calculateLayout(child, childX, childY); - currentY += (child.subtreeHeight || 0) + nodeSeparation; - }); - } - - // 执行布局流程 - if (rootNode) { - // 1. 分配节点到左右两侧 - assignSides(rootNode); - // 2. 计算子树高度 - calculateSubtreeHeight(rootNode); - // 3. 执行布局计算 - calculateLayout(rootNode, 0, 0); - } - - // 转换回原始格式 - const layoutedNodes = Array.from(nodeMap.values()).map(node => ({ - ...node, - position: node.position, - })); - - return { - nodes: layoutedNodes, - edges, - }; + return getLayout("single", options); } \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/types.ts b/apps/web/src/components/common/editor/graph/layout/types.ts new file mode 100644 index 0000000..b46b4df --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/types.ts @@ -0,0 +1,23 @@ +import { Node, Edge } from "@xyflow/react"; +// 基础接口和类型定义 +export interface LayoutOptions { + nodes: Node[]; + edges: Edge[]; + levelSeparation?: number; + nodeSeparation?: number; +} + +export interface NodeWithLayout extends Node { + children?: NodeWithLayout[]; + parent?: NodeWithLayout; + subtreeHeight?: number; + subtreeWidth?: number; + isRight?: boolean; + relativeY?: number + verticalLevel?: number +} + +// 布局策略接口 +export interface LayoutStrategy { + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] }; +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx b/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx index b5b8636..d4aec7b 100644 --- a/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx +++ b/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx @@ -1,8 +1,10 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { Handle, Position, NodeProps, Node } from '@xyflow/react'; +import { Handle, Position, NodeProps, Node, useUpdateNodeInternals } from '@xyflow/react'; import useGraphStore from '../store'; import { shallow } from 'zustand/shallow'; import { GraphState } from '../types'; +import { cn } from '@web/src/utils/classname'; +import { LEVEL_STYLES, NODE_BASE_STYLES, TEXTAREA_BASE_STYLES } from './style'; export type GraphNode = Node<{ label: string; @@ -10,157 +12,166 @@ export type GraphNode = Node<{ level?: number; }, 'graph-node'>; -const getLevelStyles = (level: number = 0) => { + + +interface TextMeasurerProps { + element: HTMLTextAreaElement; + minWidth?: number; + maxWidth?: number; + padding?: number; +} + +const measureTextWidth = ({ + element, + minWidth = 60, + maxWidth = 400, + padding = 16, +}: TextMeasurerProps): number => { + const span = document.createElement('span'); const styles = { - 0: { - container: 'bg-[#2B4B6F] text-white', - handle: 'bg-[#2B4B6F]', - fontSize: 'text-lg' - }, - 1: { - container: 'bg-blue-300 text-white', - handle: 'bg-[#3A5F84]', - fontSize: 'text-base' - }, - 2: { - container: 'bg-gray-100', - handle: 'bg-[#496F96]', - fontSize: 'text-base' - } - }; - return styles[level as keyof typeof styles] + visibility: 'hidden', + position: 'absolute', + whiteSpace: 'pre', + fontSize: window.getComputedStyle(element).fontSize, + } as const; + + Object.assign(span.style, styles); + span.textContent = element.value || element.placeholder; + document.body.appendChild(span); + + const contentWidth = Math.min(Math.max(span.offsetWidth + padding, minWidth), maxWidth); + document.body.removeChild(span); + + return contentWidth; }; - -const baseTextStyles = ` -text-center -break-words -whitespace-pre-wrap -`; -const handleStyles = ` - w-2.5 h-2.5 - border-2 border-white/80 - rounded-full - transition-colors - duration-200 - opacity-80 - hover:opacity-100 -`; const selector = (store: GraphState) => ({ updateNode: store.updateNode, }); -export const GraphNode = memo(({ id, selected, data, isConnectable }: NodeProps) => { +export const GraphNode = memo(({ id, selected, width, height, data, isConnectable }: NodeProps) => { const { updateNode } = useGraphStore(selector, shallow); const [isEditing, setIsEditing] = useState(false); - const levelStyles = getLevelStyles(data.level); const [inputValue, setInputValue] = useState(data.label); const [isComposing, setIsComposing] = useState(false); - const updateTextareaHeight = useCallback((element: HTMLTextAreaElement) => { + const containerRef = useRef(null); + const textareaRef = useRef(null); + const updateNodeInternals = useUpdateNodeInternals(); + // const [nodeWidth, setNodeWidth] = useState(width) + // const [nodeHeight, setNodeHeight] = useState(height) + const updateTextareaSize = useCallback((element: HTMLTextAreaElement) => { + const contentWidth = measureTextWidth({ element }); + element.style.whiteSpace = contentWidth >= 400 ? 'pre-wrap' : 'pre'; + element.style.width = `${contentWidth}px`; element.style.height = 'auto'; element.style.height = `${element.scrollHeight}px`; + }, []); + const handleChange = useCallback((evt: React.ChangeEvent) => { const newValue = evt.target.value; setInputValue(newValue); updateNode(id, { label: newValue }); - updateTextareaHeight(evt.target); - }, [updateNode, id, updateTextareaHeight]); + updateTextareaSize(evt.target); + }, [updateNode, id, updateTextareaSize]); + + useEffect(() => { + if (textareaRef.current) { + updateTextareaSize(textareaRef.current); + } + }, [isEditing, inputValue, updateTextareaSize]); + const handleKeyDown = useCallback((evt: React.KeyboardEvent) => { - if (!isEditing) { - if (/^[a-zA-Z0-9]$/.test(evt.key)) { - setIsEditing(true); - setInputValue(evt.key); // 将第一个字符添加到现有内容后 - updateNode(id, { label: evt.key }); - } - if (evt.key === ' ') { - setIsEditing(true); - setInputValue(data.label); // 将第一个字符添加到现有内容后 - updateNode(id, { label: data.label }); - } - evt.preventDefault(); // 阻止默认行为 - evt.stopPropagation(); // 阻止事件冒泡 - } else if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) { - setIsEditing(false); + const isAlphanumeric = /^[a-zA-Z0-9]$/.test(evt.key); + const isSpaceKey = evt.key === ' '; + + if (!isEditing && (isAlphanumeric || isSpaceKey)) { evt.preventDefault(); + evt.stopPropagation(); + + const newValue = isAlphanumeric ? evt.key : data.label; + setIsEditing(true); + setInputValue(newValue); + updateNode(id, { label: newValue }); + return; + } + + if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) { + evt.preventDefault(); + setIsEditing(false); } }, [isEditing, isComposing, data.label, id, updateNode]); + const handleDoubleClick = useCallback(() => { setIsEditing(true); }, []); - const handleBlur = useCallback(() => setIsEditing(false), []); - // 添加 ref 来获取父元素 - const containerRef = useRef(null); - const textareaRef = useRef(null); + const handleBlur = useCallback(() => { + setIsEditing(false); + }, []); useEffect(() => { - if (isEditing && textareaRef.current) { - updateTextareaHeight(textareaRef.current); - // 聚焦并将光标移到末尾 - textareaRef.current.focus(); - const length = textareaRef.current.value.length; - textareaRef.current.setSelectionRange(length, length); - } - }, [isEditing, updateTextareaHeight]); + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + updateNodeInternals(id); + } + }); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, []); return (
-