[NIFI-13977] - Updated Copy/Paste in UI to align with new backend API (#9536)

* [NIFI-13977] - Updated Copy/Paste in UI to align with new backend API
* center pasted elements on screen. zoom out to fit them if needed
* offset pasted components if the originally copied content is still in view

* copy using ClipboardItem to support Safari. other review concerns addressed. added some minor positioning fixes as well.

* copy using ClipboardItem to support Safari. other review concerns addressed. added some minor positioning fixes as well.

* remove commented out code

* Offset paste to center if it would overlap a prior paste of that content

This closes #9536
This commit is contained in:
Rob Fellows 2024-12-03 16:57:04 -05:00 committed by GitHub
parent 39aa106943
commit df793ce14e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 875 additions and 155 deletions

View File

@ -56,6 +56,7 @@ import { LoginConfigurationEffects } from './state/login-configuration/login-con
import { BannerTextEffects } from './state/banner-text/banner-text.effects';
import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltipDefaultOptions } from '@angular/material/tooltip';
import { CLIPBOARD_OPTIONS, provideMarkdown } from 'ngx-markdown';
import { CopyEffects } from './state/copy/copy.effects';
const entry = localStorage.getItem('disable-animations');
let disableAnimations: string = entry !== null ? JSON.parse(entry).item : '';
@ -97,7 +98,8 @@ export const customTooltipDefaults: MatTooltipDefaultOptions = {
ComponentStateEffects,
DocumentationEffects,
ClusterSummaryEffects,
PropertyVerificationEffects
PropertyVerificationEffects,
CopyEffects
),
StoreDevtoolsModule.instrument({
maxAge: 25,

View File

@ -18,7 +18,7 @@
import { Injectable } from '@angular/core';
import { CanvasUtils } from './canvas-utils.service';
import {
copy,
copySuccess,
deleteComponents,
disableComponents,
disableCurrentProcessGroup,
@ -30,7 +30,6 @@ import {
navigateToEditCurrentProcessGroup,
navigateToManageComponentPolicies,
openChangeColorDialog,
paste,
reloadFlow,
selectComponents,
startComponents,
@ -40,12 +39,10 @@ import {
} from '../state/flow/flow.actions';
import {
ChangeColorRequest,
CopyComponentRequest,
DeleteComponentRequest,
DisableComponentRequest,
EnableComponentRequest,
MoveComponentRequest,
PasteRequest,
SelectedComponent,
StartComponentRequest,
StopComponentRequest
@ -57,6 +54,11 @@ import { MatDialog } from '@angular/material/dialog';
import { CanvasView } from './canvas-view.service';
import { ComponentType } from 'libs/shared/src';
import { Client } from '../../../service/client.service';
import { CopyRequestContext, CopyRequestEntity, CopyResponseEntity } from '../../../state/copy';
import { CopyPasteService } from './copy-paste.service';
import { firstValueFrom } from 'rxjs';
import { selectCurrentProcessGroupId } from '../state/flow/flow.selectors';
import { snackBarError } from '../../../state/error/error.actions';
export type CanvasConditionFunction = (selection: d3.Selection<any, any, any, any>) => boolean;
export type CanvasActionFunction = (selection: d3.Selection<any, any, any, any>, extraArgs?: any) => void;
@ -139,45 +141,90 @@ export class CanvasActionsService {
return this.canvasUtils.isCopyable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
const origin = this.canvasUtils.getOrigin(selection);
const dimensions = this.canvasView.getSelectionBoundingClientRect(selection);
const components: CopyComponentRequest[] = [];
const copyRequestEntity: CopyRequestEntity = {};
selection.each((d) => {
components.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
switch (d.type) {
case ComponentType.Processor:
if (!copyRequestEntity.processors) {
copyRequestEntity.processors = [];
}
copyRequestEntity.processors.push(d.id);
break;
case ComponentType.ProcessGroup:
if (!copyRequestEntity.processGroups) {
copyRequestEntity.processGroups = [];
}
copyRequestEntity.processGroups.push(d.id);
break;
case ComponentType.Connection:
if (!copyRequestEntity.connections) {
copyRequestEntity.connections = [];
}
copyRequestEntity.connections.push(d.id);
break;
case ComponentType.RemoteProcessGroup:
if (!copyRequestEntity.remoteProcessGroups) {
copyRequestEntity.remoteProcessGroups = [];
}
copyRequestEntity.remoteProcessGroups.push(d.id);
break;
case ComponentType.InputPort:
if (!copyRequestEntity.inputPorts) {
copyRequestEntity.inputPorts = [];
}
copyRequestEntity.inputPorts.push(d.id);
break;
case ComponentType.OutputPort:
if (!copyRequestEntity.outputPorts) {
copyRequestEntity.outputPorts = [];
}
copyRequestEntity.outputPorts.push(d.id);
break;
case ComponentType.Label:
if (!copyRequestEntity.labels) {
copyRequestEntity.labels = [];
}
copyRequestEntity.labels.push(d.id);
break;
case ComponentType.Funnel:
if (!copyRequestEntity.funnels) {
copyRequestEntity.funnels = [];
}
copyRequestEntity.funnels.push(d.id);
break;
}
});
this.store.dispatch(
copy({
request: {
components,
origin,
dimensions
}
const copyRequestContext: CopyRequestContext = {
copyRequestEntity,
processGroupId: this.currentProcessGroupId()
};
let copyResponse: CopyResponseEntity | null = null;
// Safari in particular is strict in enforcing that any writing to the clipboard needs to be triggered directly by a user action.
// As such, firing a simple async rxjs action to initiate the copy sequence fails this check.
// However, below is the workaround to construct a ClipboardItem from an async call.
const clipboardItem = new ClipboardItem({
'text/plain': firstValueFrom(this.copyService.copy(copyRequestContext)).then((response) => {
copyResponse = response;
return new Blob([JSON.stringify(response, null, 2)], { type: 'text/plain' });
})
);
}
},
paste: {
id: 'paste',
condition: () => {
return this.canvasUtils.isPastable();
},
action: (selection, extraArgs) => {
const pasteRequest: PasteRequest = {};
if (extraArgs?.pasteLocation) {
pasteRequest.pasteLocation = extraArgs.pasteLocation;
}
this.store.dispatch(
paste({
request: pasteRequest
})
);
});
navigator.clipboard.write([clipboardItem]).then(() => {
if (copyResponse) {
this.store.dispatch(
copySuccess({
response: {
copyResponse,
processGroupId: copyRequestContext.processGroupId,
pasteCount: 0
}
})
);
} else {
this.store.dispatch(snackBarError({ error: 'Copy failed' }));
}
});
}
},
selectAll: {
@ -479,12 +526,15 @@ export class CanvasActionsService {
}
};
currentProcessGroupId = this.store.selectSignal(selectCurrentProcessGroupId);
constructor(
private store: Store<CanvasState>,
private canvasUtils: CanvasUtils,
private canvasView: CanvasView,
private dialog: MatDialog,
private client: Client
private client: Client,
private copyService: CopyPasteService
) {}
private select(selection: d3.Selection<any, any, any, any>) {

View File

@ -24,7 +24,6 @@ import {
selectBreadcrumbs,
selectCanvasPermissions,
selectConnections,
selectCopiedSnippet,
selectCurrentParameterContext,
selectCurrentProcessGroupId,
selectParentProcessGroupId
@ -135,13 +134,6 @@ export class CanvasUtils {
this.breadcrumbs = breadcrumbs;
});
this.store
.select(selectCopiedSnippet)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((copiedSnippet) => {
this.copiedSnippet = copiedSnippet;
});
this.store
.select(selectScale)
.pipe(takeUntilDestroyed(this.destroyRef))
@ -1011,7 +1003,7 @@ export class CanvasUtils {
}
public isPastable(): boolean {
return this.canvasPermissions.canWrite && this.copiedSnippet != null;
return this.canvasPermissions.canWrite;
}
/**

View File

@ -177,6 +177,15 @@ export class CanvasView {
this.canvasInitialized = true;
}
public getCanvasBoundingClientRect(): DOMRect | null {
const canvasContainer: any = document.getElementById('canvas-container');
if (canvasContainer == null) {
return null;
}
return canvasContainer.getBoundingClientRect() as DOMRect;
}
// filters zoom events as programmatically modifying the translate or scale now triggers the handlers
private isBirdseyeEvent(): boolean {
return this.birdseyeTranslateInProgress;
@ -252,7 +261,7 @@ export class CanvasView {
}
/**
* Determines if a bounding box is fully in the current viewable canvas area.
* Determines if a bounding box is in the current viewable canvas area.
*
* @param {type} boundingBox Bounding box to check.
* @param {boolean} strict If true, the entire bounding box must be in the viewport.
@ -260,18 +269,11 @@ export class CanvasView {
* @returns {boolean}
*/
public isBoundingBoxInViewport(boundingBox: any, strict: boolean): boolean {
const selection: any = this.canvasUtils.getSelection();
if (selection.size() !== 1) {
return false;
}
const canvasContainer: any = document.getElementById('canvas-container');
if (!canvasContainer) {
return false;
}
const yOffset = canvasContainer.getBoundingClientRect().top;
// scale the translation
const translate = [this.x / this.k, this.y / this.k];
@ -287,8 +289,8 @@ export class CanvasView {
const left = Math.ceil(boundingBox.x);
const right = Math.floor(boundingBox.x + boundingBox.width);
const top = Math.ceil(boundingBox.y - yOffset / this.k);
const bottom = Math.floor(boundingBox.y - yOffset / this.k + boundingBox.height);
const top = Math.ceil(boundingBox.y);
const bottom = Math.floor(boundingBox.y + boundingBox.height);
if (strict) {
return !(left < screenLeft || right > screenRight || top < screenTop || bottom > screenBottom);
@ -497,6 +499,13 @@ export class CanvasView {
return bbox;
}
/**
* Translates a position to the space visible on the canvas
*
* @param position
*
* @returns {Position | null}
*/
public getCanvasPosition(position: Position): Position | null {
const canvasContainer: any = document.getElementById('canvas-container');
if (!canvasContainer) {
@ -528,7 +537,11 @@ export class CanvasView {
return null;
}
private centerBoundingBox(boundingBox: any): void {
/**
* Centers the canvas to a bounding box. If a scale is provided, it will zoom to that scale.
* @param {type} boundingBox
*/
public centerBoundingBox(boundingBox: any): void {
let scale: number = this.k;
if (boundingBox.scale != null) {
scale = boundingBox.scale;

View File

@ -0,0 +1,354 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Dimensions, PasteRequest, PasteRequestContext, PasteRequestEntity } from '../state/flow';
import { Observable } from 'rxjs';
import { ClusterConnectionService } from '../../../service/cluster-connection.service';
import { Position } from '../state/shared';
import { CanvasView } from './canvas-view.service';
import { CopyRequestContext, CopyResponseEntity, PasteRequestStrategy } from '../../../state/copy';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { selectCurrentProcessGroupId } from '../state/flow/flow.selectors';
import * as d3 from 'd3';
@Injectable({
providedIn: 'root'
})
export class CopyPasteService {
private static readonly API: string = '../nifi-api';
currentProcessGroupId = this.store.selectSignal(selectCurrentProcessGroupId);
constructor(
private httpClient: HttpClient,
private clusterConnectionService: ClusterConnectionService,
private canvasView: CanvasView,
private store: Store<NiFiState>
) {}
copy(copyRequest: CopyRequestContext): Observable<CopyResponseEntity> {
return this.httpClient.post(
`${CopyPasteService.API}/process-groups/${copyRequest.processGroupId}/copy`,
copyRequest.copyRequestEntity
) as Observable<CopyResponseEntity>;
}
paste(pasteRequest: PasteRequestContext): Observable<any> {
const payload: PasteRequestEntity = {
...pasteRequest.pasteRequest,
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged()
};
return this.httpClient.put(
`${CopyPasteService.API}/process-groups/${pasteRequest.processGroupId}/paste`,
payload
);
}
public isCopiedContentInView(copyResponse: CopyResponseEntity): boolean {
const bbox = this.calculateBoundingBoxForCopiedContent(copyResponse);
return this.canvasView.isBoundingBoxInViewport(bbox, false);
}
/**
* Use when pasting components to the same process group they were copied from and some
* part of those components are still visible on canvas
* @param copyResponse
* @param pasteIncrement how many times the content has been pasted already. used to determine the overall offset.
* @private
*/
public toOffsetPasteRequest(copyResponse: CopyResponseEntity, pasteIncrement: number = 0): PasteRequest {
const offset = 25;
const paste: PasteRequest = {
copyResponse: this.cloneCopyResponseEntity(copyResponse),
strategy: PasteRequestStrategy.OFFSET_FROM_ORIGINAL
};
Object.values(paste.copyResponse)
.filter((values) => !!values && Array.isArray(values))
.forEach((values: any[]) => {
values.forEach((value) => {
if (value.position) {
value.position.x += offset * (pasteIncrement + 1);
value.position.y += offset * (pasteIncrement + 1);
} else if (value.bends) {
value.bends.forEach((bend: Position) => {
bend.x += offset * (pasteIncrement + 1);
bend.y += offset * (pasteIncrement + 1);
});
}
});
});
return paste;
}
/**
* Use when it isn't known if the copied content is still visible on the screen (possibly a different pg or browser tab),
* or it is known to be off-screen.
* @param copyResponse
* @private
*/
public toCenteredPasteRequest(copyResponse: CopyResponseEntity): PasteRequest {
const paste: PasteRequest = {
copyResponse: this.cloneCopyResponseEntity(copyResponse),
strategy: PasteRequestStrategy.CENTER_ON_CANVAS
};
// get center of canvas
const canvasBBox = this.canvasView.getCanvasBoundingClientRect();
if (canvasBBox) {
// Get the normalized center of the canvas to later compare with the center of the items being pasted
const canvasCenterNormalized = this.canvasView.getCanvasPosition({
x: canvasBBox.width / 2 + canvasBBox.left,
y: canvasBBox.height / 2 + canvasBBox.top
});
if (canvasCenterNormalized) {
// get the bounding box of the items being pasted (including the bends of connections)
const copiedBBox = this.calculateBoundingBoxForCopiedContent(paste.copyResponse);
// get it's center
const centerOfCopiedContent: Position = {
x: copiedBBox.width / 2 + copiedBBox.x,
y: copiedBBox.height / 2 + copiedBBox.y
};
// find the difference between the centers
const centerOffset: Position = {
x: canvasCenterNormalized.x - centerOfCopiedContent.x,
y: canvasCenterNormalized.y - centerOfCopiedContent.y
};
// try to detect if the proposed paste content has already been pasted and might overlap
const offset = this.calculateOffsetForCenterPaste(paste.copyResponse, centerOffset);
// offset all items (and bends) by the diff of the centers
Object.values(paste.copyResponse)
.filter((values) => !!values && Array.isArray(values))
.forEach((componentArray: any[]) => {
componentArray.forEach((component) => {
if (component.position) {
component.position.x += centerOffset.x + offset;
component.position.y += centerOffset.y + offset;
} else if (component.bends) {
component.bends.forEach((bend: Position) => {
bend.x += centerOffset.x + offset;
bend.y += centerOffset.y + offset;
});
}
});
});
// set the new bounding box on the request with a scale that would fit the contents
paste.bbox = {
height: copiedBBox.height,
width: copiedBBox.width,
x: copiedBBox.x + centerOffset.x,
y: copiedBBox.y + centerOffset.y
};
const willItFit = this.canvasView.isBoundingBoxInViewport(paste.bbox, true);
if (!willItFit) {
paste.fitToScreen = true;
const scale = Math.min(canvasBBox.width / copiedBBox.width, canvasBBox.height / copiedBBox.height);
paste.bbox.scale = scale * 0.95; // leave a bit of padding around the newly centered selection
}
}
}
return paste;
}
private calculateOffsetForCenterPaste(proposedPaste: CopyResponseEntity, centerOffset: Position): number {
// get the positions of things already on the screen
const existingPositions = this.getAllComponentPositions();
const offsetIncrement = 25;
const buffer = 4;
let offset = 0;
// get a sample component to probe the canvas with to detect a duplicate paste
const positioned = Object.values(proposedPaste)
.filter((values) => !!values && Array.isArray(values))
.flat()
.filter((component) => {
return !!component.position;
})
.map((component) => component.position);
if (positioned.length > 0) {
const sample: Position = {
x: positioned[0].x + centerOffset.x,
y: positioned[0].y + centerOffset.y
};
let foundCollision = existingPositions.some(
(position) =>
position.x >= sample.x - buffer &&
position.x <= sample.x + buffer &&
position.y >= sample.y - buffer &&
position.y <= sample.y + buffer
);
while (foundCollision) {
offset += offsetIncrement;
foundCollision = existingPositions.some(
(position) =>
position.x >= sample.x + offset - buffer &&
position.x <= sample.x + offset + buffer &&
position.y >= sample.y + offset - buffer &&
position.y <= sample.y + offset + buffer
);
}
}
return offset;
}
private getAllComponentPositions() {
const positions: Position[] = [];
const selectionBoundingBox = this.canvasView.getCanvasBoundingClientRect();
if (selectionBoundingBox) {
d3.selectAll('g.component').each((d: any) => {
positions.push(d.position);
});
}
return positions;
}
private cloneCopyResponseEntity(copyResponse: CopyResponseEntity): CopyResponseEntity {
const arrayOrUndefined = (arr: any[] | undefined) => {
if (arr && Array.isArray(arr) && arr.length > 0) {
if (arr[0].position) {
return arr.map((component: any) => {
if (component.position) {
return {
...component,
position: {
...component.position
}
};
}
});
} else {
// this is an array of connections, handle them differently to account for bends
return arr.map((connection: any) => {
if (connection.bends && connection.bends.length > 0) {
const clonedBends = connection.bends.map((bend: Position) => {
return {
...bend
};
});
return {
...connection,
bends: clonedBends
};
}
return {
...connection
};
});
}
}
return undefined;
};
return {
id: copyResponse.id,
connections: arrayOrUndefined(copyResponse.connections),
funnels: arrayOrUndefined(copyResponse.funnels),
inputPorts: arrayOrUndefined(copyResponse.inputPorts),
labels: arrayOrUndefined(copyResponse.labels),
outputPorts: arrayOrUndefined(copyResponse.outputPorts),
processGroups: arrayOrUndefined(copyResponse.processGroups),
processors: arrayOrUndefined(copyResponse.processors),
remoteProcessGroups: arrayOrUndefined(copyResponse.remoteProcessGroups),
externalControllerServiceReferences: copyResponse.externalControllerServiceReferences,
parameterContexts: copyResponse.parameterContexts,
parameterProviders: copyResponse.parameterProviders
} as CopyResponseEntity;
}
private calculateBoundingBoxForCopiedContent(copyResponse: CopyResponseEntity): any {
const bbox = {
left: Number.MAX_SAFE_INTEGER,
top: Number.MAX_SAFE_INTEGER,
right: Number.MIN_SAFE_INTEGER,
bottom: Number.MIN_SAFE_INTEGER
};
Object.values(copyResponse)
.flat()
.filter((value: any[]) => !!value)
.reduce((acc, current) => {
if (current.componentType) {
const dimensions: Dimensions = this.getComponentWidth(current);
if (current.componentType === 'CONNECTION') {
current.bends.forEach((bend: Position) => {
acc.left = Math.min(acc.left, bend.x);
acc.top = Math.min(acc.top, bend.y);
acc.right = Math.max(acc.right, bend.x);
acc.bottom = Math.max(acc.bottom, bend.y);
});
} else {
acc.left = Math.min(acc.left, current.position.x);
acc.top = Math.min(acc.top, current.position.y);
acc.right = Math.max(acc.right, current.position.x + dimensions.width);
acc.bottom = Math.max(acc.bottom, current.position.y + dimensions.height);
}
}
return acc;
}, bbox);
return {
x: bbox.left,
y: bbox.top,
width: bbox.right - bbox.left,
height: bbox.bottom - bbox.top
};
}
private getComponentWidth(component: any): Dimensions {
if (!component) {
return { height: 0, width: 0 };
}
switch (component.componentType) {
case 'PROCESSOR':
return {
width: 352,
height: 128
};
case 'PROCESS_GROUP':
case 'REMOTE_PROCESS_GROUP':
return {
width: 384,
height: 176
};
case 'INPUT_PORT':
case 'OUTPUT_PORT':
case 'REMOTE_INPUT_PORT':
case 'REMOTE_OUTPUT_PORT':
return {
width: 240,
height: 48
};
case 'FUNNEL':
return { height: 48, width: 48 };
case 'LABEL':
return { height: component.height, width: component.width };
default:
return { height: 0, width: 0 };
}
}
}

View File

@ -22,7 +22,6 @@ import { Snippet, SnippetComponentRequest } from '../state/flow';
import { ClusterConnectionService } from '../../../service/cluster-connection.service';
import { ComponentType } from 'libs/shared/src';
import { Client } from '../../../service/client.service';
import { Position } from '../state/shared';
@Injectable({ providedIn: 'root' })
export class SnippetService {
@ -97,16 +96,6 @@ export class SnippetService {
return this.httpClient.put(`${SnippetService.API}/snippets/${snippetId}`, payload);
}
copySnippet(snippetId: string, pasteLocation: Position, groupId: string): Observable<any> {
const payload: any = {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
originX: pasteLocation.x,
originY: pasteLocation.y,
snippetId
};
return this.httpClient.post(`${SnippetService.API}/process-groups/${groupId}/snippet-instance`, payload);
}
deleteSnippet(snippetId: string): Observable<any> {
const params = new HttpParams({
fromObject: {

View File

@ -22,8 +22,6 @@ import {
ChangeVersionDialogRequest,
ComponentEntity,
ConfirmStopVersionControlRequest,
CopiedSnippet,
CopyRequest,
CreateComponentRequest,
CreateComponentResponse,
CreateConnection,
@ -77,8 +75,7 @@ import {
OpenGroupComponentsDialogRequest,
OpenLocalChangesDialogRequest,
OpenSaveVersionDialogRequest,
PasteRequest,
PasteResponse,
PasteResponseContext,
RefreshRemoteProcessGroupRequest,
ReplayLastProvenanceEventRequest,
RpgManageRemotePortsRequest,
@ -114,6 +111,7 @@ import {
import { StatusHistoryRequest } from '../../../../state/status-history';
import { FetchComponentVersionsRequest } from '../../../../state/shared';
import { ErrorContext } from '../../../../state/error';
import { CopyRequest, CopyResponseContext, CopyResponseEntity } from '../../../../state/copy';
const CANVAS_PREFIX = '[Canvas]';
@ -502,11 +500,11 @@ export const moveComponents = createAction(
export const copy = createAction(`${CANVAS_PREFIX} Copy`, props<{ request: CopyRequest }>());
export const copySuccess = createAction(`${CANVAS_PREFIX} Copy Success`, props<{ copiedSnippet: CopiedSnippet }>());
export const copySuccess = createAction(`${CANVAS_PREFIX} Copy Success`, props<{ response: CopyResponseContext }>());
export const paste = createAction(`${CANVAS_PREFIX} Paste`, props<{ request: PasteRequest }>());
export const paste = createAction(`${CANVAS_PREFIX} Paste`, props<{ request: CopyResponseEntity }>());
export const pasteSuccess = createAction(`${CANVAS_PREFIX} Paste Success`, props<{ response: PasteResponse }>());
export const pasteSuccess = createAction(`${CANVAS_PREFIX} Paste Success`, props<{ response: PasteResponseContext }>());
/*
Delete Component Actions

View File

@ -22,6 +22,7 @@ import { concatLatestFrom } from '@ngrx/operators';
import * as FlowActions from './flow.actions';
import * as StatusHistoryActions from '../../../../state/status-history/status-history.actions';
import * as ErrorActions from '../../../../state/error/error.actions';
import * as CopyActions from '../../../../state/copy/copy.actions';
import {
asyncScheduler,
catchError,
@ -41,7 +42,6 @@ import {
throttleTime
} from 'rxjs';
import {
CopyComponentRequest,
CreateConnectionDialogRequest,
CreateProcessGroupDialogRequest,
DeleteComponentResponse,
@ -49,6 +49,9 @@ import {
ImportFromRegistryDialogRequest,
LoadProcessGroupResponse,
MoveComponentRequest,
PasteRequest,
PasteRequestContext,
PasteRequestEntity,
SaveVersionDialogRequest,
SaveVersionRequest,
SelectedComponent,
@ -67,9 +70,9 @@ import { Action, Store } from '@ngrx/store';
import {
selectAnySelectedComponentIds,
selectChangeVersionRequest,
selectCopiedSnippet,
selectCurrentParameterContext,
selectCurrentProcessGroupId,
selectCurrentProcessGroupRevision,
selectFlowLoadingStatus,
selectInputPort,
selectMaxZIndex,
@ -136,7 +139,6 @@ import { ClusterConnectionService } from '../../../../service/cluster-connection
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { SnippetService } from '../../service/snippet.service';
import { selectTransform } from '../transform/transform.selectors';
import { EditLabel } from '../../ui/canvas/items/label/edit-label/edit-label.component';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { selectConnectedStateChanged } from '../../../../state/cluster-summary/cluster-summary.selectors';
@ -158,6 +160,9 @@ import { selectDocumentVisibilityState } from '../../../../state/document-visibi
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DocumentVisibility } from '../../../../state/document-visibility';
import { ErrorContextKey } from '../../../../state/error';
import { CopyPasteService } from '../../service/copy-paste.service';
import { selectCopiedContent } from '../../../../state/copy/copy.selectors';
import { CopyRequestContext, CopyResponseContext } from '../../../../state/copy';
@Injectable()
export class FlowEffects {
@ -183,7 +188,8 @@ export class FlowEffects {
private propertyTableHelperService: PropertyTableHelperService,
private parameterHelperService: ParameterHelperService,
private extensionTypesService: ExtensionTypesService,
private errorHelper: ErrorHelper
private errorHelper: ErrorHelper,
private copyPasteService: CopyPasteService
) {
this.store
.select(selectDocumentVisibilityState)
@ -2225,15 +2231,48 @@ export class FlowEffects {
map((action) => action.request),
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
switchMap(([request, processGroupId]) => {
const components: CopyComponentRequest[] = request.components;
const snippet = this.snippetService.marshalSnippet(components, processGroupId);
const copyRequest: CopyRequestContext = {
...request,
processGroupId
};
return from(this.copyPasteService.copy(copyRequest)).pipe(
switchMap((response) => {
const copyBlob = new Blob([JSON.stringify(response, null, 2)], { type: 'text/plain' });
const clipboardItem: ClipboardItem = new ClipboardItem({
'text/plain': copyBlob
});
return from(navigator.clipboard.write([clipboardItem])).pipe(
switchMap(() => {
return of(
FlowActions.copySuccess({
response: {
copyResponse: response,
processGroupId,
pasteCount: 0
} as CopyResponseContext
})
);
}),
catchError((e) => {
console.log(e);
return of(FlowActions.flowSnackbarError({ error: 'Copy failed' }));
})
);
}),
catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse)))
);
})
)
);
copySuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowActions.copySuccess),
map((action) => action.response),
switchMap((response) => {
return of(
FlowActions.copySuccess({
copiedSnippet: {
snippet,
dimensions: request.dimensions,
origin: request.origin
}
CopyActions.setCopiedContent({
content: response
})
);
})
@ -2245,43 +2284,55 @@ export class FlowEffects {
ofType(FlowActions.paste),
map((action) => action.request),
concatLatestFrom(() => [
this.store.select(selectCopiedSnippet).pipe(isDefinedAndNotNull()),
this.store.select(selectCurrentProcessGroupId),
this.store.select(selectTransform)
this.store.select(selectCurrentProcessGroupRevision),
this.store.select(selectCopiedContent)
]),
switchMap(([request, copiedSnippet, processGroupId, transform]) =>
from(this.snippetService.createSnippet(copiedSnippet.snippet)).pipe(
switchMap((response) => {
let pasteLocation = request.pasteLocation;
const snippetOrigin = copiedSnippet.origin;
const dimensions = copiedSnippet.dimensions;
switchMap(([request, processGroupId, revision, copiedContent]) => {
let pasteRequest: PasteRequest | null = null;
if (!pasteLocation) {
// if the copied snippet is from a different group or the original items are not in the viewport, center the pasted snippet
if (
copiedSnippet.snippet.parentGroupId != processGroupId ||
!this.canvasView.isBoundingBoxInViewport(dimensions, false)
) {
const center = this.canvasView.getCenterForBoundingBox(dimensions);
pasteLocation = {
x: center[0] - transform.translate.x / transform.scale,
y: center[1] - transform.translate.y / transform.scale
};
} else {
pasteLocation = {
x: snippetOrigin.x + 25,
y: snippetOrigin.y + 25
};
}
// Determine if the paste should be positioned based off of previously copied items or centered.
// * The current process group is the same as the content that was last copied
// * And, the last copied content is the same as the content being pasted
// * And, the original content is still in the canvas view
if (copiedContent && processGroupId === copiedContent.processGroupId) {
if (copiedContent.copyResponse.id === request.id) {
const isInView = this.copyPasteService.isCopiedContentInView(copiedContent.copyResponse);
if (isInView) {
pasteRequest = this.copyPasteService.toOffsetPasteRequest(
request,
copiedContent.pasteCount
);
}
}
}
return from(
this.snippetService.copySnippet(response.snippet.id, pasteLocation, processGroupId)
).pipe(map((response) => FlowActions.pasteSuccess({ response })));
// If no paste request was created before, create one that is centered in the current canvas view
if (!pasteRequest) {
pasteRequest = this.copyPasteService.toCenteredPasteRequest(request);
}
const payload: PasteRequestEntity = {
copyResponse: pasteRequest.copyResponse,
revision
};
const pasteRequestContext: PasteRequestContext = {
pasteRequest: payload,
processGroupId,
pasteStrategy: pasteRequest.strategy
};
return from(this.copyPasteService.paste(pasteRequestContext)).pipe(
map((response) => {
return FlowActions.pasteSuccess({
response: {
...response,
pasteRequest
}
});
}),
catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse)))
)
)
);
})
)
);
@ -2289,7 +2340,8 @@ export class FlowEffects {
this.actions$.pipe(
ofType(FlowActions.pasteSuccess),
map((action) => action.response),
switchMap((response) => {
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
switchMap(([response, currentProcessGroupId]) => {
this.canvasView.updateCanvasVisibility();
this.birdseyeView.refresh();
@ -2359,6 +2411,19 @@ export class FlowEffects {
})
);
if (response.pasteRequest.fitToScreen && response.pasteRequest.bbox) {
this.canvasView.centerBoundingBox(response.pasteRequest.bbox);
}
this.store.dispatch(
CopyActions.contentPasted({
pasted: {
copyId: response.pasteRequest.copyResponse.id,
processGroupId: currentProcessGroupId,
strategy: response.pasteRequest.strategy
}
})
);
return of(
FlowActions.selectComponents({
request: {

View File

@ -19,7 +19,6 @@ import { createReducer, on } from '@ngrx/store';
import {
changeVersionComplete,
changeVersionSuccess,
copySuccess,
createComponentComplete,
createComponentSuccess,
createConnection,
@ -94,6 +93,9 @@ export const initialState: FlowState = {
id: 'root',
changeVersionRequest: null,
flow: {
revision: {
version: 0
},
permissions: {
canRead: false,
canWrite: false
@ -158,7 +160,6 @@ export const initialState: FlowState = {
parameterProviderBulletins: [],
reportingTaskBulletins: []
},
copiedSnippet: null,
dragging: false,
saving: false,
versionSaving: false,
@ -477,10 +478,6 @@ export const flowReducer = createReducer(
});
});
}),
on(copySuccess, (state, { copiedSnippet }) => ({
...state,
copiedSnippet
})),
on(pasteSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const labels: any[] | null = getComponentCollection(draftState, ComponentType.Label);
@ -518,8 +515,7 @@ export const flowReducer = createReducer(
if (connections) {
connections.push(...response.flow.connections);
}
draftState.copiedSnippet = null;
draftState.flow.revision = response.revision;
});
}),
on(setDragging, (state, { dragging }) => ({

View File

@ -36,9 +36,12 @@ export const selectVersionSaving = createSelector(selectFlowState, (state: FlowS
export const selectCurrentProcessGroupId = createSelector(selectFlowState, (state: FlowState) => state.id);
export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: FlowState) => state.refreshRpgDetails);
export const selectCurrentProcessGroupRevision = createSelector(
selectFlowState,
(state: FlowState) => state.flow.revision
);
export const selectCopiedSnippet = createSelector(selectFlowState, (state: FlowState) => state.copiedSnippet);
export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: FlowState) => state.refreshRpgDetails);
export const selectCurrentParameterContext = createSelector(
selectFlowState,

View File

@ -32,6 +32,7 @@ import {
import { HttpErrorResponse } from '@angular/common/http';
import { BackNavigation } from '../../../../state/navigation';
import { ComponentType, SelectOption } from 'libs/shared/src';
import { CopyResponseEntity, PasteRequestStrategy } from '../../../../state/copy';
export const flowFeatureKey = 'flowState';
@ -453,21 +454,31 @@ export interface MoveComponentsRequest {
groupId: string;
}
export interface CopyComponentRequest extends SnippetComponentRequest {}
export interface CopyRequest {
components: CopyComponentRequest[];
origin: Position;
dimensions: any;
}
///////////////////////////////////////////////////////////
export interface PasteRequest {
pasteLocation?: Position;
copyResponse: CopyResponseEntity;
strategy: PasteRequestStrategy;
fitToScreen?: boolean;
bbox?: any;
}
export interface PasteResponse {
export interface PasteRequestEntity {
copyResponse: CopyResponseEntity;
revision: Revision;
disconnectedNodeAcknowledged?: boolean;
}
export interface PasteRequestContext {
processGroupId: string;
pasteRequest: PasteRequestEntity;
pasteStrategy: PasteRequestStrategy;
}
export interface PasteResponseEntity {
flow: Flow;
revision: Revision;
}
export interface PasteResponseContext extends PasteResponseEntity {
pasteRequest: PasteRequest;
}
///////////////////////////////////////////////////////////
export interface DeleteComponentRequest {
id: string;
@ -589,6 +600,7 @@ export interface ProcessGroupFlow {
export interface ProcessGroupFlowEntity {
permissions: Permissions;
revision: Revision;
processGroupFlow: ProcessGroupFlow;
}
@ -641,7 +653,6 @@ export interface FlowState {
flowAnalysisOpen: boolean;
versionSaving: boolean;
changeVersionRequest: FlowUpdateRequestEntity | null;
copiedSnippet: CopiedSnippet | null;
status: 'pending' | 'loading' | 'success' | 'complete';
}

View File

@ -25,6 +25,7 @@ import {
editComponent,
editCurrentProcessGroup,
loadProcessGroup,
paste,
resetFlowState,
selectComponents,
setSkipTransform,
@ -70,6 +71,8 @@ import { ComponentType, isDefinedAndNotNull, selectUrl, Storage } from '@nifi/sh
import { CanvasUtils } from '../../service/canvas-utils.service';
import { CanvasActionsService } from '../../service/canvas-actions.service';
import { MatDialog } from '@angular/material/dialog';
import { CopyResponseEntity } from '../../../../state/copy';
import { snackBarError } from '../../../../state/error/error.actions';
@Component({
selector: 'fd-canvas',
@ -629,7 +632,7 @@ export class Canvas implements OnInit, OnDestroy {
this.canvasView.destroy();
}
private processKeyboardEvents(event: KeyboardEvent): boolean {
private processKeyboardEvents(event: KeyboardEvent | ClipboardEvent): boolean {
const source = event.target as any;
let searchFieldIsEventSource = false;
if (source) {
@ -696,17 +699,26 @@ export class Canvas implements OnInit, OnDestroy {
}
}
@HostListener('window:keydown.control.v', ['$event'])
handleKeyDownCtrlV(event: KeyboardEvent) {
if (this.executeAction('paste', event)) {
event.preventDefault();
@HostListener('window:paste', ['$event'])
handlePasteEvent(event: ClipboardEvent) {
if (!this.processKeyboardEvents(event) || !this.canvasUtils.isPastable()) {
// don't attempt to paste flow content
return;
}
}
@HostListener('window:keydown.meta.v', ['$event'])
handleKeyDownMetaV(event: KeyboardEvent) {
if (this.executeAction('paste', event)) {
event.preventDefault();
const textToPaste = event.clipboardData?.getData('text/plain');
if (textToPaste) {
const copyResponse: CopyResponseEntity | null = this.toCopyResponseEntity(textToPaste);
if (copyResponse) {
this.store.dispatch(
paste({
request: copyResponse
})
);
event.preventDefault();
} else {
this.store.dispatch(snackBarError({ error: 'Cannot paste: incompatible format' }));
}
}
}
@ -723,4 +735,35 @@ export class Canvas implements OnInit, OnDestroy {
event.preventDefault();
}
}
private toCopyResponseEntity(json: string): CopyResponseEntity | null {
try {
const copyResponse: CopyResponseEntity = JSON.parse(json);
const supportedKeys: string[] = [
'processGroups',
'remoteProcessGroups',
'processors',
'inputPorts',
'outputPorts',
'connections',
'labels',
'funnels'
];
// ensure at least one of the copyable component types has something to paste
const hasCopiedContent = Object.entries(copyResponse).some((entry) => {
return supportedKeys.includes(entry[0]) && Array.isArray(entry[1]) && entry[1].length > 0;
});
if (hasCopiedContent) {
return copyResponse;
}
// attempting to paste something other than CopyResponseEntity
return null;
} catch (e) {
// attempting to paste something other than CopyResponseEntity
return null;
}
}
}

View File

@ -0,0 +1,25 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createAction, props } from '@ngrx/store';
import { CopiedContentInfo, CopyResponseContext } from './index';
export const setCopiedContent = createAction('[Copy] Copied Content', props<{ content: CopyResponseContext }>());
export const contentPasted = createAction('[Copy] Copied Content Pasted', props<{ pasted: CopiedContentInfo }>());
export const resetCopiedContent = createAction('[Copy] Reset Copied Content');

View File

@ -0,0 +1,23 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Injectable } from '@angular/core';
@Injectable()
export class CopyEffects {}

View File

@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CopyState, PasteRequestStrategy } from './index';
import { createReducer, on } from '@ngrx/store';
import { contentPasted, resetCopiedContent, setCopiedContent } from './copy.actions';
import { produce } from 'immer';
export const initialCopyState: CopyState = {
copiedContent: null
};
export const copyReducer = createReducer(
initialCopyState,
on(setCopiedContent, (state, { content }) => ({
...state,
copiedContent: content
})),
on(contentPasted, (state, { pasted }) => {
// update the paste count if it was pasted with an OFFSET strategy to influence positioning of future pastes
return produce(state, (draftState) => {
if (
pasted.strategy === PasteRequestStrategy.OFFSET_FROM_ORIGINAL &&
draftState.copiedContent &&
draftState.copiedContent.copyResponse.id === pasted.copyId &&
draftState.copiedContent.processGroupId === pasted.processGroupId
) {
draftState.copiedContent.pasteCount++;
}
});
}),
on(resetCopiedContent, () => ({ ...initialCopyState }))
);

View File

@ -0,0 +1,25 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { copyFeatureKey, CopyState } from './index';
export const selectCopyState = createFeatureSelector<CopyState>(copyFeatureKey);
export const selectCopiedContent = createSelector(selectCopyState, (state: CopyState) => state.copiedContent);

View File

@ -0,0 +1,73 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ExternalControllerServiceReference } from '../shared';
export const copyFeatureKey = 'copy';
export interface CopyRequest {
copyRequestEntity: CopyRequestEntity;
}
export interface CopyRequestContext extends CopyRequest {
processGroupId: string;
}
export interface CopyResponseContext {
copyResponse: CopyResponseEntity;
processGroupId: string;
pasteCount: number;
}
export interface CopyRequestEntity {
processGroups?: string[];
remoteProcessGroups?: string[];
processors?: string[];
inputPorts?: string[];
outputPorts?: string[];
connections?: string[];
labels?: string[];
funnels?: string[];
}
export interface CopyResponseEntity {
id: string;
processGroups?: any[];
remoteProcessGroups?: any[];
processors?: any[];
inputPorts?: any[];
outputPorts?: any[];
connections?: any[];
labels?: any[];
funnels?: any[];
externalControllerServiceReferences?: { [key: string]: ExternalControllerServiceReference };
parameterContexts?: { [key: string]: any };
parameterProviders?: { [key: string]: any };
}
export enum PasteRequestStrategy {
CENTER_ON_CANVAS,
OFFSET_FROM_ORIGINAL
}
export interface CopiedContentInfo {
copyId: string;
processGroupId: string;
strategy: PasteRequestStrategy;
}
export interface CopyState {
copiedContent: CopyResponseContext | null;
}

View File

@ -47,6 +47,8 @@ import { bannerTextFeatureKey, BannerTextState } from './banner-text';
import { bannerTextReducer } from './banner-text/banner-text.reducer';
import { documentVisibilityFeatureKey, DocumentVisibilityState } from './document-visibility';
import { documentVisibilityReducer } from './document-visibility/document-visibility.reducer';
import { copyFeatureKey, CopyState } from './copy';
import { copyReducer } from './copy/copy.reducer';
export interface NiFiState {
[DEFAULT_ROUTER_FEATURENAME]: RouterReducerState;
@ -65,6 +67,7 @@ export interface NiFiState {
[documentVisibilityFeatureKey]: DocumentVisibilityState;
[clusterSummaryFeatureKey]: ClusterSummaryState;
[propertyVerificationFeatureKey]: PropertyVerificationState;
[copyFeatureKey]: CopyState;
}
export const rootReducers: ActionReducerMap<NiFiState> = {
@ -83,5 +86,6 @@ export const rootReducers: ActionReducerMap<NiFiState> = {
[componentStateFeatureKey]: componentStateReducer,
[documentVisibilityFeatureKey]: documentVisibilityReducer,
[clusterSummaryFeatureKey]: clusterSummaryReducer,
[propertyVerificationFeatureKey]: propertyVerificationReducer
[propertyVerificationFeatureKey]: propertyVerificationReducer,
[copyFeatureKey]: copyReducer
};

View File

@ -684,3 +684,8 @@ export interface OpenChangeComponentVersionDialogRequest {
fetchRequest: FetchComponentVersionsRequest;
componentVersions: DocumentedType[];
}
export interface ExternalControllerServiceReference {
identifier: string;
name: string;
}