diff --git a/examples/feature-examples/.umirc.ts b/examples/feature-examples/.umirc.ts index 872613acf..561e19293 100644 --- a/examples/feature-examples/.umirc.ts +++ b/examples/feature-examples/.umirc.ts @@ -90,6 +90,11 @@ export default defineConfig({ name: '自定义主题', component: './nodes/custom/theme', }, + { + path: '/custom-nodes/new-pool', + name: '新泳道节点', + component: './nodes/custom/pool', + }, ], }, { diff --git a/examples/feature-examples/src/pages/nodes/custom/pool/data.ts b/examples/feature-examples/src/pages/nodes/custom/pool/data.ts new file mode 100644 index 000000000..bb941cb50 --- /dev/null +++ b/examples/feature-examples/src/pages/nodes/custom/pool/data.ts @@ -0,0 +1,286 @@ +// const data = { +// nodes: [ +// { +// id: 'pool-1', +// type: 'pool', +// x: 375, +// y: 200, +// properties: { +// width: 300, +// height: 300, +// direction: 'vertical', +// }, +// }, +// { +// id: 'pool-2', +// type: 'pool', +// x: 800, +// y: 200, +// text: '水平泳池', +// properties: { +// width: 300, +// height: 300, +// direction: 'horizontal', +// laneConfig: { +// text: '水平泳道', +// }, +// }, +// // text: { +// // x: 515, +// // y: 200, +// // value: '泳池示例', +// // }, +// }, +// // { +// // id: '1-2', +// // type: 'rect', +// // x: 600, +// // y: 200, +// // properties: { +// // width: 300, +// // height: 300, +// // }, +// // text: '其他元素', +// // }, +// ], +// edges: [], +// } + +const data = { + nodes: [ + { + id: '9dfc6807-73f8-4741-8c69-e2d70f2ec934', + type: 'pool', + x: 511, + y: 224.5, + properties: { + width: 400, + height: 400, + direction: 'horizontal', + laneConfig: { + text: '水平泳道', + }, + isCollapsed: false, + children: [ + 'e5660bb9-12c7-4a2c-8be3-085eae9057be', + '19c15ef0-8b61-4a8c-8544-3b9f975ace39', + ], + }, + text: { + x: 341, + y: 224.5, + value: '横向泳池', + }, + children: [ + 'e5660bb9-12c7-4a2c-8be3-085eae9057be', + '19c15ef0-8b61-4a8c-8544-3b9f975ace39', + ], + }, + { + id: 'e5660bb9-12c7-4a2c-8be3-085eae9057be', + type: 'lane', + x: 541, + y: 324.5, + properties: { + parent: '9dfc6807-73f8-4741-8c69-e2d70f2ec934', + isHorizontal: true, + isCollapsed: false, + processRef: '', + panels: ['processRef'], + direction: 'vertical', + width: 340, + height: 200, + children: [ + 'e6cb89d0-01f9-4e76-82f7-09c25d5e1e07', + '46e2c71b-31f6-4202-ab48-28eca90820bd', + ], + }, + text: { + x: 381, + y: 324.5, + value: '水平泳道', + }, + children: [ + 'e6cb89d0-01f9-4e76-82f7-09c25d5e1e07', + '46e2c71b-31f6-4202-ab48-28eca90820bd', + ], + }, + { + id: '19c15ef0-8b61-4a8c-8544-3b9f975ace39', + type: 'lane', + x: 541, + y: 124.5, + properties: { + isHorizontal: true, + isCollapsed: false, + processRef: '', + panels: ['processRef'], + direction: 'vertical', + width: 340, + height: 200, + parent: '9dfc6807-73f8-4741-8c69-e2d70f2ec934', + position: 'above', + referenceLaneId: 'e5660bb9-12c7-4a2c-8be3-085eae9057be', + children: [ + '9dbed2ef-581a-45e1-ad2e-c3b155bb8cf3', + '324d535b-81d5-484f-aef7-1250e134fa76', + ], + }, + text: { + x: 381, + y: 124.5, + value: '水平泳道', + }, + children: [ + '9dbed2ef-581a-45e1-ad2e-c3b155bb8cf3', + '324d535b-81d5-484f-aef7-1250e134fa76', + ], + }, + { + id: 'e6cb89d0-01f9-4e76-82f7-09c25d5e1e07', + type: 'rect', + x: 458, + y: 360.5, + properties: { + parent: 'e5660bb9-12c7-4a2c-8be3-085eae9057be', + width: 100, + height: 80, + }, + text: { + x: 458, + y: 360.5, + value: 'rect', + }, + }, + { + id: '46e2c71b-31f6-4202-ab48-28eca90820bd', + type: 'rect', + x: 612, + y: 295.5, + properties: { + parent: 'e5660bb9-12c7-4a2c-8be3-085eae9057be', + width: 100, + height: 80, + }, + text: { + x: 612, + y: 295.5, + value: 'rect', + }, + }, + { + id: '9dbed2ef-581a-45e1-ad2e-c3b155bb8cf3', + type: 'circle', + x: 481, + y: 149.5, + properties: { + parent: '19c15ef0-8b61-4a8c-8544-3b9f975ace39', + width: 100, + height: 100, + }, + text: { + x: 481, + y: 149.5, + value: 'circle', + }, + }, + { + id: '324d535b-81d5-484f-aef7-1250e134fa76', + type: 'diamond', + x: 624, + y: 115.5, + properties: { + parent: '19c15ef0-8b61-4a8c-8544-3b9f975ace39', + width: 60, + height: 100, + }, + text: { + x: 624, + y: 115.5, + value: 'diamond', + }, + }, + ], + edges: [ + { + id: '12e14434-6916-4198-a397-8470f4f6a15c', + type: 'polyline', + properties: {}, + sourceNodeId: 'e6cb89d0-01f9-4e76-82f7-09c25d5e1e07', + targetNodeId: '46e2c71b-31f6-4202-ab48-28eca90820bd', + sourceAnchorId: 'e6cb89d0-01f9-4e76-82f7-09c25d5e1e07_1', + targetAnchorId: '46e2c71b-31f6-4202-ab48-28eca90820bd_3', + startPoint: { + x: 508, + y: 360.5, + }, + endPoint: { + x: 562, + y: 295.5, + }, + pointsList: [ + { + x: 508, + y: 360.5, + }, + { + x: 538, + y: 360.5, + }, + { + x: 538, + y: 328, + }, + { + x: 532, + y: 328, + }, + { + x: 532, + y: 295.5, + }, + { + x: 562, + y: 295.5, + }, + ], + }, + { + id: '831c9bce-68be-45b5-9a24-012d226a0d45', + type: 'polyline', + properties: {}, + sourceNodeId: '9dbed2ef-581a-45e1-ad2e-c3b155bb8cf3', + targetNodeId: '324d535b-81d5-484f-aef7-1250e134fa76', + sourceAnchorId: '9dbed2ef-581a-45e1-ad2e-c3b155bb8cf3_1', + targetAnchorId: '324d535b-81d5-484f-aef7-1250e134fa76_3', + startPoint: { + x: 531, + y: 149.5, + }, + endPoint: { + x: 594, + y: 115.5, + }, + pointsList: [ + { + x: 531, + y: 149.5, + }, + { + x: 562.5, + y: 149.5, + }, + { + x: 562.5, + y: 115.5, + }, + { + x: 594, + y: 115.5, + }, + ], + }, + ], +} + +export default data diff --git a/examples/feature-examples/src/pages/nodes/custom/pool/index.less b/examples/feature-examples/src/pages/nodes/custom/pool/index.less new file mode 100644 index 000000000..3cda77224 --- /dev/null +++ b/examples/feature-examples/src/pages/nodes/custom/pool/index.less @@ -0,0 +1,62 @@ +.pool-demo-container { + display: flex; + flex-direction: column; + height: 100vh; + padding: 20px; + background: #fff; + + .demo-header { + margin-bottom: 20px; + text-align: center; + + h2 { + margin: 0 0 8px; + color: #262626; + font-weight: 600; + font-size: 24px; + } + + p { + margin: 0; + color: #8c8c8c; + font-size: 14px; + } + } + + .demo-content { + flex: 1; + overflow: hidden; + border: 1px solid #e8e8e8; + border-radius: 6px; + + .pool-demo-graph { + width: 100%; + height: 100%; + } + } + + .demo-footer { + margin-top: 20px; + padding: 16px; + background: #f9f9f9; + border-radius: 6px; + + h3 { + margin: 0 0 12px; + color: #262626; + font-weight: 600; + font-size: 16px; + } + + ul { + margin: 0; + padding-left: 20px; + + li { + margin: 4px 0; + color: #595959; + font-size: 14px; + } + } + } +} diff --git a/examples/feature-examples/src/pages/nodes/custom/pool/index.tsx b/examples/feature-examples/src/pages/nodes/custom/pool/index.tsx new file mode 100644 index 000000000..34905f11b --- /dev/null +++ b/examples/feature-examples/src/pages/nodes/custom/pool/index.tsx @@ -0,0 +1,235 @@ +import React, { useEffect, useRef } from 'react' +import LogicFlow from '@logicflow/core' +import { Flex } from 'antd' +import { PoolElements } from '@logicflow/extension' +import data from './data' +import '@logicflow/core/dist/index.css' +import './index.less' + +const config = { + grid: true, + background: { + color: '#f5f5f5', + }, + keyboard: { + enabled: true, + }, +} + +const PoolNodeDemo: React.FC = () => { + const containerRef = useRef(null) + const lfRef = useRef() + + const handleDragItem = (node: any) => { + lfRef?.current?.dnd.startDrag(node) + } + const handleGetGraphData = () => { + console.log('lf', lfRef.current?.getGraphData(), lfRef.current?.graphModel) + } + + useEffect(() => { + console.log('useEffect run') + if (!containerRef.current) return + + const lf = new LogicFlow({ + ...config, + container: containerRef.current, + width: 1200, + height: 1200, + allowResize: true, + allowRotate: true, + plugins: [PoolElements], + }) + + // 注册泳池和泳道节点 + // registerPoolNodes(lf) + lf.on('node:click', ({ data }) => { + const clickNodeModel = lf.getNodeModelById(data.id) + console.log( + '点击了节点', + clickNodeModel, + clickNodeModel?.zIndex, + lf.graphModel.nodes.map((node) => `${node.id}-${node.zIndex}`), + ) + if (clickNodeModel?.isLane) { + const parentModel = lf.getNodeModelById( + clickNodeModel.properties.parent, + ) + console.log('parentModel', parentModel?.zIndex) + } + }) + lf.on('edge:add', ({ data }) => { + console.log('添加了边', data) + const { id } = data + const edgeModel = lf.getEdgeModelById(id) + console.log('边的层级', edgeModel?.zIndex) + }) + + lf.render(data) + console.log('lf', lf.getGraphData()) + + lfRef.current = lf + + return () => { + lfRef.current?.destroy() + } + }, []) + + return ( +
+
+

泳池和泳道节点示例

+

基于动态分组插件和内置resizable能力重新实现

+
+ 当前图ID: {lfRef.current?.flowId} + +
+ handleDragItem({ + type: 'rect', + text: 'rect', + }) + } + > + 矩形 +
+
{ + handleDragItem({ + type: 'circle', + text: 'circle', + }) + }} + > + 圆形 +
+
{ + handleDragItem({ + type: 'diamond', + text: 'diamond', + }) + }} + > + 菱形 +
+
{ + handleDragItem({ + type: 'ellipse', + text: 'ellipse', + properties: { + rx: 40, + ry: 80, + }, + }) + }} + > + 椭圆 +
+
{ + handleDragItem({ + type: 'html', + text: 'html', + }) + }} + > + html +
+
{ + handleDragItem({ + type: 'polygon', + text: 'polygon', + properties: { + width: 110, + height: 100, + style: { + fill: '#ffd591', + stroke: '#ffa940', + strokeWidth: 2, + fillRule: 'evenodd', + }, + }, + }) + }} + > + 多边形 +
+
{ + handleDragItem({ + type: 'text', + text: '文本', + }) + }} + > + 文本 +
+
{ + handleDragItem({ + type: 'pool', + text: '横向泳池', + properties: { + width: 400, + height: 200, + direction: 'horizontal', + laneConfig: { + text: '水平泳道', + }, + }, + }) + }} + > + 横向泳道 +
+
{ + handleDragItem({ + type: 'pool', + text: '竖向泳池', + properties: { + width: 200, + height: 400, + direction: 'vertical', + laneConfig: { + text: '水平泳道', + }, + }, + }) + }} + > + 竖向泳道 +
+
+ 获取图数据 +
+
+
+
+
+
+
+
+ ) +} + +export default PoolNodeDemo diff --git a/examples/feature-examples/src/pages/nodes/custom/rect/index.tsx b/examples/feature-examples/src/pages/nodes/custom/rect/index.tsx index f4e5d2c34..19203d3b0 100644 --- a/examples/feature-examples/src/pages/nodes/custom/rect/index.tsx +++ b/examples/feature-examples/src/pages/nodes/custom/rect/index.tsx @@ -10,6 +10,7 @@ const config: Partial = { isSilentMode: false, stopScrollGraph: true, stopZoomGraph: true, + allowResize: true, style: { rect: { width: 100, diff --git a/packages/core/src/model/GraphModel.ts b/packages/core/src/model/GraphModel.ts index e21e05135..c0321f815 100644 --- a/packages/core/src/model/GraphModel.ts +++ b/packages/core/src/model/GraphModel.ts @@ -500,7 +500,6 @@ export class GraphModel { this.elementsModelMap.clear() this.nodeModelMap.clear() this.edgeModelMap.clear() - if (graphData.nodes) { this.nodes = map(graphData.nodes, (node: NodeConfig) => { const nodeModel = this.getModelAfterSnapToGrid(node) diff --git a/packages/core/src/view/shape/Text.tsx b/packages/core/src/view/shape/Text.tsx index 2925086f7..a8a30821a 100644 --- a/packages/core/src/view/shape/Text.tsx +++ b/packages/core/src/view/shape/Text.tsx @@ -129,7 +129,6 @@ export function renderHtmlText(props: ITextProps): h.JSX.Element { if (isEllipsis) { foreignObjectHeight = fontSize + 2 } - return ( , 'e' | 'position'>) => { const nodeModel = this.lf.getNodeModelById(node.id) this.sendNodeToFront(nodeModel) - // 重置所有 group 的 zIndex,防止 group 节点 zIndex 增长为正数(目的是保持 group 节点在最底层) if (this.topGroupZIndex > DEFAULT_TOP_Z_INDEX) { const { nodes } = this.lf.graphModel diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index 94dfc8eba..3aed26897 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -9,6 +9,8 @@ export * from './bpmn-adapter/json2xml' // Adapter export * from './turbo-adapter' +// 泳道相关 +export * from './pool' // 新版 Group export * from './dynamic-group' // 折线上动态插入节点 diff --git a/packages/extension/src/pool/LaneModel.ts b/packages/extension/src/pool/LaneModel.ts new file mode 100644 index 000000000..4637e1f2f --- /dev/null +++ b/packages/extension/src/pool/LaneModel.ts @@ -0,0 +1,226 @@ +/** + * 基于DynamicGroup重新实现的泳道节点组件 + * 继承DynamicGroupNodeModel和DynamicGroupNode,提供泳道特定功能 + */ +import LogicFlow from '@logicflow/core' +import { DynamicGroupNodeModel } from '../dynamic-group' +import { forEach } from 'lodash-es' +import { laneConfig } from './constant' + +export class LaneModel extends DynamicGroupNodeModel { + readonly isLane: boolean = true + defaultZIndex: number = -1 + + initNodeData(data: LogicFlow.NodeConfig) { + super.initNodeData(data) + // 泳道特定配置 + this.width = data.width || laneConfig.defaultWidth + this.height = data.height || laneConfig.defaultHeight + this.draggable = true // 允许拖拽(实际拖拽逻辑由泳池控制) + this.resizable = true // 允许调整大小 + this.rotatable = false // 禁止旋转 + + // 设置泳道层级 + // 如果传入了zIndex,使用传入的值,否则默认为2 + // 泳道层级应该比所属泳池高,确保显示在泳池上方 + this.defaultZIndex = data.zIndex || -1 + this.setZIndex(this.defaultZIndex) + this.autoToFront = true + + this.text.editable = true + this.style.stroke = '#000' + this.style.strokeWidth = 1 + + // 泳道属性配置 + this.properties = { + ...this.properties, + processRef: '', // 流程引用标识 + panels: ['processRef'], // 可配置面板 + direction: data.properties?.direction || 'vertical', + } + + // 设置折叠尺寸(泳道不支持折叠,设置为与正常尺寸相同) + this.collapsedWidth = this.width + this.collapsedHeight = this.height + this.expandWidth = this.width + this.expandHeight = this.height + } + + setAttributes(): void { + super.setAttributes() + this.updateTextPosition() + } + + setZIndex(zIndex: number) { + this.zIndex = Math.min(zIndex, this.defaultZIndex) || this.defaultZIndex + } + + /** + * 重写折叠方法 - 泳道不支持折叠 + */ + toggleCollapse() { + // 泳道不支持折叠功能,保持展开状态 + this.isCollapsed = false + this.setProperties({ isCollapsed: false }) + } + + /** + * 获取所属泳池ID + */ + getPoolId(): string | null { + try { + // 检查graphModel是否存在 + if (!this.graphModel) { + console.warn('GraphModel is not available') + return null + } + + // 安全地获取泳池ID + const poolModel = this.graphModel.nodes.find((node) => { + return node.children && node.children.has(this.id) + }) + return poolModel?.id || null + } catch (error) { + console.error('Error getting pool ID:', error) + return null + } + } + + /** + * 获取所属泳池模型 + */ + getPoolModel(): any { + try { + const poolId = this.getPoolId() + if (!poolId) { + return null + } + + // 检查graphModel是否存在 + if (!this.graphModel) { + console.warn('GraphModel is not available for getting pool model') + return null + } + + const poolModel = this.graphModel.getNodeModelById(poolId) + return poolModel || null + } catch (error) { + console.error('Error getting pool model:', error) + return null + } + } + + /** + * 动态修改泳道属性 + */ + changeAttribute({ width, height, x, y }: any) { + if (width) this.width = width // 更新宽度 + if (height) this.height = height // 更新高度 + if (x) this.x = x // 更新X坐标 + if (y) this.y = y // 更新Y坐标 + } + + /** + * 重写获取数据方法,添加泳道特定属性 + */ + getData(): LogicFlow.NodeData { + const data = super.getData() + // const poolModel = this.getPoolModel() + return { + ...data, + properties: { + ...data.properties, + width: this.width, + height: this.height, + processRef: this.properties.processRef, + direction: this.properties.direction, + }, + } + } + /** + * 重写 isAllowAppendIn,禁止 Lane 嵌套 + */ + isAllowAppendIn(nodeData: LogicFlow.NodeData): boolean { + // 禁止 Lane 节点被添加到 Lane 中 + return String(nodeData.type) !== 'lane' + } + + /** + * 获取需要移动的节点 + * @param groupModel + */ + getNodesInGroup(groupModel: DynamicGroupNodeModel): string[] { + const nodeIds: string[] = [] + const { + properties: { parent }, + isDragging, + } = groupModel + if (isDragging && parent) { + nodeIds.push(parent as string) + } + forEach(Array.from(groupModel.children), (nodeId: string) => { + const nodeModel = this.graphModel.getNodeModelById(nodeId) + // 只有非 Lane 类型的节点才会被带动 + if ( + nodeModel && + !nodeModel.isDragging && + String(nodeModel.type) !== 'lane' + ) { + nodeIds.push(nodeId) + } + }) + return nodeIds + } + getNodeStyle() { + const style = super.getNodeStyle() + style.strokeWidth = 2 + return style + } + /** + * 获取文本样式 + */ + getTextStyle() { + const { isHorizontal = false } = this.properties + const style = super.getTextStyle() + style.overflowMode = 'ellipsis' + style.strokeWidth = 2 + style.textWidth = isHorizontal ? this.height : this.width + style.textHeight = isHorizontal ? this.width : this.height + if (isHorizontal) { + style.transform = 'rotate(-90deg)' + style.textAlign = 'center' + } + return style + } + + /** + * 获取子泳道 + */ + getSubNodes() { + const children: any[] = [] + Array.from(this.children).forEach((childId) => { + const childModel = this.graphModel.getNodeModelById(childId) + if (childModel) { + children.push(childModel) + } + }) + return children + } + + /** + * 初始化文本位置 - 根据布局方向设置文本位置 + */ + updateTextPosition() { + if (this.properties.isHorizontal) { + // 横向泳池:文本显示在左侧标题区域 + this.text.x = this.x - this.width / 2 + laneConfig.titleSize / 2 + this.text.y = this.y + } else { + // 纵向泳池:文本显示在顶部标题区域 + this.text.x = this.x + this.text.y = this.y - this.height / 2 + laneConfig.titleSize / 2 + } + } +} + +export default null diff --git a/packages/extension/src/pool/LaneView.ts b/packages/extension/src/pool/LaneView.ts new file mode 100644 index 000000000..4e81dbe50 --- /dev/null +++ b/packages/extension/src/pool/LaneView.ts @@ -0,0 +1,220 @@ +/** + * 基于DynamicGroup重新实现的泳道节点组件 + * 继承DynamicGroupNodeModel和DynamicGroupNode,提供泳道特定功能 + */ +import { h } from '@logicflow/core' +import { DynamicGroupNode } from '../dynamic-group' +import { laneConfig } from './constant' +import { LaneModel } from './LaneModel' + +export class LaneView extends DynamicGroupNode { + getAppendAreaShape(): h.JSX.Element | null { + // DONE: 此区域用于初始化 group container, 即元素拖拽进入感应区域 + const { model } = this.props + const { width, height, x, y, radius, groupAddable } = model + if (!groupAddable) return null + + const { strokeWidth = 0 } = model.getNodeStyle() + const style = model.getAddableOutlineStyle() + + const newWidth = width + strokeWidth + 8 + const newHeight = height + strokeWidth + 8 + return h('rect', { + ...style, + width: newWidth, + height: newHeight, + x: x - newWidth / 2, + y: y - newHeight / 2, + rx: radius, + ry: radius, + }) + } + getShape() { + const { model } = this.props + const { + x, + y, + width, + height, + properties: { textStyle: customTextStyle = {}, isHorizontal }, + } = model + const style = model.getNodeStyle() + const base = { fill: '#ffffff', stroke: '#000000', strokeWidth: 1 } + const left = x - width / 2 + const top = y - height / 2 + // 泳道主体 + const rectAttrs = { + x: x - width / 2, + y: y - height / 2, + width, + height, + stroke: '#000000', + strokeWidth: 2, + fill: 'transparent', + ...style, + } + // 操作图标区域 + const icons = this.getOperateIcons() + const titleRect = { + ...base, + ...style, + x: isHorizontal ? left + laneConfig.titleSize : left, + y: isHorizontal ? top : top + laneConfig.titleSize, + width: isHorizontal ? width - laneConfig.titleSize : width, + height: isHorizontal ? height : laneConfig.titleSize, + ...(isHorizontal ? customTextStyle : {}), + } + return h('g', {}, [ + this.getAppendAreaShape(), + h('rect', titleRect), + h('rect', { ...rectAttrs }), + ...icons, + ]) + } + + /** + * 获取操作图标 + */ + getOperateIcons() { + const { model } = this.props + const { isSelected } = model + if (!isSelected) { + return [] + } + + const poolModel = (model as LaneModel).getPoolModel() + if (!poolModel) { + return [] + } + + const { isHorizontal } = poolModel + const laneData = model.getData() + + return [ + this.addBeforeLaneIcon(isHorizontal, () => + isHorizontal + ? poolModel.addChildAbove?.(laneData) + : poolModel.addChildRight?.(laneData), + ), + this.addAfterLaneIcon(isHorizontal, () => + isHorizontal + ? poolModel.addChildBelow?.(laneData) + : poolModel.addChildLeft?.(laneData), + ), + this.deleteLaneIcon(() => poolModel.deleteChild?.(laneData.id)), + ] + } + + addBeforeLaneIcon(isHorizontal: boolean, callback: () => void) { + const { x, y, width, height } = this.props.model + // 图标与泳道之间加固定的间距 + const positionX = x + width / 2 + laneConfig.iconSpacing + const positionY = y - height / 2 + const baseAttr = { + width: laneConfig.iconSize / 2, + height: laneConfig.iconSize, + strokeWidth: 1, + fill: '#fff', + stroke: '#000', + x: positionX, + y: positionY, + } + let iconView: h.JSX.Element[] = [ + h('rect', { + ...baseAttr, + x: positionX + laneConfig.iconSize / 2, + strokeDasharray: '2 2', + }), + h('rect', baseAttr), + ] + if (isHorizontal) { + iconView = [ + h('rect', { + ...baseAttr, + width: laneConfig.iconSize, + height: laneConfig.iconSize / 2, + strokeDasharray: '2 2', + }), + h('rect', { + ...baseAttr, + width: laneConfig.iconSize, + height: laneConfig.iconSize / 2, + y: positionY + laneConfig.iconSize / 2, + }), + ] + } + return h('g', { cursor: 'pointer', onClick: callback }, iconView) + } + addAfterLaneIcon(isHorizontal: boolean, callback: () => void) { + const { x, y, width, height } = this.props.model + const positionX = x + width / 2 + laneConfig.iconSpacing + const positionY = + y - height / 2 + laneConfig.iconSize + laneConfig.iconSpacing + const baseAttr = { + width: laneConfig.iconSize / 2, + height: laneConfig.iconSize, + strokeWidth: 1, + fill: '#fff', + stroke: '#000', + x: positionX, + y: positionY, + } + let iconView: h.JSX.Element[] = [ + h('rect', { + ...baseAttr, + x: positionX + laneConfig.iconSize / 2, + }), + h('rect', { + ...baseAttr, + strokeDasharray: '2 2', + }), + ] + if (isHorizontal) { + iconView = [ + h('rect', { + ...baseAttr, + width: laneConfig.iconSize, + height: laneConfig.iconSize / 2, + }), + h('rect', { + ...baseAttr, + width: laneConfig.iconSize, + height: laneConfig.iconSize / 2, + y: positionY + laneConfig.iconSize / 2, + strokeDasharray: '2 2', + }), + ] + } + return h('g', { cursor: 'pointer', onClick: callback }, iconView) + } + deleteLaneIcon(callback: () => void) { + const { x, y, width, height } = this.props.model + const positionX = x + width / 2 + laneConfig.iconSpacing + const positionY = + y - height / 2 + (laneConfig.iconSize + laneConfig.iconSpacing) * 3 + return h( + 'g', + { + cursor: 'pointer', + onClick: callback, + width: laneConfig.iconSize, + height: laneConfig.iconSize, + transform: `translate(${positionX}, ${positionY})`, + }, + [ + h('rect', { + width: laneConfig.iconSize, + height: laneConfig.iconSize, + fill: 'transparent', + }), + h('path', { + transform: `translate(2, 1) scale(${laneConfig.iconSize / 18})`, + fill: '#000', + d: 'M1.6361705,0.07275847000000002L1.6362224,0.07267305000000002L5.1435161,2.2034403L6.3516493,1.28341734Q7.2009554,0.63665058,8.0902505,1.22722644L10.1215935,2.5762291Q11.006711,3.1640306,10.745867,4.1940317L10.4062386,5.5351257L13.625054,7.5778356L13.625001,7.5779204Q13.678322,7.6117587,13.721552,7.6577945Q13.764784,7.7038307,13.795207,7.7591715Q13.82563,7.8145118,13.841336,7.87568Q13.857041,7.9368477,13.857041,8Q13.85704,8.0492353,13.847435,8.0975251Q13.83783,8.145814900000001,13.818987,8.191302799999999Q13.800144,8.2367907,13.772791,8.2777286Q13.745438,8.318666499999999,13.710623,8.3534818Q13.675808,8.3882966,13.63487,8.4156504Q13.593931,8.4430046,13.548444,8.461846399999999Q13.502956,8.4806881,13.454666,8.4902935Q13.406377,8.4998994,13.357141,8.499899899999999Q13.211908,8.499899899999999,13.089283,8.4220805L13.08923,8.4221654L4.9074116,3.229857L1.1170242400000001,0.92732695L1.1170761599999999,0.92724147Q1.06204063,0.8938076500000001,1.0172748,0.84751782Q0.97250897,0.80122799,0.9409355500000001,0.74510445Q0.9093622,0.68898091,0.89304277,0.626688Q0.87672335,0.564395107,0.87672332,0.5Q0.8767232899999999,0.450764146,0.88632876,0.402474351Q0.8959341599999999,0.35418455,0.91477591,0.30869657Q0.93361765,0.26320857,0.9609716500000001,0.22227046Q0.9883256,0.18133234999999998,1.02314061,0.14651734Q1.05795562,0.11170232000000002,1.0988937,0.08434838Q1.13983184,0.056994409999999995,1.18531984,0.038152660000000005Q1.2308077800000001,0.019310890000000025,1.27909762,0.009705450000000004Q1.32738745,0.00010001999999997846,1.3766233300000001,0.00009998999999999425Q1.516567,0.00009998999999999425,1.6361705,0.07275847000000002ZM9.5175018,4.9711194L9.7764683,3.9485345Q9.8634167,3.6052005,9.5683784,3.4092672L7.537035,2.0602646Q7.240603,1.8634058,6.9575009,2.0789949L6.0496349,2.7703574L9.5175018,4.9711194ZM11.227273,14.5L11.227273,9.7307692L11.227173,9.7307692Q11.227173,9.6815329,11.217567,9.6332426Q11.207962,9.5849533,11.189119,9.539465Q11.170278,9.4939766,11.142924,9.4530392Q11.11557,9.4121017,11.080755,9.3772869Q11.04594,9.3424721,11.005002,9.3151178Q10.964064,9.2877636,10.918575,9.2689209Q10.873087,9.2500801,10.824797,9.2404747Q10.776508,9.2308693,10.727273,9.2308693Q10.678036,9.2308693,10.629745,9.2404747Q10.581455,9.2500801,10.535968,9.2689209Q10.4904804,9.2877636,10.449542,9.3151178Q10.4086046,9.3424721,10.3737898,9.377286Q10.338975,9.4121008,10.3116207,9.4530382Q10.2842674,9.4939766,10.2654257,9.539465Q10.2465839,9.5849533,10.2369785,9.6332426Q10.2273731,9.6815329,10.2273731,9.7307692L10.2272739,9.7307692L10.2272739,14.5Q10.2272739,15,9.727273,15L7.7207794,15L7.7207789,8.2500091L7.7206788,8.2500091Q7.7206783,8.2007728,7.7110729,8.152483Q7.7014675,8.104193200000001,7.6826253,8.0587053Q7.6637836,8.013217000000001,7.6364298,7.9722791Q7.6090755,7.9313412,7.5742612,7.8965263Q7.5394459,7.861711,7.4985075,7.8343568Q7.4575696,7.807003,7.4120817,7.7881613Q7.3665934,7.7693195,7.3183041,7.7597141Q7.2700143,7.7501092,7.2207789,7.7501092Q7.1715426,7.7501092,7.1232524,7.7597141Q7.0749626,7.7693195,7.0294747,7.7881613Q6.9839869,7.807003,6.943049,7.8343573Q6.9021111,7.861711,6.8672962,7.8965263Q6.8324809,7.9313412,6.8051271,7.9722791Q6.7777729,8.013217000000001,6.7589312,8.0587053Q6.7400894,8.1041937,6.7304845,8.1524839Q6.7208786,8.2007732,6.7208791,8.2500095L6.7207789,8.2500091L6.7207794,15L4.2142854,15L4.2142854,6.2692308L4.2141855,6.2692308Q4.2141852,6.2199945,4.204579799999999,6.1717048Q4.1949743999999995,6.123415,4.1761324,6.0779266Q4.1572905,6.0324383,4.1299367,5.9915004Q4.1025827,5.9505625,4.0677679,5.9157476Q4.0329528,5.8809328,3.9920146,5.8535786Q3.9510765,5.8262248,3.9055884,5.8073831Q3.8601003,5.7885418,3.811811,5.7789364Q3.7635212,5.769331,3.7142854,5.769331Q3.6650493,5.769331,3.6167595,5.7789364Q3.5684695,5.7885418,3.5229816,5.8073831Q3.4774938,5.8262248,3.4365554,5.8535786Q3.3956175,5.8809328,3.3608027,5.9157476Q3.3259873,5.9505625,3.2986333,5.9915004Q3.2712793,6.0324383,3.2524376,6.0779266Q3.2335958,6.123415,3.2239904,6.1717048Q3.214385,6.2199945,3.2143853,6.2692308L3.2142854,6.2692308L3.2142854,15L1.5000002,15Q1.0000001200000002,15,1.0000001200000002,14.5L1,5.4150848Q1,5.0384622,1.3766233300000001,5.0384622L1.3766233300000001,5.0383615Q1.42585915,5.0383615,1.47414887,5.0287557Q1.5224386,5.0191503,1.5679266,5.0003085Q1.6134146,4.9814663,1.6543528,4.954113Q1.695291,4.9267588,1.730106,4.8919439Q1.7649209,4.8571291,1.792275,4.8161907Q1.8196288,4.7752523,1.8384706,4.7297645Q1.8573124,4.6842766,1.8669178,4.6359868Q1.8765233,4.587697,1.8765234,4.5384617Q1.8765233,4.4892254000000005,1.8669178,4.4409355999999995Q1.8573124,4.3926458,1.8384707,4.3471577Q1.819629,4.3016696,1.792275,4.2607315Q1.7649209,4.2197936,1.730106,4.1849787Q1.695291,4.1501637,1.6543529,4.1228096Q1.6134148,4.0954556,1.5679268,4.0766139Q1.5224388,4.0577724,1.4741489300000001,4.048166999999999Q1.42585915,4.0385615999999995,1.3766233300000001,4.0385615999999995L1.3766233300000001,4.0384617Q0.8064074800000001,4.0384617,0.403203636,4.4416654Q0,4.8448691,0,5.4150848L9.000000000813912e-8,14.5Q2.9999999984209325e-8,15.121321,0.439339694,15.56066Q0.8786805,16.000002000000002,1.5000002,16.000002000000002L9.727273,16.000002000000002Q10.3485928,16.000002000000002,10.787933,15.56066Q11.227273,15.121321,11.227273,14.5Z', + }), + ], + ) + } +} + +export default null diff --git a/packages/extension/src/pool/PoolModel.ts b/packages/extension/src/pool/PoolModel.ts new file mode 100644 index 000000000..779dd4677 --- /dev/null +++ b/packages/extension/src/pool/PoolModel.ts @@ -0,0 +1,631 @@ +/** + * 基于DynamicGroup重新实现的泳池节点组件 + * 充分利用DynamicGroup的分组管理能力,实现完整的泳道功能 + */ +import LogicFlow, { GraphModel, BaseEdgeModel } from '@logicflow/core' +import { computed } from 'mobx' +import { forEach, merge, cloneDeep, isEmpty } from 'lodash-es' +import { + DynamicGroupNodeModel, + IGroupNodeProperties, +} from '../dynamic-group/model' +import { poolConfig, laneConfig } from './constant' + +// import { LaneModel } from './NewLane' + +import NodeConfig = LogicFlow.NodeConfig + +export class PoolModel extends DynamicGroupNodeModel { + // 泳池特定属性 + // 标题区域大小:如果是垂直方向,指代的就是标题区的宽度,如果是水平方向,指代的就是标题区的高度 + titleSize: number = poolConfig.titleSize + poolConfig: typeof poolConfig = poolConfig + readonly isPool: boolean = true + + // 标记是否已创建默认泳道 + _defaultLaneCreated: boolean = false + constructor(data: NodeConfig, graphModel: GraphModel) { + super(data, graphModel) + } + + @computed get isHorizontal() { + return this.properties?.direction === 'horizontal' + } + + initNodeData(data: LogicFlow.NodeConfig) { + super.initNodeData(data) + if (data.properties) { + // 泳池基础配置 + this.width = data.properties?.width || poolConfig.defaultWidth + this.height = data.properties?.height || poolConfig.defaultHeight + } + + // 动态分组配置 + this.autoResize = false + this.isRestrict = true + this.transformWithContainer = true + this.resizable = false + this.rotatable = false + this.autoToFront = false + + // 允许文本编辑 + this.text.editable = true + + // 初始化文本位置 + this.updateTextPosition() + this.addEventListeners() + this.resizePool() + } + + // 增加监听事件 + addEventListeners() { + this.graphModel.eventCenter.on('node:resize', ({ data, index }) => { + // 如果resize的是子泳道 + if (this.children.has(data.id)) { + // 检查是否为泳道节点的尺寸变化 + const resizedNode = this.graphModel.getNodeModelById(data.id) + if (!resizedNode || !resizedNode.type || resizedNode.type !== 'lane') { + return + } + + // 获取所有子泳道 + const lanes = this.getLanes() + + // 更新泳池宽高 + let newWidth: number + let newHeight: number + let deltaX: number = 0 + let deltaY: number = 0 + if (this.isHorizontal) { + // 横向布局: + // 泳池宽度 = 最大泳道宽度 + 标题区域 + + const totalLaneHeight = lanes.reduce( + (sum, lane) => sum + lane.height, + 0, + ) + newWidth = resizedNode.width + poolConfig.titleSize + // 泳池高度 = 所有泳道高度之和 + newHeight = totalLaneHeight + } else { + // 竖向布局: + // 泳池高度 = 最大泳道高度 + 标题区域 + const totalLaneWidth = lanes.reduce( + (sum, lane) => sum + lane.width, + 0, + ) + newHeight = resizedNode.height + poolConfig.titleSize + // 泳池宽度 = 所有泳道宽度之和 + newWidth = totalLaneWidth + } + // 根据拖拽控制点方向计算位移方向 + // ResizeControlIndex: 0-左上, 1-右上, 2-右下, 3-左下 + const resizeIndex = typeof index === 'number' ? index : 2 + const isLeft = resizeIndex === 0 || resizeIndex === 3 + const isTop = resizeIndex === 0 || resizeIndex === 1 + const signX = isLeft ? -1 : 1 + const signY = isTop ? -1 : 1 + deltaX = signX * (newWidth - this.width) + deltaY = signY * (newHeight - this.height) + this.width = newWidth + this.height = newHeight + this.move(deltaX / 2, deltaY / 2) + } + // 重新布局泳道以适应新的泳池尺寸 + this.resizeChildren() + // 更新泳池文本位置 + this.updateTextPosition() + }) + } + + /** + * 获取需要移动的节点 + * @param groupModel + */ + getNodesInGroup(groupModel: DynamicGroupNodeModel): string[] { + const nodeIds: string[] = [] + if (groupModel.isGroup) { + forEach(Array.from(groupModel.children), (nodeId: string) => { + const nodeModel = this.graphModel.getNodeModelById(nodeId) + // 拖拽泳道时会触发泳池的getNodesInGroup,这时泳池再触发移动的子泳道里就需要剔除当前正在拖拽的泳道 + if (nodeModel && !nodeModel.isDragging) { + nodeIds.push(nodeId) + } + }) + } + return nodeIds + } + /** + * 初始化文本位置 - 根据布局方向设置文本位置 + */ + private updateTextPosition() { + if (this.isHorizontal) { + // 横向泳池:文本显示在左侧标题区域 + this.text.x = this.x - this.width / 2 + poolConfig.titleSize / 2 + this.text.y = this.y + } else { + // 纵向泳池:文本显示在顶部标题区域 + this.text.x = this.x + this.text.y = this.y - this.height / 2 + poolConfig.titleSize / 2 + } + } + + /** + * 根据子泳道自动调整泳池尺寸 + */ + resizePool() { + const lanes = this.getLanes() + if (lanes.length === 0) return + let contentWidth = 0 + let contentHeight = 0 + if (this.isHorizontal) { + // 横向布局:计算所有泳道的边界 + forEach(lanes, (lane) => { + const laneWidth = lane.width + const laneHeight = lane.height + contentWidth = Math.max(contentWidth, laneWidth) + contentHeight += laneHeight + }) + // 计算新尺寸(横向布局:宽度包含标题区域) + this.width = contentWidth + poolConfig.titleSize + this.height = contentHeight + } else { + // 竖向布局:计算所有泳道的边界 + forEach(lanes, (lane) => { + const laneWidth = lane.width + const laneHeight = lane.height + contentWidth += laneWidth + contentHeight = Math.max(contentHeight, laneHeight) + }) + // 计算新尺寸(竖向布局:高度包含标题区域) + this.width = contentWidth + this.height = contentHeight + poolConfig.titleSize + } + + // 更新文本位置 + this.updateTextPosition() + } + + /** + * 重新调整所有泳道布局 + * @param newLanePosition 添加位置(可选):'above'|'below'|'left'|'right' + * @param newLaneId 新添加的泳道ID(可选) + */ + resizeChildrenWithNewLane( + newLanePosition?: 'above' | 'below' | 'left' | 'right', + newLaneId?: string, + ) { + const lanes = this.getLanes() + const isAddingNewLane = newLanePosition && newLaneId + if (!isAddingNewLane || isEmpty(lanes)) return + let orderedLanes = [] as any[] + // 找到新创建的泳道 + const newLane = lanes.find((lane) => lane.id === newLaneId) + // 先找到触发新增泳道的泳道的index + orderedLanes = lanes + .filter((lane) => lane.id !== newLaneId) + .slice() + .sort((a: any, b: any) => (this.isHorizontal ? a.y - b.y : a.x - b.x)) + if (newLane) { + const refId = (newLane as any).properties?.referenceLaneId + const refIndex = refId + ? orderedLanes.findIndex((l: any) => l.id === refId) + : 0 + const insertIndex = ['above', 'left'].includes(newLanePosition) + ? Math.max(refIndex, 0) + : Math.min(refIndex + 1, orderedLanes.length) + // 按顺序插入新泳道 + orderedLanes.splice(insertIndex, 0, newLane) + } + if (this.isHorizontal) { + // 统一泳道宽度 + const laneWidth = this.width - poolConfig.titleSize + // 计算泳道在内容区域内的分布 + const newHeight = orderedLanes.reduce((sum: number, lane: any) => { + return sum + lane.height + }, 0) + let laneTopDistance: number = this.y - newHeight / 2 + orderedLanes.forEach((lane: any, index: number) => { + const newLaneY = laneTopDistance + lane.height / 2 + // 统一泳道文本位置 + lane.text = { + ...lane.text, + x: lane.x - laneWidth / 2 + laneConfig.titleSize / 2, + y: lane.y, + } + this.moveLane(lane, lane.x, newLaneY) + // 为下一个泳道计算Y坐标 + if (index < orderedLanes.length - 1) { + laneTopDistance += lane.height + } + }) + this.height = newHeight + } else { + // 统一泳道高度 + const laneHeight = this.height - poolConfig.titleSize + const newWidth = orderedLanes.reduce((sum: number, lane: any) => { + return sum + lane.width + }, 0) + let laneLeftDistance: number = this.x - newWidth / 2 + // 遍历所有泳道,设置它们的位置 + orderedLanes.forEach((lane: any, index: number) => { + const newLaneX = laneLeftDistance + lane.width / 2 + // 统一泳道文本位置 + lane.text = { + ...lane.text, + x: lane.x, + y: lane.y - laneHeight / 2 + laneConfig.titleSize / 2, + } + this.moveLane(lane, newLaneX, lane.y) + // 为下一个泳道计算X坐标 + if (index < orderedLanes.length - 1) { + laneLeftDistance += lane.width + } + }) + this.width = newWidth + } + // 更新文本位置 + this.updateTextPosition() + } + + moveLane(lane: any, newX, newY) { + // 更新泳子节点位置 + const childrenRelPos: { id: string; dx: number; dy: number }[] = [] + if (lane.children && lane.children.size > 0) { + lane.children.forEach((childId: string) => { + const childNode = this.graphModel.getNodeModelById(childId) + // 过滤掉拖拽中的节点和 Lane 类型(避免递归) + if ( + childNode && + !childNode.isDragging && + String(childNode.type) !== 'lane' + ) { + childrenRelPos.push({ + id: childId, + dx: childNode.x - lane.x, + dy: childNode.y - lane.y, + }) + } + }) + } + // 设置泳道位置和尺寸 + lane.moveTo(newX, newY, true) + childrenRelPos.forEach(({ id, dx, dy }) => { + const childNode = this.graphModel.getNodeModelById(id) + if (childNode) { + const { x, y } = childNode + const newChildX = lane.x + dx + const newChildY = lane.y + dy + childNode.moveTo(newChildX, newChildY) + const { edges: incomingEdges } = childNode.incoming + const { edges: outgoingEdges } = childNode.outgoing + incomingEdges.forEach((edge: BaseEdgeModel) => { + edge.moveEndPoint(newChildX - x, newChildY - y) + }) + outgoingEdges.forEach((edge: BaseEdgeModel) => { + edge.moveStartPoint(newChildX - x, newChildY - y) + }) + } + }) + } + + /** + * 重新调整所有泳道布局 + * @param newLanePosition 添加位置(可选):'above'|'below'|'left'|'right' + * @param newLaneId 新添加的泳道ID(可选) + */ + resizeChildren() { + // 遍历所有泳道,horizontal泳道按Y轴排序,vertical泳道按X轴排序并调整位置 + const lanes = this.getLanes() + if (lanes.length === 0) return + + if (this.isHorizontal) { + this.height = lanes.reduce((sum: number, lane: any) => { + return sum + lane.height + }, 0) + // 遍历所有泳道,产出它们在y轴从上到下的顺序 + const orderedLanes = lanes.slice().sort((a: any, b: any) => a.y - b.y) + lanes.forEach((lane: any) => { + lane.width = this.width - poolConfig.titleSize + const laneIndex = orderedLanes.findIndex( + (orderedLane: any) => orderedLane.id === lane.id, + ) + // 遍历orderedLanes,计算出lane相比泳池顶部的距离 + const laneTopDistance = orderedLanes.reduce( + (sum: number, orderedLane: any, index: number) => { + if (index < laneIndex && orderedLane.id !== lane.id) { + return sum + orderedLane.height + } + return sum + }, + this.y - this.height / 2, + ) + + lane.moveTo( + this.x - this.width / 2 + poolConfig.titleSize + lane.width / 2, + laneTopDistance + lane.height / 2, + true, + ) + }) + } else { + this.width = lanes.reduce((sum: number, lane: any) => { + return sum + lane.width + }, 0) + // 垂直泳道按X轴排序 + const orderedLanes = cloneDeep(lanes).sort((a: any, b: any) => a.x - b.x) + lanes.forEach((lane: any) => { + lane.height = this.height - poolConfig.titleSize + const laneIndex = orderedLanes.findIndex( + (orderedLane: any) => orderedLane.id === lane.id, + ) + // 遍历orderedLanes,计算出lane相比泳池顶部的距离 + const laneLeftDistance = orderedLanes.reduce( + (sum: number, orderedLane: any, index: number) => { + if (index < laneIndex && orderedLane.id !== lane.id) { + return sum + orderedLane.width + } + return sum + }, + this.x - this.width / 2, + ) + lane.moveTo( + laneLeftDistance + lane.width / 2, + this.y - this.height / 2 + poolConfig.titleSize + lane.height / 2, + true, + ) + }) + } + } + + /** + * 获取子泳道 + */ + getLanes() { + const children: any[] = [] + Array.from(this.children).forEach((childId) => { + const childModel = this.graphModel.getNodeModelById(childId) + if (childModel && String(childModel.type) === 'lane') { + children.push(childModel) + } + }) + return children + } + + /** + * 添加泳道的公共方法 + * @param position 添加位置:'above'|'below'|'left'|'right' + * @param laneData 泳道数据 + */ + addLane(position: 'above' | 'below' | 'left' | 'right', laneData?: any) { + const lanes = this.getLanes() + if (lanes.length === 0) { + return this.createDefaultLane(laneData) + } + + // 计算初始位置 + let initialX = this.x + let initialY = this.y + // 参考泳道(用于定位) + const referenceLane = lanes.find((lane) => lane.id === laneData?.id) + // 用于确定新泳道尺寸的参考泳道,优先使用referenceLane,其次使用现有第一个泳道,最后回退到泳池尺寸 + const sizeLane = referenceLane || lanes[0] + const laneWidth = sizeLane?.width ?? this.width + const laneHeight = sizeLane?.height ?? this.height + + if (this.isHorizontal && ['above', 'below'].includes(position)) { + if (referenceLane) { + initialY = + position === 'above' + ? referenceLane.y - referenceLane.height / 2 - laneHeight / 2 + : referenceLane.y + referenceLane.height / 2 + laneHeight / 2 + initialX = referenceLane.x + } + } + if (!this.isHorizontal && ['left', 'right'].includes(position)) { + if (referenceLane) { + initialX = + position === 'left' + ? referenceLane.x - referenceLane.width / 2 - laneWidth / 2 + : referenceLane.x + referenceLane.width / 2 + laneWidth / 2 + initialY = referenceLane.y + } + } + + // 确保不将referenceLaneId作为parent或者其他可能引起递归引用的属性传入 + // laneData可能包含一些运行时属性,需要清理 + const cleanLaneData = cloneDeep(laneData) + if (cleanLaneData) { + delete cleanLaneData.id + delete cleanLaneData.children + delete cleanLaneData.properties?.parent + delete cleanLaneData.properties?.children + } + + const nodeConfig = merge( + cleanLaneData, + { + type: 'lane', + x: initialX, + y: initialY, + width: laneWidth, + height: laneHeight, + text: '新泳道', + properties: { + parent: this.id, // 确保父节点始终指向泳池 + position: position, // 记录添加位置,供resizeChildren使用 + referenceLaneId: referenceLane?.id, // 记录参考泳道ID + }, + zIndex: this.zIndex, + }, + this.properties.laneConfig, + ) + const newLane = this.graphModel.addNode(nodeConfig) + this.setZIndex(this.zIndex - 1) + this.addChild(newLane.id) + + // 调用优化后的resizeChildren,它会处理所有位置计算和泳池尺寸调整 + this.resizeChildrenWithNewLane(position, newLane.id) + return newLane + } + + setZIndex(zIndex: number) { + // this.zIndex = zIndex + this.zIndex = Math.min(zIndex, -100) + } + + /** + * 在上方添加泳道 + */ + addChildAbove(laneData?: any) { + return this.addLane('above', laneData) + } + + /** + * 在下方添加泳道 + */ + addChildBelow(laneData?: any) { + return this.addLane('below', laneData) + } + + /** + * 在左侧添加泳道 + */ + addChildLeft(laneData?: any) { + return this.addLane('left', laneData) + } + + /** + * 在右侧添加泳道(纵向布局专用) + */ + addChildRight(laneData?: any) { + return this.addLane('right', laneData) + } + + /** + * 创建默认泳道 + */ + createDefaultLane(laneConfig?: any) { + let newLane: any = null + // 只在没有子节点时创建默认泳道 + if (this.isHorizontal) { + // 横向泳池:泳道垂直排列 + const laneWidth = this.width - poolConfig.titleSize + const laneHeight = this.height + newLane = this.graphModel.addNode( + merge( + { + type: 'lane', + x: this.x - this.width / 2 + poolConfig.titleSize + laneWidth / 2, + y: this.y, + width: laneWidth, + height: laneHeight, + text: { + x: this.x - this.width / 2 + poolConfig.titleSize / 2, + y: this.y, + value: '泳道1', + }, + properties: { + parent: this.id, + isHorizontal: this.isHorizontal, + }, + zIndex: this.zIndex, + }, + laneConfig, + ), + ) + } else { + // 纵向泳池:泳道水平排列 + // 修复:初始泳道在泳池中心位置,与resizeChildren逻辑保持一致 + const laneWidth = this.width + const laneHeight = this.height - poolConfig.titleSize + newLane = this.graphModel.addNode( + merge( + { + type: 'lane', + x: this.x, + y: + this.y - + this.height / 2 + + poolConfig.titleSize + + (this.height - poolConfig.titleSize) / 2, + width: laneWidth, + height: laneHeight, + text: { + x: this.x, + y: this.y - this.height / 2 + poolConfig.titleSize / 2, + value: '泳道1', + }, + properties: { + parent: this.id, + }, + zIndex: this.zIndex, + }, + laneConfig, + ), + ) + } + this.setZIndex(this.zIndex - 1) + this.addChild(newLane.id) + this.resizeChildren() + this.updateTextPosition() + return newLane + } + + /** + * 删除泳道 + */ + deleteChild(childId: string) { + const lanes = this.getLanes() + if (lanes.length <= 1) return + + const laneToDelete = lanes.find((lane) => lane.id === childId) + if (!laneToDelete) return + + // 移除子节点 + this.removeChild(childId) + this.graphModel.deleteNode(childId) + + // 重新调整泳池 + this.resizePool() + this.resizeChildren() + } + + getNodeStyle() { + const style = super.getNodeStyle() + style.strokeWidth = 2 + return style + } + + /** + * 获取文本样式 + */ + getTextStyle() { + const style = super.getTextStyle() + style.overflowMode = 'ellipsis' + style.strokeWidth = 2 + style.textWidth = this.isHorizontal ? this.height : this.width + style.textHeight = this.isHorizontal ? this.width : this.height + if (this.isHorizontal) { + style.transform = 'rotate(-90deg)' + style.textAlign = 'center' + } + return style + } + + getData(): LogicFlow.NodeData { + const data = super.getData() + // const poolModel = this.getPoolModel() + return { + ...data, + properties: { + ...data.properties, + width: this.width, + height: this.height, + }, + } + } +} + +export default { + PoolModel, +} diff --git a/packages/extension/src/pool/PoolView.ts b/packages/extension/src/pool/PoolView.ts new file mode 100644 index 000000000..f90f365ba --- /dev/null +++ b/packages/extension/src/pool/PoolView.ts @@ -0,0 +1,75 @@ +import { h } from '@logicflow/core' +import { DynamicGroupNode } from '../dynamic-group/node' +import { poolConfig } from './constant' + +export class PoolView extends DynamicGroupNode { + componentDidMount(): void { + const { graphModel, model } = this.props + const index = graphModel.nodes.findIndex((node) => node.id === model.id) + const poolCount = graphModel.nodes.filter( + (node) => String(node.type) === 'pool', + ).length + // 设置一个足够低的z-index,确保泳池在所有节点的最底层 + model.setZIndex(-((poolCount - index) * 100)) + if ( + !model.properties?.children?.length && + !model._defaultLaneCreated && + !model.virtual + ) { + model.createDefaultLane(model.properties?.laneConfig) + model._defaultLaneCreated = true + } + } + + /** + * 渲染泳池形状 - 根据布局方向分为标题区域和内容区域 + */ + getShape() { + const { model } = this.props + const { + x, + y, + width, + height, + properties: { textStyle: customTextStyle = {}, style: customStyle = {} }, + isHorizontal, + } = model + const style = model.getNodeStyle() + const base = { fill: '#ffffff', stroke: '#000000', strokeWidth: 1 } + const left = x - width / 2 + const top = y - height / 2 + + const titleRect = { + ...base, + ...style, + x: left, + y: top, + width: isHorizontal ? poolConfig.titleSize : width, + height: isHorizontal ? height : poolConfig.titleSize, + ...(isHorizontal && customTextStyle), + } + const contentRect = { + ...base, + ...style, + x: isHorizontal ? left + poolConfig.titleSize : left, + y: isHorizontal ? top : top + poolConfig.titleSize, + width: isHorizontal ? width - poolConfig.titleSize : width, + height: isHorizontal ? height : height - poolConfig.titleSize, + ...(isHorizontal && customStyle), + } + return h('g', {}, [h('rect', titleRect), h('rect', contentRect)]) + } + + /** + * 获取调整控制点 - 只在展开状态下显示 + */ + getResizeControl() { + const { resizable, isCollapsed } = this.props.model + const showResizeControl = resizable && !isCollapsed + return showResizeControl ? super.getResizeControl() : null + } +} + +export default { + PoolView, +} diff --git a/packages/extension/src/pool/constant.ts b/packages/extension/src/pool/constant.ts new file mode 100644 index 000000000..35f5673c7 --- /dev/null +++ b/packages/extension/src/pool/constant.ts @@ -0,0 +1,19 @@ +// 泳池配置常量 +export const poolConfig = { + // 默认尺寸 + defaultWidth: 120, + defaultHeight: 120, + // 标题区域 + titleSize: 60, + poolMinSize: 20, +} + +export const laneConfig = { + defaultWidth: 120, + defaultHeight: 120, + titleSize: 20, + iconSize: 20, + iconSpacing: 15, +} + +export default null diff --git a/packages/extension/src/pool/index.ts b/packages/extension/src/pool/index.ts new file mode 100644 index 000000000..44de46a63 --- /dev/null +++ b/packages/extension/src/pool/index.ts @@ -0,0 +1,621 @@ +import LogicFlow, { + CallbackArgs, + Model, + BaseNodeModel, + BaseEdgeModel, + EventType, + transformNodeData, + transformEdgeData, +} from '@logicflow/core' +import { assign, filter, forEach, cloneDeep, has, map } from 'lodash-es' +import { PoolModel } from './PoolModel' +import { PoolView } from './PoolView' +import { LaneModel } from './LaneModel' +import { LaneView } from './LaneView' +import { isAllowMoveTo, isBoundsInLane } from './utils' + +import GraphConfigData = LogicFlow.GraphConfigData +import GraphElements = LogicFlow.GraphElements +import EdgeConfig = LogicFlow.EdgeConfig +import EdgeData = LogicFlow.EdgeData +import NodeData = LogicFlow.NodeData +import BoxBoundsPoint = Model.BoxBoundsPoint +type ElementsInfoInGroup = { + childNodes: BaseNodeModel[] // 分组节点的所有子节点 model + edgesData: EdgeData[] // 属于分组内的线的 EdgeData (即开始节点和结束节点都在 Group 内) +} + +export const PoolNode = { + type: 'pool', + view: PoolView, + model: PoolModel, +} + +export const LaneNode = { + type: 'lane', + view: LaneView, + model: LaneModel, +} + +export class PoolElements { + static pluginName = 'PoolElements' + private lf: LogicFlow + // 激活态的 group 节点 + activeGroup?: LaneModel + // 存储节点与 group 的映射关系 + nodeLaneMap: Map = new Map() + + constructor({ lf, options }: LogicFlow.IExtensionProps) { + lf.register(PoolNode) + lf.register(LaneNode) + this.lf = lf + assign(this, options) + // 初始化插件,从监听事件开始及设置规则开始 + this.init() + } + + /** + * 获取节点所属的泳道 + * @param nodeId + */ + getLaneByNodeId(nodeId: string) { + const laneId = this.nodeLaneMap.get(nodeId) + if (laneId) { + return this.lf.getNodeModelById(laneId) + } + } + + /** + * 获取指定范围内的泳道 + * 当泳道重合时,优先返回最上层的泳道 + * @param bounds + * @param nodeData + */ + getLaneByBounds(bounds: BoxBoundsPoint, nodeData: NodeData): any | undefined { + const { nodes } = this.lf.graphModel + const lanes = filter(nodes, (node) => { + return ( + !!node.isGroup && + isBoundsInLane(bounds, node) && + node.id !== nodeData.id + ) + }) + + const count = lanes.length + if (count <= 1) { + return lanes[0] as LaneModel + } else { + let topZIndexLane = lanes[count - 1] + for (let i = count - 2; i >= 0; i--) { + if (lanes[i].zIndex > topZIndexLane.zIndex) { + topZIndexLane = lanes[i] + } + } + return topZIndexLane as LaneModel + } + } + + /** + * 提高元素的层级,如果是 group,同时提高其子元素的层级 + * @param model + */ + onSelectionDrop = () => { + const { nodes: selectedNodes } = this.lf.graphModel.getSelectElements() + selectedNodes.forEach((node) => { + this.addNodeToGroup(node) + }) + } + onNodeAddOrDrop = ({ data: node }: CallbackArgs<'node:add'>) => { + this.addNodeToGroup(node) + } + + addNodeToGroup = (node: LogicFlow.NodeData) => { + // 1. 如果该节点之前已经有泳道了,则将其从之前的泳道移除 + const preLaneId = this.nodeLaneMap.get(node.id) + if (preLaneId) { + const lane = this.lf.getNodeModelById(preLaneId) as LaneModel + + lane.removeChild(node.id) + this.nodeLaneMap.delete(node.id) + lane.setAllowAppendChild(false) + } + + // 2. 然后再判断这个节点是否在某个泳道范围内,如果是,则将其添加到对应的泳道中 + const nodeModel = this.lf.getNodeModelById(node.id) + const bounds = nodeModel?.getBounds() + + if (nodeModel && bounds) { + // TODO: 确认下面的注释内容 + // https://github.com/didi/LogicFlow/issues/1261 + // 当使用 SelectionSelect 框选后触发 lf.addNode(Group) + // 会触发 appendNodeToGroup() 的执行 + // 由于 this.getGroup() 会判断 node.id !== nodeData.id + // 因此当 addNode 是 Group 类型时,this.getGroup() 会一直返回空 + // 导致了下面这段代码无法执行,也就是无法将当前添加的 Group 添加到 this.nodeLaneMap 中 + // 这导致了折叠分组时触发的 foldEdge() 无法正确通过 getNodeGroup() 拿到正确的 groupId + // 从而导致折叠分组时一直都会创建一个虚拟边 + // 而初始化分组时由于正确设置了nodeLaneMap的数据,因此不会产生虚拟边的错误情况 + if (nodeModel.isGroup) { + const lane = nodeModel as LaneModel + forEach(Array.from(lane.children), (childId) => { + this.nodeLaneMap.set(childId, node.id) + }) + } + + const lane = this.getLaneByBounds(bounds, node) + if (lane) { + const isAllowAppendIn = lane.isAllowAppendIn(node) + if (isAllowAppendIn) { + lane.addChild(node.id) + // 建立节点与 lane 的映射关系放在了 lane.addChild 触发的事件中,与直接调用 addChild 的行为保持一致 + lane.setAllowAppendChild(false) + const nodeModel = this.lf.getNodeModelById(node.id) + nodeModel?.setProperties({ + ...nodeModel.properties, + parent: lane.id, + // relativeDistanceX: nodeModel.x - lane.x, + // relativeDistanceY: nodeModel.y - lane.y, + }) + } else { + // 抛出不允许插入的事件 + this.lf.emit('lane:not-allowed', { + lane: lane.getData(), + node, + }) + } + } + } + } + + onGroupAddNode = ({ + data: groupData, + childId, + }: CallbackArgs<'group:add-node'>) => { + this.nodeLaneMap.set(childId, groupData.id) + } + + removeNodeFromGroup = ({ + data: node, + model, + }: CallbackArgs<'node:delete'>) => { + if (model.isGroup && node.children) { + forEach(Array.from((node as LaneModel).children), (childId) => { + this.nodeLaneMap.delete(childId) + this.lf.deleteNode(childId) + }) + } + + const laneId = this.nodeLaneMap.get(node.id) + if (laneId) { + const lane = this.lf.getNodeModelById(laneId) + lane && (lane as LaneModel).removeChild(node.id) + this.nodeLaneMap.delete(node.id) + const nodeModel = this.lf.getNodeModelById(node.id) + // 移除时删除properties中的parent和relativeDistanceX、relativeDistanceY + const newProperties = { + ...nodeModel?.properties, + parent: undefined, + relativeDistanceX: undefined, + relativeDistanceY: undefined, + } + nodeModel?.setProperties(newProperties) + } + } + + onSelectionDrag = () => { + const { nodes: selectedNodes } = this.lf.graphModel.getSelectElements() + selectedNodes.forEach((node) => { + this.setActiveGroup(node) + }) + } + + onNodeDrag = ({ data: node }: CallbackArgs<'node:drag'>) => { + this.setActiveGroup(node) + } + + setActiveGroup = (node: LogicFlow.NodeData) => { + const nodeModel = this.lf.getNodeModelById(node.id) + const bounds = nodeModel?.getBounds() + + if (nodeModel && bounds) { + const targetGroup = this.getLaneByBounds(bounds, node) + if (this.activeGroup) { + this.activeGroup.setAllowAppendChild(false) + } + + if (!targetGroup || (nodeModel.isGroup && targetGroup.id === node.id)) { + return + } + + const isAllowAppendIn = targetGroup.isAllowAppendIn(node) + if (!isAllowAppendIn) return + + this.activeGroup = targetGroup + this.activeGroup?.setAllowAppendChild(true) + } + } + /** + * @param node + * @param isMultiple + * @param isSelected + */ + onNodeSelect = ({ + data: node, + isMultiple, + isSelected, + }: Omit, 'e' | 'position'>) => { + const nodeModel = this.lf.getNodeModelById(node.id) + // FIX #1004 + // 如果节点被多选, + // 这个节点是分组,则将分组的所有子节点取消选中 + // 这个节点是分组的子节点,且其所属分组节点已选,则取消选中 + if (isMultiple && isSelected) { + if (nodeModel?.isGroup) { + const { children } = nodeModel as LaneModel + forEach(Array.from(children), (childId) => { + const childModel = this.lf.getNodeModelById(childId) + childModel?.setSelected(false) + }) + } else { + const laneId = this.nodeLaneMap.get(node.id) + if (laneId) { + const laneModel = this.lf.getNodeModelById(laneId) + laneModel?.isSelected && nodeModel?.setSelected(false) + } + } + } + } + + onNodeMove = ({ + deltaX, + deltaY, + data, + }: Omit, 'e' | 'position'>) => { + const { id, x, y, properties } = data + if (!properties) { + return + } + const { width, height } = properties + const groupId = this.nodeLaneMap.get(id) + if (!groupId) { + return + } + const groupModel = this.lf.getNodeModelById(groupId) as LaneModel + + if (!groupModel || !groupModel.isRestrict || !groupModel.autoResize) { + return + } + // 当父节点isRestrict=true & autoResize=true + // 子节点在父节点中移动时,父节点会自动调整大小 + + // step1: 计算出当前child的bounds + const newX = x + deltaX / 2 + const newY = y + deltaY / 2 + const minX = newX - width! / 2 + const minY = newY - height! / 2 + const maxX = newX + width! / 2 + const maxY = newY + height! / 2 + // step2:比较当前child.bounds与parent.bounds的差异,比如child.minX newGroupBounds.maxX) { + newGroupBounds.maxX = maxX + hasChange = true + } + if (maxY > newGroupBounds.maxY) { + newGroupBounds.maxY = maxY + hasChange = true + } + if (!hasChange) { + return + } + // step3: 根据当前parent.bounds去计算出最新的x、y、width、height + const newGroupX = + newGroupBounds.minX + (newGroupBounds.maxX - newGroupBounds.minX) / 2 + const newGroupY = + newGroupBounds.minY + (newGroupBounds.maxY - newGroupBounds.minY) / 2 + const newGroupWidth = newGroupBounds.maxX - newGroupBounds.minX + const newGroupHeight = newGroupBounds.maxY - newGroupBounds.minY + groupModel.moveTo(newGroupX, newGroupY) + groupModel.width = newGroupWidth + groupModel.height = newGroupHeight + } + + onGraphRendered = ({ data }: CallbackArgs<'graph:rendered'>) => { + forEach(data.nodes, (node) => { + if (node.children) { + forEach(node.children, (childId) => { + this.nodeLaneMap.set(childId, node.id) + }) + } + }) + } + + removeChildrenInGroupNodeData< + T extends LogicFlow.NodeData | LogicFlow.NodeConfig, + >(nodeData: T) { + const newNodeData = cloneDeep(nodeData) + delete newNodeData.children + if (newNodeData.properties?.children) { + delete newNodeData.properties.children + } + return newNodeData + } + + /** + * 创建一个 Group 类型节点内部所有子节点的副本 + * 并且在遍历所有 nodes 的过程中,顺便拿到所有 edges (只在 Group 范围的 edges) + */ + initGroupChildNodes( + nodeIdMap: Record, + children: Set, + curGroup: LaneModel, + distance: number, + ): ElementsInfoInGroup { + // Group 中所有子节点 + const allChildNodes: BaseNodeModel[] = [] + // 属于 Group 内部边的 EdgeData + const edgesDataArr: EdgeData[] = [] + // 所有有关联的连线 + const allRelatedEdges: BaseEdgeModel[] = [] + + forEach(Array.from(children), (childId: string) => { + const childNode = this.lf.getNodeModelById(childId) + if (childNode) { + const childNodeChildren = childNode.children + const childNodeData = childNode.getData() + const eventType = EventType.NODE_GROUP_COPY || 'node:group-copy-add' + + const newNodeConfig = transformNodeData( + this.removeChildrenInGroupNodeData(childNodeData), + distance, + ) + const tempChildNode = this.lf.addNode(newNodeConfig, eventType) + curGroup.addChild(tempChildNode.id) + + nodeIdMap[childId] = tempChildNode.id // id 同 childId,做映射存储 + allChildNodes.push(tempChildNode) + + // 1. 存储 children 内部节点相关的输入边(incoming) + allRelatedEdges.push( + ...[...tempChildNode.incoming.edges, ...tempChildNode.outgoing.edges], + ) + + if (childNodeChildren instanceof Set) { + const { childNodes, edgesData } = this.initGroupChildNodes( + nodeIdMap, + childNodeChildren, + tempChildNode as LaneModel, + distance, + ) + + allChildNodes.push(...childNodes) + edgesDataArr.push(...edgesData) + } + } + }) + + // 1. 判断每一条边的开始节点、目标节点是否在 Group 中 + const edgesInnerGroup = filter(allRelatedEdges, (edge) => { + return ( + has(nodeIdMap, edge.sourceNodeId) && has(nodeIdMap, edge.targetNodeId) + ) + }) + // 2. 为「每一条 Group 的内部边」构建出 EdgeData 数据,得到 EdgeConfig,生成新的线 + const edgesDataInnerGroup = map(edgesInnerGroup, (edge) => { + return edge.getData() + }) + + return { + childNodes: allChildNodes, + edgesData: edgesDataArr.concat(edgesDataInnerGroup), + } + } + + /** + * 根据参数 edge 选择是新建边还是基于已有边,复制一条边出来 + * @param edge + * @param nodeIdMap + * @param distance + */ + createEdge( + edge: EdgeConfig | EdgeData, + nodeIdMap: Record, + distance: number, + ) { + const { sourceNodeId, targetNodeId } = edge + const sourceId = nodeIdMap[sourceNodeId] ?? sourceNodeId + const targetId = nodeIdMap[targetNodeId] ?? targetNodeId + + // 如果是有 id 且 text 是对象的边,需要重新计算位置,否则直接用 edgeConfig 生成边 + let newEdgeConfig = cloneDeep(edge) + if (edge.id && typeof edge.text === 'object' && edge.text !== null) { + newEdgeConfig = transformEdgeData(edge as EdgeData, distance) + } + + return this.lf.graphModel.addEdge({ + ...newEdgeConfig, + sourceNodeId: sourceId, + targetNodeId: targetId, + }) + } + + /** + * 检测group:resize后的bounds是否会小于children的bounds + * 限制group进行resize时不能小于内部的占地面积 + * @param groupModel + * @param deltaX + * @param deltaY + * @param newWidth + * @param newHeight + */ + checkGroupBoundsWithChildren( + groupModel: LaneModel, + deltaX: number, + deltaY: number, + newWidth: number, + newHeight: number, + ) { + if (groupModel.children) { + const { children, x, y } = groupModel + // 根据deltaX和deltaY计算出当前model的bounds + const newX = x + deltaX / 2 + const newY = y + deltaY / 2 + const groupMinX = newX - newWidth / 2 + const groupMinY = newY - newHeight / 2 + const groupMaxX = newX + newWidth / 2 + const groupMaxY = newY + newHeight / 2 + + const childrenArray = Array.from(children) + for (let i = 0; i < childrenArray.length; i++) { + const childId = childrenArray[i] + const child = this.lf.getNodeModelById(childId) + if (!child) { + continue + } + const childBounds = child.getBounds() + const { minX, minY, maxX, maxY } = childBounds + // parent:resize后的bounds不能小于child:bounds,否则阻止其resize + const canResize = + groupMinX <= minX && + groupMinY <= minY && + groupMaxX >= maxX && + groupMaxY >= maxY + if (!canResize) { + return false + } + } + } + + return true + } + + init() { + const { lf } = this + const { graphModel } = lf + // 添加分组节点移动规则 + // 1. 移动分组节点时,同时移动分组内所有节点 + // 2. 移动子节点时,判断是否有限制规则(isRestrict) + graphModel.addNodeMoveRules((model, deltaX, deltaY) => { + // 判断如果是 group,移动时需要同时移动组内的所有节点 + if (model.isGroup) { + return true + } + + const groupId = this.nodeLaneMap.get(model.id)! + const groupModel = this.lf.getNodeModelById(groupId) as LaneModel + + if (groupModel && groupModel.isRestrict) { + if (groupModel.autoResize) { + // 子节点在父节点中移动时,父节点会自动调整大小 + // 在node:mousemove中进行父节点的调整 + return true + } else { + // 如果移动的节点存在于某个分组中,且这个分组禁止子节点移出去 + const groupBounds = groupModel.getBounds() + return isAllowMoveTo(groupBounds, model, deltaX, deltaY) + } + } + + return true + }) + graphModel.addNodeResizeRules((model, deltaX, deltaY, width, height) => { + if (model.isGroup && model.isRestrict) { + return this.checkGroupBoundsWithChildren( + model as LaneModel, + deltaX, + deltaY, + width, + height, + ) + } + return true + }) + + graphModel.dynamicGroup = this + lf.on('node:add,node:drop,node:dnd-add', this.onNodeAddOrDrop) + lf.on('selection:drop', this.onSelectionDrop) + lf.on('node:delete', this.removeNodeFromGroup) + lf.on('node:drag,node:dnd-drag', this.onNodeDrag) + lf.on('selection:drag', this.onSelectionDrag) + lf.on('node:click', this.onNodeSelect) + lf.on('node:mousemove', this.onNodeMove) + lf.on('graph:rendered', this.onGraphRendered) + + lf.on('group:add-node', this.onGroupAddNode) + + lf.addElements = ( + { nodes: selectedNodes, edges: selectedEdges }: GraphConfigData, + distance = 40, + ): GraphElements => { + // oldNodeId -> newNodeId 映射 Map + const nodeIdMap: Record = {} + // 本次添加的所有节点和边 + const elements: GraphElements = { + nodes: [], + edges: [], + } + // 所有属于分组内的边 -> sourceNodeId 和 targetNodeId 都在 Group 内 + const edgesInnerGroup: EdgeData[] = [] + + forEach(selectedNodes, (node) => { + const originId = node.id + const children = node.properties?.children ?? node.children + + const model = lf.addNode(this.removeChildrenInGroupNodeData(node)) + + if (originId) nodeIdMap[originId] = model.id + elements.nodes.push(model) // 此时为 group 的 nodeModel + + if (model.isGroup) { + const { edgesData } = this.initGroupChildNodes( + nodeIdMap, + children, + model as LaneModel, + distance, + ) + edgesInnerGroup.push(...edgesData) + } + }) + + forEach(edgesInnerGroup, (edge) => { + this.createEdge(edge, nodeIdMap, distance) + }) + forEach(selectedEdges, (edge) => { + elements.edges.push(this.createEdge(edge, nodeIdMap, distance)) + }) + + // 返回 elements 进行选中效果,即触发 element.selectElementById() + // shortcut.ts 也会对最外层的 nodes 和 edges 进行偏移,即 translationNodeData() + return elements + } + + this.render() + } + + render() {} + + destroy() { + // 销毁监听的事件,并移除渲染的 dom 内容 + this.lf.off('node:add,node:drop,node:dnd-add', this.onNodeAddOrDrop) + this.lf.off('selection:drop', this.onSelectionDrop) + this.lf.off('node:delete', this.removeNodeFromGroup) + this.lf.off('node:drag,node:dnd-drag', this.onNodeDrag) + this.lf.off('selection:drag', this.onSelectionDrag) + this.lf.off('node:click', this.onNodeSelect) + this.lf.off('node:mousemove', this.onNodeMove) + this.lf.off('graph:rendered', this.onGraphRendered) + this.lf.off('group:add-node', this.onGroupAddNode) + } +} + +export default PoolElements diff --git a/packages/extension/src/pool/utils.ts b/packages/extension/src/pool/utils.ts new file mode 100644 index 000000000..600439864 --- /dev/null +++ b/packages/extension/src/pool/utils.ts @@ -0,0 +1,46 @@ +import { BaseNodeModel, Model } from '@logicflow/core' +import BoxBoundsPoint = Model.BoxBoundsPoint + +/** + * + * @param bounds + * @param group + */ +export function isBoundsInLane(bounds: BoxBoundsPoint, group: BaseNodeModel) { + const { minX, minY, maxX, maxY } = bounds + const { x, y, width, height } = group + return ( + minX >= x - width / 2 && + maxX <= x + width / 2 && + minY >= y - height / 2 && + maxY <= y + height / 2 + ) +} + +/** + * 判断 bounds 是否可以移动到下一个范围 + * @param groupBounds + * @param node + * @param deltaX + * @param deltaY + */ +export function isAllowMoveTo( + groupBounds: BoxBoundsPoint, + node: BaseNodeModel, + deltaX: number, + deltaY: number, +) { + const { minX, minY, maxX, maxY } = groupBounds + const { x, y, width, height } = node + + // DONE: 计算节点坐标 (x, y) 可移动的范围,并判断 x + deltaX, y + deltaY 是否在范围内 + const allowMoveMinX = minX + width / 2 + const allowMoveMinY = minY + height / 2 + const allowMoveMaxX = maxX - width / 2 + const allowMoveMaxY = maxY - height / 2 + + return { + x: x + deltaX >= allowMoveMinX && x + deltaX <= allowMoveMaxX, + y: y + deltaY >= allowMoveMinY && y + deltaY <= allowMoveMaxY, + } +} diff --git a/sites/docs/docs/tutorial/extension/pool.en.md b/sites/docs/docs/tutorial/extension/pool.en.md new file mode 100644 index 000000000..cf0e76775 --- /dev/null +++ b/sites/docs/docs/tutorial/extension/pool.en.md @@ -0,0 +1,227 @@ +--- +nav: Guide +group: + title: Plug-in functionality + order: 3 +title: Swimlane (Pool) +order: 14 +toc: content +--- + +LogicFlow provides a swimlane solution built on the DynamicGroup mechanism. A pool (`pool`) contains multiple lanes (`lane`). Lanes hold business nodes, and the plugin can automatically assign nodes to lanes during drag/drop. It also provides built-in interactions to insert and delete lanes. + +## Demonstration + + + +## Using the Plugin + +```tsx | pure +import LogicFlow from '@logicflow/core' +import { PoolElements } from '@logicflow/extension' +import '@logicflow/core/es/index.css' +import '@logicflow/extension/es/index.css' + +const lf = new LogicFlow({ + container: document.querySelector('#container') as HTMLElement, + plugins: [PoolElements], + allowResize: true, +}) +``` + +## Quick Start + +Create a node with `type: 'pool'`. If no lanes exist when the pool is first rendered, a default lane is created automatically. + +```ts | pure +lf.render({ + nodes: [ + { + id: 'pool_1', + type: 'pool', + x: 400, + y: 260, + text: 'Pool', + properties: { + direction: 'horizontal', + width: 520, + height: 360, + }, + }, + ], + edges: [], +}) +``` + +## Data Format + +Pool/Lane nodes are special nodes. You can use `children` to describe hierarchy. During interactions, the plugin also maintains `properties.parent` for child nodes automatically. + +### Pool (`pool`) + +```ts +type PoolProperties = { + direction?: 'horizontal' | 'vertical' + width?: number + height?: number + laneConfig?: Record + children?: string[] +} +``` + +### Lane (`lane`) + +```ts +type LaneProperties = { + parent?: string + width?: number + height?: number + isRestrict?: boolean + autoResize?: boolean + children?: string[] +} +``` + +### Full Example + +```ts | pure +lf.render({ + nodes: [ + { + id: 'pool_1', + type: 'pool', + x: 500, + y: 260, + text: 'Pool (H)', + properties: { + direction: 'horizontal', + width: 520, + height: 360, + children: ['lane_1', 'lane_2'], + }, + children: ['lane_1', 'lane_2'], + }, + { + id: 'lane_1', + type: 'lane', + x: 540, + y: 340, + text: 'Lane 1', + properties: { + parent: 'pool_1', + width: 440, + height: 180, + isRestrict: true, + autoResize: false, + children: ['rect_1'], + }, + children: ['rect_1'], + }, + { + id: 'lane_2', + type: 'lane', + x: 540, + y: 160, + text: 'Lane 2', + properties: { + parent: 'pool_1', + width: 440, + height: 180, + children: ['circle_1'], + }, + children: ['circle_1'], + }, + { + id: 'rect_1', + type: 'rect', + x: 470, + y: 350, + text: 'Node A', + properties: { + parent: 'lane_1', + }, + }, + { + id: 'circle_1', + type: 'circle', + x: 620, + y: 150, + text: 'Node B', + properties: { + parent: 'lane_2', + }, + }, + ], + edges: [], +}) +``` + +## Interactions + +### Automatic Lane Assignment + +When you drag/drop a node into a lane area, the plugin assigns the node to that lane. If the node already belongs to another lane, it is removed from the previous lane first. + +### Insert/Delete Lanes + +When a lane is selected, operation icons appear on the right: + +- Insert: insert a new lane before/after the current lane (Up/Down for horizontal pools, Left/Right for vertical pools) +- Delete: delete the current lane (at least one lane is kept) + +## API + +### Plugin API + +After enabling the plugin, access the plugin instance via `lf.graphModel.dynamicGroup`: + +#### getLaneByNodeId(nodeId) + +Get the lane model that a node belongs to. + +```ts | pure +const laneModel = lf.graphModel.dynamicGroup.getLaneByNodeId('node_1') +``` + +#### getLaneByBounds(bounds, nodeData) + +Get the lane model for a given bounds. If multiple lanes overlap, the topmost lane is returned. + +```ts | pure +const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 } +const nodeData = { id: 'temp', type: 'rect' } +const laneModel = lf.graphModel.dynamicGroup.getLaneByBounds(bounds, nodeData) +``` + +### Pool Model Methods + +#### getLanes + +Get all lane models in the pool. + +#### addChildAbove / addChildBelow / addChildLeft / addChildRight + +Insert a new lane before/after a reference lane (depending on pool direction). + +#### deleteChild(childId) + +Delete a lane. + +### Lane Model Methods + +#### getPoolId / getPoolModel + +Get the owning pool of a lane. + +## Events + +### lane:not-allowed + +Triggered when a node is not allowed to be appended into a lane: + +```ts | pure +lf.on('lane:not-allowed', ({ lane, node }) => { + console.log('not allowed', lane.id, node.id) +}) +``` + diff --git a/sites/docs/docs/tutorial/extension/pool.zh.md b/sites/docs/docs/tutorial/extension/pool.zh.md new file mode 100644 index 000000000..01a43d58d --- /dev/null +++ b/sites/docs/docs/tutorial/extension/pool.zh.md @@ -0,0 +1,227 @@ +--- +nav: 指南 +group: + title: 插件功能 + order: 3 +title: 泳池泳道 (Pool) +order: 14 +toc: content +--- + +LogicFlow 支持泳池泳道能力。泳池(pool)用于承载多个泳道(lane),泳道用于承载业务节点,并支持在拖拽/放置时自动把节点加入对应泳道,同时提供插入/删除泳道的交互入口。 + +## 演示 + + + +## 使用插件 + +```tsx | pure +import LogicFlow from '@logicflow/core' +import { PoolElements } from '@logicflow/extension' +import '@logicflow/core/es/index.css' +import '@logicflow/extension/es/index.css' + +const lf = new LogicFlow({ + container: document.querySelector('#container') as HTMLElement, + plugins: [PoolElements], + allowResize: true, +}) +``` + +## 快速开始 + +只需要新增一个 `type: 'pool'` 的节点即可。泳池在首次渲染且没有泳道时,会自动创建一条默认泳道。 + +```ts | pure +lf.render({ + nodes: [ + { + id: 'pool_1', + type: 'pool', + x: 400, + y: 260, + text: '泳池', + properties: { + direction: 'horizontal', + width: 520, + height: 360, + }, + }, + ], + edges: [], +}) +``` + +## 数据格式 + +泳池/泳道基于 DynamicGroup 机制实现,因此它们仍然是“特殊节点”。在数据层面,你可以使用 `children` 来描述层级关系;同时插件也会在交互过程中自动维护子节点的 `properties.parent`。 + +### 泳池(pool) + +```ts +type PoolProperties = { + direction?: 'horizontal' | 'vertical' + width?: number + height?: number + laneConfig?: Record + children?: string[] +} +``` + +### 泳道(lane) + +```ts +type LaneProperties = { + parent?: string + width?: number + height?: number + isRestrict?: boolean + autoResize?: boolean + children?: string[] +} +``` + +### 完整示例 + +```ts | pure +lf.render({ + nodes: [ + { + id: 'pool_1', + type: 'pool', + x: 500, + y: 260, + text: '横向泳池', + properties: { + direction: 'horizontal', + width: 520, + height: 360, + children: ['lane_1', 'lane_2'], + }, + children: ['lane_1', 'lane_2'], + }, + { + id: 'lane_1', + type: 'lane', + x: 540, + y: 340, + text: '泳道1', + properties: { + parent: 'pool_1', + width: 440, + height: 180, + isRestrict: true, + autoResize: false, + children: ['rect_1'], + }, + children: ['rect_1'], + }, + { + id: 'lane_2', + type: 'lane', + x: 540, + y: 160, + text: '泳道2', + properties: { + parent: 'pool_1', + width: 440, + height: 180, + children: ['circle_1'], + }, + children: ['circle_1'], + }, + { + id: 'rect_1', + type: 'rect', + x: 470, + y: 350, + text: '节点A', + properties: { + parent: 'lane_1', + }, + }, + { + id: 'circle_1', + type: 'circle', + x: 620, + y: 150, + text: '节点B', + properties: { + parent: 'lane_2', + }, + }, + ], + edges: [], +}) +``` + +## 交互说明 + +### 节点自动归属泳道 + +当你把节点拖拽/放置到某条泳道区域内时,插件会自动把节点加入该泳道。若节点原本属于其他泳道,会先从旧泳道移除,再加入新泳道。 + +### 插入/删除泳道 + +选中泳道后,泳道右侧会显示操作按钮: + +- 插入:在当前泳道的前/后插入一条新泳道(横向泳池为“上/下”,竖向泳池为“左/右”) +- 删除:删除当前泳道(至少保留 1 条泳道) + +## API + +### 插件类 API + +启用插件后,可通过 `lf.graphModel.dynamicGroup` 访问插件实例方法: + +#### getLaneByNodeId(nodeId) + +根据节点 id 获取其所属泳道的模型。 + +```ts | pure +const laneModel = lf.graphModel.dynamicGroup.getLaneByNodeId('node_1') +``` + +#### getLaneByBounds(bounds, nodeData) + +根据边界框获取该区域所属的泳道。当泳道重合时,优先返回最上层的泳道。 + +```ts | pure +const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 } +const nodeData = { id: 'temp', type: 'rect' } +const laneModel = lf.graphModel.dynamicGroup.getLaneByBounds(bounds, nodeData) +``` + +### 泳池(pool)模型方法 + +#### getLanes + +获取泳池内所有泳道模型。 + +#### addChildAbove / addChildBelow / addChildLeft / addChildRight + +在指定泳道的前/后插入新泳道(方向由泳池布局决定)。 + +#### deleteChild(childId) + +删除泳道。 + +### 泳道(lane)模型方法 + +#### getPoolId / getPoolModel + +从泳道反查其所属泳池。 + +## 事件 + +### lane:not-allowed + +当节点不被允许加入目标泳道时触发: + +```ts | pure +lf.on('lane:not-allowed', ({ lane, node }) => { + console.log('not allowed', lane.id, node.id) +}) +``` + diff --git a/sites/docs/src/tutorial/extension/pool/index.module.less b/sites/docs/src/tutorial/extension/pool/index.module.less new file mode 100644 index 000000000..8d672eaa0 --- /dev/null +++ b/sites/docs/src/tutorial/extension/pool/index.module.less @@ -0,0 +1,5 @@ +.viewport { + position: relative; + height: 70vh; + overflow: hidden; +} diff --git a/sites/docs/src/tutorial/extension/pool/index.tsx b/sites/docs/src/tutorial/extension/pool/index.tsx new file mode 100644 index 000000000..1519ea132 --- /dev/null +++ b/sites/docs/src/tutorial/extension/pool/index.tsx @@ -0,0 +1,135 @@ +import LogicFlow from '@logicflow/core'; +import { + Control, + DndPanel, + PoolElements, + ShapeItem, +} from '@logicflow/extension'; + +import { Button, Card, Divider, Flex, message } from 'antd'; +import { useEffect, useRef } from 'react'; +import GraphConfigData = LogicFlow.GraphConfigData; + +import '@logicflow/core/es/index.css'; +import '@logicflow/extension/es/index.css'; +import styles from './index.module.less'; + +const config: Partial = { + multipleSelectKey: 'alt', + stopMoveGraph: true, + grid: { + size: 10, + }, + allowResize: true, + allowRotate: true, + plugins: [PoolElements, Control, DndPanel], +}; + +const getDndPanelConfig = (): ShapeItem[] => [ + { + type: 'pool', + label: '横向泳池', + text: 'Pool (H)', + properties: { + width: 520, + height: 360, + direction: 'horizontal', + laneConfig: { + text: '泳道', + }, + }, + }, + { + type: 'pool', + label: '竖向泳池', + text: 'Pool (V)', + properties: { + width: 360, + height: 520, + direction: 'vertical', + laneConfig: { + text: '泳道', + }, + }, + }, + { type: 'rect', label: '矩形', text: 'Rect' }, + { type: 'circle', label: '圆形', text: 'Circle' }, + { type: 'diamond', label: '菱形', text: 'Diamond' }, +]; + +export default function PoolDemo() { + const lfRef = useRef(); + const containerRef = useRef(null); + + useEffect(() => { + if (!lfRef.current) { + const lf = new LogicFlow({ + ...config, + container: containerRef.current as HTMLElement, + }); + + lf.setPatternItems(getDndPanelConfig()); + + const graphData: GraphConfigData = { + nodes: [ + { + id: 'pool_1', + type: 'pool', + x: 520, + y: 280, + text: '横向泳池', + properties: { + width: 520, + height: 360, + direction: 'horizontal', + laneConfig: { + text: '泳道', + }, + }, + }, + { + id: 'rect_1', + type: 'rect', + x: 860, + y: 240, + text: '拖拽到泳道里', + }, + { + id: 'circle_1', + type: 'circle', + x: 860, + y: 340, + text: '拖拽到泳道里', + }, + ], + edges: [], + }; + + lf.render(graphData); + lf.translateCenter(); + + lf.on('lane:not-allowed', () => { + message.warning('该节点不允许加入此泳道'); + }); + + lfRef.current = lf; + } + }, []); + + const getGraphData = () => { + const graphData = lfRef.current?.getGraphRawData(); + console.log('graph data:', graphData); + }; + + return ( + + + + + +
+
+ ); +}