feat(history): free layout supports move node into container operation

This commit is contained in:
liuyangxing 2025-03-17 16:58:39 +08:00
parent 6c13bffbf3
commit 304cf69387
13 changed files with 227 additions and 13 deletions

View File

@ -17,6 +17,7 @@ import {
FlowNodeJSON, FlowNodeJSON,
MoveNodeConfig, MoveNodeConfig,
OnNodeAddEvent, OnNodeAddEvent,
OnNodeMoveEvent,
} from '../typings'; } from '../typings';
import { FlowDocument } from '../flow-document'; import { FlowDocument } from '../flow-document';
import { FlowNodeEntity } from '../entities'; import { FlowNodeEntity } from '../entities';
@ -38,9 +39,13 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
protected toDispose = new DisposableCollection(); protected toDispose = new DisposableCollection();
private onNodeMoveEmitter = new Emitter<OnNodeMoveEvent>();
readonly onNodeMove = this.onNodeMoveEmitter.event;
@postConstruct() @postConstruct()
protected init() { protected init() {
this.toDispose.push(this.onNodeAddEmitter); this.toDispose.pushAll([this.onNodeAddEmitter, this.onNodeMoveEmitter]);
} }
addNode(nodeJSON: FlowNodeJSON, config: AddNodeConfig = {}): FlowNodeEntity { addNode(nodeJSON: FlowNodeJSON, config: AddNodeConfig = {}): FlowNodeEntity {
@ -127,7 +132,7 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
return; return;
} }
let toIndex = typeof index === 'undefined' ? parent.children.length : index; let toIndex = typeof index === 'undefined' ? newParentEntity.collapsedChildren.length : index;
return this.doMoveNode(entity, newParentEntity, toIndex); return this.doMoveNode(entity, newParentEntity, toIndex);
} }
@ -301,10 +306,23 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
} }
protected doMoveNode(node: FlowNodeEntity, newParent: FlowNodeEntity, index: number) { protected doMoveNode(node: FlowNodeEntity, newParent: FlowNodeEntity, index: number) {
return this.document.moveChildNodes({ if (!node.parent) {
throw new Error('root node cannot move');
}
const event: OnNodeMoveEvent = {
node,
fromParent: node.parent,
toParent: newParent,
fromIndex: this.getNodeIndex(node),
toIndex: index,
};
this.document.moveChildNodes({
nodeIds: [this.toId(node)], nodeIds: [this.toId(node)],
toParentId: this.toId(newParent), toParentId: this.toId(newParent),
toIndex: index, toIndex: index,
}); });
this.onNodeMoveEmitter.fire(event);
} }
} }

View File

@ -233,6 +233,17 @@ export interface OnNodeAddEvent {
data: AddNodeData; data: AddNodeData;
} }
/**
*
*/
export interface OnNodeMoveEvent {
node: FlowNodeEntity;
fromParent: FlowNodeEntity;
fromIndex: number;
toParent: FlowNodeEntity;
toIndex: number;
}
export interface FlowOperationBaseService extends Disposable { export interface FlowOperationBaseService extends Disposable {
/** /**
* *
@ -300,6 +311,11 @@ export interface FlowOperationBaseService extends Disposable {
* *
*/ */
onNodeAdd: Event<OnNodeAddEvent>; onNodeAdd: Event<OnNodeAddEvent>;
/**
*
*/
onNodeMove: Event<OnNodeMoveEvent>;
} }
export const FlowOperationBaseService = Symbol('FlowOperationBaseService'); export const FlowOperationBaseService = Symbol('FlowOperationBaseService');

View File

@ -2,3 +2,4 @@ export * from './workflow-select-service';
export * from './workflow-hover-service'; export * from './workflow-hover-service';
export * from './workflow-drag-service'; export * from './workflow-drag-service';
export * from './workflow-reset-layout-service'; export * from './workflow-reset-layout-service';
export * from './workflow-operation-base-service';

View File

