From 6950290c247bfa81b6ea0458bc79246e6c7bb39f Mon Sep 17 00:00:00 2001 From: Scott Aslan Date: Wed, 8 May 2024 15:07:42 -0400 Subject: [PATCH] [NIFI-13162] horizontal and vertical canvas component alignment (#8762) * [NIFI-13162] horizontal and vertical canvas component alignment * review feedback * rename to updatePositionRequestId * use enum * use the appropriate generic type and separate components and connections updates * use enum This closes #8762 --- .../behavior/draggable-behavior.service.ts | 8 +- .../service/canvas-context-menu.service.ts | 200 +++++++++++++++++- .../service/canvas-utils.service.ts | 38 ++++ .../flow-designer/state/flow/flow.effects.ts | 3 + 4 files changed, 234 insertions(+), 15 deletions(-) diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/behavior/draggable-behavior.service.ts b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/behavior/draggable-behavior.service.ts index 5404e91769..5b228c9049 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/behavior/draggable-behavior.service.ts +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/behavior/draggable-behavior.service.ts @@ -40,7 +40,7 @@ export class DraggableBehavior { private scale: number = INITIAL_SCALE; - private updateConnectionRequestId = 0; + private updatePositionRequestId = 0; constructor( private store: Store, @@ -240,7 +240,7 @@ export class DraggableBehavior { this.store.dispatch( updatePositions({ request: { - requestId: this.updateConnectionRequestId++, + requestId: this.updatePositionRequestId++, componentUpdates: Array.from(componentUpdates.values()), connectionUpdates: Array.from(connectionUpdates.values()) } @@ -298,7 +298,7 @@ export class DraggableBehavior { ); } - private updateComponentPosition(d: any, delta: Position): UpdateComponentRequest { + updateComponentPosition(d: any, delta: Position): UpdateComponentRequest { const newPosition = { x: this.snapEnabled ? Math.round((d.position.x + delta.x) / this.snapAlignmentPixels) * this.snapAlignmentPixels @@ -334,7 +334,7 @@ export class DraggableBehavior { * @param delta The change in position * @returns {*} */ - private updateConnectionPosition(connection: any, delta: any): UpdateComponentRequest | null { + updateConnectionPosition(connection: any, delta: any): UpdateComponentRequest | null { // only update if necessary if (connection.bends.length === 0) { return null; diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts index 2872417bba..b1a68e31d1 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts @@ -47,14 +47,16 @@ import { requestRefreshRemoteProcessGroup, runOnce, stopVersionControlRequest, - terminateThreads + terminateThreads, + updatePositions } from '../state/flow/flow.actions'; import { ComponentType } from '../../../state/shared'; import { ConfirmStopVersionControlRequest, MoveComponentRequest, OpenChangeVersionDialogRequest, - OpenLocalChangesDialogRequest + OpenLocalChangesDialogRequest, + UpdateComponentRequest } from '../state/flow'; import { ContextMenuDefinition, @@ -68,9 +70,13 @@ import * as d3 from 'd3'; import { Client } from '../../../service/client.service'; import { CanvasView } from './canvas-view.service'; import { CanvasActionsService } from './canvas-actions.service'; +import * as FlowActions from '../state/flow/flow.actions'; +import { DraggableBehavior } from './behavior/draggable-behavior.service'; @Injectable({ providedIn: 'root' }) export class CanvasContextMenu implements ContextMenuDefinitionProvider { + private updatePositionRequestId = 0; + readonly VERSION_MENU = { id: 'version', menuItems: [ @@ -301,24 +307,195 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { menuItems: [ { condition: (selection: any) => { - // TODO - canAlign - return false; + return this.canvasUtils.canAlign(selection); }, clazz: 'fa fa-align-center fa-rotate-90', text: 'Horizontally', - action: () => { - // TODO - alignHorizontal + action: (selection: any) => { + const componentUpdates: Map = new Map(); + const connectionUpdates: Map = new Map(); + + // determine the extent + let minY: number = 0, + maxY: number = 0; + selection.each((d: any) => { + if (d.type !== ComponentType.Connection) { + if (minY === 0 || d.position.y < minY) { + minY = d.position.y; + } + const componentMaxY = d.position.y + d.dimensions.height; + if (maxY === 0 || componentMaxY > maxY) { + maxY = componentMaxY; + } + } + }); + + const center = (minY + maxY) / 2; + + // align all components with top most component + selection.each((d: any) => { + if (d.type !== ComponentType.Connection) { + const delta = { + x: 0, + y: center - (d.position.y + d.dimensions.height / 2) + }; + + // if this component is already centered, no need to updated it + if (delta.y !== 0) { + // consider any connections + const connections = this.canvasUtils.getComponentConnections(d.id); + + connections.forEach((connection: any) => { + const connectionSelection = d3.select('#id-' + connection.id); + + if ( + !connectionUpdates.has(connection.id) && + this.canvasUtils.getConnectionSourceComponentId(connection) === + this.canvasUtils.getConnectionDestinationComponentId(connection) + ) { + // this connection is self looping and hasn't been updated by the delta yet + const connectionUpdate = this.draggableBehavior.updateConnectionPosition( + connectionSelection.datum(), + delta + ); + if (connectionUpdate !== null) { + connectionUpdates.set(connection.id, connectionUpdate); + } + } else if ( + !connectionUpdates.has(connection.id) && + connectionSelection.classed('selected') && + this.canvasUtils.canModify(connectionSelection) + ) { + // this is a selected connection that hasn't been updated by the delta yet + if ( + this.canvasUtils.getConnectionSourceComponentId(connection) === d.id || + !this.canvasUtils.isSourceSelected(connection, selection) + ) { + // the connection is either outgoing or incoming when the source of the connection is not part of the selection + const connectionUpdate = this.draggableBehavior.updateConnectionPosition( + connectionSelection.datum(), + delta + ); + if (connectionUpdate !== null) { + connectionUpdates.set(connection.id, connectionUpdate); + } + } + } + }); + + componentUpdates.set(d.id, this.draggableBehavior.updateComponentPosition(d, delta)); + } + } + }); + + if (connectionUpdates.size > 0 || componentUpdates.size > 0) { + // dispatch the position updates + this.store.dispatch( + updatePositions({ + request: { + requestId: this.updatePositionRequestId++, + componentUpdates: Array.from(componentUpdates.values()), + connectionUpdates: Array.from(connectionUpdates.values()) + } + }) + ); + } } }, { condition: (selection: any) => { - // TODO - canAlign - return false; + return this.canvasUtils.canAlign(selection); }, clazz: 'fa fa-align-center', text: 'Vertically', - action: () => { - // TODO - alignVertical + action: (selection: any) => { + const componentUpdates: Map = new Map(); + const connectionUpdates: Map = new Map(); + + // determine the extent + let minX = 0; + let maxX = 0; + selection.each((d: any) => { + if (d.type !== ComponentType.Connection) { + if (minX === 0 || d.position.x < minX) { + minX = d.position.x; + } + const componentMaxX = d.position.x + d.dimensions.width; + if (maxX === 0 || componentMaxX > maxX) { + maxX = componentMaxX; + } + } + }); + + const center = (minX + maxX) / 2; + + // align all components with top most component + selection.each((d: any) => { + if (d.type !== ComponentType.Connection) { + const delta = { + x: center - (d.position.x + d.dimensions.width / 2), + y: 0 + }; + + // if this component is already centered, no need to updated it + if (delta.x !== 0) { + // consider any connections + const connections = this.canvasUtils.getComponentConnections(d.id); + connections.forEach((connection: any) => { + const connectionSelection = d3.select('#id-' + connection.id); + + if ( + !connectionUpdates.has(connection.id) && + this.canvasUtils.getConnectionSourceComponentId(connection) === + this.canvasUtils.getConnectionDestinationComponentId(connection) + ) { + // this connection is self looping and hasn't been updated by the delta yet + const connectionUpdate = this.draggableBehavior.updateConnectionPosition( + connectionSelection.datum(), + delta + ); + if (connectionUpdate !== null) { + connectionUpdates.set(connection.id, connectionUpdate); + } + } else if ( + !connectionUpdates.has(connection.id) && + connectionSelection.classed('selected') && + this.canvasUtils.canModify(connectionSelection) + ) { + // this is a selected connection that hasn't been updated by the delta yet + if ( + this.canvasUtils.getConnectionSourceComponentId(connection) === d.id || + !this.canvasUtils.isSourceSelected(connection, selection) + ) { + // the connection is either outgoing or incoming when the source of the connection is not part of the selection + const connectionUpdate = this.draggableBehavior.updateConnectionPosition( + connectionSelection.datum(), + delta + ); + if (connectionUpdate !== null) { + connectionUpdates.set(connection.id, connectionUpdate); + } + } + } + }); + + componentUpdates.set(d.id, this.draggableBehavior.updateComponentPosition(d, delta)); + } + } + }); + + if (connectionUpdates.size > 0 || componentUpdates.size > 0) { + // dispatch the position updates + this.store.dispatch( + updatePositions({ + request: { + requestId: this.updatePositionRequestId++, + componentUpdates: Array.from(componentUpdates.values()), + connectionUpdates: Array.from(connectionUpdates.values()) + } + }) + ); + } } } ] @@ -1145,7 +1322,8 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { private canvasUtils: CanvasUtils, private client: Client, private canvasView: CanvasView, - private canvasActionsService: CanvasActionsService + private canvasActionsService: CanvasActionsService, + private draggableBehavior: DraggableBehavior ) { this.allMenus = new Map(); this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU); diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts index a843478dae..44e33f0465 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts @@ -231,6 +231,33 @@ export class CanvasUtils { return this.isNotRootGroup() && selection.empty(); } + /** + * Determines if the specified selection is alignable (in a single action). + * + * @param {selection} selection The selection + * @returns {boolean} + */ + public canAlign(selection: any) { + let canAlign = true; + + // determine if the current selection is entirely connections + const selectedConnections = selection.filter((d: any) => { + return d.type == ComponentType.Connection; + }); + + // require multiple selections besides connections + if (selection.size() - selectedConnections.size() < 2) { + canAlign = false; + } + + // require write permissions + if (!this.canModify(selection)) { + canAlign = false; + } + + return canAlign; + } + /** * Determines whether the components in the specified selection are writable. * @@ -526,6 +553,17 @@ export class CanvasUtils { return selection.size() === 1 && selection.classed('funnel'); } + // determine if the source of this connection is part of the selection + public isSourceSelected(connection: any, selection: any): boolean { + return ( + selection + .filter((d: any) => { + return this.getConnectionSourceComponentId(connection) === d.id; + }) + .size() > 0 + ); + } + /** * Determines whether the current selection is a stateful processor. * diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts index a15a72712c..eb8adff199 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts @@ -1927,6 +1927,9 @@ export class FlowEffects { updatePositionComplete$ = createEffect(() => this.actions$.pipe( ofType(FlowActions.updatePositionComplete), + tap(() => { + this.birdseyeView.refresh(); + }), map((action) => action.response), switchMap((response) => of(FlowActions.renderConnectionsForComponent({ id: response.id, updatePath: true, updateLabel: false }))