This is an automated email from the ASF dual-hosted git repository.

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new df793ce14e [NIFI-13977] - Updated Copy/Paste in UI to align with new 
backend API (#9536)
df793ce14e is described below

commit df793ce14e0ce50988c0f217b72cbe89a553bde7
Author: Rob Fellows <[email protected]>
AuthorDate: Tue Dec 3 16:57:04 2024 -0500

    [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
---
 .../main/frontend/apps/nifi/src/app/app.module.ts  |   4 +-
 .../service/canvas-actions.service.ts              | 130 +++++---
 .../flow-designer/service/canvas-utils.service.ts  |  10 +-
 .../flow-designer/service/canvas-view.service.ts   |  35 +-
 .../flow-designer/service/copy-paste.service.ts    | 354 +++++++++++++++++++++
 .../pages/flow-designer/service/snippet.service.ts |  11 -
 .../pages/flow-designer/state/flow/flow.actions.ts |  12 +-
 .../pages/flow-designer/state/flow/flow.effects.ts | 153 ++++++---
 .../pages/flow-designer/state/flow/flow.reducer.ts |  12 +-
 .../flow-designer/state/flow/flow.selectors.ts     |   7 +-
 .../app/pages/flow-designer/state/flow/index.ts    |  35 +-
 .../flow-designer/ui/canvas/canvas.component.ts    |  63 +++-
 .../apps/nifi/src/app/state/copy/copy.actions.ts   |  25 ++
 .../apps/nifi/src/app/state/copy/copy.effects.ts   |  23 ++
 .../apps/nifi/src/app/state/copy/copy.reducer.ts   |  49 +++
 .../apps/nifi/src/app/state/copy/copy.selectors.ts |  25 ++
 .../frontend/apps/nifi/src/app/state/copy/index.ts |  73 +++++
 .../main/frontend/apps/nifi/src/app/state/index.ts |   6 +-
 .../apps/nifi/src/app/state/shared/index.ts        |   5 +
 19 files changed, 876 insertions(+), 156 deletions(-)

diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts
index 270f8c989f..c28d4970bf 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts
@@ -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,
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts
index 16e5d0ffff..fc13989716 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts
@@ -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
-                        }
-                    })
-                );
-            }
-        },
-        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
+                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' });
                     })
-                );
+                });
+                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>) {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
index 949fe47c5f..8e574fa3cd 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
@@ -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;
     }
 
     /**
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
index ada2f5efc1..345e5210ee 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
@@ -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;
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/copy-paste.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/copy-paste.service.ts
new file mode 100644
index 0000000000..ae28d6c03d
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/copy-paste.service.ts
@@ -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 };
+        }
+    }
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts
index 84c221edbe..f97db726f2 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts
@@ -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: {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
index dec106d2f8..a5882f5396 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
@@ -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
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index ffc14fa944..6c540083e7 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -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;
-
-                        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
-                                };
-                            }
+            switchMap(([request, processGroupId, revision, copiedContent]) => {
+                let pasteRequest: PasteRequest | null = null;
+
+                // 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
+                            );
                         }
+                    }
+                }
+
+                // If no paste request was created before, create one that is 
centered in the current canvas view
+                if (!pasteRequest) {
+                    pasteRequest = 
this.copyPasteService.toCenteredPasteRequest(request);
+                }
 
-                        return from(
-                            
this.snippetService.copySnippet(response.snippet.id, pasteLocation, 
processGroupId)
-                        ).pipe(map((response) => FlowActions.pasteSuccess({ 
response })));
+                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: {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
index d284d643df..cd3ec9f31b 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
@@ -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 }) => ({
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
index fb4620432e..101dcb8c77 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
@@ -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,
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
index d1d9041e6d..b224ce1c79 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
@@ -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';
 }
 
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
index 1b6fe87b67..6478a32475 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
@@ -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;
+        }
+    }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.actions.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.actions.ts
new file mode 100644
index 0000000000..12f78be1f0
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.actions.ts
@@ -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');
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.effects.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.effects.ts
new file mode 100644
index 0000000000..eb374ea729
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.effects.ts
@@ -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 {}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.reducer.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.reducer.ts
new file mode 100644
index 0000000000..65fb0d829e
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.reducer.ts
@@ -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 }))
+);
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.selectors.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.selectors.ts
new file mode 100644
index 0000000000..615e913c6c
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.selectors.ts
@@ -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);
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/index.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/index.ts
new file mode 100644
index 0000000000..00b564e8b3
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/index.ts
@@ -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;
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts
index f7a3c02679..e2a99c2487 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts
@@ -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
 };
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
index 17b9126cca..dad3971841 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
@@ -684,3 +684,8 @@ export interface OpenChangeComponentVersionDialogRequest {
     fetchRequest: FetchComponentVersionsRequest;
     componentVersions: DocumentedType[];
 }
+
+export interface ExternalControllerServiceReference {
+    identifier: string;
+    name: string;
+}

Reply via email to