@ -0,0 +1,45 @@
import { inject } from 'inversify';
import { IPoint, Emitter } from '@flowgram.ai/utils';
import { FlowNodeEntityOrId, FlowOperationBaseServiceImpl } from '@flowgram.ai/document';
import { TransformData } from '@flowgram.ai/core';
import { WorkflowDocument } from '../workflow-document';
import {
NodePostionUpdateEvent,
WorkflowOperationBaseService,
} from '../typings/workflow-operation';
export class WorkflowOperationBaseServiceImpl
extends FlowOperationBaseServiceImpl
implements WorkflowOperationBaseService
{
@inject(WorkflowDocument)
protected declare document: WorkflowDocument;
private onNodePostionUpdateEmitter = new Emitter<NodePostionUpdateEvent>();
public readonly onNodePostionUpdate = this.onNodePostionUpdateEmitter.event;
updateNodePosition(nodeOrId: FlowNodeEntityOrId, position: IPoint): void {
const node = this.toNodeEntity(nodeOrId);
if (!node) {
return;
}
const transformData = node.getData(TransformData);
const oldPosition = {
x: transformData.position.x,
y: transformData.position.y,
};
transformData.update({
position,
});
this.onNodePostionUpdateEmitter.fire({
node,
oldPosition,
newPosition: position,
});
}
}

View File

@ -4,6 +4,7 @@ export * from './workflow-node';
export * from './workflow-registry'; export * from './workflow-registry';
export * from './workflow-line'; export * from './workflow-line';
export * from './workflow-sub-canvas'; export * from './workflow-sub-canvas';
export * from './workflow-operation';
export const URLParams = Symbol(''); export const URLParams = Symbol('');

View File

@ -0,0 +1,28 @@
import { IPoint, Event } from '@flowgram.ai/utils';
import {
FlowNodeEntity,
FlowNodeEntityOrId,
FlowOperationBaseService,
} from '@flowgram.ai/document';
export interface NodePostionUpdateEvent {
node: FlowNodeEntity;
oldPosition: IPoint;
newPosition: IPoint;
}
export interface WorkflowOperationBaseService extends FlowOperationBaseService {
/**
*
*/
readonly onNodePostionUpdate: Event<NodePostionUpdateEvent>;
/**
*
* @param nodeOrId
* @param position
* @returns
*/
updateNodePosition(nodeOrId: FlowNodeEntityOrId, position: IPoint): void;
}
export const WorkflowOperationBaseService = Symbol('WorkflowOperationBaseService');

View File

@ -1,6 +1,6 @@
import { ContainerModule } from 'inversify'; import { ContainerModule } from 'inversify';
import { FlowDocument, FlowDocumentContribution } from '@flowgram.ai/document';
import { bindContributions } from '@flowgram.ai/utils'; import { bindContributions } from '@flowgram.ai/utils';
import { FlowDocument, FlowDocumentContribution } from '@flowgram.ai/document';
import { WorkflowLinesManager } from './workflow-lines-manager'; import { WorkflowLinesManager } from './workflow-lines-manager';
import { import {
@ -10,12 +10,13 @@ import {
import { WorkflowDocumentContribution } from './workflow-document-contribution'; import { WorkflowDocumentContribution } from './workflow-document-contribution';
import { WorkflowDocument, WorkflowDocumentProvider } from './workflow-document'; import { WorkflowDocument, WorkflowDocumentProvider } from './workflow-document';
import { getUrlParams } from './utils/get-url-params'; import { getUrlParams } from './utils/get-url-params';
import { URLParams } from './typings'; import { URLParams, WorkflowOperationBaseService } from './typings';
import { import {
WorkflowDragService, WorkflowDragService,
WorkflowHoverService, WorkflowHoverService,
WorkflowSelectService, WorkflowSelectService,
WorkflowResetLayoutService, WorkflowResetLayoutService,
WorkflowOperationBaseServiceImpl,
} from './service'; } from './service';
import { FreeLayout } from './layout'; import { FreeLayout } from './layout';
@ -28,6 +29,7 @@ export const WorkflowDocumentContainerModule = new ContainerModule(
bind(WorkflowSelectService).toSelf().inSingletonScope(); bind(WorkflowSelectService).toSelf().inSingletonScope();
bind(WorkflowHoverService).toSelf().inSingletonScope(); bind(WorkflowHoverService).toSelf().inSingletonScope();
bind(WorkflowResetLayoutService).toSelf().inSingletonScope(); bind(WorkflowResetLayoutService).toSelf().inSingletonScope();
bind(WorkflowOperationBaseService).to(WorkflowOperationBaseServiceImpl).inSingletonScope();
bind(URLParams) bind(URLParams)
.toDynamicValue(() => getUrlParams()) .toDynamicValue(() => getUrlParams())
.inSingletonScope(); .inSingletonScope();
@ -37,7 +39,7 @@ export const WorkflowDocumentContainerModule = new ContainerModule(
}); });
rebind(FlowDocument).toService(WorkflowDocument); rebind(FlowDocument).toService(WorkflowDocument);
bind(WorkflowDocumentProvider) bind(WorkflowDocumentProvider)
.toDynamicValue(ctx => () => ctx.container.get(WorkflowDocument)) .toDynamicValue((ctx) => () => ctx.container.get(WorkflowDocument))
.inSingletonScope(); .inSingletonScope();
}, }
); );

View File

@ -105,6 +105,32 @@ describe('history-operation-service moveNode', () => {
expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']);
}); });
it('move node with parent and without index', async () => {
const root = flowDocument.getNode('root');
const block0 = flowDocument.getNode('block_0');
flowOperationService.addNode(
{ id: 'test0', type: 'test' },
{
parent: block0,
}
);
flowDocument.addFromNode('start_0', {
type: 'test',
id: 'test1',
});
flowOperationService.moveNode('test1', { parent: 'block_0' });
expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']);
expect(getNodeChildrenIds(block0)).toEqual(['$blockOrderIcon$block_0', 'test0', 'test1']);
await historyService.undo();
expect(getNodeChildrenIds(root)).toEqual(['start_0', 'test1', 'dynamicSplit_0', 'end_0']);
expect(getNodeChildrenIds(block0)).toEqual(['$blockOrderIcon$block_0', 'test0']);
});
it('move node with parent and index', async () => { it('move node with parent and index', async () => {
const root = flowDocument.getNode('root'); const root = flowDocument.getNode('root');
flowDocument.addFromNode('dynamicSplit_0', { flowDocument.addFromNode('dynamicSplit_0', {

View File

@ -7,12 +7,14 @@ import {
WorkflowDocument, WorkflowDocument,
WorkflowResetLayoutService, WorkflowResetLayoutService,
WorkflowDragService, WorkflowDragService,
WorkflowOperationBaseService,
} from '@flowgram.ai/free-layout-core'; } from '@flowgram.ai/free-layout-core';
import { FlowNodeFormData } from '@flowgram.ai/form-core'; import { FlowNodeFormData } from '@flowgram.ai/form-core';
import { FormManager } from '@flowgram.ai/form-core'; import { FormManager } from '@flowgram.ai/form-core';
import { OperationType } from '@flowgram.ai/document';
import { type PluginContext, PositionData } from '@flowgram.ai/core'; import { type PluginContext, PositionData } from '@flowgram.ai/core';
import { type FreeHistoryPluginOptions, FreeOperationType } from './types'; import { DragNodeOperationValue, type FreeHistoryPluginOptions, FreeOperationType } from './types';
import { HistoryEntityManager } from './history-entity-manager'; import { HistoryEntityManager } from './history-entity-manager';
import { DragNodesHandler } from './handlers/drag-nodes-handler'; import { DragNodesHandler } from './handlers/drag-nodes-handler';
import { ChangeNodeDataHandler } from './handlers/change-node-data-handler'; import { ChangeNodeDataHandler } from './handlers/change-node-data-handler';
@ -40,6 +42,9 @@ export class FreeHistoryManager {
private _toDispose: DisposableCollection = new DisposableCollection(); private _toDispose: DisposableCollection = new DisposableCollection();
@inject(WorkflowOperationBaseService)
private _operationService: WorkflowOperationBaseService;
onInit(ctx: PluginContext, opts: FreeHistoryPluginOptions) { onInit(ctx: PluginContext, opts: FreeHistoryPluginOptions) {
const document = ctx.get<WorkflowDocument>(WorkflowDocument); const document = ctx.get<WorkflowDocument>(WorkflowDocument);
const historyService = ctx.get<HistoryService>(HistoryService); const historyService = ctx.get<HistoryService>(HistoryService);
@ -101,6 +106,39 @@ export class FreeHistoryManager {
{ noApply: true } { noApply: true }
); );
}), }),
this._operationService.onNodeMove(({ node, fromParent, fromIndex, toParent, toIndex }) => {
historyService.pushOperation(
{
type: OperationType.moveChildNodes,
value: {
fromParentId: fromParent.id,
fromIndex,
nodeIds: [node.id],
toParentId: toParent.id,
toIndex,
},
},
{
noApply: true,
}
);
}),
this._operationService.onNodePostionUpdate((event) => {
const value: DragNodeOperationValue = {
ids: [event.node.id],
value: [event.newPosition],
oldValue: [event.oldPosition],
};
historyService.pushOperation(
{
type: FreeOperationType.dragNodes,
value,
},
{
noApply: true,
}
);
}),
]); ]);
} }

View File

@ -1,7 +1,7 @@
import { type OperationMeta } from '@flowgram.ai/history';
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document'; import { FlowNodeTransformData } from '@flowgram.ai/document';
import { type PluginContext, TransformData } from '@flowgram.ai/core'; import { type PluginContext, TransformData } from '@flowgram.ai/core';
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { type OperationMeta } from '@flowgram.ai/history';
import { type DragNodeOperationValue, FreeOperationType } from '../types'; import { type DragNodeOperationValue, FreeOperationType } from '../types';
import { baseOperationMeta } from './base'; import { baseOperationMeta } from './base';
@ -9,7 +9,7 @@ import { baseOperationMeta } from './base';
export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, PluginContext, void> = { export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, PluginContext, void> = {
...baseOperationMeta, ...baseOperationMeta,
type: FreeOperationType.dragNodes, type: FreeOperationType.dragNodes,
inverse: op => ({ inverse: (op) => ({
...op, ...op,
value: { value: {
...op.value, ...op.value,
@ -35,7 +35,7 @@ export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, Plugi
}); });
// 嵌套情况下需将子节点 transform 设为 dirty // 嵌套情况下需将子节点 transform 设为 dirty
if (node.collapsedChildren?.length > 0) { if (node.collapsedChildren?.length > 0) {
node.collapsedChildren.forEach(childNode => { node.collapsedChildren.forEach((childNode) => {
const childNodeTransformData = const childNodeTransformData =
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData); childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
childNodeTransformData.fireChange(); childNodeTransformData.fireChange();
@ -43,5 +43,4 @@ export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, Plugi
} }
}); });
}, },
shouldMerge: () => false,
}; };

View File

@ -1,4 +1,5 @@
import { resetLayoutOperationMeta } from './reset-layout'; import { resetLayoutOperationMeta } from './reset-layout';
import { moveChildNodesOperationMeta } from './move-child-nodes';
import { dragNodesOperationMeta } from './drag-nodes'; import { dragNodesOperationMeta } from './drag-nodes';
import { deleteNodeOperationMeta } from './delete-node'; import { deleteNodeOperationMeta } from './delete-node';
import { deleteLineOperationMeta } from './delete-line'; import { deleteLineOperationMeta } from './delete-line';
@ -14,4 +15,5 @@ export const operationMetas = [
changeNodeDataOperationMeta, changeNodeDataOperationMeta,
resetLayoutOperationMeta, resetLayoutOperationMeta,
dragNodesOperationMeta, dragNodesOperationMeta,
moveChildNodesOperationMeta,
]; ];

View File

@ -0,0 +1,37 @@
import { OperationMeta } from '@flowgram.ai/history';
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { MoveChildNodesOperationValue, OperationType } from '@flowgram.ai/document';
import { FlowNodeBaseType } from '@flowgram.ai/document';
import { PluginContext, TransformData } from '@flowgram.ai/core';
import { baseOperationMeta } from './base';
export const moveChildNodesOperationMeta: OperationMeta<
MoveChildNodesOperationValue,
PluginContext,
void
> = {
...baseOperationMeta,
type: OperationType.moveChildNodes,
inverse: (op) => ({
...op,
value: {
...op.value,
fromIndex: op.value.toIndex,
toIndex: op.value.fromIndex,
fromParentId: op.value.toParentId,
toParentId: op.value.fromParentId,
},
}),
apply: (operation, ctx: PluginContext) => {
const document = ctx.get<WorkflowDocument>(WorkflowDocument);
document.moveChildNodes(operation.value);
const fromContainer = document.getNode(operation.value.fromParentId);
requestAnimationFrame(() => {
if (fromContainer && fromContainer.flowNodeType !== FlowNodeBaseType.ROOT) {
const fromContainerTransformData = fromContainer.getData(TransformData);
fromContainerTransformData.fireChange();
}
});
},
};

View File

@ -21,6 +21,7 @@ export enum FreeOperationType {
changeNodeData = 'changeNodeData', changeNodeData = 'changeNodeData',
resetLayout = 'resetLayout', resetLayout = 'resetLayout',
dragNodes = 'dragNodes', dragNodes = 'dragNodes',
moveChildNodes = 'moveChildNodes',
} }
export interface AddOrDeleteLineOperationValue extends WorkflowLinePortInfo { export interface AddOrDeleteLineOperationValue extends WorkflowLinePortInfo {